3 Commits

Author SHA1 Message Date
331e49ea29 v1.0.1 2026-06-23 13:53:55 +08:00
c336f176e6 cleanup 2026-06-23 13:15:54 +08:00
0e30ed0b36 v1.0.0 2026-06-22 15:10:22 +08:00
27 changed files with 1467 additions and 7697 deletions

131
PROJECT.md Normal file
View File

@@ -0,0 +1,131 @@
# HPL Toolbox — Project Summary for AI
## What this project is
HPL Toolbox is an internal tool for a playable ad team. It ships as **two separate deliverables** from the same repo:
1. **VS Code Extension** (`src/`) — a `.vsix` that adds tool panels as webview tabs inside VS Code.
2. **Standalone Windows App** (`standalone/`) — a Go + WebView2 desktop app that exposes the same tools through a local HTTP server rendered in a native window. It is a **real, complete, production app** — not a prototype or subset.
Both deliverables implement the same set of tools. New features are typically added to both.
---
## Directory structure
```
HPLToolbox-Extension/
├── src/
│ ├── extension.ts # Extension entry point, registers commands
│ ├── tools/ # One file per tool (VS Code webview panels)
│ │ ├── shared.ts # Shared utilities: singletonPanel, webview styles, clipboard/open handlers
│ │ ├── plecUpload.ts # PLEC Upload tool
│ │ ├── applovin.ts # AppLovin Playable Preview
│ │ ├── base64Scanner.ts # Base64 Scanner
│ │ ├── mraidChecker.ts # MRAID Checker
│ │ ├── mintegral.ts # Mintegral Checker
│ │ ├── sendToMobile.ts # Send To Mobile
│ │ ├── playworks.ts # Playworks Converter
│ │ ├── deviceSimulator.ts# Device Simulator
│ │ └── projectInit.ts # Initialize Project
│ └── tools.json # Tool launcher config (name, command, icon, description)
├── standalone/ # The Go standalone app — complete, production-grade
│ ├── main.go # Entry point: HTTP server + WebView2 window + route registration
│ ├── platform.go # AppConfig struct, defaultConfig(), LoadConfig/SaveConfig, OS helpers
│ ├── settings.go # /api/settings POST endpoint; settings modal HTML
│ ├── layout.go # Shared HTML layout, nav sidebar, CSS
│ ├── home.go # Home page / tool launcher
│ ├── plec.go # PLEC Upload: page HTML, pick endpoints, upload (legacy + WordPress)
│ ├── applovin.go # AppLovin upload
│ ├── base64.go # Base64 Scanner
│ ├── mraid.go # MRAID Checker
│ ├── mintegral.go # Mintegral Checker
│ ├── mobile.go # Send To Mobile
│ ├── playworks.go # Playworks Converter
│ ├── device_simulator.go # Device Simulator
│ ├── project_init.go # Initialize Project
│ ├── changelog.go # Changelog page
│ ├── json.go # newJSONStream helper (NDJSON streaming responses)
│ ├── update_checker.go # Auto-update check against Gitea release API
│ ├── single_instance.go # Single-instance lock via TCP port file
│ └── assets/ # Embedded static assets (qrcode.min.js, mintegral preview util)
├── scripts/
│ ├── build.ps1 # Builds both .vsix and standalone exe into dist/
│ └── run-standalone.ps1 # Runs the standalone app in dev mode (go run .)
├── data/
│ └── manifest.json # Project Init manifest (team template files list)
├── aiAssets/ # Sample HTML files used for testing/reference
├── package.json # Extension manifest, VS Code settings contributions, npm scripts
├── tsconfig.json
└── README.md
```
---
## Tools
| Tool | Description |
|---|---|
| PLEC Upload | Upload HTML playable files to internal PLEC servers. Supports two servers: Default (167.99.227.249, legacy PHP) and Backup (20.255.60.183, WordPress). Backup server uses auto-login via credentials. |
| AppLovin Playable Preview | Upload HTML files to p.applov.in and generate QR preview links. |
| Base64 Scanner | Scan HTML files for external or non-base64 asset references. |
| Send To Mobile | Serve selected HTML files over LAN and display a QR code for mobile preview. |
| MRAID Checker | Check HTML files against MRAID requirements and best practices. |
| Mintegral Checker | Validate Mintegral playable ZIPs against PlayTurbo requirements with embedded runtime preview. |
| Playworks Converter | Convert Playworks HTML into per-network variants. |
| Device Simulator | Preview HTML playables inside accurate mobile device frames with notch/Dynamic Island overlays. |
| Initialize Project | Download team template files from a shared manifest (Google Drive or direct URL). |
---
## Architecture notes
### VS Code Extension
- Each tool is a singleton webview panel (`singletonPanel` in `shared.ts`).
- The panel HTML is returned as a template string from `getHtml()` inside each tool file.
- Communication between extension host and webview uses `panel.webview.postMessage` / `onDidReceiveMessage`.
- Settings are read from `vscode.workspace.getConfiguration('hplToolbox.*')` and contributed in `package.json`.
- Session-persistent UI state (e.g. last selected server) uses `context.globalState`.
### Standalone App
- Go HTTP server on a random localhost port, served into a WebView2 window.
- All pages return full HTML strings (no template files on disk).
- Long-running operations (uploads, scans) use `newJSONStream` to stream NDJSON progress events — the browser reads these with `ReadableStream` and updates the UI incrementally.
- Config is persisted to `%LOCALAPPDATA%\HPLToolbox\config.json` via `LoadConfig` / `SaveConfig`.
- `localStorage` is used for lightweight UI-only state (e.g. last selected server in PLEC Upload).
- Single-instance enforcement: on launch, tries to connect to a previous instance's port from a lock file and `POST /api/focus` to bring its window forward, then exits.
### PLEC Upload — dual server detail
- **Default (legacy)**: `POST http://167.99.227.249/src/upload_HTML_Experimental.php` with `fileUpload_N` / `iterationNameUpload_N` fields. Filenames are timestamped.
- **Backup (WordPress)**: `POST http://20.255.60.183/wp-admin/admin-ajax.php` with `action=plec_upload_files`, `nonce`, `rows` (JSON array), and `file_N` fields. Login flow: POST `/wp-login.php` → GET `/file-upload/` to extract nonce. Iteration name is used as the uploaded filename. Preview URL is constructed as `/preview?theme=calcite&n1=<original>&m1=<saved>`.
---
## Build
```powershell
npm run build # builds both .vsix and standalone .exe into dist/
npm run build -- -Extension # .vsix only
npm run build -- -Standalone # .exe only
npm run run-standalone # dev: go run . in standalone/
npm run compile # tsc only
npm run watch # tsc watch
```
Requires: Node.js, TypeScript (`npm install`), Go, optionally `vsce` and `rsrc`.
---
## Key config (VS Code settings)
All under `hplToolbox.*`:
| Key | Default | Purpose |
|---|---|---|
| `plec.uploadUrl` | `http://167.99.227.249/src/upload_HTML_Experimental.php` | Legacy server endpoint |
| `plec.originUrl` | `http://167.99.227.249` | Origin/Referer for legacy uploads |
| `plec.wpUsername` | `pm` | WordPress username for backup server |
| `plec.wpPassword` | _(set)_ | WordPress password for backup server |
| `applovin.cookie` | _(empty)_ | `__Host-APPLOVINID` cookie for p.applov.in |
| `deviceSimulator.devicesUrl` | Google Drive link | Remote devices.json URL |
| `deviceSimulator.languagesUrl` | Google Drive link | Remote languages.json URL |

View File

@@ -10,7 +10,8 @@ Bundled VS Code extension and standalone tools for playable ad workflows.
- **Send To Mobile** - Share selected HTML files to a phone over LAN with a QR code.
- **MRAID Checker** - Check HTML files against MRAID requirements and best practices.
- **Mintegral Checker** - Validate Mintegral playable ZIPs against PlayTurbo requirements.
- **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta.
- **Playworks Converter** - Convert Playworks HTML into per-network variants.
- **Device Simulator** - Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.
- **Initialize Project** - Download team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared Google Drive manifest.
# Changelog
@@ -131,3 +132,35 @@ Added
- Destination folder is selectable via folder picker, defaulting to workspace root in the extension.
- Standalone stores the manifest URL in config.json and exposes an inline URL editor via the settings (⚙) button.
```
**v1.0.0**
```
Added
- Added remote devices.json and languages.json support for Device Simulator.
- Device and language lists are fetched from a configurable Google Drive URL on open and cached for offline use, with built-in defaults as fallback.
- Device Simulator remote JSON URLs are configurable in VS Code extension settings (hplToolbox.deviceSimulator.devicesUrl / languagesUrl).
- Settings modal in standalone — accessible from any page via the ⚙ button, with Device Simulator and Initialize Project sections highlighted when opened from those tools.
- Device list is now sorted alphabetically in both extension and standalone.
Changed
- Device Simulator settings gear now opens the VS Code extension settings (extension) or the global settings modal (standalone).
- Device management (add/edit/delete/reorder) removed from Device Simulator in both extension and standalone; device list is now managed via remote JSON.
- Settings page in standalone replaced by a modal overlay available on every page.
- Initialize Project manifest URL is now configured via the settings modal instead of the inline settings panel.
Fixed
- Fixed Device Simulator not appearing in the VS Code extension launcher due to a trailing comma in tools.json causing JSON.parse to fail and fall back to a hardcoded list that excluded it.
```
**v1.0.1**
```
Added
- Added backup PLEC server (20.255.60.183) as a selectable upload target in PLEC Upload.
- Server dropdown in the PLEC Upload inputs panel — Default (167.99.227.249) or Backup (20.255.60.183).
- Backup server uses WordPress authentication (auto-login, nonce fetch) — no manual token management.
- Iteration name is used as the uploaded filename on the backup server.
- Selected server persists across sessions (VS Code globalState / localStorage in standalone).
Changed
- Build scripts moved to scripts/ and added as npm run build and npm run run-standalone.
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

243
data/devices.json Normal file
View File

@@ -0,0 +1,243 @@
{
"version": "1.0.0",
"devices": [
{
"id": "iphone-17-pro-max",
"name": "iPhone 17 Pro Max",
"sw": 440,
"sh": 956,
"scr": 44,
"notch": "island",
"nw": 126,
"nh": 37,
"nr": 20,
"sat": 62,
"sab": 34
},
{
"id": "iphone-17-pro",
"name": "iPhone 17 Pro",
"sw": 402,
"sh": 874,
"scr": 42,
"notch": "island",
"nw": 126,
"nh": 37,
"nr": 20,
"sat": 62,
"sab": 34
},
{
"id": "iphone-air",
"name": "iPhone Air",
"sw": 420,
"sh": 912,
"scr": 42,
"notch": "island",
"nw": 126,
"nh": 37,
"nr": 20,
"sat": 62,
"sab": 34
},
{
"id": "iphone-17",
"name": "iPhone 17",
"sw": 402,
"sh": 874,
"scr": 42,
"notch": "island",
"nw": 126,
"nh": 37,
"nr": 20,
"sat": 62,
"sab": 34
},
{
"id": "iphone-16-pro-max",
"name": "iPhone 16 Pro Max",
"sw": 440,
"sh": 956,
"scr": 44,
"notch": "island",
"nw": 126,
"nh": 37,
"nr": 20,
"sat": 62,
"sab": 34
},
{
"id": "iphone-16-pro",
"name": "iPhone 16 Pro",
"sw": 402,
"sh": 874,
"scr": 42,
"notch": "island",
"nw": 126,
"nh": 37,
"nr": 20,
"sat": 62,
"sab": 34
},
{
"id": "iphone-16-plus",
"name": "iPhone 16 Plus",
"sw": 430,
"sh": 932,
"scr": 42,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-16",
"name": "iPhone 16",
"sw": 390,
"sh": 844,
"scr": 40,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-15-pro-max",
"name": "iPhone 15 Pro Max",
"sw": 430,
"sh": 932,
"scr": 42,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-15-pro",
"name": "iPhone 15 Pro",
"sw": 393,
"sh": 852,
"scr": 40,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-15-plus",
"name": "iPhone 15 Plus",
"sw": 430,
"sh": 932,
"scr": 42,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-15",
"name": "iPhone 15",
"sw": 390,
"sh": 844,
"scr": 38,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-14-pro-max",
"name": "iPhone 14 Pro Max",
"sw": 430,
"sh": 932,
"scr": 42,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-14-pro",
"name": "iPhone 14 Pro",
"sw": 393,
"sh": 852,
"scr": 40,
"notch": "island",
"nw": 120,
"nh": 36,
"nr": 18,
"sat": 59,
"sab": 34
},
{
"id": "iphone-14-plus",
"name": "iPhone 14 Plus",
"sw": 428,
"sh": 926,
"scr": 40,
"notch": "notch",
"nw": 130,
"nh": 34,
"sat": 47,
"sab": 34
},
{
"id": "iphone-14",
"name": "iPhone 14",
"sw": 390,
"sh": 844,
"scr": 38,
"notch": "notch",
"nw": 130,
"nh": 34,
"sat": 47,
"sab": 34
},
{
"id": "iphone-se3",
"name": "iPhone SE (3rd gen)",
"sw": 375,
"sh": 667,
"scr": 4,
"notch": "none",
"sat": 20,
"sab": 0
},
{
"id": "pixel-8",
"name": "Google Pixel 8",
"sw": 411,
"sh": 914,
"scr": 22,
"notch": "punch",
"ps": 14,
"sat": 28,
"sab": 24
},
{
"id": "galaxy-s24",
"name": "Samsung Galaxy S24",
"sw": 360,
"sh": 780,
"scr": 20,
"notch": "punch",
"ps": 12,
"sat": 28,
"sab": 24
}
]
}

15
data/languages.json Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "1.0.0",
"languages": [
{ "name": "English", "code": "en" },
{ "name": "Chinese (Simplified)", "code": "zh-hans" },
{ "name": "Chinese (Traditional)", "code": "zh-hant" },
{ "name": "French", "code": "fr" },
{ "name": "German", "code": "de" },
{ "name": "Japanese", "code": "ja" },
{ "name": "Korean", "code": "ko" },
{ "name": "Portuguese", "code": "pt" },
{ "name": "Russian", "code": "ru" },
{ "name": "Spanish", "code": "es" }
]
}

Binary file not shown.

BIN
dist/hpl-toolbox-1.0.1.vsix vendored Normal file

Binary file not shown.

View File

@@ -2,7 +2,7 @@
"name": "hpl-toolbox",
"displayName": "HPL Toolbox",
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
"version": "0.2.4",
"version": "1.0.1",
"publisher": "hesukastro",
"license": "UNLICENSED",
"repository": {
@@ -75,7 +75,8 @@
{
"type": "webview",
"id": "hplToolbox.launcher",
"name": ""
"name": "",
"icon": "media/hpl.png"
}
]
},
@@ -102,6 +103,16 @@
"type": "boolean",
"default": false,
"description": "Skip the pre-check that verifies the filename does not already exist on the preview server."
},
"hplToolbox.plec.wpUsername": {
"type": "string",
"default": "pm",
"description": "WordPress username for the backup PLEC server (20.255.60.183)."
},
"hplToolbox.plec.wpPassword": {
"type": "string",
"default": "nRLz#LV@y#)q@CW@z8fGEPyI",
"description": "WordPress password for the backup PLEC server (20.255.60.183)."
}
}
},
@@ -145,13 +156,30 @@
"description": "URL to a JSON manifest file listing the files to download. The manifest must be an array of { \"filename\": string, \"url\": string } objects. Can be a raw GitHub/Gitea file URL or any direct JSON URL."
}
}
},
{
"title": "HPL Toolbox — Device Simulator",
"properties": {
"hplToolbox.deviceSimulator.devicesUrl": {
"type": "string",
"default": "https://drive.google.com/file/d/1w04wviCmgNqtDCO1GLyYfwEeWpjBPGJ4/view?usp=drive_link",
"description": "URL to a remote devices.json file. When set, the Device Simulator fetches this on open and uses it as the default device list. Leave empty to use built-in defaults only. Supports Google Drive share links (file/d/...) or any direct JSON URL."
},
"hplToolbox.deviceSimulator.languagesUrl": {
"type": "string",
"default": "https://drive.google.com/file/d/1I3ZiI8CLU2JxYZEtWCz6Vr0r8Uj2732f/view?usp=drive_link",
"description": "URL to a remote languages.json file. When set, the Device Simulator fetches this on open and populates the language dropdown from it. Leave empty to use built-in defaults only."
}
}
}
]
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"vscode:prepublish": "npm run compile"
"vscode:prepublish": "npm run compile",
"build": "pwsh -File scripts/build.ps1",
"run-standalone": "pwsh -File scripts/run-standalone.ps1"
},
"devDependencies": {
"@types/node": "^20.0.0",

View File

@@ -15,7 +15,7 @@ param(
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $repoRoot
# If neither was specified, build both.

View File

@@ -1,177 +0,0 @@
import { chromium } from 'playwright';
import { createServer } from 'node:http';
import { execFileSync } from 'node:child_process';
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const root = process.cwd();
const dogCatRoot = process.env.DOGCAT_ROOT || path.join(root, 'aiAssets', 'DogCat3');
const waitMs = Number(process.env.RUNTIME_WAIT_MS || 9000);
const mraidStub = `
window.mraid = window.mraid || {
getState: function(){ return 'default'; },
isViewable: function(){ return true; },
addEventListener: function(name, cb){ if (name === 'ready') setTimeout(cb, 0); },
removeEventListener: function(){},
open: function(url){ console.log('[stub mraid.open]', url); },
close: function(){ console.log('[stub mraid.close]'); },
supports: function(){ return false; }
};
`;
const exitApiStub = `
window.ExitApi = window.ExitApi || {
exit: function(){ console.log('[stub ExitApi.exit]'); }
};
`;
async function collectCases(tempDir) {
const cases = [];
const networkFolders = [
'Applovin',
'Facebook',
'Ironsource',
'Moloco',
'Unity',
'GoogleAds',
'Mintegral',
'Vungle',
];
for (const name of networkFolders) {
const dir = path.join(dogCatRoot, name);
if (!existsSync(dir)) continue;
const entries = await readdir(dir);
const htmlName = entries.find((entry) => /\.html?$/i.test(entry));
if (htmlName) {
cases.push([name, await readFile(path.join(dir, htmlName))]);
continue;
}
const zipName = entries.find((entry) => /\.zip$/i.test(entry));
if (!zipName) continue;
const zip = path.join(dir, zipName);
const out = path.join(tempDir, name);
execFileSync('powershell', [
'-NoProfile',
'-Command',
`Expand-Archive -LiteralPath '${zip.replaceAll("'", "''")}' -DestinationPath '${out.replaceAll("'", "''")}' -Force`,
], { stdio: 'ignore' });
const expanded = await readdir(out);
const indexName = expanded.find((entry) => entry.toLowerCase() === 'index.html');
if (indexName) cases.push([name, await readFile(path.join(out, indexName))]);
}
return cases;
}
async function startServer(cases) {
const html = new Map(cases.map(([name, body]) => [`/${name}/index.html`, body]));
const server = createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
if (url.pathname.endsWith('/mraid.js')) {
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end(mraidStub);
return;
}
if (url.pathname.endsWith('/exitapi.js')) {
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end(exitApiStub);
return;
}
const body = html.get(url.pathname);
if (body) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(body);
return;
}
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
return {
baseUrl: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((resolve) => server.close(resolve)),
};
}
function simplifyUrl(url) {
return url.replace(/^https?:\/\/127\.0\.0\.1:\d+\//, '/');
}
async function runCase(browser, baseUrl, name) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true });
const issues = [];
page.on('pageerror', (error) => {
issues.push({ type: 'pageerror', text: error.stack || error.message });
});
page.on('console', (message) => {
if (['error', 'assert'].includes(message.type())) {
const location = message.location();
const suffix = location.url ? ` @ ${simplifyUrl(location.url)}:${location.lineNumber}:${location.columnNumber}` : '';
issues.push({ type: `console.${message.type()}`, text: `${message.text()}${suffix}` });
}
});
page.on('requestfailed', (request) => {
issues.push({
type: 'requestfailed',
text: `${simplifyUrl(request.url())} :: ${request.failure()?.errorText || 'failed'}`,
});
});
page.on('response', (response) => {
if (response.status() >= 400) {
issues.push({
type: `http.${response.status()}`,
text: simplifyUrl(response.url()),
});
}
});
await page.goto(`${baseUrl}/${name}/index.html`, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForTimeout(waitMs);
const bodyText = await page.locator('body').evaluate((body) => body.innerText.slice(0, 2000)).catch(() => '');
const canvasCount = await page.locator('canvas').count().catch(() => 0);
const title = await page.title().catch(() => '');
await page.close();
return { issues, canvasCount, title, bodyText };
}
async function main() {
const tempDir = await mkdtemp(path.join(tmpdir(), 'dogcat3-playwright-'));
let server;
let browser;
try {
const cases = await collectCases(tempDir);
server = await startServer(cases);
browser = await chromium.launch({ channel: 'msedge', headless: true });
for (const [name] of cases) {
const result = await runCase(browser, server.baseUrl, name);
console.log(`\n## ${name}`);
console.log(`canvas=${result.canvasCount}`);
if (result.title) console.log(`title=${result.title}`);
if (!result.issues.length) {
console.log('No page errors, console errors, failed requests, or HTTP 4xx/5xx responses captured.');
} else {
for (const issue of result.issues) {
console.log(`- ${issue.type}: ${issue.text.replace(/\s+/g, ' ').slice(0, 700)}`);
}
}
if (result.bodyText && /error|exception|cannot|failed/i.test(result.bodyText)) {
console.log(`body_error_text=${result.bodyText.replace(/\s+/g, ' ').slice(0, 700)}`);
}
}
} finally {
if (browser) await browser.close();
if (server) await server.close();
await rm(tempDir, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -1,6 +1,6 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$standalone = Join-Path $root 'standalone'
Push-Location $standalone

View File

@@ -49,7 +49,6 @@ const FALLBACK_TOOLS: ToolDefinition[] = [
command: 'hplToolbox.openPlayworksConverter',
title: 'Playworks Converter',
description: 'Convert Playworks HTML to per-network variants',
beta: true,
},
];

View File

@@ -2,8 +2,26 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as https from 'https';
import { getToolWebviewStyles } from './shared';
const GS_DEVICES_KEY = 'hplDeviceSimulator.remoteDevices.v1';
const GS_LANGUAGES_KEY = 'hplDeviceSimulator.remoteLanguages.v1';
function getSimulatorConfig() {
const cfg = vscode.workspace.getConfiguration('hplToolbox.deviceSimulator');
return {
devicesUrl: (cfg.get<string>('devicesUrl') || '').trim(),
languagesUrl: (cfg.get<string>('languagesUrl') || '').trim(),
};
}
function resolveGDriveUrl(url: string): string {
const fileId = url.match(/\/file\/d\/([a-zA-Z0-9_-]+)/)?.[1]
?? url.match(/[?&]id=([a-zA-Z0-9_-]+)/)?.[1];
return fileId ? `https://drive.google.com/uc?export=download&id=${fileId}` : url;
}
const store: {
panel: vscode.WebviewPanel | null;
previewServer: http.Server | null;
@@ -23,33 +41,49 @@ interface Device {
}
const DEVICES: Device[] = [
// ── iPhone 17 ──────────────────────────────────────────────────────────────
{ id: 'iphone-17-pro-max', name: 'iPhone 17 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-17-pro', name: 'iPhone 17 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-air', name: 'iPhone Air', sw: 420, sh: 912, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-17', name: 'iPhone 17', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
// ── iPhone 16 ──────────────────────────────────────────────────────────────
{ id: 'iphone-16-pro-max', name: 'iPhone 16 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-16-pro', name: 'iPhone 16 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-16-plus', name: 'iPhone 16 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-16', name: 'iPhone 16', sw: 390, sh: 844, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
// ── iPhone 15 ──────────────────────────────────────────────────────────────
{ id: 'iphone-15-pro-max', name: 'iPhone 15 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15-pro', name: 'iPhone 15 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15-plus', name: 'iPhone 15 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15', name: 'iPhone 15', sw: 390, sh: 844, scr: 38, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
// ── iPhone 14 ──────────────────────────────────────────────────────────────
{ id: 'iphone-14-pro-max', name: 'iPhone 14 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-14-pro', name: 'iPhone 14 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', sw: 428, sh: 926, scr: 40, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
{ id: 'iphone-14', name: 'iPhone 14', sw: 390, sh: 844, scr: 38, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
// ── Other ──────────────────────────────────────────────────────────────────
{ id: 'iphone-se3', name: 'iPhone SE (3rd gen)', sw: 375, sh: 667, scr: 4, notch: 'none', sat: 20, sab: 0 },
{ id: 'pixel-8', name: 'Google Pixel 8', sw: 411, sh: 914, scr: 22, notch: 'punch', ps: 14, sat: 28, sab: 24 },
{ id: 'galaxy-s24', name: 'Samsung Galaxy S24', sw: 360, sh: 780, scr: 20, notch: 'punch', ps: 12, sat: 28, sab: 24 },
{ id: 'pixel-8', name: 'Google Pixel 8', sw: 411, sh: 914, scr: 22, notch: 'punch', ps: 14, sat: 28, sab: 24 },
{ id: 'iphone-14', name: 'iPhone 14', sw: 390, sh: 844, scr: 38, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', sw: 428, sh: 926, scr: 40, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
{ id: 'iphone-14-pro', name: 'iPhone 14 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-14-pro-max', name: 'iPhone 14 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15', name: 'iPhone 15', sw: 390, sh: 844, scr: 38, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15-plus', name: 'iPhone 15 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15-pro', name: 'iPhone 15 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-15-pro-max', name: 'iPhone 15 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-16', name: 'iPhone 16', sw: 390, sh: 844, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-16-plus', name: 'iPhone 16 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
{ id: 'iphone-16-pro', name: 'iPhone 16 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-16-pro-max', name: 'iPhone 16 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-17', name: 'iPhone 17', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-17-pro', name: 'iPhone 17 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-17-pro-max', name: 'iPhone 17 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-air', name: 'iPhone Air', sw: 420, sh: 912, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
{ id: 'iphone-se3', name: 'iPhone SE (3rd gen)', sw: 375, sh: 667, scr: 4, notch: 'none', sat: 20, sab: 0 },
{ id: 'galaxy-s24', name: 'Samsung Galaxy S24', sw: 360, sh: 780, scr: 20, notch: 'punch', ps: 12, sat: 28, sab: 24 },
];
export function openDeviceSimulator(_context: vscode.ExtensionContext) {
function fetchJson(url: string, redirectsLeft = 3): Promise<unknown> {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { 'User-Agent': 'HPLToolbox-Extension' } }, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (redirectsLeft > 0) {
fetchJson(res.headers.location, redirectsLeft - 1).then(resolve).catch(reject);
} else {
reject(new Error('Too many redirects'));
}
return;
}
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
});
req.on('error', reject);
req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
});
}
export function openDeviceSimulator(context: vscode.ExtensionContext) {
if (store.panel) {
store.panel.reveal();
return;
@@ -75,6 +109,29 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
});
panel.webview.html = getHtml(DEVICES);
// Send cached remote data immediately so dropdowns update without waiting for network
const cachedDevices = context.globalState.get<string>(GS_DEVICES_KEY);
const cachedLanguages = context.globalState.get<string>(GS_LANGUAGES_KEY);
if (cachedDevices) panel.webview.postMessage({ type: 'updateRemoteDevices', payload: cachedDevices });
if (cachedLanguages) panel.webview.postMessage({ type: 'updateRemoteLanguages', payload: cachedLanguages });
// Background fetch — updates cache and pushes fresh data if panel is still open
const simCfg = getSimulatorConfig();
if (simCfg.devicesUrl) {
fetchJson(resolveGDriveUrl(simCfg.devicesUrl)).then((data) => {
const payload = JSON.stringify(data);
context.globalState.update(GS_DEVICES_KEY, payload);
panel.webview.postMessage({ type: 'updateRemoteDevices', payload });
}).catch(() => {});
}
if (simCfg.languagesUrl) {
fetchJson(resolveGDriveUrl(simCfg.languagesUrl)).then((data) => {
const payload = JSON.stringify(data);
context.globalState.update(GS_LANGUAGES_KEY, payload);
panel.webview.postMessage({ type: 'updateRemoteLanguages', payload });
}).catch(() => {});
}
let lastFilePath = '';
panel.webview.onDidReceiveMessage(async (msg) => {
@@ -92,15 +149,8 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
}
} else if (msg.type === 'reload') {
if (lastFilePath) sendFile(panel, lastFilePath, false);
} else if (msg.type === 'exportDevices') {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file('hpl-device-simulator-devices.json'),
filters: { 'JSON': ['json'] },
saveLabel: 'Export Devices',
});
if (target) {
await fs.promises.writeFile(target.fsPath, String(msg.content || ''), 'utf8');
}
} else if (msg.type === 'openSettings') {
vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.deviceSimulator');
}
});
}
@@ -204,27 +254,18 @@ function getHtml(devices: Device[]): string {
<style>
${getToolWebviewStyles()}
/* ── layout ─────────────────────────────────────────────────────────── */
html, body { min-height: 100%; }
/* Setup view — shown before any file is loaded */
#setup-view { min-height: 100%; }
/* Simulator view — shown after first file load */
#sim-view {
display: none;
height: min(720px, calc(100vh - 280px));
min-height: 520px;
margin-top: var(--tool-gap-lg);
gap: var(--tool-gap-lg);
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
overflow: hidden;
align-items: stretch;
}
#sim-view.active { display: grid; }
/* shared panel chrome */
.sim-panel {
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
@@ -234,8 +275,6 @@ html, body { min-height: 100%; }
flex-direction: column;
min-height: 0;
}
/* ── left: preview ───────────────────────────────────────────────────── */
#preview-area {
flex: 1 1 0;
min-height: 0;
@@ -245,10 +284,7 @@ html, body { min-height: 100%; }
padding: 20px;
position: relative;
}
#screen-scaler {
transform-origin: top center;
flex: 0 0 auto;
}
#screen-scaler { transform-origin: top center; flex: 0 0 auto; }
#device-screen {
--cutout-bg: #000;
position: relative;
@@ -260,176 +296,29 @@ html, body { min-height: 100%; }
0 0 0 4px rgba(255,255,255,0.05),
0 8px 32px rgba(0,0,0,0.5);
}
#device-screen iframe {
display: block;
border: none;
position: absolute;
top: 0; left: 0;
}
.cutout {
position: absolute;
pointer-events: none;
z-index: 10;
background: var(--cutout-bg);
}
.title-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.title-select {
width: 180px;
min-width: 120px;
}
.title-orient {
min-height: 28px;
}
.orient-icon {
display: block;
transition: transform 120ms ease;
}
.title-orient.landscape .orient-icon {
transform: rotate(90deg);
}
#device-screen iframe { display: block; border: none; position: absolute; top: 0; left: 0; }
.cutout { position: absolute; pointer-events: none; z-index: 10; background: var(--cutout-bg); }
.title-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.title-select { width: 180px; min-width: 120px; }
.title-orient { min-height: 28px; }
.orient-icon { display: block; transition: transform 120ms ease; }
.title-orient.landscape .orient-icon { transform: rotate(90deg); }
.icon-btn {
width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
display: grid;
place-items: center;
font-size: 15px;
line-height: 1;
width: 28px; height: 28px; min-height: 28px; padding: 0;
display: grid; place-items: center; font-size: 15px; line-height: 1;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border-color: var(--tool-border);
}
.icon-btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
.icon-btn[aria-pressed="true"] { color: var(--vscode-testing-iconPassed, #73c991); }
.device-settings-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.device-settings-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(0,0,0,0.48);
}
.device-settings-modal.active { display: flex; }
.device-settings-dialog {
width: min(560px, 100%);
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: var(--vscode-editor-background);
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
overflow: hidden;
}
.device-list-manager {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
gap: 8px;
}
.device-list {
min-height: 220px;
max-height: 320px;
overflow: auto;
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: var(--tool-panel-soft);
}
.device-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
border-bottom: 1px solid var(--tool-border);
}
.device-row:last-child { border-bottom: 0; }
.device-row.active { background: rgba(0,127,212,0.16); }
.device-row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row-size {
color: var(--vscode-descriptionForeground);
font-size: 11px;
white-space: nowrap;
}
.device-list-controls {
display: flex;
flex-direction: column;
gap: 6px;
}
.device-settings-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.device-settings-footer button {
min-height: 30px;
}
.device-settings-status {
margin-top: 10px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
min-height: 18px;
}
.device-form {
display: none;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--tool-border);
}
.device-form.active { display: block; }
.device-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.device-form-grid label {
display: grid;
gap: 4px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
}
.device-form-grid .full {
grid-column: 1 / -1;
}
.settings-close {
margin-left: auto;
}
@media (max-width: 720px) {
#sim-view {
height: auto;
min-height: 0;
grid-template-columns: 1fr;
grid-template-rows: minmax(420px, 60vh);
overflow: visible;
}
.title-select {
width: min(180px, 36vw);
}
#sim-view { height: auto; min-height: 0; grid-template-rows: minmax(420px, 60vh); overflow: visible; }
.title-select { width: min(180px, 36vw); }
}
</style>
</head>
<body>
<!-- ── setup view (no file loaded yet) ─────────────────────────────── -->
<div id="setup-view">
<main class="tool-page">
<header class="tool-header">
@@ -437,9 +326,9 @@ html, body { min-height: 100%; }
<p class="tool-description">Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header device-settings-header">
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<h3 class="panel-title">Input</h3>
<button id="settings-btn" class="icon-btn settings-close" title="Device Settings" aria-label="Device Settings">&#9881;</button>
<button id="settings-btn" class="icon-btn" title="Open Extension Settings" aria-label="Open Extension Settings">&#9881;</button>
</div>
<div class="panel-body">
<div class="control-label">Playable HTML</div>
@@ -450,62 +339,7 @@ html, body { min-height: 100%; }
<div id="setup-status" class="status-panel"></div>
</div>
</section>
<div id="device-settings-modal" class="device-settings-modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-title">
<div class="device-settings-dialog">
<div class="panel-header device-settings-header">
<h3 id="device-settings-title" class="panel-title">Device Settings</h3>
<button id="close-settings-btn" class="icon-btn settings-close" title="Close" aria-label="Close">&times;</button>
</div>
<div class="panel-body">
<div class="device-list-manager">
<div id="device-list" class="device-list" role="listbox" aria-label="Devices"></div>
<div class="device-list-controls">
<button id="add-device-btn" class="icon-btn" title="Add Device" aria-label="Add Device">+</button>
<button id="remove-device-btn" class="icon-btn" title="Delete Selected Devices" aria-label="Delete Selected Devices">-</button>
<button id="move-device-up-btn" class="icon-btn" title="Move Selected Up" aria-label="Move Selected Up">&#8593;</button>
<button id="move-device-down-btn" class="icon-btn" title="Move Selected Down" aria-label="Move Selected Down">&#8595;</button>
</div>
</div>
<div id="device-settings-status" class="device-settings-status"></div>
<div id="device-form" class="device-form">
<div class="device-form-grid">
<label class="full">Name<input id="device-name-input" type="text" /></label>
<label>Width<input id="device-sw-input" type="text" inputmode="numeric" /></label>
<label>Height<input id="device-sh-input" type="text" inputmode="numeric" /></label>
<label>Corner Radius<input id="device-scr-input" type="text" inputmode="numeric" /></label>
<label>Notch
<select id="device-notch-input">
<option value="none">none</option>
<option value="notch">notch</option>
<option value="island">island</option>
<option value="punch">punch</option>
</select>
</label>
<label>Notch Width<input id="device-nw-input" type="text" inputmode="numeric" /></label>
<label>Notch Height<input id="device-nh-input" type="text" inputmode="numeric" /></label>
<label>Notch Radius<input id="device-nr-input" type="text" inputmode="numeric" /></label>
<label>Punch Size<input id="device-ps-input" type="text" inputmode="numeric" /></label>
<label>Safe Top<input id="device-sat-input" type="text" inputmode="numeric" /></label>
<label>Safe Bottom<input id="device-sab-input" type="text" inputmode="numeric" /></label>
</div>
<div class="action-row">
<button id="save-device-btn">Save Device</button>
<button id="cancel-device-btn" class="secondary">Cancel</button>
</div>
</div>
<div class="device-settings-footer">
<button id="restore-devices-btn" class="secondary">Restore Defaults</button>
<button id="import-devices-btn" class="secondary">Import Devices</button>
<button id="export-devices-btn" class="secondary">Export Devices</button>
</div>
<input id="import-devices-input" type="file" accept="application/json,.json" style="display:none" />
</div>
</div>
</div>
<!-- ── simulator view (file loaded) ───────────────────────────── -->
<div id="sim-view">
<!-- left: device preview -->
<section class="sim-panel">
<div class="panel-header">
<h3 class="panel-title">Playable</h3>
@@ -525,15 +359,13 @@ html, body { min-height: 100%; }
<div id="screen-scaler"></div>
</div>
</section>
</div>
</div>
</main>
</div>
<script>
const vscode = acquireVsCodeApi();
const DEFAULT_DEVICES = ${devicesJson};
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
const LANGUAGES = [
let DEFAULT_DEVICES = ${devicesJson};
let LANGUAGES = [
{ name: 'English', code: 'en' },
{ name: 'Chinese (Simplified)', code: 'zh-hans' },
{ name: 'Chinese (Traditional)', code: 'zh-hant' },
@@ -546,8 +378,8 @@ const LANGUAGES = [
{ name: 'Spanish', code: 'es' }
];
let devices = loadDevices();
let device = devices[0] || DEFAULT_DEVICES[0];
let devices = DEFAULT_DEVICES.slice();
let device = devices[0];
let language = 'en';
let landscape = false;
let currentContent = null;
@@ -560,13 +392,12 @@ const mediaVolumes = new WeakMap();
let muteObserver = null;
let muteDocument = null;
/* ── device selector ─────────────────────────────────────────────────── */
const sel = document.getElementById('device-select');
const languageSelect = document.getElementById('language-select');
populateDeviceSelect(device.id);
populateLanguageSelect();
sel.addEventListener('change', function() {
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
device = devices[parseInt(sel.value, 10)] || devices[0];
renderScreen();
if (currentContent) loadContent(currentContent);
});
@@ -574,94 +405,9 @@ languageSelect.addEventListener('change', function() {
language = languageSelect.value || LANGUAGES[0].code;
if (currentContent) loadContent(currentContent);
});
document.getElementById('add-device-btn').addEventListener('click', addDevice);
document.getElementById('remove-device-btn').addEventListener('click', removeCurrentDevice);
document.getElementById('import-devices-btn').addEventListener('click', function() {
document.getElementById('import-devices-input').click();
document.getElementById('settings-btn').addEventListener('click', function() {
vscode.postMessage({ type: 'openSettings' });
});
document.getElementById('export-devices-btn').addEventListener('click', exportDevices);
document.getElementById('restore-devices-btn').addEventListener('click', restoreDefaultDevices);
document.getElementById('import-devices-input').addEventListener('change', importDevices);
document.getElementById('settings-btn').addEventListener('click', openDeviceSettings);
document.getElementById('close-settings-btn').addEventListener('click', closeDeviceSettings);
document.getElementById('save-device-btn').addEventListener('click', saveDeviceForm);
document.getElementById('cancel-device-btn').addEventListener('click', hideDeviceForm);
document.getElementById('move-device-up-btn').addEventListener('click', moveSelectedDevicesUp);
document.getElementById('move-device-down-btn').addEventListener('click', moveSelectedDevicesDown);
document.getElementById('device-settings-modal').addEventListener('click', function(event) {
if (event.target === event.currentTarget) closeDeviceSettings();
});
function openDeviceSettings() {
document.getElementById('device-settings-modal').classList.add('active');
renderDeviceList();
setDeviceSettingsStatus('');
}
function closeDeviceSettings() {
document.getElementById('device-settings-modal').classList.remove('active');
hideDeviceForm();
}
function setDeviceSettingsStatus(message) {
document.getElementById('device-settings-status').textContent = message || '';
}
function showDeviceForm() {
const source = device || DEFAULT_DEVICES[0];
document.getElementById('device-name-input').value = source.name ? source.name + ' Copy' : 'Custom Device';
document.getElementById('device-sw-input').value = String(source.sw || 390);
document.getElementById('device-sh-input').value = String(source.sh || 844);
document.getElementById('device-scr-input').value = String(source.scr || 40);
document.getElementById('device-notch-input').value = source.notch || 'none';
document.getElementById('device-nw-input').value = source.nw === undefined ? '' : String(source.nw);
document.getElementById('device-nh-input').value = source.nh === undefined ? '' : String(source.nh);
document.getElementById('device-nr-input').value = source.nr === undefined ? '' : String(source.nr);
document.getElementById('device-ps-input').value = source.ps === undefined ? '' : String(source.ps);
document.getElementById('device-sat-input').value = String(source.sat || 0);
document.getElementById('device-sab-input').value = String(source.sab || 0);
document.getElementById('device-form').classList.add('active');
setDeviceSettingsStatus('');
}
function hideDeviceForm() {
document.getElementById('device-form').classList.remove('active');
}
function formOptionalNumber(id) {
const value = document.getElementById(id).value.trim();
return value === '' ? undefined : Number(value);
}
function saveDeviceForm() {
const name = document.getElementById('device-name-input').value.trim();
const created = normalizeDevice({
id: (slugify(name) || 'custom-device') + '-' + Date.now(),
name: name,
sw: Number(document.getElementById('device-sw-input').value),
sh: Number(document.getElementById('device-sh-input').value),
scr: Number(document.getElementById('device-scr-input').value),
notch: document.getElementById('device-notch-input').value,
nw: formOptionalNumber('device-nw-input'),
nh: formOptionalNumber('device-nh-input'),
nr: formOptionalNumber('device-nr-input'),
ps: formOptionalNumber('device-ps-input'),
sat: Number(document.getElementById('device-sat-input').value),
sab: Number(document.getElementById('device-sab-input').value)
}, devices.length);
if (!created) {
setDeviceSettingsStatus('Enter at least a valid width and height.');
return;
}
devices.push(created);
saveDevices();
populateDeviceSelect(created.id);
renderDeviceList([created.id]);
hideDeviceForm();
setDeviceSettingsStatus('Added ' + created.name + '.');
renderScreen();
if (currentContent) loadContent(currentContent);
}
function populateDeviceSelect(selectedId) {
sel.innerHTML = '';
@@ -671,11 +417,9 @@ function populateDeviceSelect(selectedId) {
o.textContent = d.name;
sel.appendChild(o);
});
const foundIndex = devices.findIndex(function(d) { return d.id === selectedId; });
const index = foundIndex >= 0 ? foundIndex : 0;
sel.value = String(index);
device = devices[index] || DEFAULT_DEVICES[0];
renderDeviceList(getCheckedDeviceIds());
const idx = Math.max(0, devices.findIndex(function(d) { return d.id === selectedId; }));
sel.value = String(idx);
device = devices[idx] || devices[0];
}
function populateLanguageSelect() {
@@ -689,83 +433,6 @@ function populateLanguageSelect() {
languageSelect.value = language;
}
function renderDeviceList(checkedIds) {
const list = document.getElementById('device-list');
if (!list) return;
const checked = new Set(checkedIds || []);
list.innerHTML = '';
devices.forEach(function(d, i) {
const row = document.createElement('label');
row.className = 'device-row' + (d.id === device.id ? ' active' : '');
row.setAttribute('role', 'option');
row.title = d.name;
const input = document.createElement('input');
input.type = 'checkbox';
input.value = d.id;
input.checked = checked.has(d.id);
const name = document.createElement('span');
name.className = 'device-row-name';
name.textContent = d.name;
const size = document.createElement('span');
size.className = 'device-row-size';
size.textContent = d.sw + ' x ' + d.sh;
row.append(input, name, size);
row.addEventListener('dblclick', function() {
populateDeviceSelect(d.id);
renderScreen();
if (currentContent) loadContent(currentContent);
});
list.appendChild(row);
});
}
function getCheckedDeviceIds() {
return Array.from(document.querySelectorAll('#device-list input[type="checkbox"]:checked')).map(function(input) {
return input.value;
});
}
function getCheckedDeviceIndexes() {
const checked = new Set(getCheckedDeviceIds());
return devices.map(function(d, i) { return checked.has(d.id) ? i : -1; }).filter(function(i) { return i >= 0; });
}
function loadDevices() {
try {
const raw = localStorage.getItem(DEVICES_STORAGE_KEY);
if (raw) {
const parsed = normalizeDeviceList(JSON.parse(raw));
if (parsed.length) return parsed;
}
} catch {}
return DEFAULT_DEVICES.map(function(d) { return Object.assign({}, d); });
}
function saveDevices() {
localStorage.setItem(DEVICES_STORAGE_KEY, JSON.stringify(devices));
}
function cloneDefaultDevices() {
return DEFAULT_DEVICES.map(function(d) { return Object.assign({}, d); });
}
function restoreDefaultDevices() {
const existingIds = new Set(devices.map(function(d) { return d.id; }));
const restored = cloneDefaultDevices().filter(function(d) { return !existingIds.has(d.id); });
if (!restored.length) {
setDeviceSettingsStatus('All default devices are already present.');
return;
}
devices = devices.concat(restored);
saveDevices();
populateDeviceSelect(restored[0].id);
hideDeviceForm();
renderDeviceList(restored.map(function(d) { return d.id; }));
setDeviceSettingsStatus('Restored ' + restored.length + ' default device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
}
function normalizeDeviceList(value) {
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
return list.map(normalizeDevice).filter(Boolean);
@@ -803,103 +470,13 @@ function slugify(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function addDevice() {
showDeviceForm();
function sortDevicesByName(arr) {
return arr.slice().sort(function(a, b) {
const na = a.name.toLowerCase(), nb = b.name.toLowerCase();
return na < nb ? -1 : na > nb ? 1 : 0;
});
}
function removeCurrentDevice() {
const selectedIds = getCheckedDeviceIds();
if (!selectedIds.length) {
setDeviceSettingsStatus('Select one or more devices to delete.');
return;
}
if (devices.length - selectedIds.length < 1) {
setDeviceSettingsStatus('At least one device is required.');
return;
}
const selected = new Set(selectedIds);
const oldDeviceId = device.id;
const removedCount = selectedIds.length;
devices = devices.filter(function(d) { return !selected.has(d.id); });
saveDevices();
const nextDevice = devices.find(function(d) { return d.id === oldDeviceId; }) || devices[0];
populateDeviceSelect(nextDevice.id);
renderDeviceList([]);
setDeviceSettingsStatus('Deleted ' + removedCount + ' device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
}
function moveSelectedDevicesUp() {
moveSelectedDevices(-1);
}
function moveSelectedDevicesDown() {
moveSelectedDevices(1);
}
function moveSelectedDevices(direction) {
const selectedIds = getCheckedDeviceIds();
if (!selectedIds.length) {
setDeviceSettingsStatus('Select one or more devices to move.');
return;
}
const selected = new Set(selectedIds);
if (direction < 0) {
for (let i = 1; i < devices.length; i++) {
if (selected.has(devices[i].id) && !selected.has(devices[i - 1].id)) {
const tmp = devices[i - 1];
devices[i - 1] = devices[i];
devices[i] = tmp;
}
}
} else {
for (let i = devices.length - 2; i >= 0; i--) {
if (selected.has(devices[i].id) && !selected.has(devices[i + 1].id)) {
const tmp = devices[i + 1];
devices[i + 1] = devices[i];
devices[i] = tmp;
}
}
}
saveDevices();
populateDeviceSelect(device.id);
renderDeviceList(selectedIds);
setDeviceSettingsStatus('Moved ' + selectedIds.length + ' device(s).');
}
function exportDevices() {
const payload = JSON.stringify({ version: 1, devices: devices }, null, 2);
vscode.postMessage({ type: 'exportDevices', content: payload });
}
function importDevices(event) {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = function() {
try {
const imported = normalizeDeviceList(JSON.parse(String(reader.result || '')));
if (!imported.length) {
setDeviceSettingsStatus('No valid devices found.');
return;
}
devices = imported;
saveDevices();
populateDeviceSelect(devices[0].id);
renderDeviceList([]);
setDeviceSettingsStatus('Imported ' + imported.length + ' device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
} catch (e) {
setDeviceSettingsStatus('Could not import devices: ' + (e.message || e));
}
};
reader.readAsText(file);
}
/* ── orientation ────────────────────────────────────────────────────── */
document.getElementById('orient-btn').addEventListener('click', function() {
landscape = !landscape;
renderOrientationButton();
@@ -940,11 +517,9 @@ document.getElementById('mute-btn').addEventListener('click', function() {
applyMute();
});
/* ── helpers ─────────────────────────────────────────────────────────── */
function sw() { return landscape ? device.sh : device.sw; }
function sh() { return landscape ? device.sw : device.sh; }
/* ── cutout overlay HTML ─────────────────────────────────────────────── */
function buildCutout() {
const d = device;
if (d.notch === 'notch') {
@@ -979,13 +554,11 @@ function buildHomeBar() {
return '<div style="' + base + 'right:8px;top:50%;transform:translateY(-50%);width:5px;height:100px;"></div>';
}
/* ── render ──────────────────────────────────────────────────────────── */
function renderScreen() {
const W = sw(), H = sh();
const scaler = document.getElementById('screen-scaler');
scaler.innerHTML =
'<div id="device-screen"' +
' style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
'<div id="device-screen" style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
buildCutout() +
'<iframe id="preview-frame" width="' + W + '" height="' + H +
'" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
@@ -1000,20 +573,14 @@ function renderScreen() {
function loadContent(html) {
const frame = document.getElementById('preview-frame');
if (!frame) return;
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = null;
}
if (currentSourceUrl) {
frame.src = withPreviewParams(currentSourceUrl);
return;
}
// Revoke previous blob URL to free memory
// Blob URL iframe: the blob document has its own security context — VS Code
// does not inject its nonce-based CSP into it, so inline scripts run freely.
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
currentBlobUrl = URL.createObjectURL(blob);
frame.src = currentBlobUrl;
@@ -1121,6 +688,38 @@ function updateScale() {
window.addEventListener('message', function(event) {
const msg = event.data;
if (!msg || !msg.type) return;
if (msg.type === 'updateRemoteDevices') {
try {
const newDefaults = normalizeDeviceList(JSON.parse(msg.payload));
if (!newDefaults.length) return;
DEFAULT_DEVICES = sortDevicesByName(newDefaults);
devices = DEFAULT_DEVICES.slice();
const currentId = device ? device.id : null;
const stillExists = currentId && devices.some(function(d) { return d.id === currentId; });
populateDeviceSelect(stillExists ? currentId : devices[0].id);
renderScreen();
if (currentContent) loadContent(currentContent);
} catch {}
return;
}
if (msg.type === 'updateRemoteLanguages') {
try {
const parsed = JSON.parse(msg.payload);
const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.languages) ? parsed.languages : []);
const valid = list.filter(function(l) { return l && l.name && l.code; });
if (!valid.length) return;
LANGUAGES = valid;
const prev = language;
populateLanguageSelect();
const stillValid = LANGUAGES.some(function(l) { return l.code === prev; });
language = stillValid ? prev : LANGUAGES[0].code;
languageSelect.value = language;
} catch {}
return;
}
if (msg.type === 'fileLoaded') {
currentContent = msg.content;
currentSourceUrl = msg.sourceUrl || '';

View File

@@ -6,15 +6,25 @@ import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openPlecUpload(_context: vscode.ExtensionContext) {
export function openPlecUpload(context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload');
if (!isNew) return;
panel.webview.html = getHtml();
// Restore last selected server
const lastServer = context.globalState.get<string>('plec.lastServer', 'legacy');
panel.webview.postMessage({ type: 'restoreServer', value: lastServer });
panel.webview.onDidReceiveMessage(async (msg) => {
try {
if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'saveServer') {
context.globalState.update('plec.lastServer', msg.value);
return;
}
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
@@ -51,7 +61,7 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
}
if (msg.type === 'upload') {
await handleUpload(panel, msg.rows);
await handleUpload(panel, msg.rows, msg.server);
return;
}
} catch (err: any) {
@@ -66,11 +76,7 @@ interface UploadRow {
iteration: string;
}
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
const uploadUrl = cfg.get<string>('uploadUrl')!;
const originUrl = cfg.get<string>('originUrl')!;
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[], server: string) {
const valid: UploadRow[] = [];
for (const r of rows) {
if (!r.path || !fs.existsSync(r.path)) {
@@ -92,11 +98,21 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
return;
}
if (server === 'wordpress') {
await handleUploadWordPress(panel, valid);
} else {
await handleUploadLegacy(panel, valid);
}
}
async function handleUploadLegacy(panel: vscode.WebviewPanel, valid: UploadRow[]) {
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
const uploadUrl = cfg.get<string>('uploadUrl')!;
const originUrl = cfg.get<string>('originUrl')!;
const stamp = timestampSuffix();
const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp));
panel.webview.postMessage({ type: 'status', message: 'Uploading...' });
const form = new FormData();
for (let i = 0; i < valid.length; i++) {
const r = valid[i];
@@ -129,20 +145,14 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
const text = await res.text();
if (!res.ok) {
panel.webview.postMessage({
type: 'error',
message: `HTTP ${res.status}\n${text.slice(0, 800)}`,
});
panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` });
return;
}
let json: any;
try { json = JSON.parse(text); }
catch {
panel.webview.postMessage({
type: 'error',
message: `Server returned non-JSON:\n${text.slice(0, 800)}`,
});
panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` });
return;
}
@@ -150,6 +160,154 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
}
async function wpAuthenticate(panel: vscode.WebviewPanel): Promise<{ cookie: string; nonce: string }> {
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
const username = cfg.get<string>('wpUsername') || '';
const password = cfg.get<string>('wpPassword') || '';
const baseUrl = 'http://20.255.60.183';
if (!username || !password) {
throw new Error('WordPress credentials not set. Add hplToolbox.plec.wpUsername and wpPassword in VS Code settings.');
}
panel.webview.postMessage({ type: 'status', message: 'Authenticating with backup server...' });
const loginBody = new URLSearchParams({
log: username,
pwd: password,
'wp-submit': 'Log In',
redirect_to: '/wp-admin/',
testcookie: '1',
});
const loginRes = await fetch(`${baseUrl}/wp-login.php`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': 'wordpress_test_cookie=WP Cookie check',
'Origin': baseUrl,
'Referer': `${baseUrl}/wp-login.php`,
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
},
body: loginBody.toString(),
redirect: 'manual',
});
const location = loginRes.headers.get('location') || '';
// Extract Set-Cookie headers — try getSetCookie() first (Node 18+), fall back to entries()
let rawCookies: string[] = [];
if (typeof (loginRes.headers as any).getSetCookie === 'function') {
rawCookies = (loginRes.headers as any).getSetCookie();
} else {
for (const [k, v] of (loginRes.headers as any).entries()) {
if (k.toLowerCase() === 'set-cookie') rawCookies.push(v);
}
}
const cookie = rawCookies.map((c: string) => c.split(';')[0]).join('; ');
// WordPress sends a 302 to /wp-admin/ on success, or 200 (login page with error) on failure
const loginOk = (loginRes.status === 301 || loginRes.status === 302) && !location.includes('wp-login');
if (!loginOk) {
const loginHtml = await loginRes.text();
const errorMatch = loginHtml.match(/<div id="login_error">([\s\S]*?)<\/div>/);
const wpError = errorMatch
? errorMatch[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
: null;
const bodyPreview = loginHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
throw new Error(
`WordPress login failed (HTTP ${loginRes.status}).\n` +
(wpError ? `Server: "${wpError}"\n` : '') +
`Body: ${bodyPreview}`
);
}
if (!cookie) {
throw new Error(`Login succeeded (redirected to ${location}) but no session cookies were returned. Check server config.`);
}
panel.webview.postMessage({ type: 'status', message: 'Fetching upload token...' });
const pageRes = await fetch(`${baseUrl}/file-upload/`, {
headers: { 'Cookie': cookie, 'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)' },
});
const pageText = await pageRes.text();
const match = pageText.match(/"nonce"\s*:\s*"([a-f0-9]+)"/);
if (!match) {
throw new Error('Could not extract nonce from backup server page. Are you sure the account has access?');
}
return { cookie, nonce: match[1] };
}
async function handleUploadWordPress(panel: vscode.WebviewPanel, valid: UploadRow[]) {
const { cookie, nonce } = await wpAuthenticate(panel);
const baseUrl = 'http://20.255.60.183';
const form = new FormData();
form.append('action', 'plec_upload_files');
form.append('nonce', nonce);
const rowsMeta: { field: string; iterationName: string }[] = [];
for (let i = 0; i < valid.length; i++) {
const r = valid[i];
const idx = i + 1;
const fieldName = `file_${idx}`;
const sanitized = r.iteration.replace(/\s+/g, '');
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${sanitized}.html` });
const buf = fs.readFileSync(r.path);
const file = new File([buf], `${sanitized}.html`, { type: 'text/html' });
form.append(fieldName, file as any);
rowsMeta.push({ field: fieldName, iterationName: sanitized });
}
form.append('rows', JSON.stringify(rowsMeta));
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
let res: Response;
try {
res = await fetch(`${baseUrl}/wp-admin/admin-ajax.php`, {
method: 'POST',
body: form,
headers: {
'Cookie': cookie,
'Origin': baseUrl,
'Referer': `${baseUrl}/file-upload/`,
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
'X-Requested-With': 'XMLHttpRequest',
'Accept': '*/*',
},
});
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: 'Network error: ' + (err?.message || err) });
return;
}
const text = await res.text();
if (!res.ok) {
panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` });
return;
}
let json: any;
try { json = JSON.parse(text); }
catch {
panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` });
return;
}
const files: { original: string; saved: string }[] = json.data?.files || [];
let preview: string | null = json.preview || json.previewURL || null;
if (!preview && files.length > 0) {
const params = new URLSearchParams({ theme: 'calcite' });
files.forEach((f, i) => {
params.append(`n${i + 1}`, f.original.replace(/\.html?$/i, ''));
params.append(`m${i + 1}`, f.saved);
});
preview = `${baseUrl}/preview?${params.toString()}`;
}
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
}
function timestampSuffix(d: Date = new Date()): string {
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
return (
@@ -225,6 +383,17 @@ ${getToolWebviewStyles()}
.result a { color: var(--vscode-textLink-foreground); }
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.panel-header { display: flex; align-items: center; }
#serverSelect {
margin-left: auto;
background: var(--vscode-dropdown-background);
color: var(--vscode-dropdown-foreground);
border: 1px solid var(--vscode-dropdown-border);
padding: 3px 6px;
border-radius: 2px;
font-size: 12px;
cursor: pointer;
}
</style>
</head>
<body>
@@ -237,6 +406,10 @@ ${getToolWebviewStyles()}
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
<select id="serverSelect">
<option value="legacy">167.99.227.249 (Default)</option>
<option value="wordpress">20.255.60.183 (Backup)</option>
</select>
</div>
<div class="panel-body">
<div class="control-group">
@@ -377,6 +550,10 @@ ${getToolWebviewStyles()}
copyResultBtn.disabled = !previewUrl;
}
document.getElementById('serverSelect').addEventListener('change', (e) => {
vscode.postMessage({ type: 'saveServer', value: e.target.value });
});
document.getElementById('pickFolder').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
});
@@ -402,7 +579,7 @@ ${getToolWebviewStyles()}
}
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'upload', rows });
vscode.postMessage({ type: 'upload', rows, server: document.getElementById('serverSelect').value });
});
openResultBtn.addEventListener('click', () => {
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
@@ -418,6 +595,10 @@ ${getToolWebviewStyles()}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'restoreServer') {
document.getElementById('serverSelect').value = m.value;
return;
}
if (m.type === 'filesSelected') {
addFiles(m.files || []);
} else if (m.type === 'status') {

View File

@@ -2,10 +2,15 @@ package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
type deviceSimulatorDevice struct {
@@ -24,25 +29,25 @@ type deviceSimulatorDevice struct {
}
var deviceSimulatorDevices = []deviceSimulatorDevice{
{ID: "iphone-17-pro-max", Name: "iPhone 17 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-17-pro", Name: "iPhone 17 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-air", Name: "iPhone Air", SW: 420, SH: 912, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-17", Name: "iPhone 17", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-16-pro-max", Name: "iPhone 16 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-16-pro", Name: "iPhone 16 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-16-plus", Name: "iPhone 16 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-16", Name: "iPhone 16", SW: 390, SH: 844, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15-pro-max", Name: "iPhone 15 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15-pro", Name: "iPhone 15 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15-plus", Name: "iPhone 15 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15", Name: "iPhone 15", SW: 390, SH: 844, SCR: 38, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-14-pro-max", Name: "iPhone 14 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-14-pro", Name: "iPhone 14 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-14-plus", Name: "iPhone 14 Plus", SW: 428, SH: 926, SCR: 40, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
{ID: "iphone-14", Name: "iPhone 14", SW: 390, SH: 844, SCR: 38, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
{ID: "iphone-se3", Name: "iPhone SE (3rd gen)", SW: 375, SH: 667, SCR: 4, Notch: "none", SAT: 20, SAB: 0},
{ID: "pixel-8", Name: "Google Pixel 8", SW: 411, SH: 914, SCR: 22, Notch: "punch", PS: 14, SAT: 28, SAB: 24},
{ID: "galaxy-s24", Name: "Samsung Galaxy S24", SW: 360, SH: 780, SCR: 20, Notch: "punch", PS: 12, SAT: 28, SAB: 24},
{ID: "pixel-8", Name: "Google Pixel 8", SW: 411, SH: 914, SCR: 22, Notch: "punch", PS: 14, SAT: 28, SAB: 24},
{ID: "iphone-14", Name: "iPhone 14", SW: 390, SH: 844, SCR: 38, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
{ID: "iphone-14-plus", Name: "iPhone 14 Plus", SW: 428, SH: 926, SCR: 40, Notch: "notch", NW: 130, NH: 34, SAT: 47, SAB: 34},
{ID: "iphone-14-pro", Name: "iPhone 14 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-14-pro-max", Name: "iPhone 14 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15", Name: "iPhone 15", SW: 390, SH: 844, SCR: 38, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15-plus", Name: "iPhone 15 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15-pro", Name: "iPhone 15 Pro", SW: 393, SH: 852, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-15-pro-max", Name: "iPhone 15 Pro Max", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-16", Name: "iPhone 16", SW: 390, SH: 844, SCR: 40, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-16-plus", Name: "iPhone 16 Plus", SW: 430, SH: 932, SCR: 42, Notch: "island", NW: 120, NH: 36, NR: 18, SAT: 59, SAB: 34},
{ID: "iphone-16-pro", Name: "iPhone 16 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-16-pro-max", Name: "iPhone 16 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-17", Name: "iPhone 17", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-17-pro", Name: "iPhone 17 Pro", SW: 402, SH: 874, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-17-pro-max", Name: "iPhone 17 Pro Max", SW: 440, SH: 956, SCR: 44, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-air", Name: "iPhone Air", SW: 420, SH: 912, SCR: 42, Notch: "island", NW: 126, NH: 37, NR: 20, SAT: 62, SAB: 34},
{ID: "iphone-se3", Name: "iPhone SE (3rd gen)", SW: 375, SH: 667, SCR: 4, Notch: "none", SAT: 20, SAB: 0},
{ID: "galaxy-s24", Name: "Samsung Galaxy S24", SW: 360, SH: 780, SCR: 20, Notch: "punch", PS: 12, SAT: 28, SAB: 24},
}
func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
@@ -56,7 +61,7 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
<section class="tool-panel input-panel">
<div class="panel-header device-settings-header">
<h3 class="panel-title">Input</h3>
<button id="settingsBtn" class="icon-btn settings-close" title="Device Settings" aria-label="Device Settings">&#9881;</button>
<button id="settingsBtn" class="icon-btn" title="Settings" aria-label="Settings" onclick="openSettingsModal('device-simulator')">&#9881;</button>
</div>
<div class="panel-body">
<div class="control-label">Playable HTML</div>
@@ -71,58 +76,6 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
</div>
</section>
<div id="deviceSettingsModal" class="device-settings-modal" role="dialog" aria-modal="true" aria-labelledby="deviceSettingsTitle">
<div class="device-settings-dialog">
<div class="panel-header device-settings-header">
<h3 id="deviceSettingsTitle" class="panel-title">Device Settings</h3>
<button id="closeSettingsBtn" class="icon-btn settings-close" title="Close" aria-label="Close">&times;</button>
</div>
<div class="panel-body">
<div class="device-list-manager">
<div id="deviceList" class="device-list" role="listbox" aria-label="Devices"></div>
<div class="device-list-controls">
<button id="addDeviceBtn" class="icon-btn" title="Add Device" aria-label="Add Device">+</button>
<button id="removeDeviceBtn" class="icon-btn" title="Delete Selected Devices" aria-label="Delete Selected Devices">-</button>
<button id="moveDeviceUpBtn" class="icon-btn" title="Move Selected Up" aria-label="Move Selected Up">&#8593;</button>
<button id="moveDeviceDownBtn" class="icon-btn" title="Move Selected Down" aria-label="Move Selected Down">&#8595;</button>
</div>
</div>
<div id="deviceSettingsStatus" class="device-settings-status"></div>
<div id="deviceForm" class="device-form">
<div class="device-form-grid">
<label class="full">Name<input id="deviceNameInput" type="text" /></label>
<label>Width<input id="deviceSwInput" type="text" inputmode="numeric" /></label>
<label>Height<input id="deviceShInput" type="text" inputmode="numeric" /></label>
<label>Corner Radius<input id="deviceScrInput" type="text" inputmode="numeric" /></label>
<label>Notch
<select id="deviceNotchInput">
<option value="none">none</option>
<option value="notch">notch</option>
<option value="island">island</option>
<option value="punch">punch</option>
</select>
</label>
<label>Notch Width<input id="deviceNwInput" type="text" inputmode="numeric" /></label>
<label>Notch Height<input id="deviceNhInput" type="text" inputmode="numeric" /></label>
<label>Notch Radius<input id="deviceNrInput" type="text" inputmode="numeric" /></label>
<label>Punch Size<input id="devicePsInput" type="text" inputmode="numeric" /></label>
<label>Safe Top<input id="deviceSatInput" type="text" inputmode="numeric" /></label>
<label>Safe Bottom<input id="deviceSabInput" type="text" inputmode="numeric" /></label>
</div>
<div class="action-row">
<button id="saveDeviceBtn">Save Device</button>
<button id="cancelDeviceBtn" class="secondary">Cancel</button>
</div>
</div>
<div class="device-settings-footer">
<button id="restoreDevicesBtn" class="secondary">Restore Defaults</button>
<button id="importDevicesBtn" class="secondary">Import Devices</button>
<button id="exportDevicesBtn" class="secondary">Export Devices</button>
</div>
<input id="importDevicesInput" type="file" accept="application/json,.json" style="display:none" />
</div>
</div>
</div>
<section class="sim-panel" id="simPanel">
<div class="panel-header sim-header">
@@ -192,99 +145,6 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
justify-content: space-between;
gap: 8px;
}
.device-settings-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(0,0,0,0.48);
}
.device-settings-modal.active { display: flex; }
.device-settings-dialog {
width: min(560px, 100%);
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: #1e1e1e;
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
overflow: hidden;
}
.device-list-manager {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
gap: 8px;
}
.device-list {
min-height: 220px;
max-height: 320px;
overflow: auto;
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: rgba(128,128,128,0.08);
}
.device-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
border-bottom: 1px solid var(--tool-border);
}
.device-row:last-child { border-bottom: 0; }
.device-row.active { background: rgba(0,127,212,0.16); }
.device-row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row-size {
color: #a7a7a7;
font-size: 11px;
white-space: nowrap;
}
.device-list-controls {
display: flex;
flex-direction: column;
gap: 6px;
}
.device-settings-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.device-settings-footer button { min-height: 30px; }
.device-settings-status {
margin-top: 10px;
color: #a7a7a7;
font-size: 12px;
min-height: 18px;
}
.device-form {
display: none;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--tool-border);
}
.device-form.active { display: block; }
.device-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.device-form-grid label {
display: grid;
gap: 4px;
color: #a7a7a7;
font-size: 11px;
}
.device-form-grid .full { grid-column: 1 / -1; }
.settings-close { margin-left: auto; }
#previewArea {
flex: 1 1 0;
min-height: 0;
@@ -316,9 +176,8 @@ func DeviceSimulatorPage(w http.ResponseWriter, r *http.Request) {
</style>
<script>
const DEFAULT_DEVICES = ` + string(devicesJSON) + `;
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
const LANGUAGES = [
let DEFAULT_DEVICES = ` + string(devicesJSON) + `;
let LANGUAGES = [
{ name: 'English', code: 'en' },
{ name: 'Chinese (Simplified)', code: 'zh-hans' },
{ name: 'Chinese (Traditional)', code: 'zh-hant' },
@@ -330,7 +189,7 @@ const LANGUAGES = [
{ name: 'Russian', code: 'ru' },
{ name: 'Spanish', code: 'es' }
];
let devices = loadDevices();
let devices = DEFAULT_DEVICES.slice();
let device = devices[0] || DEFAULT_DEVICES[0];
let language = 'en';
let landscape = false;
@@ -370,94 +229,6 @@ languageSelect.addEventListener('change', () => {
language = languageSelect.value || LANGUAGES[0].code;
if (currentContent) loadContent(currentContent);
});
document.getElementById('addDeviceBtn').addEventListener('click', addDevice);
document.getElementById('removeDeviceBtn').addEventListener('click', removeCurrentDevice);
document.getElementById('importDevicesBtn').addEventListener('click', () => {
document.getElementById('importDevicesInput').click();
});
document.getElementById('exportDevicesBtn').addEventListener('click', exportDevices);
document.getElementById('importDevicesInput').addEventListener('change', importDevices);
document.getElementById('restoreDevicesBtn').addEventListener('click', restoreDefaultDevices);
document.getElementById('settingsBtn').addEventListener('click', openDeviceSettings);
document.getElementById('closeSettingsBtn').addEventListener('click', closeDeviceSettings);
document.getElementById('saveDeviceBtn').addEventListener('click', saveDeviceForm);
document.getElementById('cancelDeviceBtn').addEventListener('click', hideDeviceForm);
document.getElementById('moveDeviceUpBtn').addEventListener('click', moveSelectedDevicesUp);
document.getElementById('moveDeviceDownBtn').addEventListener('click', moveSelectedDevicesDown);
document.getElementById('deviceSettingsModal').addEventListener('click', (event) => {
if (event.target === event.currentTarget) closeDeviceSettings();
});
function openDeviceSettings() {
document.getElementById('deviceSettingsModal').classList.add('active');
renderDeviceList();
setDeviceSettingsStatus('');
}
function closeDeviceSettings() {
document.getElementById('deviceSettingsModal').classList.remove('active');
hideDeviceForm();
}
function setDeviceSettingsStatus(message) {
document.getElementById('deviceSettingsStatus').textContent = message || '';
}
function showDeviceForm() {
const source = device || DEFAULT_DEVICES[0];
document.getElementById('deviceNameInput').value = source.name ? source.name + ' Copy' : 'Custom Device';
document.getElementById('deviceSwInput').value = String(source.sw || 390);
document.getElementById('deviceShInput').value = String(source.sh || 844);
document.getElementById('deviceScrInput').value = String(source.scr || 40);
document.getElementById('deviceNotchInput').value = source.notch || 'none';
document.getElementById('deviceNwInput').value = source.nw === undefined ? '' : String(source.nw);
document.getElementById('deviceNhInput').value = source.nh === undefined ? '' : String(source.nh);
document.getElementById('deviceNrInput').value = source.nr === undefined ? '' : String(source.nr);
document.getElementById('devicePsInput').value = source.ps === undefined ? '' : String(source.ps);
document.getElementById('deviceSatInput').value = String(source.sat || 0);
document.getElementById('deviceSabInput').value = String(source.sab || 0);
document.getElementById('deviceForm').classList.add('active');
setDeviceSettingsStatus('');
}
function hideDeviceForm() {
document.getElementById('deviceForm').classList.remove('active');
}
function formOptionalNumber(id) {
const value = document.getElementById(id).value.trim();
return value === '' ? undefined : Number(value);
}
function saveDeviceForm() {
const name = document.getElementById('deviceNameInput').value.trim();
const created = normalizeDevice({
id: (slugify(name) || 'custom-device') + '-' + Date.now(),
name: name,
sw: Number(document.getElementById('deviceSwInput').value),
sh: Number(document.getElementById('deviceShInput').value),
scr: Number(document.getElementById('deviceScrInput').value),
notch: document.getElementById('deviceNotchInput').value,
nw: formOptionalNumber('deviceNwInput'),
nh: formOptionalNumber('deviceNhInput'),
nr: formOptionalNumber('deviceNrInput'),
ps: formOptionalNumber('devicePsInput'),
sat: Number(document.getElementById('deviceSatInput').value),
sab: Number(document.getElementById('deviceSabInput').value)
}, devices.length);
if (!created) {
setDeviceSettingsStatus('Enter at least a valid width and height.');
return;
}
devices.push(created);
saveDevices();
populateDeviceSelect(created.id);
renderDeviceList([created.id]);
hideDeviceForm();
setDeviceSettingsStatus('Added ' + created.name + '.');
renderScreen();
if (currentContent) loadContent(currentContent);
}
function populateDeviceSelect(selectedId) {
sel.innerHTML = '';
@@ -471,7 +242,6 @@ function populateDeviceSelect(selectedId) {
const index = foundIndex >= 0 ? foundIndex : 0;
sel.value = String(index);
device = devices[index] || DEFAULT_DEVICES[0];
renderDeviceList(getCheckedDeviceIds());
}
function populateLanguageSelect() {
@@ -485,75 +255,6 @@ function populateLanguageSelect() {
languageSelect.value = language;
}
function renderDeviceList(checkedIds) {
const list = document.getElementById('deviceList');
if (!list) return;
const checked = new Set(checkedIds || []);
list.innerHTML = '';
devices.forEach((d, i) => {
const row = document.createElement('label');
row.className = 'device-row' + (d.id === device.id ? ' active' : '');
row.setAttribute('role', 'option');
row.title = d.name;
const input = document.createElement('input');
input.type = 'checkbox';
input.value = d.id;
input.checked = checked.has(d.id);
const name = document.createElement('span');
name.className = 'device-row-name';
name.textContent = d.name;
const size = document.createElement('span');
size.className = 'device-row-size';
size.textContent = d.sw + ' x ' + d.sh;
row.append(input, name, size);
row.addEventListener('dblclick', () => {
populateDeviceSelect(d.id);
renderScreen();
if (currentContent) loadContent(currentContent);
});
list.appendChild(row);
});
}
function getCheckedDeviceIds() {
return Array.from(document.querySelectorAll('#deviceList input[type="checkbox"]:checked')).map(input => input.value);
}
function loadDevices() {
try {
const raw = localStorage.getItem(DEVICES_STORAGE_KEY);
if (raw) {
const parsed = normalizeDeviceList(JSON.parse(raw));
if (parsed.length) return parsed;
}
} catch {}
return DEFAULT_DEVICES.map(d => Object.assign({}, d));
}
function saveDevices() {
localStorage.setItem(DEVICES_STORAGE_KEY, JSON.stringify(devices));
}
function cloneDefaultDevices() {
return DEFAULT_DEVICES.map(d => Object.assign({}, d));
}
function restoreDefaultDevices() {
const existingIds = new Set(devices.map(d => d.id));
const restored = cloneDefaultDevices().filter(d => !existingIds.has(d.id));
if (!restored.length) {
setDeviceSettingsStatus('All default devices are already present.');
return;
}
devices = devices.concat(restored);
saveDevices();
populateDeviceSelect(restored[0].id);
hideDeviceForm();
renderDeviceList(restored.map(d => d.id));
setDeviceSettingsStatus('Restored ' + restored.length + ' default device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
}
function normalizeDeviceList(value) {
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
@@ -592,107 +293,13 @@ function slugify(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function addDevice() {
showDeviceForm();
function sortDevicesByName(arr) {
return arr.slice().sort(function(a, b) {
const na = a.name.toLowerCase(), nb = b.name.toLowerCase();
return na < nb ? -1 : na > nb ? 1 : 0;
});
}
function removeCurrentDevice() {
const selectedIds = getCheckedDeviceIds();
if (!selectedIds.length) {
setDeviceSettingsStatus('Select one or more devices to delete.');
return;
}
if (devices.length - selectedIds.length < 1) {
setDeviceSettingsStatus('At least one device is required.');
return;
}
const selected = new Set(selectedIds);
const oldDeviceId = device.id;
const removedCount = selectedIds.length;
devices = devices.filter(d => !selected.has(d.id));
saveDevices();
const nextDevice = devices.find(d => d.id === oldDeviceId) || devices[0];
populateDeviceSelect(nextDevice.id);
renderDeviceList([]);
setDeviceSettingsStatus('Deleted ' + removedCount + ' device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
}
function moveSelectedDevicesUp() {
moveSelectedDevices(-1);
}
function moveSelectedDevicesDown() {
moveSelectedDevices(1);
}
function moveSelectedDevices(direction) {
const selectedIds = getCheckedDeviceIds();
if (!selectedIds.length) {
setDeviceSettingsStatus('Select one or more devices to move.');
return;
}
const selected = new Set(selectedIds);
if (direction < 0) {
for (let i = 1; i < devices.length; i++) {
if (selected.has(devices[i].id) && !selected.has(devices[i - 1].id)) {
const tmp = devices[i - 1];
devices[i - 1] = devices[i];
devices[i] = tmp;
}
}
} else {
for (let i = devices.length - 2; i >= 0; i--) {
if (selected.has(devices[i].id) && !selected.has(devices[i + 1].id)) {
const tmp = devices[i + 1];
devices[i + 1] = devices[i];
devices[i] = tmp;
}
}
}
saveDevices();
populateDeviceSelect(device.id);
renderDeviceList(selectedIds);
setDeviceSettingsStatus('Moved ' + selectedIds.length + ' device(s).');
}
function exportDevices() {
const payload = JSON.stringify({ version: 1, devices: devices }, null, 2);
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hpl-device-simulator-devices.json';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function importDevices(event) {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = normalizeDeviceList(JSON.parse(String(reader.result || '')));
if (!imported.length) {
setDeviceSettingsStatus('No valid devices found.');
return;
}
devices = imported;
saveDevices();
populateDeviceSelect(devices[0].id);
renderDeviceList([]);
setDeviceSettingsStatus('Imported ' + imported.length + ' device(s).');
renderScreen();
if (currentContent) loadContent(currentContent);
} catch (e) {
setDeviceSettingsStatus('Could not import devices: ' + (e.message || e));
}
};
reader.readAsText(file);
}
document.getElementById('orientBtn').addEventListener('click', (event) => {
landscape = !landscape;
renderOrientationButton();
@@ -904,6 +511,34 @@ function updateScale() {
}
window.addEventListener('resize', updateScale);
new ResizeObserver(updateScale).observe(document.getElementById('previewArea'));
(function fetchRemoteData() {
fetch('/api/device-simulator/remoteDevices').then(r => r.json()).then(data => {
if (data.error) return;
const newDefaults = normalizeDeviceList(data);
if (!newDefaults.length) return;
DEFAULT_DEVICES = sortDevicesByName(newDefaults);
devices = DEFAULT_DEVICES.slice();
const currentId = device ? device.id : null;
const stillExists = currentId && devices.some(d => d.id === currentId);
populateDeviceSelect(stillExists ? currentId : devices[0].id);
renderScreen();
if (currentContent) loadContent(currentContent);
}).catch(() => {});
fetch('/api/device-simulator/remoteLanguages').then(r => r.json()).then(data => {
if (data.error) return;
const list = Array.isArray(data) ? data : (data && Array.isArray(data.languages) ? data.languages : []);
const valid = list.filter(l => l && l.name && l.code);
if (!valid.length) return;
const prev = language;
LANGUAGES = valid;
populateLanguageSelect();
const stillValid = LANGUAGES.some(l => l.code === prev);
language = stillValid ? prev : LANGUAGES[0].code;
languageSelect.value = language;
}).catch(() => {});
})();
</script>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/device-simulator", "Device Simulator", body)))
@@ -1002,6 +637,120 @@ func DeviceSimulatorPreview(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(data)
}
var reGDriveFileIDSim = regexp.MustCompile(`/file/d/([a-zA-Z0-9_-]+)`)
var reGDriveIDParamSim = regexp.MustCompile(`[?&]id=([a-zA-Z0-9_-]+)`)
func resolveSimGDriveURL(rawURL string) string {
if m := reGDriveFileIDSim.FindStringSubmatch(rawURL); m != nil {
return "https://drive.google.com/uc?export=download&id=" + m[1]
}
if m := reGDriveIDParamSim.FindStringSubmatch(rawURL); m != nil {
return "https://drive.google.com/uc?export=download&id=" + m[1]
}
return rawURL
}
type simRemoteCache struct {
mu sync.Mutex
devices string
languages string
devURL string
langURL string
}
var simCache simRemoteCache
func fetchRemoteJSON(rawURL string) (string, error) {
url := resolveSimGDriveURL(rawURL)
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
loc := resp.Header.Get("Location")
if loc != "" {
return fetchRemoteJSON(loc)
}
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var v any
if err := json.Unmarshal(body, &v); err != nil {
return "", fmt.Errorf("invalid JSON: %w", err)
}
return string(body), nil
}
func DeviceSimulatorRemoteDevicesEndpoint(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
url := strings.TrimSpace(cfg.DeviceSimulator.DevicesURL)
if url == "" {
writeJSON(w, map[string]any{"error": "devicesUrl not configured"})
return
}
simCache.mu.Lock()
if simCache.devURL == url && simCache.devices != "" {
cached := simCache.devices
simCache.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(cached))
return
}
simCache.mu.Unlock()
data, err := fetchRemoteJSON(url)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
simCache.mu.Lock()
simCache.devURL = url
simCache.devices = data
simCache.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(data))
}
func DeviceSimulatorRemoteLanguagesEndpoint(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
url := strings.TrimSpace(cfg.DeviceSimulator.LanguagesURL)
if url == "" {
writeJSON(w, map[string]any{"error": "languagesUrl not configured"})
return
}
simCache.mu.Lock()
if simCache.langURL == url && simCache.languages != "" {
cached := simCache.languages
simCache.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(cached))
return
}
simCache.mu.Unlock()
data, err := fetchRemoteJSON(url)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
simCache.mu.Lock()
simCache.langURL = url
simCache.languages = data
simCache.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(data))
}
func injectDeviceSimulatorMuteBridge(html string) string {
bridge := `<script>(function(){if(window.__hplMuteBridgeInstalled)return;window.__hplMuteBridgeInstalled=true;var muted=false,media=[],contexts=[],gains=[],destinations=[];function rememberMedia(el){if(!el||media.indexOf(el)>=0)return;media.push(el);applyMedia(el);}function applyMedia(el){try{el.muted=muted;if(muted){if(el.__hplVolume===undefined)el.__hplVolume=el.volume;el.volume=0;}else{if(el.__hplVolume!==undefined)el.volume=el.__hplVolume;}}catch(e){}}function apply(){for(var i=0;i<media.length;i++)applyMedia(media[i]);for(var j=0;j<gains.length;j++){try{gains[j].gain.value=muted?0:1;}catch(e){}}}window.__hplSetMuted=function(v){muted=!!v;window.__hplMuted=muted;apply();};window.addEventListener("message",function(e){if(e.data&&e.data.type==="hplDeviceSimulatorMute")window.__hplSetMuted(e.data.muted);});function patchAudioContext(name){var Orig=window[name];if(!Orig||Orig.__hplPatched)return;function Wrapped(){var ctx=arguments.length?new Orig(arguments[0]):new Orig();try{var gain=ctx.createGain();gain.gain.value=muted?0:1;gain.connect(ctx.destination);contexts.push(ctx);gains.push(gain);destinations.push(ctx.destination);}catch(e){}return ctx;}Wrapped.prototype=Orig.prototype;try{Object.setPrototypeOf(Wrapped,Orig);}catch(e){}Wrapped.__hplPatched=true;window[name]=Wrapped;}try{var origConnect=window.AudioNode&&window.AudioNode.prototype&&window.AudioNode.prototype.connect;if(origConnect&&!origConnect.__hplPatched){var patched=function(target){var args=Array.prototype.slice.call(arguments);var idx=destinations.indexOf(args[0]);if(idx>=0&&gains[idx])args[0]=gains[idx];return origConnect.apply(this,args);};patched.__hplPatched=true;window.AudioNode.prototype.connect=patched;}}catch(e){}try{patchAudioContext("AudioContext");patchAudioContext("webkitAudioContext");}catch(e){}try{var play=window.HTMLMediaElement&&window.HTMLMediaElement.prototype&&window.HTMLMediaElement.prototype.play;if(play&&!play.__hplPatched){var p=function(){rememberMedia(this);return play.apply(this,arguments);};p.__hplPatched=true;window.HTMLMediaElement.prototype.play=p;}}catch(e){}function scan(root){try{(root||document).querySelectorAll("audio,video").forEach(rememberMedia);}catch(e){}}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){scan(document);});else scan(document);try{new MutationObserver(function(ms){ms.forEach(function(m){Array.prototype.forEach.call(m.addedNodes,function(n){if(n.nodeType!==1)return;if(n.matches&&n.matches("audio,video"))rememberMedia(n);scan(n);});});}).observe(document.documentElement,{childList:true,subtree:true});}catch(e){}})();</script>`
lower := strings.ToLower(html)

View File

@@ -20,9 +20,12 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
}
body := `
<header class="tool-header">
<h2 class="tool-title">HPL Toolbox</h2>
<p class="tool-description">Standalone build. Pick a tool to open.</p>
<header class="tool-header" style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;">
<div>
<h2 class="tool-title">HPL Toolbox</h2>
<p class="tool-description">Standalone build. Pick a tool to open.</p>
</div>
<button type="button" onclick="openSettingsModal()" title="Settings" aria-label="Settings" style="flex-shrink:0;width:32px;height:32px;padding:0;display:grid;place-items:center;font-size:18px;background:#3a3d41;color:#ddd;border:1px solid #444;border-radius:4px;cursor:pointer;">&#9881;</button>
</header>
<div class="home-tools">
` + items.String() + `

Binary file not shown.

View File

@@ -311,6 +311,27 @@ const SharedCSS = `
.err { color: #f48771; white-space: pre-wrap; }
.ok { color: #89d185; }
.hint { font-size: 12px; opacity: 0.7; }
.settings-modal { position:fixed; inset:0; z-index:200; display:none; align-items:center; justify-content:center; padding:18px; background:rgba(0,0,0,.48); }
.settings-modal.active { display:flex; }
.settings-modal-dialog { width:min(840px,94vw); min-height:min(560px,80vh); max-height:calc(100vh - 36px); display:flex; flex-direction:column; border:1px solid var(--tool-border); border-radius:var(--tool-radius); background:#1e1e1e; box-shadow:0 16px 48px rgba(0,0,0,.45); overflow:hidden; }
.settings-modal-dialog > .panel-header { flex-shrink:0; }
.settings-modal-body { overflow-y:auto; padding:12px; flex:1 1 0; min-height:0; }
.settings-modal-body .tool-panel { margin-bottom:var(--tool-gap-lg); scroll-margin-top:8px; }
@keyframes settings-flash { 0%,100%{box-shadow:none} 25%,75%{box-shadow:0 0 0 2px #007fd4} }
.settings-highlight { animation:settings-flash 1.2s ease forwards; }
.settings-grid { display:flex; flex-direction:column; gap:12px; }
.settings-label { display:flex; flex-direction:column; gap:5px; color:#a7a7a7; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; max-width:100%; }
.settings-label input[type=text] { width:100%; }
.settings-label.settings-checkbox { flex-direction:row; align-items:center; gap:8px; text-transform:none; letter-spacing:0; font-size:13px; font-weight:400; color:#ddd; cursor:pointer; }
.settings-hint { color:#a7a7a7; font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; }
.settings-save-status { font-size:12px; color:#89d185; min-height:20px; }
.settings-save-status.err { color:#f48771; }
.advanced-details { margin-bottom:var(--tool-gap-lg); }
.advanced-summary { display:inline-flex; align-items:center; gap:6px; margin-bottom:10px; padding:5px 10px; color:#a7a7a7; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; border:1px solid var(--tool-border); border-radius:var(--tool-radius); background:rgba(128,128,128,.08); cursor:pointer; user-select:none; list-style:none; }
.advanced-summary::-webkit-details-marker { display:none; }
.advanced-summary::before { content:"▶"; font-size:9px; transition:transform 120ms ease; }
details[open] > .advanced-summary::before { transform:rotate(90deg); }
.advanced-summary:hover { color:#ddd; border-color:#555; }
`
const SharedDropZoneScript = `
@@ -483,7 +504,7 @@ var navItems = []navItem{
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants"},
{Path: "/device-simulator", Label: "Device Simulator", Description: "Preview HTML playables inside mobile device frames"},
}
@@ -555,6 +576,7 @@ func navInitials(label string) string {
func Page(activePath, title, body string) string {
cfg := LoadConfig()
betaToolsEnabled := cfg.BetaToolsEnabled
randomizeUA := cfg.Applovin.RandomizeUserAgent == nil || *cfg.Applovin.RandomizeUserAgent
var tabs strings.Builder
for _, n := range visibleNavItems(betaToolsEnabled) {
cls := ""
@@ -730,7 +752,156 @@ if (sessionStorage.getItem('hplToolboxUpdateChecked') !== 'true') {
sessionStorage.setItem('hplToolboxUpdateChecked', 'true');
checkForUpdates(true);
}
function openSettingsModal(sectionId) {
document.getElementById('settingsModal').classList.add('active');
if (sectionId) {
const el = document.getElementById('settings-section-' + sectionId);
if (el) {
const details = el.closest('details');
if (details) details.open = true;
setTimeout(function() {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
el.classList.add('settings-highlight');
setTimeout(function() { el.classList.remove('settings-highlight'); }, 1300);
}, 50);
}
}
}
function closeSettingsModal() {
document.getElementById('settingsModal').classList.remove('active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('settingsModal').classList.contains('active')) closeSettingsModal();
});
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('settingsModalClose').addEventListener('click', closeSettingsModal);
document.getElementById('settingsModal').addEventListener('click', function(e) {
if (e.target === this) closeSettingsModal();
});
document.getElementById('settingsModalForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.currentTarget;
const status = document.getElementById('settingsModalStatus');
const getVal = (name) => (form.querySelector('[name="' + name + '"]') || {}).value || '';
const getChecked = (name) => !!(form.querySelector('[name="' + name + '"]') || {}).checked;
const payload = {
plec: { uploadUrl: getVal('plec.uploadUrl'), originUrl: getVal('plec.originUrl'), previewBase: getVal('plec.previewBase'), skipExistsCheck: getChecked('plec.skipExistsCheck') },
applovin: { cookie: getVal('applovin.cookie'), delayMs: parseInt(getVal('applovin.delayMs'), 10) || 0, randomizeUserAgent: getChecked('applovin.randomizeUserAgent') },
sendToMobile: { port: parseInt(getVal('sendToMobile.port'), 10) || 0 },
projectInitManifestUrl: getVal('projectInitManifestUrl'),
deviceSimulator: { devicesUrl: getVal('deviceSimulator.devicesUrl'), languagesUrl: getVal('deviceSimulator.languagesUrl') },
};
try {
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const j = await r.json();
if (j.ok) {
status.textContent = 'Saved.';
status.className = 'settings-save-status';
setTimeout(closeSettingsModal, 900);
} else {
status.textContent = 'Error: ' + (j.error || 'unknown');
status.className = 'settings-save-status err';
}
} catch (err) {
status.textContent = 'Error: ' + (err.message || err);
status.className = 'settings-save-status err';
}
setTimeout(function() { status.textContent = ''; status.className = 'settings-save-status'; }, 4000);
});
});
</script>
<div id="settingsModal" class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="settings-modal-dialog">
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<h3 id="settingsModalTitle" class="panel-title">Settings</h3>
<button id="settingsModalClose" type="button" class="icon-btn" style="margin-left:auto;" title="Close" aria-label="Close">&times;</button>
</div>
<div class="settings-modal-body">
<form id="settingsModalForm">
<section id="settings-section-device-simulator" class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Device Simulator</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">Devices JSON URL
<input type="text" name="deviceSimulator.devicesUrl" value="` + htmlEsc(cfg.DeviceSimulator.DevicesURL) + `" placeholder="Google Drive share link or direct JSON URL" />
<span class="settings-hint">Remote devices.json — fetched on open, falls back to built-in list if empty or unreachable.</span>
</label>
<label class="settings-label">Languages JSON URL
<input type="text" name="deviceSimulator.languagesUrl" value="` + htmlEsc(cfg.DeviceSimulator.LanguagesURL) + `" placeholder="Google Drive share link or direct JSON URL" />
<span class="settings-hint">Remote languages.json — fetched on open, falls back to built-in list if empty or unreachable.</span>
</label>
</div>
</div>
</section>
<section id="settings-section-initialize-project" class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Initialize Project</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">Manifest URL
<input type="text" name="projectInitManifestUrl" value="` + htmlEsc(cfg.ProjectInitManifestURL) + `" placeholder="Google Drive share link or direct JSON URL" />
</label>
</div>
</div>
</section>
<details class="advanced-details">
<summary class="advanced-summary">Advanced</summary>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">PLEC Upload</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">Upload URL
<input type="text" name="plec.uploadUrl" value="` + htmlEsc(cfg.Plec.UploadUrl) + `" />
</label>
<label class="settings-label">Origin URL
<input type="text" name="plec.originUrl" value="` + htmlEsc(cfg.Plec.OriginUrl) + `" />
</label>
<label class="settings-label">Preview Base URL
<input type="text" name="plec.previewBase" value="` + htmlEsc(cfg.Plec.PreviewBase) + `" />
</label>
<label class="settings-label settings-checkbox">
<input type="checkbox" name="plec.skipExistsCheck" ` + checkedAttr(cfg.Plec.SkipExistsCheck) + ` />
Skip duplicate filename check
</label>
</div>
</div>
</section>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">AppLovin Playable Preview</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">__Host-APPLOVINID Cookie
<input type="text" name="applovin.cookie" value="` + htmlEsc(cfg.Applovin.Cookie) + `" placeholder="Log in at p.applov.in and copy from DevTools" />
</label>
<label class="settings-label">Upload delay (ms)
<input type="text" name="applovin.delayMs" value="` + intStr(cfg.Applovin.DelayMs) + `" inputmode="numeric" />
</label>
<label class="settings-label settings-checkbox">
<input type="checkbox" name="applovin.randomizeUserAgent" ` + checkedAttr(randomizeUA) + ` />
Randomize User-Agent on every request
</label>
</div>
</div>
</section>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Send To Mobile</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">LAN server port
<input type="text" name="sendToMobile.port" value="` + intStr(cfg.SendToMobile.Port) + `" inputmode="numeric" />
<span class="settings-hint">0 = OS-assigned random port</span>
</label>
</div>
</div>
</section>
</details>
<div class="action-row" style="margin-top:4px;">
<button type="submit">Save Settings</button>
<span id="settingsModalStatus" class="settings-save-status"></span>
</div>
</form>
</div>
</div>
</div>
</body></html>`
}

View File

@@ -116,6 +116,7 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("GET /mobile", MobilePage)
mux.HandleFunc("GET /playworks", PlayworksPage)
mux.HandleFunc("GET /device-simulator", DeviceSimulatorPage)
mux.HandleFunc("GET /settings", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) })
mux.HandleFunc("GET /changelog", ChangelogPage)
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
@@ -178,6 +179,11 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /api/device-simulator/pick", DeviceSimulatorPick)
mux.HandleFunc("POST /api/device-simulator/load", DeviceSimulatorLoad)
mux.HandleFunc("GET /api/device-simulator/preview/", DeviceSimulatorPreview)
mux.HandleFunc("GET /api/device-simulator/remoteDevices", DeviceSimulatorRemoteDevicesEndpoint)
mux.HandleFunc("GET /api/device-simulator/remoteLanguages", DeviceSimulatorRemoteLanguagesEndpoint)
// Settings
mux.HandleFunc("POST /api/settings", SettingsSaveEndpoint)
return mux
}

View File

@@ -16,6 +16,8 @@ type PlecConfig struct {
OriginUrl string `json:"originUrl"`
PreviewBase string `json:"previewBase"`
SkipExistsCheck bool `json:"skipExistsCheck"`
WpUsername string `json:"wpUsername"`
WpPassword string `json:"wpPassword"`
}
type ApplovinConfig struct {
@@ -28,13 +30,19 @@ type SendToMobileConfig struct {
Port int `json:"port"`
}
type DeviceSimulatorConfig struct {
DevicesURL string `json:"devicesUrl"`
LanguagesURL string `json:"languagesUrl"`
}
type AppConfig struct {
Plec PlecConfig `json:"plec"`
Applovin ApplovinConfig `json:"applovin"`
SendToMobile SendToMobileConfig `json:"sendToMobile"`
BetaToolsEnabled bool `json:"betaToolsEnabled"`
LastPickDir string `json:"lastPickDir,omitempty"`
ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"`
Plec PlecConfig `json:"plec"`
Applovin ApplovinConfig `json:"applovin"`
SendToMobile SendToMobileConfig `json:"sendToMobile"`
DeviceSimulator DeviceSimulatorConfig `json:"deviceSimulator"`
BetaToolsEnabled bool `json:"betaToolsEnabled"`
LastPickDir string `json:"lastPickDir,omitempty"`
ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"`
}
var configMu sync.Mutex
@@ -63,9 +71,15 @@ func defaultConfig() AppConfig {
OriginUrl: "http://167.99.227.249",
PreviewBase: "https://playable.applovindemo.com/phaser/",
SkipExistsCheck: false,
WpUsername: "pm",
WpPassword: "nRLz#LV@y#)q@CW@z8fGEPyI",
},
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
SendToMobile: SendToMobileConfig{Port: 0},
DeviceSimulator: DeviceSimulatorConfig{
DevicesURL: "https://drive.google.com/file/d/1w04wviCmgNqtDCO1GLyYfwEeWpjBPGJ4/view?usp=drive_link",
LanguagesURL: "https://drive.google.com/file/d/1I3ZiI8CLU2JxYZEtWCz6Vr0r8Uj2732f/view?usp=drive_link",
},
}
}

View File

@@ -7,6 +7,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
urlpkg "net/url"
"os"
"path/filepath"
@@ -23,7 +24,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
</header>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
<select id="serverSelect">
<option value="legacy">167.99.227.249 (Default)</option>
<option value="wordpress">20.255.60.183 (Backup)</option>
</select>
</div>
<div class="panel-body">
<div class="control-group">
<div id="dropZone" class="drop-zone">
@@ -54,10 +61,15 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
<style>
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
#rows input[type=text] { width: 100%; }
.panel-header { display: flex; align-items: center; }
#serverSelect { margin-left:auto; padding:3px 6px; font-size:12px; cursor:pointer; }
</style>
<script>
const PAGE_SIZE = 5;
const serverSelect = document.getElementById('serverSelect');
serverSelect.value = localStorage.getItem('plec.lastServer') || 'legacy';
serverSelect.addEventListener('change', () => localStorage.setItem('plec.lastServer', serverSelect.value));
let nextId = 1;
let rows = [];
let page = 0;
@@ -206,7 +218,7 @@ document.getElementById('upload').addEventListener('click', async () => {
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
body: JSON.stringify({ rows, server: serverSelect.value }),
});
await readJSONStream(res, (msg) => {
if (msg.type === 'status') {
@@ -299,7 +311,8 @@ type plecRow struct {
func PlecUpload(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct {
Rows []plecRow `json:"rows"`
Rows []plecRow `json:"rows"`
Server string `json:"server"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
@@ -335,6 +348,14 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
return
}
if req.Server == "wordpress" {
plecUploadWordPress(send, cfg, valid)
} else {
plecUploadLegacy(send, cfg, valid)
}
}
func plecUploadLegacy(send func(any), cfg PlecConfig, valid []plecRow) {
stamp := time.Now().Format("20060102150405")
var buf bytes.Buffer
@@ -357,9 +378,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
iter := encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
@@ -408,6 +427,171 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
}
func wpAuthenticate(send func(any), baseUrl, username, password string) (*http.Client, string, error) {
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
// Pre-seed the test cookie WordPress checks for
testURL, _ := urlpkg.Parse(baseUrl)
jar.SetCookies(testURL, []*http.Cookie{{Name: "wordpress_test_cookie", Value: "WP Cookie check"}})
send(map[string]any{"type": "status", "message": "Authenticating with backup server..."})
form := urlpkg.Values{
"log": {username},
"pwd": {password},
"wp-submit": {"Log In"},
"redirect_to": {"/wp-admin/"},
"testcookie": {"1"},
}
loginReq, _ := http.NewRequest("POST", baseUrl+"/wp-login.php", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginReq.Header.Set("Origin", baseUrl)
loginReq.Header.Set("Referer", baseUrl+"/wp-login.php")
loginReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
loginResp, err := client.Do(loginReq)
if err != nil {
return nil, "", fmt.Errorf("login request failed: %w", err)
}
io.ReadAll(loginResp.Body)
loginResp.Body.Close()
// After following redirects, check final URL — wp-admin means success
if strings.Contains(loginResp.Request.URL.String(), "wp-login") {
return nil, "", fmt.Errorf("WordPress login failed — check wpUsername / wpPassword in settings")
}
send(map[string]any{"type": "status", "message": "Fetching upload token..."})
pageReq, _ := http.NewRequest("GET", baseUrl+"/file-upload/", nil)
pageReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
pageResp, err := client.Do(pageReq)
if err != nil {
return nil, "", fmt.Errorf("nonce fetch failed: %w", err)
}
pageBody, _ := io.ReadAll(pageResp.Body)
pageResp.Body.Close()
nonceRx := regexp.MustCompile(`"nonce"\s*:\s*"([a-f0-9]+)"`)
m := nonceRx.FindSubmatch(pageBody)
if m == nil {
return nil, "", fmt.Errorf("could not extract nonce from backup server page")
}
return client, string(m[1]), nil
}
func plecUploadWordPress(send func(any), cfg PlecConfig, valid []plecRow) {
const baseUrl = "http://20.255.60.183"
if cfg.WpUsername == "" || cfg.WpPassword == "" {
send(map[string]any{"type": "error", "message": "WordPress credentials not set. Configure wpUsername / wpPassword in settings."})
return
}
client, nonce, err := wpAuthenticate(send, baseUrl, cfg.WpUsername, cfg.WpPassword)
if err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("action", "plec_upload_files")
_ = mw.WriteField("nonce", nonce)
type rowMeta struct {
Field string `json:"field"`
IterationName string `json:"iterationName"`
}
var rowsMeta []rowMeta
for i, row := range valid {
idx := i + 1
fieldName := fmt.Sprintf("file_%d", idx)
sanitized := strings.ReplaceAll(row.Iteration, " ", "")
filename := sanitized + ".html"
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filename)})
data, err := os.ReadFile(row.Path)
if err != nil {
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
return
}
fw, err := createFormFileWithType(mw, fieldName, filename, "text/html")
if err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
if _, err := fw.Write(data); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
rowsMeta = append(rowsMeta, rowMeta{Field: fieldName, IterationName: sanitized})
}
rowsJSON, _ := json.Marshal(rowsMeta)
_ = mw.WriteField("rows", string(rowsJSON))
if err := mw.Close(); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
httpReq, _ := http.NewRequest("POST", baseUrl+"/wp-admin/admin-ajax.php", &buf)
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
httpReq.Header.Set("Origin", baseUrl)
httpReq.Header.Set("Referer", baseUrl+"/file-upload/")
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
httpReq.Header.Set("Accept", "*/*")
resp, err := client.Do(httpReq)
if err != nil {
send(map[string]any{"type": "error", "message": "Network error: " + err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
return
}
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
return
}
var preview string
if data, ok := parsed["data"].(map[string]any); ok {
if files, ok := data["files"].([]any); ok && len(files) > 0 {
params := urlpkg.Values{"theme": {"calcite"}}
nonceRx := regexp.MustCompile(`(?i)\.html?$`)
for i, f := range files {
if fm, ok := f.(map[string]any); ok {
orig, _ := fm["original"].(string)
saved, _ := fm["saved"].(string)
n := nonceRx.ReplaceAllString(orig, "")
params.Set(fmt.Sprintf("n%d", i+1), n)
params.Set(fmt.Sprintf("m%d", i+1), saved)
}
}
preview = baseUrl + "/preview?" + params.Encode()
}
}
if preview == "" {
if v, ok := parsed["preview"].(string); ok {
preview = v
} else if v, ok := parsed["previewURL"].(string); ok {
preview = v
}
}
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
}
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
Text string `json:"text"`

View File

@@ -94,7 +94,6 @@ func getManifestURL() string {
}
func ProjectInitPage(w http.ResponseWriter, r *http.Request) {
manifestURL := getManifestURL()
body := `
<header class="tool-header">
<h2 class="tool-title">Initialize Project</h2>
@@ -107,17 +106,10 @@ func ProjectInitPage(w http.ResponseWriter, r *http.Request) {
<div style="display:flex;gap:4px;align-items:center;">
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:4px;"></span>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">&#8635;</button>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="toggleSettings()">&#9881;</button>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="openSettingsModal('initialize-project')">&#9881;</button>
</div>
</div>
<div class="panel-body">
<div id="settingsGroup" class="control-group" style="display:none;">
<p class="control-label">Manifest URL</p>
<div class="field-row">
<input type="text" id="manifestUrlInput" value="` + htmlEscape(manifestURL) + `" style="flex:1;" placeholder="https://…" />
<button onclick="saveManifestUrl()">Save &amp; Refresh</button>
</div>
</div>
<div class="control-group">
<div id="fileList"></div>
</div>
@@ -139,29 +131,11 @@ func ProjectInitPage(w http.ResponseWriter, r *http.Request) {
<script>
let files = [];
let allSelected = true;
let settingsVisible = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function toggleSettings() {
settingsVisible = !settingsVisible;
document.getElementById('settingsGroup').style.display = settingsVisible ? '' : 'none';
}
async function saveManifestUrl() {
const url = document.getElementById('manifestUrlInput').value.trim();
await fetch('/api/project-init/setManifestUrl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
settingsVisible = false;
document.getElementById('settingsGroup').style.display = 'none';
refresh();
}
async function refresh() {
setStatus('', '');
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest&#8230;</p>';

93
standalone/settings.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
func SettingsSaveEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
Plec struct {
UploadUrl string `json:"uploadUrl"`
OriginUrl string `json:"originUrl"`
PreviewBase string `json:"previewBase"`
SkipExistsCheck bool `json:"skipExistsCheck"`
WpUsername string `json:"wpUsername"`
WpPassword string `json:"wpPassword"`
} `json:"plec"`
Applovin struct {
Cookie string `json:"cookie"`
DelayMs int `json:"delayMs"`
RandomizeUserAgent *bool `json:"randomizeUserAgent"`
} `json:"applovin"`
SendToMobile struct {
Port int `json:"port"`
} `json:"sendToMobile"`
DeviceSimulator struct {
DevicesURL string `json:"devicesUrl"`
LanguagesURL string `json:"languagesUrl"`
} `json:"deviceSimulator"`
ProjectInitManifestURL string `json:"projectInitManifestUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
cfg := LoadConfig()
cfg.Plec.UploadUrl = strings.TrimSpace(req.Plec.UploadUrl)
cfg.Plec.OriginUrl = strings.TrimSpace(req.Plec.OriginUrl)
cfg.Plec.PreviewBase = strings.TrimSpace(req.Plec.PreviewBase)
cfg.Plec.SkipExistsCheck = req.Plec.SkipExistsCheck
cfg.Plec.WpUsername = strings.TrimSpace(req.Plec.WpUsername)
cfg.Plec.WpPassword = req.Plec.WpPassword
cfg.Applovin.Cookie = strings.TrimSpace(req.Applovin.Cookie)
cfg.Applovin.DelayMs = req.Applovin.DelayMs
cfg.Applovin.RandomizeUserAgent = req.Applovin.RandomizeUserAgent
cfg.SendToMobile.Port = req.SendToMobile.Port
cfg.DeviceSimulator.DevicesURL = strings.TrimSpace(req.DeviceSimulator.DevicesURL)
cfg.DeviceSimulator.LanguagesURL = strings.TrimSpace(req.DeviceSimulator.LanguagesURL)
if req.ProjectInitManifestURL != "" {
cfg.ProjectInitManifestURL = strings.TrimSpace(req.ProjectInitManifestURL)
}
// Invalidate remote cache when URLs change
simCache.mu.Lock()
if simCache.devURL != cfg.DeviceSimulator.DevicesURL {
simCache.devURL = ""
simCache.devices = ""
}
if simCache.langURL != cfg.DeviceSimulator.LanguagesURL {
simCache.langURL = ""
simCache.languages = ""
}
simCache.mu.Unlock()
if err := SaveConfig(cfg); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true})
}
func htmlEsc(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&#34;")
return s
}
func checkedAttr(v bool) string {
if v {
return "checked"
}
return ""
}
func intStr(n int) string {
return fmt.Sprintf("%d", n)
}

View File

@@ -53,7 +53,7 @@
"description": "Convert Playworks HTML to per-network variants",
"command": "hplToolbox.openPlayworksConverter",
"path": "/playworks",
"beta": true
"beta": false
},
{
"id": "mintegral",
@@ -70,5 +70,5 @@
"command": "hplToolbox.openDeviceSimulator",
"path": "/device-simulator",
"beta": false
},
}
]