Compare commits
21 Commits
1c445e2771
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 331e49ea29 | |||
| c336f176e6 | |||
| 0e30ed0b36 | |||
| 8e5d56ab1d | |||
| 68d15aa208 | |||
| b254daf06a | |||
| 16de3cc116 | |||
| 5fa1ce949c | |||
| e50b4c2133 | |||
| cebb65fb35 | |||
| 7094e6c3b0 | |||
| 6c09beea43 | |||
| ad6403f3e4 | |||
| 700bb135ad | |||
| 986993a027 | |||
| 9e24342142 | |||
| b71e730482 | |||
| e3f454c5f2 | |||
| 88981d209b | |||
| 2524bacb5d | |||
| 9e919756fd |
131
PROJECT.md
Normal file
131
PROJECT.md
Normal 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 |
|
||||||
95
README.md
95
README.md
@@ -9,7 +9,10 @@ Bundled VS Code extension and standalone tools for playable ad workflows.
|
|||||||
- **Base64 Scanner** - Scan HTML files for external or non-base64 asset references.
|
- **Base64 Scanner** - Scan HTML files for external or non-base64 asset references.
|
||||||
- **Send To Mobile** - Share selected HTML files to a phone over LAN with a QR code.
|
- **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.
|
- **MRAID Checker** - Check HTML files against MRAID requirements and best practices.
|
||||||
- **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta.
|
- **Mintegral Checker** - Validate Mintegral playable ZIPs against PlayTurbo requirements.
|
||||||
|
- **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
|
# Changelog
|
||||||
|
|
||||||
@@ -71,3 +74,93 @@ Fixed
|
|||||||
- Fixed cleanup behavior for temporary dropped files when clearing or removing rows.
|
- Fixed cleanup behavior for temporary dropped files when clearing or removing rows.
|
||||||
- Fixed standalone PLEC Upload Open button using an incorrect/mangled URL by switching to the Windows URL protocol handler.
|
- Fixed standalone PLEC Upload Open button using an incorrect/mangled URL by switching to the Windows URL protocol handler.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**v0.2.0**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Mintegral Checker to the VS Code extension.
|
||||||
|
- Added Mintegral Checker to the standalone app.
|
||||||
|
- Added PlayTurbo-compatible runtime validation for Mintegral playables using the injected preview utility script.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Mintegral Checker is available as a regular tool, not a beta tool.
|
||||||
|
- Standalone navigation now uses a collapsible sidebar with compact tool initials.
|
||||||
|
- Mintegral Checker UI now embeds the playable preview, checklist, runtime events, mute, and reload controls in one view.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.2.1**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Device Simulator to the VS Code extension and standalone app.
|
||||||
|
- Added editable Device Simulator device lists with add, delete, restore defaults, import, and export.
|
||||||
|
- Added drag-and-drop reordering for Device Simulator devices.
|
||||||
|
- Added iPhone 17, iPhone Air, iPhone 17 Pro, and iPhone 17 Pro Max defaults.
|
||||||
|
- Added drag-and-drop tool reordering with persisted order in the VS Code launcher and standalone Home/sidebar.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Device Simulator settings now use a modal list manager with multi-select, add/delete, and move up/down controls.
|
||||||
|
- Standalone Device Simulator sidebar abbreviation is now DS.
|
||||||
|
- Send To Mobile browser preview now opens playables in a delayed full-screen preview wrapper while Download still serves the original HTML.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed Device Simulator mute for HTML media and WebAudio playables.
|
||||||
|
- Fixed Device Simulator Add Device and Remove Device interactions inside embedded webviews.
|
||||||
|
- Fixed Send To Mobile browser preview spending the View button's initial tap as a premature playable click-through while preserving real user-initiated redirects.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.2.2**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added automatic update checking to the VS Code extension and standalone app.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.2.3**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Localization dropdown in Device Simulator.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.2.4**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added Initialize Project tool to the VS Code extension and standalone app.
|
||||||
|
- Downloads team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared manifest.
|
||||||
|
- Manifest is a JSON file hosted on Google Drive or any direct URL, listing files with name, URL, and description.
|
||||||
|
- Manifest URL is configurable per-user; defaults to the shared team Google Drive manifest.
|
||||||
|
- Google Drive share links are automatically converted to direct download URLs.
|
||||||
|
- Files panel shows a checklist loaded from the manifest with per-file Open button and Refresh/Configure controls.
|
||||||
|
- 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.
|
||||||
|
```
|
||||||
155
TODO.md
Normal file
155
TODO.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Mintegral Checker — TODO
|
||||||
|
|
||||||
|
## Task Overview
|
||||||
|
|
||||||
|
Build a local VS Code webview equivalent of the Mintegral PlayTurbo checker
|
||||||
|
(`https://www.playturbo.com/review`). Validates a Mintegral playable ad ZIP against
|
||||||
|
PlayTurbo requirements. Two check categories:
|
||||||
|
|
||||||
|
- **Static** — parse `index.html` + ZIP structure without running the ad.
|
||||||
|
- **Runtime** — load the ad in a sandboxed `srcdoc` iframe, inject a monitoring stub,
|
||||||
|
and detect whether lifecycle callbacks (`gameReady`, `gameEnd`, `gameStart`,
|
||||||
|
`gameClose`, `install`) actually fire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Finished
|
||||||
|
|
||||||
|
- [x] Pure-Node ZIP parser (EOCD → Central Directory → Local File Headers, zlib DEFLATE)
|
||||||
|
- [x] All 9 static checks:
|
||||||
|
- HTML Requirements (charset, viewport, single-file)
|
||||||
|
- CTA Method (`window.install` reference in scripts)
|
||||||
|
- Game End / Game Ready / Game Start / Game Close (regex on scripts)
|
||||||
|
- File Handling (no external URLs, base64 assets)
|
||||||
|
- File Spec (ZIP ≤ 5 MB, single `index.html`)
|
||||||
|
- Storage (no `localStorage.setItem` / `sessionStorage.setItem`)
|
||||||
|
- [x] Device-frame simulator (phone mockup around `srcdoc` iframe)
|
||||||
|
- [x] Event log panel showing runtime events as they fire
|
||||||
|
- [x] Runtime timer with 60s timeout and per-check waiting badges
|
||||||
|
- [x] `__ping__` handshake → "Simulator connected" in event log (confirms stub runs)
|
||||||
|
- [x] Dual notification channels: polling `__hplMtgEvents` + `parent.__hplMtgNotify`
|
||||||
|
- [x] `MW_INIT` interceptor (wraps `emitCheckData`, forces `_hasReview`/`_isPreview`)
|
||||||
|
- [x] `window.postMessage` override (catches `previewer:gameReady` self-posts)
|
||||||
|
- [x] `mkAccess` — `Object.defineProperty` accessor with undefined getter so AD's
|
||||||
|
`typeof window.gameReady === 'undefined'` guard passes, setter wraps assignment
|
||||||
|
- [x] Capture-phase `load` safety net — wraps any SDK function declarations that
|
||||||
|
overwrote accessors, fires before `body.onload`
|
||||||
|
- [x] Mute button UI (shows on ZIP load, hidden on reset, toggles Mute/Unmute label)
|
||||||
|
- [x] Diagnostic lifecycle logging (`post-parse`, `DOMContentLoaded`, `load`,
|
||||||
|
`load+500ms`, callback assignment/call traces)
|
||||||
|
- [x] AudioContext construction proxy for mute/unmute of closure-owned engine audio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
|
||||||
|
### 1. Callbacks never fire (critical, diagnostics added)
|
||||||
|
|
||||||
|
**Symptom:** Event log shows "Simulator connected" (`__ping__` fires) but no lifecycle
|
||||||
|
events appear. The same ZIP passes all checks on the real PlayTurbo site.
|
||||||
|
|
||||||
|
**Root cause candidates (narrowed down but not confirmed):**
|
||||||
|
|
||||||
|
A. **`body.onload` vs capture `load` ordering on `window`** — When the event target is
|
||||||
|
`window` itself, capture and bubble listeners both run at the "at target" phase in
|
||||||
|
registration order. Our stub is in `<head>` and registers first, so it should fire
|
||||||
|
before the body's `onload`. But this assumes no edge-case behaviour in VS Code's
|
||||||
|
Chromium sandbox / srcdoc context.
|
||||||
|
|
||||||
|
B. **SDK function declaration closes over a local reference** — If the ad does:
|
||||||
|
```js
|
||||||
|
var gameReady = function() { ... };
|
||||||
|
window.gameReady = gameReady;
|
||||||
|
```
|
||||||
|
and `body.onload="gameReady()"` resolves to the local var (via closure), then
|
||||||
|
reassigning `window.gameReady` in our load-time wrapper has no effect.
|
||||||
|
|
||||||
|
C. **CSP in VS Code webview blocks stub execution silently** — The webview panel has
|
||||||
|
an implicit CSP. The stub is injected into `srcdoc`; it might run in a restricted
|
||||||
|
context that prevents property interception or `parent` access for later events (even
|
||||||
|
though `__ping__` works because it runs synchronously at parse time).
|
||||||
|
|
||||||
|
D. **Ad calls `gameReady()` before load** — If the ad boots via DOMContentLoaded and
|
||||||
|
calls `gameReady()` there (rather than body.onload), our load-event safety net fires
|
||||||
|
too late. The `mkAccess` accessor should still catch it, but only if the ad assigns
|
||||||
|
`window.gameReady` first.
|
||||||
|
|
||||||
|
**What's been added:**
|
||||||
|
- Injected diagnostic logging for lifecycle function `typeof` at post-parse,
|
||||||
|
DOMContentLoaded, load capture before/after wrapping, and 500 ms after load.
|
||||||
|
- Logs callback assignment/wrapping and wrapper call paths to the event log.
|
||||||
|
- Logs dynamic `document.createElement('script')` calls to help trace script injection.
|
||||||
|
|
||||||
|
**What's still needed if callbacks still do not fire:**
|
||||||
|
- Check if the ad bundles `preview-util.js` inside `index.html` or references it
|
||||||
|
externally (would fail file-handling check anyway).
|
||||||
|
- Compare the diagnostic timeline against the real PlayTurbo execution path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Mute button does nothing (fixed in monitor stub)
|
||||||
|
|
||||||
|
**Symptom:** Clicking Mute/Unmute changes the label but has no audible effect.
|
||||||
|
|
||||||
|
**Root cause:** The current approach searches `Object.keys(cw)` for an `AudioContext`
|
||||||
|
instance. AudioContexts are almost always created in closures (inside Phaser or the ad
|
||||||
|
IIFE) and are never assigned as window properties — `Object.keys` will never find them.
|
||||||
|
`cw.Phaser.game.sound` also likely fails because Phaser stores the game instance
|
||||||
|
internally, not at `window.Phaser.game`.
|
||||||
|
|
||||||
|
**Fix applied:**
|
||||||
|
Intercepts `AudioContext`/`webkitAudioContext` construction in the monitoring stub,
|
||||||
|
stores captured contexts/gain nodes on `window.__hplAudioContexts` and
|
||||||
|
`window.__hplMasterGains`, and routes `ctx.destination` through a master gain when
|
||||||
|
possible. The mute button now sets captured master gain values first, then falls back
|
||||||
|
to suspending/resuming captured contexts and exposed Phaser/global game sound objects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvements / Nice-to-Have
|
||||||
|
|
||||||
|
- [ ] **Inject SDK alongside ad** — The real PlayTurbo checker loads `preview-util.js`
|
||||||
|
next to the ad. Bundling (or downloading once) the 265 KB SDK and injecting it into
|
||||||
|
the srcdoc would let `MW_INIT`/`emitCheckData` fire natively, which is the most
|
||||||
|
reliable callback path and matches what the real checker does.
|
||||||
|
|
||||||
|
- [ ] **Diagnostic mode** — A "Debug" toggle that logs `typeof` of each lifecycle
|
||||||
|
function at key moments (post-parse, DCL, load, 500ms after load) to the event log,
|
||||||
|
making root-cause analysis faster.
|
||||||
|
|
||||||
|
- [ ] **Replay button** — Reload only the iframe (not the full ZIP re-parse) to retest
|
||||||
|
without re-picking the file.
|
||||||
|
|
||||||
|
- [ ] **Portrait / landscape toggle** — Flip the device frame aspect ratio.
|
||||||
|
|
||||||
|
- [ ] **Multiple HTML entry points** — Some ZIPs have `playable.html` instead of
|
||||||
|
`index.html`; add a fallback search.
|
||||||
|
|
||||||
|
- [ ] **Orientation lock detection** — Check for `screen.orientation.lock()` calls
|
||||||
|
(Mintegral requires the ad to handle both orientations).
|
||||||
|
|
||||||
|
- [ ] **Version bump and changelog entry** — Once callbacks are working, bump to 0.1.8
|
||||||
|
and add a changelog entry for the Mintegral Checker tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues / Constraints
|
||||||
|
|
||||||
|
- VS Code `WebviewPanel` runs in a sandboxed Chromium process. `srcdoc` iframes are
|
||||||
|
same-origin with the webview, so `parent` access works — but the webview's implicit
|
||||||
|
CSP may still block certain APIs inside nested iframes.
|
||||||
|
|
||||||
|
- The `Object.defineProperty` accessor approach (`mkAccess`) is the only way to let the
|
||||||
|
AD's `typeof window.X === 'undefined'` guard pass while still intercepting the
|
||||||
|
assignment. Any early concrete assignment (even `undefined`) breaks the guard in some
|
||||||
|
runtimes.
|
||||||
|
|
||||||
|
- SDK `function` declarations at brace-depth 0 are `[[DefineOwnProperty]]` calls at
|
||||||
|
parse time; they overwrite our configurable accessors unconditionally. The load-time
|
||||||
|
safety net is the only recovery path for that scenario.
|
||||||
|
|
||||||
|
- The `applyStaticResults` function sets `runtimeStatus = null` for lifecycle checks
|
||||||
|
that already failed statically. `handleRuntimeEvent` only transitions
|
||||||
|
`runtimeStatus === 'waiting'` → `'pass'`; it silently ignores `null`. This means a
|
||||||
|
static-fail check can't be runtime-rescued via the direct lifecycle path (though the
|
||||||
|
`handleSdkCheck` path does handle `null`). Intentional, but worth documenting.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
|||||||
# Unified Layout
|
|
||||||
|
|
||||||
[Select Folder] [Select Files] [Clear]
|
|
||||||
|
|
||||||
(PLEC UPLOAD)
|
|
||||||
| FILENAME |ITERATION NAME| |
|
|
||||||
|-----------------|--------------|---|
|
|
||||||
|pato-to-file.html| 01_full | X |
|
|
||||||
|pato-to-file.html| 01_full | X |
|
|
||||||
|pato-to-file.html| 01_full | X |
|
|
||||||
|
|
||||||
(AppLovin Playable Preview)(Base64 Scanner)(Send To Mobile)(MRAID Checker)
|
|
||||||
| FILENAME | |
|
|
||||||
|-----------------|-------|
|
|
||||||
|pato-to-file.html| X |
|
|
||||||
|pato-to-file.html| X |
|
|
||||||
|pato-to-file.html| X |
|
|
||||||
|
|
||||||
[Scan]/[Upload]/[Start]
|
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
243
data/devices.json
Normal file
243
data/devices.json
Normal 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
15
data/languages.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
17
data/manifest.json
Normal file
17
data/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"filename": "AGENTS.md",
|
||||||
|
"description": "AI coding guidelines for Phaser 3 playable ads. Covers stack (Phaser 3.90, TypeScript, Vite), supported ad networks, project structure, responsibility split between game modules and GameScene, performance rules, responsive coordinate system (sx/sy/sd), and CTA fallback chain.",
|
||||||
|
"url": "https://drive.google.com/file/d/1O3Rp9HIfVA1Urr3APFyvIM0KDBxhEOOS/view?usp=sharing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "LOCALIZATION.md",
|
||||||
|
"description": "Runtime localization system for playable ads. Documents the two-layer pattern (reusable runtime + project adapter), 10 supported locales (EN/DE/FR/ES/PT/RU/ZH/KO/JA), locale detection order (URL override → browser language → English fallback), and a drop-in AI prompt for implementing localization in a new or existing project.",
|
||||||
|
"url": "https://drive.google.com/file/d/1bDldnMJX6Vwngkakeve9cxonG7NU_KZo/view?usp=drive_link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": ".gitignore",
|
||||||
|
"description": "Standard .gitignore for Phaser 3 / Vite playable ad projects.",
|
||||||
|
"url": "https://drive.google.com/file/d/1wh3Yo_HZDxLqe4EDM6KsvWeG8eFzp5pG/view?usp=drive_link"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
dist/hpl-toolbox-0.1.7.vsix
vendored
BIN
dist/hpl-toolbox-0.1.7.vsix
vendored
Binary file not shown.
Binary file not shown.
BIN
dist/hpl-toolbox-1.0.1.vsix
vendored
Normal file
BIN
dist/hpl-toolbox-1.0.1.vsix
vendored
Normal file
Binary file not shown.
1
media/mintegral/preview-util.js
Normal file
1
media/mintegral/preview-util.js
Normal file
File diff suppressed because one or more lines are too long
60
package.json
60
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"displayName": "HPL Toolbox",
|
"displayName": "HPL Toolbox",
|
||||||
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||||
"version": "0.1.7",
|
"version": "1.0.1",
|
||||||
"publisher": "hesukastro",
|
"publisher": "hesukastro",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -43,6 +43,22 @@
|
|||||||
{
|
{
|
||||||
"command": "hplToolbox.openPlayworksConverter",
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
"title": "HPL Toolbox: Open Playworks Converter"
|
"title": "HPL Toolbox: Open Playworks Converter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openMintegralChecker",
|
||||||
|
"title": "HPL Toolbox: Open Mintegral Checker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
|
"title": "HPL Toolbox: Open Device Simulator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.checkForUpdates",
|
||||||
|
"title": "HPL Toolbox: Check for Updates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openProjectInit",
|
||||||
|
"title": "HPL Toolbox: Initialize Project"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
@@ -59,7 +75,8 @@
|
|||||||
{
|
{
|
||||||
"type": "webview",
|
"type": "webview",
|
||||||
"id": "hplToolbox.launcher",
|
"id": "hplToolbox.launcher",
|
||||||
"name": ""
|
"name": "",
|
||||||
|
"icon": "media/hpl.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -86,6 +103,16 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Skip the pre-check that verifies the filename does not already exist on the preview server."
|
"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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -119,13 +146,40 @@
|
|||||||
"description": "Port for the temporary LAN HTTP server. 0 (default) = OS-assigned ephemeral port."
|
"description": "Port for the temporary LAN HTTP server. 0 (default) = OS-assigned ephemeral port."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "HPL Toolbox — Initialize Project",
|
||||||
|
"properties": {
|
||||||
|
"hplToolbox.projectInit.manifestUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link",
|
||||||
|
"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": {
|
"scripts": {
|
||||||
"compile": "tsc -p ./",
|
"compile": "tsc -p ./",
|
||||||
"watch": "tsc -watch -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": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
|
||||||
# If neither was specified, build both.
|
# If neither was specified, build both.
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
$standalone = Join-Path $root 'standalone'
|
$standalone = Join-Path $root 'standalone'
|
||||||
|
|
||||||
Push-Location $standalone
|
Push-Location $standalone
|
||||||
@@ -6,7 +6,11 @@ import { openBase64Scanner } from './tools/base64Scanner';
|
|||||||
import { openMraidChecker } from './tools/mraidChecker';
|
import { openMraidChecker } from './tools/mraidChecker';
|
||||||
import { openSendToMobile } from './tools/sendToMobile';
|
import { openSendToMobile } from './tools/sendToMobile';
|
||||||
import { openPlayworksConverter } from './tools/playworksConverter';
|
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||||
|
import { openMintegralChecker } from './tools/mintegralChecker';
|
||||||
import { openChangelog } from './changelogView';
|
import { openChangelog } from './changelogView';
|
||||||
|
import { openDeviceSimulator } from './tools/deviceSimulator';
|
||||||
|
import { openProjectInit } from './tools/projectInit';
|
||||||
|
import { checkForUpdates } from './updateChecker';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
@@ -16,9 +20,15 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.openDeviceSimulator', () => openDeviceSimulator(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.openProjectInit', () => openProjectInit(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
||||||
|
vscode.commands.registerCommand('hplToolbox.checkForUpdates', () => checkForUpdates(context)),
|
||||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void checkForUpdates(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate() {}
|
export function deactivate() {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
||||||
|
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
|
||||||
|
|
||||||
interface ToolDefinition {
|
interface ToolDefinition {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -13,6 +14,12 @@ interface ToolDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_TOOLS: ToolDefinition[] = [
|
const FALLBACK_TOOLS: ToolDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'project-init',
|
||||||
|
command: 'hplToolbox.openProjectInit',
|
||||||
|
title: 'Initialize Project',
|
||||||
|
description: 'Download AGENTS.md and .gitignore templates from Google Drive',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
command: 'hplToolbox.openPlecUpload',
|
command: 'hplToolbox.openPlecUpload',
|
||||||
title: 'PLEC Upload',
|
title: 'PLEC Upload',
|
||||||
@@ -42,7 +49,6 @@ const FALLBACK_TOOLS: ToolDefinition[] = [
|
|||||||
command: 'hplToolbox.openPlayworksConverter',
|
command: 'hplToolbox.openPlayworksConverter',
|
||||||
title: 'Playworks Converter',
|
title: 'Playworks Converter',
|
||||||
description: 'Convert Playworks HTML to per-network variants',
|
description: 'Convert Playworks HTML to per-network variants',
|
||||||
beta: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -54,15 +60,20 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
|||||||
const version = this.context.extension.packageJSON.version as string;
|
const version = this.context.extension.packageJSON.version as string;
|
||||||
const tools = loadToolDefinitions(this.context.extensionPath);
|
const tools = loadToolDefinitions(this.context.extensionPath);
|
||||||
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
||||||
view.webview.html = getHtml(version, betaToolsEnabled, tools);
|
const toolOrder = this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []);
|
||||||
|
view.webview.html = getHtml(version, betaToolsEnabled, tools, toolOrder);
|
||||||
view.webview.onDidReceiveMessage(async (msg) => {
|
view.webview.onDidReceiveMessage(async (msg) => {
|
||||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||||
vscode.commands.executeCommand(msg.command);
|
vscode.commands.executeCommand(msg.command);
|
||||||
} else if (msg?.type === 'openChangelog') {
|
} else if (msg?.type === 'openChangelog') {
|
||||||
vscode.commands.executeCommand('hplToolbox.openChangelog');
|
vscode.commands.executeCommand('hplToolbox.openChangelog');
|
||||||
|
} else if (msg?.type === 'checkForUpdates') {
|
||||||
|
vscode.commands.executeCommand('hplToolbox.checkForUpdates');
|
||||||
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||||
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
||||||
view.webview.html = getHtml(version, msg.enabled, tools);
|
view.webview.html = getHtml(version, msg.enabled, tools, this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []));
|
||||||
|
} else if (msg?.type === 'setToolOrder' && Array.isArray(msg.order)) {
|
||||||
|
await this.context.globalState.update(TOOL_ORDER_KEY, msg.order.filter((id: unknown) => typeof id === 'string'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,8 +99,8 @@ function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[]): string {
|
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[], toolOrder: string[]): string {
|
||||||
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta));
|
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta), toolOrder);
|
||||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -127,6 +138,9 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
.tool-btn.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
.tool-btn:hover {
|
.tool-btn:hover {
|
||||||
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
||||||
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
||||||
@@ -197,6 +211,7 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
justify-self: center;
|
||||||
}
|
}
|
||||||
.changelog-link {
|
.changelog-link {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
@@ -210,16 +225,51 @@ ${toolButtons}
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
|
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
|
||||||
<span class="footer-version">${version} - JJGC 00784</span>
|
<button id="updateCheck" class="footer-action footer-version" title="Check for updates">${version} - JJGC 00784</button>
|
||||||
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
|
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.dragging === 'true') {
|
||||||
|
btn.dataset.dragging = 'false';
|
||||||
|
return;
|
||||||
|
}
|
||||||
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const toolsEl = document.querySelector('.tools');
|
||||||
|
let dragged = null;
|
||||||
|
toolsEl.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||||
|
btn.draggable = true;
|
||||||
|
btn.addEventListener('dragstart', (event) => {
|
||||||
|
dragged = btn;
|
||||||
|
btn.classList.add('dragging');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', btn.dataset.id || '');
|
||||||
|
});
|
||||||
|
btn.addEventListener('dragend', () => {
|
||||||
|
btn.classList.remove('dragging');
|
||||||
|
btn.dataset.dragging = 'true';
|
||||||
|
dragged = null;
|
||||||
|
persistToolOrder();
|
||||||
|
setTimeout(() => { btn.dataset.dragging = 'false'; }, 0);
|
||||||
|
});
|
||||||
|
btn.addEventListener('dragover', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dragged || dragged === btn) return;
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const after = event.clientY > rect.top + rect.height / 2;
|
||||||
|
toolsEl.insertBefore(dragged, after ? btn.nextSibling : btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function persistToolOrder() {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'setToolOrder',
|
||||||
|
order: Array.from(toolsEl.querySelectorAll('.tool-btn')).map(btn => btn.dataset.id).filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
||||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||||
@@ -227,22 +277,37 @@ ${toolButtons}
|
|||||||
document.getElementById('changelogLink').addEventListener('click', () => {
|
document.getElementById('changelogLink').addEventListener('click', () => {
|
||||||
vscode.postMessage({ type: 'openChangelog' });
|
vscode.postMessage({ type: 'openChangelog' });
|
||||||
});
|
});
|
||||||
|
document.getElementById('updateCheck').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'checkForUpdates' });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
|
function sortToolsForDisplay(tools: ToolDefinition[], toolOrder: string[]): ToolDefinition[] {
|
||||||
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta)));
|
const order = new Map(toolOrder.map((id, index) => [id, index]));
|
||||||
|
return [...tools].sort((a, b) => {
|
||||||
|
const aId = toolId(a);
|
||||||
|
const bId = toolId(b);
|
||||||
|
const ai = order.has(aId) ? order.get(aId)! : Number.MAX_SAFE_INTEGER;
|
||||||
|
const bi = order.has(bId) ? order.get(bId)! : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (ai !== bi) return ai - bi;
|
||||||
|
return Number(Boolean(a.beta)) - Number(Boolean(b.beta));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolButton(tool: ToolDefinition): string {
|
function renderToolButton(tool: ToolDefinition): string {
|
||||||
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
|
return ` <button class="tool-btn" data-id="${escapeHtml(toolId(tool))}" data-cmd="${escapeHtml(tool.command)}">
|
||||||
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
||||||
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolId(tool: ToolDefinition): string {
|
||||||
|
return tool.id || tool.command;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value.replace(/[&<>"']/g, ch => ({
|
return value.replace(/[&<>"']/g, ch => ({
|
||||||
'&': '&',
|
'&': '&',
|
||||||
|
|||||||
749
src/tools/deviceSimulator.ts
Normal file
749
src/tools/deviceSimulator.ts
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
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;
|
||||||
|
previewPort: number;
|
||||||
|
previewContent: string;
|
||||||
|
previewDir: string;
|
||||||
|
} = { panel: null, previewServer: null, previewPort: 0, previewContent: '', previewDir: '' };
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sw: number; sh: number;
|
||||||
|
scr: number;
|
||||||
|
notch: 'none' | 'notch' | 'island' | 'punch';
|
||||||
|
nw?: number; nh?: number; nr?: number; ps?: number;
|
||||||
|
sat: number; sab: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICES: Device[] = [
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = vscode.window.createWebviewPanel(
|
||||||
|
'hplToolbox.deviceSimulator',
|
||||||
|
'Device Simulator',
|
||||||
|
vscode.ViewColumn.Active,
|
||||||
|
{ enableScripts: true, retainContextWhenHidden: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
store.panel = panel;
|
||||||
|
panel.onDidDispose(() => {
|
||||||
|
store.panel = null;
|
||||||
|
store.previewContent = '';
|
||||||
|
store.previewDir = '';
|
||||||
|
if (store.previewServer) {
|
||||||
|
store.previewServer.close();
|
||||||
|
store.previewServer = null;
|
||||||
|
store.previewPort = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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) => {
|
||||||
|
if (msg.type === 'pickFile') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select File',
|
||||||
|
filters: { 'HTML': ['html', 'htm'] },
|
||||||
|
});
|
||||||
|
if (picked?.[0]) {
|
||||||
|
lastFilePath = picked[0].fsPath;
|
||||||
|
sendFile(panel, lastFilePath, true);
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'reload') {
|
||||||
|
if (lastFilePath) sendFile(panel, lastFilePath, false);
|
||||||
|
} else if (msg.type === 'openSettings') {
|
||||||
|
vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.deviceSimulator');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(panel: vscode.WebviewPanel, filePath: string, resetMute: boolean) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
store.previewContent = content;
|
||||||
|
store.previewDir = path.dirname(filePath);
|
||||||
|
ensurePreviewServer(() => {
|
||||||
|
const sourceUrl = store.previewPort ? `http://127.0.0.1:${store.previewPort}/preview/index.html` : '';
|
||||||
|
panel.webview.postMessage({ type: 'fileLoaded', content, name: path.basename(filePath), sourceUrl, resetMute });
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
vscode.window.showErrorMessage('Device Simulator: could not read ' + path.basename(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePreviewServer(callback: () => void) {
|
||||||
|
if (store.previewServer && store.previewPort) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (!req.url || !req.url.startsWith('/preview')) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestUrl = new URL(req.url, 'http://127.0.0.1');
|
||||||
|
const assetPath = decodeURIComponent(requestUrl.pathname.replace(/^\/preview\/?/, ''));
|
||||||
|
if (!assetPath || assetPath === 'index.html') {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(injectServerMuteBridge(store.previewContent));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(store.previewDir, assetPath);
|
||||||
|
if (!store.previewDir || !resolved.startsWith(path.resolve(store.previewDir) + path.sep)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end('Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.readFile(resolved, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Cache-Control': 'no-store' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
store.previewServer = server;
|
||||||
|
store.previewPort = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
server.on('error', () => {
|
||||||
|
store.previewServer = null;
|
||||||
|
store.previewPort = 0;
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectServerMuteBridge(html: string): string {
|
||||||
|
const bridge =
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(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){}' +
|
||||||
|
'})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
if (/<head\b[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<head\b[^>]*>/i, (match) => match + bridge);
|
||||||
|
}
|
||||||
|
return bridge + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(devices: Device[]): string {
|
||||||
|
const devicesJson = JSON.stringify(devices);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
${getToolWebviewStyles()}
|
||||||
|
|
||||||
|
html, body { min-height: 100%; }
|
||||||
|
#setup-view { min-height: 100%; }
|
||||||
|
#sim-view {
|
||||||
|
display: none;
|
||||||
|
height: min(720px, calc(100vh - 280px));
|
||||||
|
min-height: 520px;
|
||||||
|
margin-top: var(--tool-gap-lg);
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#sim-view.active { display: grid; }
|
||||||
|
.sim-panel {
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: var(--tool-panel-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
#preview-area {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#screen-scaler { transform-origin: top center; flex: 0 0 auto; }
|
||||||
|
#device-screen {
|
||||||
|
--cutout-bg: #000;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255,255,255,0.08),
|
||||||
|
0 0 0 3px #1a1a1a,
|
||||||
|
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); }
|
||||||
|
.icon-btn {
|
||||||
|
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); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#sim-view { height: auto; min-height: 0; grid-template-rows: minmax(420px, 60vh); overflow: visible; }
|
||||||
|
.title-select { width: min(180px, 36vw); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="setup-view">
|
||||||
|
<main class="tool-page">
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Device Simulator</h2>
|
||||||
|
<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" 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" title="Open Extension Settings" aria-label="Open Extension Settings">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-label">Playable HTML</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<button id="pick-btn" class="secondary">Select File</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="setup-file-name" style="margin-top:8px;">(no file selected)</div>
|
||||||
|
<div id="setup-status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="sim-view">
|
||||||
|
<section class="sim-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Playable</h3>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden;margin-left:auto;">
|
||||||
|
<span id="file-label" style="font-size:11px;color:var(--vscode-descriptionForeground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;"></span>
|
||||||
|
<span id="dim-label" style="font-size:11px;color:var(--vscode-descriptionForeground);white-space:nowrap;flex-shrink:0;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="title-actions">
|
||||||
|
<select id="device-select" class="title-select" title="Device"></select>
|
||||||
|
<select id="language-select" class="title-select" title="Language" aria-label="Language"></select>
|
||||||
|
<button id="orient-btn" class="icon-btn title-orient" title="Switch to Landscape" aria-label="Switch to Landscape"><span class="orient-icon">📱</span></button>
|
||||||
|
<button id="mute-btn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
||||||
|
<button id="reload-btn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="preview-area">
|
||||||
|
<div id="screen-scaler"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let DEFAULT_DEVICES = ${devicesJson};
|
||||||
|
let 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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let devices = DEFAULT_DEVICES.slice();
|
||||||
|
let device = devices[0];
|
||||||
|
let language = 'en';
|
||||||
|
let landscape = false;
|
||||||
|
let currentContent = null;
|
||||||
|
let currentSourceUrl = '';
|
||||||
|
let currentBlobUrl = null;
|
||||||
|
let loadVersion = 0;
|
||||||
|
let cutoutColor = '#000000';
|
||||||
|
let muted = false;
|
||||||
|
const mediaVolumes = new WeakMap();
|
||||||
|
let muteObserver = null;
|
||||||
|
let muteDocument = null;
|
||||||
|
|
||||||
|
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];
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
languageSelect.addEventListener('change', function() {
|
||||||
|
language = languageSelect.value || LANGUAGES[0].code;
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
document.getElementById('settings-btn').addEventListener('click', function() {
|
||||||
|
vscode.postMessage({ type: 'openSettings' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function populateDeviceSelect(selectedId) {
|
||||||
|
sel.innerHTML = '';
|
||||||
|
devices.forEach(function(d, i) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(i);
|
||||||
|
o.textContent = d.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
const idx = Math.max(0, devices.findIndex(function(d) { return d.id === selectedId; }));
|
||||||
|
sel.value = String(idx);
|
||||||
|
device = devices[idx] || devices[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateLanguageSelect() {
|
||||||
|
languageSelect.innerHTML = '';
|
||||||
|
LANGUAGES.forEach(function(lang) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = lang.code;
|
||||||
|
o.textContent = lang.name;
|
||||||
|
languageSelect.appendChild(o);
|
||||||
|
});
|
||||||
|
languageSelect.value = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceList(value) {
|
||||||
|
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
|
||||||
|
return list.map(normalizeDevice).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDevice(raw, index) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const name = String(raw.name || '').trim();
|
||||||
|
const notch = ['none', 'notch', 'island', 'punch'].includes(raw.notch) ? raw.notch : 'none';
|
||||||
|
const d = {
|
||||||
|
id: String(raw.id || slugify(name) || ('device-' + Date.now() + '-' + index)).trim(),
|
||||||
|
name: name || 'Custom Device',
|
||||||
|
sw: intInRange(raw.sw, 1, 5000),
|
||||||
|
sh: intInRange(raw.sh, 1, 5000),
|
||||||
|
scr: intInRange(raw.scr, 0, 500),
|
||||||
|
notch: notch,
|
||||||
|
sat: intInRange(raw.sat, 0, 500),
|
||||||
|
sab: intInRange(raw.sab, 0, 500)
|
||||||
|
};
|
||||||
|
if (!d.sw || !d.sh) return null;
|
||||||
|
if (raw.nw !== undefined) d.nw = intInRange(raw.nw, 0, 1000);
|
||||||
|
if (raw.nh !== undefined) d.nh = intInRange(raw.nh, 0, 1000);
|
||||||
|
if (raw.nr !== undefined) d.nr = intInRange(raw.nr, 0, 500);
|
||||||
|
if (raw.ps !== undefined) d.ps = intInRange(raw.ps, 0, 500);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intInRange(value, min, max) {
|
||||||
|
const n = Math.round(Number(value));
|
||||||
|
if (!Number.isFinite(n)) return min;
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('orient-btn').addEventListener('click', function() {
|
||||||
|
landscape = !landscape;
|
||||||
|
renderOrientationButton();
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderOrientationButton() {
|
||||||
|
const btn = document.getElementById('orient-btn');
|
||||||
|
btn.classList.toggle('landscape', landscape);
|
||||||
|
btn.title = landscape ? 'Switch to Portrait' : 'Switch to Landscape';
|
||||||
|
btn.setAttribute('aria-label', landscape ? 'Switch to Portrait' : 'Switch to Landscape');
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerPick(btn) {
|
||||||
|
const status = document.getElementById('setup-status');
|
||||||
|
if (status) {
|
||||||
|
status.className = 'status-panel is-busy';
|
||||||
|
status.textContent = 'Opening file picker...';
|
||||||
|
}
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Selecting...'; }
|
||||||
|
vscode.postMessage({ type: 'pickFile' });
|
||||||
|
setTimeout(function() {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Select File'; }
|
||||||
|
if (status && status.textContent === 'Opening file picker...') {
|
||||||
|
status.className = 'status-panel';
|
||||||
|
status.textContent = '';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
document.getElementById('pick-btn').addEventListener('click', function() { triggerPick(this); });
|
||||||
|
document.getElementById('reload-btn').addEventListener('click', function() {
|
||||||
|
vscode.postMessage({ type: 'reload' });
|
||||||
|
});
|
||||||
|
document.getElementById('mute-btn').addEventListener('click', function() {
|
||||||
|
muted = !muted;
|
||||||
|
renderMuteButton();
|
||||||
|
applyMute();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sw() { return landscape ? device.sh : device.sw; }
|
||||||
|
function sh() { return landscape ? device.sw : device.sh; }
|
||||||
|
|
||||||
|
function buildCutout() {
|
||||||
|
const d = device;
|
||||||
|
if (d.notch === 'notch') {
|
||||||
|
const r = Math.round((d.nh || 0) * 0.5);
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div class="cutout" style="top:0;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:0 0 ' + r + 'px ' + r + 'px;"></div>';
|
||||||
|
}
|
||||||
|
return '<div class="cutout" style="left:0;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:0 ' + r + 'px ' + r + 'px 0;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'island') {
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div class="cutout" style="top:12px;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
}
|
||||||
|
return '<div class="cutout" style="left:12px;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'punch') {
|
||||||
|
const ps = d.ps || 12;
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div class="cutout" style="top:10px;left:50%;transform:translateX(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
}
|
||||||
|
return '<div class="cutout" style="left:10px;top:50%;transform:translateY(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHomeBar() {
|
||||||
|
if (!device.sab) return '';
|
||||||
|
const base = 'position:absolute;background:rgba(255,255,255,0.3);border-radius:3px;pointer-events:none;z-index:10;';
|
||||||
|
if (!landscape) {
|
||||||
|
return '<div style="' + base + 'bottom:8px;left:50%;transform:translateX(-50%);width:130px;height:5px;"></div>';
|
||||||
|
}
|
||||||
|
return '<div style="' + base + 'right:8px;top:50%;transform:translateY(-50%);width:5px;height:100px;"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;">' +
|
||||||
|
buildCutout() +
|
||||||
|
'<iframe id="preview-frame" width="' + W + '" height="' + H +
|
||||||
|
'" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
|
||||||
|
buildHomeBar() +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('dim-label').textContent = W + ' \xd7 ' + H;
|
||||||
|
document.getElementById('device-screen').style.setProperty('--cutout-bg', cutoutColor);
|
||||||
|
document.getElementById('preview-frame').addEventListener('load', applyMute);
|
||||||
|
updateScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||||
|
currentBlobUrl = URL.createObjectURL(blob);
|
||||||
|
frame.src = currentBlobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPreviewParams(url) {
|
||||||
|
const separator = url.indexOf('?') >= 0 ? '&' : '?';
|
||||||
|
loadVersion += 1;
|
||||||
|
return url + separator + 'lang=' + encodeURIComponent(language) + '&v=' + loadVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectMuteBridge(html) {
|
||||||
|
const languageBridge =
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(function(){try{' +
|
||||||
|
'var url=new URL(window.location.href);' +
|
||||||
|
'url.searchParams.set("lang","' + language + '");' +
|
||||||
|
'history.replaceState(null,"",url.href);' +
|
||||||
|
'}catch(e){}})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
const bridge = languageBridge +
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(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){}' +
|
||||||
|
'})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
if (/<head\b[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<head\b[^>]*>/i, function(match) { return match + bridge; });
|
||||||
|
}
|
||||||
|
return bridge + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMuteButton() {
|
||||||
|
const btn = document.getElementById('mute-btn');
|
||||||
|
btn.innerHTML = muted ? '🔈' : '🔇';
|
||||||
|
btn.title = muted ? 'Unmute' : 'Mute';
|
||||||
|
btn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
|
||||||
|
btn.setAttribute('aria-pressed', muted ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMute() {
|
||||||
|
const frame = document.getElementById('preview-frame');
|
||||||
|
if (!frame || !frame.contentWindow) return;
|
||||||
|
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||||
|
try {
|
||||||
|
frame.contentWindow.__hplMuted = muted;
|
||||||
|
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||||
|
const doc = frame.contentWindow.document;
|
||||||
|
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
if (muteDocument !== doc) {
|
||||||
|
if (muteObserver) muteObserver.disconnect();
|
||||||
|
muteDocument = doc;
|
||||||
|
muteObserver = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
mutation.addedNodes.forEach(function(node) {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
if (node.matches && node.matches('audio,video')) syncMediaMute(node);
|
||||||
|
if (node.querySelectorAll) node.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
muteObserver.observe(doc.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMediaMute(media) {
|
||||||
|
if (muted) {
|
||||||
|
if (!mediaVolumes.has(media)) mediaVolumes.set(media, media.volume);
|
||||||
|
media.muted = true;
|
||||||
|
media.volume = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
media.muted = false;
|
||||||
|
if (mediaVolumes.has(media)) media.volume = mediaVolumes.get(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScale() {
|
||||||
|
const area = document.getElementById('preview-area');
|
||||||
|
const screen = document.getElementById('device-screen');
|
||||||
|
const scaler = document.getElementById('screen-scaler');
|
||||||
|
if (!screen || !area || !scaler) return;
|
||||||
|
scaler.style.transform = 'none';
|
||||||
|
const nw = screen.offsetWidth, nh = screen.offsetHeight;
|
||||||
|
const aw = area.clientWidth - 40, ah = area.clientHeight - 40;
|
||||||
|
const scale = Math.min(aw / nw, ah / nh, 1);
|
||||||
|
scaler.style.transform = 'scale(' + scale + ')';
|
||||||
|
scaler.style.marginBottom = Math.round(nh * (scale - 1)) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── messages ────────────────────────────────────────────────────────── */
|
||||||
|
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 || '';
|
||||||
|
if (msg.resetMute) {
|
||||||
|
muted = false;
|
||||||
|
renderMuteButton();
|
||||||
|
}
|
||||||
|
document.getElementById('file-label').textContent = msg.name;
|
||||||
|
document.getElementById('setup-file-name').textContent = msg.name;
|
||||||
|
var setupStatus = document.getElementById('setup-status');
|
||||||
|
setupStatus.className = 'status-panel';
|
||||||
|
setupStatus.textContent = '';
|
||||||
|
var pb = document.getElementById('pick-btn');
|
||||||
|
pb.disabled = false; pb.textContent = 'Select File';
|
||||||
|
var first = !document.getElementById('sim-view').classList.contains('active');
|
||||||
|
document.getElementById('sim-view').classList.add('active');
|
||||||
|
if (first) renderScreen();
|
||||||
|
loadContent(currentContent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateScale);
|
||||||
|
new ResizeObserver(updateScale).observe(document.getElementById('preview-area'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
1709
src/tools/mintegralChecker.ts
Normal file
1709
src/tools/mintegralChecker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,25 @@ import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './
|
|||||||
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
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');
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload');
|
||||||
if (!isNew) return;
|
if (!isNew) return;
|
||||||
|
|
||||||
panel.webview.html = getHtml();
|
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) => {
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
try {
|
try {
|
||||||
if (handleClipboardAndOpen(msg)) return;
|
if (handleClipboardAndOpen(msg)) return;
|
||||||
|
|
||||||
|
if (msg.type === 'saveServer') {
|
||||||
|
context.globalState.update('plec.lastServer', msg.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'pickFolder') {
|
if (msg.type === 'pickFolder') {
|
||||||
const picked = await vscode.window.showOpenDialog({
|
const picked = await vscode.window.showOpenDialog({
|
||||||
canSelectFolders: true,
|
canSelectFolders: true,
|
||||||
@@ -51,7 +61,7 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'upload') {
|
if (msg.type === 'upload') {
|
||||||
await handleUpload(panel, msg.rows);
|
await handleUpload(panel, msg.rows, msg.server);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -66,11 +76,7 @@ interface UploadRow {
|
|||||||
iteration: string;
|
iteration: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[], server: string) {
|
||||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
|
|
||||||
const uploadUrl = cfg.get<string>('uploadUrl')!;
|
|
||||||
const originUrl = cfg.get<string>('originUrl')!;
|
|
||||||
|
|
||||||
const valid: UploadRow[] = [];
|
const valid: UploadRow[] = [];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!r.path || !fs.existsSync(r.path)) {
|
if (!r.path || !fs.existsSync(r.path)) {
|
||||||
@@ -92,11 +98,21 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
|||||||
return;
|
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 stamp = timestampSuffix();
|
||||||
const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp));
|
const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp));
|
||||||
|
|
||||||
panel.webview.postMessage({ type: 'status', message: 'Uploading...' });
|
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (let i = 0; i < valid.length; i++) {
|
for (let i = 0; i < valid.length; i++) {
|
||||||
const r = valid[i];
|
const r = valid[i];
|
||||||
@@ -129,20 +145,14 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
|||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
panel.webview.postMessage({
|
panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` });
|
||||||
type: 'error',
|
|
||||||
message: `HTTP ${res.status}\n${text.slice(0, 800)}`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json: any;
|
let json: any;
|
||||||
try { json = JSON.parse(text); }
|
try { json = JSON.parse(text); }
|
||||||
catch {
|
catch {
|
||||||
panel.webview.postMessage({
|
panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` });
|
||||||
type: 'error',
|
|
||||||
message: `Server returned non-JSON:\n${text.slice(0, 800)}`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +160,154 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
|||||||
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
|
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 {
|
function timestampSuffix(d: Date = new Date()): string {
|
||||||
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
|
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
|
||||||
return (
|
return (
|
||||||
@@ -225,6 +383,17 @@ ${getToolWebviewStyles()}
|
|||||||
.result a { color: var(--vscode-textLink-foreground); }
|
.result a { color: var(--vscode-textLink-foreground); }
|
||||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -237,6 +406,10 @@ ${getToolWebviewStyles()}
|
|||||||
<section class="tool-panel input-panel">
|
<section class="tool-panel input-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3 class="panel-title">Inputs</h3>
|
<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>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
@@ -377,6 +550,10 @@ ${getToolWebviewStyles()}
|
|||||||
copyResultBtn.disabled = !previewUrl;
|
copyResultBtn.disabled = !previewUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('serverSelect').addEventListener('change', (e) => {
|
||||||
|
vscode.postMessage({ type: 'saveServer', value: e.target.value });
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('pickFolder').addEventListener('click', () => {
|
document.getElementById('pickFolder').addEventListener('click', () => {
|
||||||
vscode.postMessage({ type: 'pickFolder' });
|
vscode.postMessage({ type: 'pickFolder' });
|
||||||
});
|
});
|
||||||
@@ -402,7 +579,7 @@ ${getToolWebviewStyles()}
|
|||||||
}
|
}
|
||||||
statusEl.textContent = 'Uploading...';
|
statusEl.textContent = 'Uploading...';
|
||||||
statusEl.classList.add('is-busy');
|
statusEl.classList.add('is-busy');
|
||||||
vscode.postMessage({ type: 'upload', rows });
|
vscode.postMessage({ type: 'upload', rows, server: document.getElementById('serverSelect').value });
|
||||||
});
|
});
|
||||||
openResultBtn.addEventListener('click', () => {
|
openResultBtn.addEventListener('click', () => {
|
||||||
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
|
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
|
||||||
@@ -418,6 +595,10 @@ ${getToolWebviewStyles()}
|
|||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
|
if (m.type === 'restoreServer') {
|
||||||
|
document.getElementById('serverSelect').value = m.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (m.type === 'filesSelected') {
|
if (m.type === 'filesSelected') {
|
||||||
addFiles(m.files || []);
|
addFiles(m.files || []);
|
||||||
} else if (m.type === 'status') {
|
} else if (m.type === 'status') {
|
||||||
|
|||||||
266
src/tools/projectInit.ts
Normal file
266
src/tools/projectInit.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { singletonPanel, getToolWebviewStyles, handleClipboardAndOpen } from './shared';
|
||||||
|
|
||||||
|
interface FileEntry { filename: string; url: string; description?: string; }
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
function gdriveToDirectUrl(url: string): string {
|
||||||
|
const fileMatch = url.match(/\/file\/d\/([a-zA-Z0-9_-]+)/);
|
||||||
|
if (fileMatch) { return `https://drive.google.com/uc?export=download&id=${fileMatch[1]}`; }
|
||||||
|
const idMatch = url.match(/[?&]id=([a-zA-Z0-9_-]+)/);
|
||||||
|
if (idMatch) { return `https://drive.google.com/uc?export=download&id=${idMatch[1]}`; }
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManifest(url: string): Promise<FileEntry[]> {
|
||||||
|
const res = await fetch(gdriveToDirectUrl(url), { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||||
|
if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); }
|
||||||
|
const json = await res.json() as unknown;
|
||||||
|
if (!Array.isArray(json)) { throw new Error('Manifest must be a JSON array of { filename, url } objects.'); }
|
||||||
|
return (json as FileEntry[]).filter(e => e.filename && e.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url: string, destPath: string): Promise<void> {
|
||||||
|
const directUrl = gdriveToDirectUrl(url);
|
||||||
|
const res = await fetch(directUrl, { headers: { 'User-Agent': 'Mozilla/5.0' }, redirect: 'follow' });
|
||||||
|
if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); }
|
||||||
|
const contentType = res.headers.get('content-type') ?? '';
|
||||||
|
const text = await res.text();
|
||||||
|
const ext = path.extname(destPath).toLowerCase();
|
||||||
|
if (contentType.includes('text/html') && ext !== '.html' && ext !== '.htm') {
|
||||||
|
throw new Error(
|
||||||
|
'Got an HTML page instead of a file — the Google Drive link may require sign-in or a download confirmation. Make sure the file is set to "Anyone with the link can view".'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fs.promises.writeFile(destPath, text, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
${getToolWebviewStyles()}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="tool-page">
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Initialize Project</h2>
|
||||||
|
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Files</h3>
|
||||||
|
<div style="display:flex;gap:var(--tool-gap-xs);align-items:center;">
|
||||||
|
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:var(--tool-gap-xs);"></span>
|
||||||
|
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">↻</button>
|
||||||
|
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="openManifestSettings()">⚙</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div id="fileList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<p class="control-label">Download folder</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="text" id="destFolder" value="" placeholder="(workspace root)" readonly />
|
||||||
|
<button class="secondary" onclick="pickFolder()">Browse…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
|
||||||
|
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
const savedState = vscode.getState() || {};
|
||||||
|
document.getElementById('destFolder').value = savedState.destFolder || '';
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
let allSelected = true;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
setStatus('', '');
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest…</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
vscode.postMessage({ type: 'fetchManifest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManifestSettings() { vscode.postMessage({ type: 'openSettings' }); }
|
||||||
|
function pickFolder() { vscode.postMessage({ type: 'pickFolder' }); }
|
||||||
|
|
||||||
|
function initProject() {
|
||||||
|
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
|
||||||
|
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
|
||||||
|
const destFolder = document.getElementById('destFolder').value.trim();
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
|
||||||
|
el.className = 'status-panel is-busy';
|
||||||
|
vscode.postMessage({ type: 'init', files: checked, destFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
allSelected = !allSelected;
|
||||||
|
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
|
||||||
|
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(list, manifestUrl) {
|
||||||
|
files = list;
|
||||||
|
allSelected = true;
|
||||||
|
document.getElementById('manifestSource').textContent =
|
||||||
|
manifestUrl ? '(' + manifestUrl.replace(/^https?:\\/\\//, '').split('/')[0] + ')' : '';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.map((f, i) =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
|
||||||
|
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
|
||||||
|
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
|
||||||
|
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" data-url="' + escapeHtml(f.url) + '" onclick="openUrl(this)">Open</button></td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
document.getElementById('fileList').innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th style="width:20px;"></th>' +
|
||||||
|
'<th style="width:20%;">Filename</th>' +
|
||||||
|
'<th style="width:70%;">Description</th>' +
|
||||||
|
'<th style="width:10%;"></th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('initBtn').disabled = false;
|
||||||
|
document.getElementById('selectAllBtn').style.display = '';
|
||||||
|
document.getElementById('selectAllBtn').textContent = 'Deselect all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUrl(btn) {
|
||||||
|
vscode.postMessage({ type: 'open', text: btn.dataset.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', e => {
|
||||||
|
const msg = e.data;
|
||||||
|
if (msg.type === 'manifest') { renderFiles(msg.files, msg.manifestUrl); setStatus('', ''); }
|
||||||
|
if (msg.type === 'manifestError') {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg.text) + '</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
}
|
||||||
|
if (msg.type === 'folder') {
|
||||||
|
document.getElementById('destFolder').value = msg.path;
|
||||||
|
const s = vscode.getState() || {};
|
||||||
|
s.destFolder = msg.path;
|
||||||
|
vscode.setState(s);
|
||||||
|
}
|
||||||
|
if (msg.type === 'result') { setStatus(msg.text, msg.ok ? 'ok' : 'err'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = 'status-panel' + (cls ? ' ' + cls : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openProjectInit(context: vscode.ExtensionContext) {
|
||||||
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.projectInit', 'Initialize Project');
|
||||||
|
if (!isNew) { return; }
|
||||||
|
|
||||||
|
panel.webview.html = getHtml();
|
||||||
|
|
||||||
|
async function sendManifest() {
|
||||||
|
const manifestUrl = vscode.workspace.getConfiguration('hplToolbox.projectInit').get<string>('manifestUrl', '');
|
||||||
|
if (!manifestUrl) {
|
||||||
|
panel.webview.postMessage({
|
||||||
|
type: 'manifestError',
|
||||||
|
text: 'No manifest URL configured. Click ⚙ to set it in Settings.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const files = await fetchManifest(manifestUrl);
|
||||||
|
panel.webview.postMessage({ type: 'manifest', files, manifestUrl });
|
||||||
|
} catch (err: any) {
|
||||||
|
panel.webview.postMessage({ type: 'manifestError', text: `Failed to load manifest: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
if (handleClipboardAndOpen(msg)) { return; }
|
||||||
|
|
||||||
|
if (msg.type === 'fetchManifest') {
|
||||||
|
await sendManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'openSettings') {
|
||||||
|
vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.projectInit.manifestUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'pickFolder') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Destination Folder',
|
||||||
|
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
|
||||||
|
});
|
||||||
|
if (picked?.[0]) {
|
||||||
|
panel.webview.postMessage({ type: 'folder', path: picked[0].fsPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'init') {
|
||||||
|
const files: FileEntry[] = msg.files;
|
||||||
|
const dest: string = msg.destFolder || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
|
if (!dest) {
|
||||||
|
panel.webview.postMessage({ type: 'result', ok: false, text: 'No destination folder selected and no workspace is open.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
await downloadFile(f.url, path.join(dest, f.filename));
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`${f.filename}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length) {
|
||||||
|
panel.webview.postMessage({ type: 'result', ok: false, text: `${files.length - errors.length}/${files.length} downloaded.\n\nErrors:\n${errors.join('\n')}` });
|
||||||
|
} else {
|
||||||
|
panel.webview.postMessage({ type: 'result', ok: true, text: `Done! ${files.length} file(s) downloaded to: ${dest}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, undefined, context.subscriptions);
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
|||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
});
|
});
|
||||||
res.end(file.buf);
|
res.end(mobilePreviewPage(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +296,35 @@ function chooserPage(files: SharedFile[]): string {
|
|||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mobilePreviewPage(file: SharedFile): string {
|
||||||
|
const playableJson = JSON.stringify(file.buf.toString('utf8')).replace(/<\/script/gi, '<\\/script');
|
||||||
|
const title = file.filename.replace(/[&<>"]/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)
|
||||||
|
);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
|
||||||
|
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||||
|
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="boot" class="boot">Loading preview...</div>
|
||||||
|
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
|
||||||
|
<script>
|
||||||
|
const playableHtml = ${playableJson};
|
||||||
|
setTimeout(() => {
|
||||||
|
const frame = document.getElementById('playable');
|
||||||
|
document.getElementById('boot').style.display = 'none';
|
||||||
|
frame.srcdoc = playableHtml;
|
||||||
|
}, 750);
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
interface LanIp { address: string; iface: string; }
|
interface LanIp { address: string; iface: string; }
|
||||||
|
|
||||||
function getLanIps(): LanIp[] {
|
function getLanIps(): LanIp[] {
|
||||||
|
|||||||
80
src/updateChecker.ts
Normal file
80
src/updateChecker.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
const REMOTE_PACKAGE_JSON_URL = 'https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox/raw/branch/main/package.json';
|
||||||
|
|
||||||
|
interface RemotePackageJson {
|
||||||
|
version?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdates(context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
const currentVersion = String(context.extension.packageJSON.version ?? '').trim();
|
||||||
|
|
||||||
|
if (!currentVersion) {
|
||||||
|
vscode.window.showWarningMessage('HPL Toolbox could not read the installed version.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remoteVersion = await fetchRemoteVersion();
|
||||||
|
const comparison = compareVersions(remoteVersion, currentVersion);
|
||||||
|
|
||||||
|
if (comparison > 0) {
|
||||||
|
const action = await vscode.window.showInformationMessage(
|
||||||
|
`HPL Toolbox update available: ${remoteVersion} (installed: ${currentVersion}).`,
|
||||||
|
'Open Repository'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (action === 'Open Repository') {
|
||||||
|
await vscode.env.openExternal(vscode.Uri.parse('https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vscode.window.showInformationMessage(`HPL Toolbox is up to date (${currentVersion}).`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
vscode.window.showWarningMessage(`HPL Toolbox update check failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemoteVersion(): Promise<string> {
|
||||||
|
const response = await fetch(REMOTE_PACKAGE_JSON_URL, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`remote package.json returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = await response.json() as RemotePackageJson;
|
||||||
|
if (typeof packageJson.version !== 'string' || !packageJson.version.trim()) {
|
||||||
|
throw new Error('remote package.json is missing a version');
|
||||||
|
}
|
||||||
|
|
||||||
|
return packageJson.version.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(left: string, right: string): number {
|
||||||
|
const leftParts = parseVersion(left);
|
||||||
|
const rightParts = parseVersion(right);
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const leftPart = leftParts[index] ?? 0;
|
||||||
|
const rightPart = rightParts[index] ?? 0;
|
||||||
|
|
||||||
|
if (leftPart > rightPart) return 1;
|
||||||
|
if (leftPart < rightPart) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(version: string): number[] {
|
||||||
|
return version
|
||||||
|
.split(/[.-]/)
|
||||||
|
.map(part => Number.parseInt(part, 10))
|
||||||
|
.filter(part => Number.isFinite(part));
|
||||||
|
}
|
||||||
1
standalone/assets/mintegral/preview-util.js
Normal file
1
standalone/assets/mintegral/preview-util.js
Normal file
File diff suppressed because one or more lines are too long
766
standalone/device_simulator.go
Normal file
766
standalone/device_simulator.go
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceSimulatorDevice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SW int `json:"sw"`
|
||||||
|
SH int `json:"sh"`
|
||||||
|
SCR int `json:"scr"`
|
||||||
|
Notch string `json:"notch"`
|
||||||
|
NW int `json:"nw,omitempty"`
|
||||||
|
NH int `json:"nh,omitempty"`
|
||||||
|
NR int `json:"nr,omitempty"`
|
||||||
|
PS int `json:"ps,omitempty"`
|
||||||
|
SAT int `json:"sat"`
|
||||||
|
SAB int `json:"sab"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceSimulatorDevices = []deviceSimulatorDevice{
|
||||||
|
{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) {
|
||||||
|
devicesJSON, _ := json.Marshal(deviceSimulatorDevices)
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Device Simulator</h2>
|
||||||
|
<p class="tool-description">Preview HTML playables inside mobile device frames with notch and Dynamic Island overlays.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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" title="Settings" aria-label="Settings" onclick="openSettingsModal('device-simulator')">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-label">Playable HTML</div>
|
||||||
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickBtn" class="secondary">Select File</button>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-text">or drop an HTML file here</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="fileName" style="margin-top:8px;">(no file selected)</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="sim-panel" id="simPanel">
|
||||||
|
<div class="panel-header sim-header">
|
||||||
|
<h3 class="panel-title">Playable</h3>
|
||||||
|
<div class="sim-meta">
|
||||||
|
<span id="fileLabel"></span>
|
||||||
|
<span id="dimLabel"></span>
|
||||||
|
</div>
|
||||||
|
<div class="title-actions">
|
||||||
|
<select id="deviceSelect" class="title-select" title="Device"></select>
|
||||||
|
<select id="languageSelect" class="title-select" title="Language" aria-label="Language"></select>
|
||||||
|
<button id="orientBtn" class="icon-btn title-orient" title="Switch to Landscape" aria-label="Switch to Landscape"><span class="orient-icon">📱</span></button>
|
||||||
|
<button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button>
|
||||||
|
<button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="previewArea"><div id="screenScaler"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sim-panel {
|
||||||
|
display: none;
|
||||||
|
height: min(720px, calc(100vh - 280px));
|
||||||
|
min-height: 520px;
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: rgba(128,128,128,0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sim-panel.active { display: flex; }
|
||||||
|
.sim-header { display:flex; align-items:center; gap:8px; }
|
||||||
|
.sim-meta {
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #a7a7a7;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
#fileLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#dimLabel { white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.title-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||||
|
.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;
|
||||||
|
background:#3a3d41;
|
||||||
|
color:#ddd;
|
||||||
|
border:1px solid #444;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background:#45494e; }
|
||||||
|
.icon-btn[aria-pressed="true"] { color:#89d185; }
|
||||||
|
.device-settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
#previewArea {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#screenScaler { transform-origin: top center; flex: 0 0 auto; }
|
||||||
|
#deviceScreen {
|
||||||
|
--cutout-bg:#000;
|
||||||
|
position:relative;
|
||||||
|
overflow:hidden;
|
||||||
|
background:#000;
|
||||||
|
box-shadow:0 0 0 1px rgba(255,255,255,.08),0 0 0 3px #1a1a1a,0 0 0 4px rgba(255,255,255,.05),0 8px 32px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
#deviceScreen iframe { display:block; border:0; position:absolute; inset:0; background:#000; }
|
||||||
|
.cutout { position:absolute; pointer-events:none; z-index:10; background:var(--cutout-bg); }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.sim-panel { height:auto; min-height:420px; }
|
||||||
|
.sim-header { flex-wrap:wrap; }
|
||||||
|
.sim-meta { order:3; width:100%; margin-left:0; }
|
||||||
|
.title-actions { margin-left:auto; }
|
||||||
|
.title-select { width:min(180px, 42vw); }
|
||||||
|
#previewArea { min-height:420px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let DEFAULT_DEVICES = ` + string(devicesJSON) + `;
|
||||||
|
let 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' }
|
||||||
|
];
|
||||||
|
let devices = DEFAULT_DEVICES.slice();
|
||||||
|
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
let language = 'en';
|
||||||
|
let landscape = false;
|
||||||
|
let currentPath = '';
|
||||||
|
let currentContent = '';
|
||||||
|
let currentBlobUrl = '';
|
||||||
|
let loadVersion = 0;
|
||||||
|
let muted = false;
|
||||||
|
const mediaVolumes = new WeakMap();
|
||||||
|
let muteObserver = null;
|
||||||
|
let muteDocument = null;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const fileNameEl = document.getElementById('fileName');
|
||||||
|
const fileLabel = document.getElementById('fileLabel');
|
||||||
|
const dimLabel = document.getElementById('dimLabel');
|
||||||
|
const simPanel = document.getElementById('simPanel');
|
||||||
|
const sel = document.getElementById('deviceSelect');
|
||||||
|
const languageSelect = document.getElementById('languageSelect');
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||||
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
|
}
|
||||||
|
function setBusy(text) {
|
||||||
|
statusEl.textContent = text || '';
|
||||||
|
statusEl.classList.toggle('is-busy', !!text);
|
||||||
|
}
|
||||||
|
populateDeviceSelect(device.id);
|
||||||
|
populateLanguageSelect();
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
languageSelect.addEventListener('change', () => {
|
||||||
|
language = languageSelect.value || LANGUAGES[0].code;
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function populateDeviceSelect(selectedId) {
|
||||||
|
sel.innerHTML = '';
|
||||||
|
devices.forEach((d, i) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(i);
|
||||||
|
o.textContent = d.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
const foundIndex = devices.findIndex(d => d.id === selectedId);
|
||||||
|
const index = foundIndex >= 0 ? foundIndex : 0;
|
||||||
|
sel.value = String(index);
|
||||||
|
device = devices[index] || DEFAULT_DEVICES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateLanguageSelect() {
|
||||||
|
languageSelect.innerHTML = '';
|
||||||
|
LANGUAGES.forEach((lang) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = lang.code;
|
||||||
|
o.textContent = lang.name;
|
||||||
|
languageSelect.appendChild(o);
|
||||||
|
});
|
||||||
|
languageSelect.value = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function normalizeDeviceList(value) {
|
||||||
|
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
|
||||||
|
return list.map(normalizeDevice).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDevice(raw, index) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const name = String(raw.name || '').trim();
|
||||||
|
const notch = ['none', 'notch', 'island', 'punch'].includes(raw.notch) ? raw.notch : 'none';
|
||||||
|
const d = {
|
||||||
|
id: String(raw.id || slugify(name) || ('device-' + Date.now() + '-' + index)).trim(),
|
||||||
|
name: name || 'Custom Device',
|
||||||
|
sw: intInRange(raw.sw, 1, 5000),
|
||||||
|
sh: intInRange(raw.sh, 1, 5000),
|
||||||
|
scr: intInRange(raw.scr, 0, 500),
|
||||||
|
notch: notch,
|
||||||
|
sat: intInRange(raw.sat, 0, 500),
|
||||||
|
sab: intInRange(raw.sab, 0, 500)
|
||||||
|
};
|
||||||
|
if (!d.sw || !d.sh) return null;
|
||||||
|
if (raw.nw !== undefined) d.nw = intInRange(raw.nw, 0, 1000);
|
||||||
|
if (raw.nh !== undefined) d.nh = intInRange(raw.nh, 0, 1000);
|
||||||
|
if (raw.nr !== undefined) d.nr = intInRange(raw.nr, 0, 500);
|
||||||
|
if (raw.ps !== undefined) d.ps = intInRange(raw.ps, 0, 500);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intInRange(value, min, max) {
|
||||||
|
const n = Math.round(Number(value));
|
||||||
|
if (!Number.isFinite(n)) return min;
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('orientBtn').addEventListener('click', (event) => {
|
||||||
|
landscape = !landscape;
|
||||||
|
renderOrientationButton();
|
||||||
|
renderScreen();
|
||||||
|
if (currentContent) loadContent(currentContent);
|
||||||
|
});
|
||||||
|
function renderOrientationButton() {
|
||||||
|
const btn = document.getElementById('orientBtn');
|
||||||
|
btn.classList.toggle('landscape', landscape);
|
||||||
|
btn.title = landscape ? 'Switch to Portrait' : 'Switch to Landscape';
|
||||||
|
btn.setAttribute('aria-label', landscape ? 'Switch to Portrait' : 'Switch to Landscape');
|
||||||
|
}
|
||||||
|
document.getElementById('pickBtn').addEventListener('click', async () => {
|
||||||
|
setBusy('Opening file picker...');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/device-simulator/pick', { method:'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) await loadPath(j.path, true);
|
||||||
|
else setBusy('');
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = 'Error: ' + (e.message || e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||||
|
if (currentPath) loadPath(currentPath, false);
|
||||||
|
});
|
||||||
|
document.getElementById('muteBtn').addEventListener('click', () => {
|
||||||
|
muted = !muted;
|
||||||
|
renderMuteButton();
|
||||||
|
applyMute();
|
||||||
|
});
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
if (j.paths && j.paths.length) loadPath(j.paths[0], true);
|
||||||
|
else if (j.skipped) statusEl.textContent = 'Drop an HTML file.';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPath(path, resetMute) {
|
||||||
|
currentPath = path;
|
||||||
|
setBusy('Reading ' + basename(path) + '...');
|
||||||
|
const r = await fetch('/api/device-simulator/load', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({ path })
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
if (j.error) {
|
||||||
|
statusEl.textContent = 'Error: ' + j.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentContent = j.content || '';
|
||||||
|
fileNameEl.textContent = j.name;
|
||||||
|
fileLabel.textContent = j.name;
|
||||||
|
statusEl.textContent = '';
|
||||||
|
if (resetMute) {
|
||||||
|
muted = false;
|
||||||
|
renderMuteButton();
|
||||||
|
}
|
||||||
|
simPanel.classList.add('active');
|
||||||
|
renderScreen();
|
||||||
|
loadContent(currentContent);
|
||||||
|
}
|
||||||
|
function sw() { return landscape ? device.sh : device.sw; }
|
||||||
|
function sh() { return landscape ? device.sw : device.sh; }
|
||||||
|
function buildCutout() {
|
||||||
|
const d = device;
|
||||||
|
if (d.notch === 'notch') {
|
||||||
|
const r = Math.round((d.nh || 0) * 0.5);
|
||||||
|
if (!landscape) return '<div class="cutout" style="top:0;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:0 0 ' + r + 'px ' + r + 'px;"></div>';
|
||||||
|
return '<div class="cutout" style="left:0;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:0 ' + r + 'px ' + r + 'px 0;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'island') {
|
||||||
|
if (!landscape) return '<div class="cutout" style="top:12px;left:50%;transform:translateX(-50%);width:' + d.nw + 'px;height:' + d.nh + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
return '<div class="cutout" style="left:12px;top:50%;transform:translateY(-50%);width:' + d.nh + 'px;height:' + d.nw + 'px;border-radius:' + d.nr + 'px;"></div>';
|
||||||
|
}
|
||||||
|
if (d.notch === 'punch') {
|
||||||
|
const ps = d.ps || 12;
|
||||||
|
if (!landscape) return '<div class="cutout" style="top:10px;left:50%;transform:translateX(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
return '<div class="cutout" style="left:10px;top:50%;transform:translateY(-50%);width:' + ps + 'px;height:' + ps + 'px;border-radius:50%;"></div>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function buildHomeBar() {
|
||||||
|
if (!device.sab) return '';
|
||||||
|
const base = 'position:absolute;background:rgba(255,255,255,0.3);border-radius:3px;pointer-events:none;z-index:10;';
|
||||||
|
if (!landscape) return '<div style="' + base + 'bottom:8px;left:50%;transform:translateX(-50%);width:130px;height:5px;"></div>';
|
||||||
|
return '<div style="' + base + 'right:8px;top:50%;transform:translateY(-50%);width:5px;height:100px;"></div>';
|
||||||
|
}
|
||||||
|
function renderScreen() {
|
||||||
|
const W = sw(), H = sh();
|
||||||
|
const scaler = document.getElementById('screenScaler');
|
||||||
|
scaler.innerHTML = '<div id="deviceScreen" style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
|
||||||
|
buildCutout() +
|
||||||
|
'<iframe id="previewFrame" width="' + W + '" height="' + H + '" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
|
||||||
|
buildHomeBar() +
|
||||||
|
'</div>';
|
||||||
|
dimLabel.textContent = W + ' x ' + H;
|
||||||
|
document.getElementById('previewFrame').addEventListener('load', applyMute);
|
||||||
|
updateScale();
|
||||||
|
}
|
||||||
|
function loadContent(html) {
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
if (!frame) return;
|
||||||
|
if (currentBlobUrl) {
|
||||||
|
URL.revokeObjectURL(currentBlobUrl);
|
||||||
|
currentBlobUrl = '';
|
||||||
|
}
|
||||||
|
if (currentPath) {
|
||||||
|
frame.src = previewUrl();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||||
|
currentBlobUrl = URL.createObjectURL(blob);
|
||||||
|
frame.src = currentBlobUrl;
|
||||||
|
}
|
||||||
|
function previewUrl() {
|
||||||
|
loadVersion += 1;
|
||||||
|
return '/api/device-simulator/preview/index.html?path=' + encodeURIComponent(currentPath) +
|
||||||
|
'&lang=' + encodeURIComponent(language) +
|
||||||
|
'&v=' + loadVersion;
|
||||||
|
}
|
||||||
|
function injectMuteBridge(html) {
|
||||||
|
const languageBridge =
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(function(){try{' +
|
||||||
|
'var url=new URL(window.location.href);' +
|
||||||
|
'url.searchParams.set("lang","' + language + '");' +
|
||||||
|
'history.replaceState(null,"",url.href);' +
|
||||||
|
'}catch(e){}})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
const bridge = languageBridge +
|
||||||
|
'<scr' + 'ipt>' +
|
||||||
|
'(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){}' +
|
||||||
|
'})();' +
|
||||||
|
'</scr' + 'ipt>';
|
||||||
|
if (/<head\b[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<head\b[^>]*>/i, function(match) { return match + bridge; });
|
||||||
|
}
|
||||||
|
return bridge + html;
|
||||||
|
}
|
||||||
|
function renderMuteButton() {
|
||||||
|
const btn = document.getElementById('muteBtn');
|
||||||
|
btn.innerHTML = muted ? '🔈' : '🔇';
|
||||||
|
btn.title = muted ? 'Unmute' : 'Mute';
|
||||||
|
btn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
|
||||||
|
btn.setAttribute('aria-pressed', muted ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
function applyMute() {
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
if (!frame || !frame.contentWindow) return;
|
||||||
|
frame.contentWindow.postMessage({ type: 'hplDeviceSimulatorMute', muted: muted }, '*');
|
||||||
|
try {
|
||||||
|
frame.contentWindow.__hplMuted = muted;
|
||||||
|
if (typeof frame.contentWindow.__hplSetMuted === 'function') frame.contentWindow.__hplSetMuted(muted);
|
||||||
|
const doc = frame.contentWindow.document;
|
||||||
|
doc.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
if (muteDocument !== doc) {
|
||||||
|
if (muteObserver) muteObserver.disconnect();
|
||||||
|
muteDocument = doc;
|
||||||
|
muteObserver = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
if (node.matches && node.matches('audio,video')) syncMediaMute(node);
|
||||||
|
if (node.querySelectorAll) node.querySelectorAll('audio,video').forEach(syncMediaMute);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
muteObserver.observe(doc.documentElement, { childList:true, subtree:true });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
function syncMediaMute(media) {
|
||||||
|
if (muted) {
|
||||||
|
if (!mediaVolumes.has(media)) mediaVolumes.set(media, media.volume);
|
||||||
|
media.muted = true;
|
||||||
|
media.volume = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
media.muted = false;
|
||||||
|
if (mediaVolumes.has(media)) media.volume = mediaVolumes.get(media);
|
||||||
|
}
|
||||||
|
function updateScale() {
|
||||||
|
const area = document.getElementById('previewArea');
|
||||||
|
const screen = document.getElementById('deviceScreen');
|
||||||
|
const scaler = document.getElementById('screenScaler');
|
||||||
|
if (!screen || !area || !scaler) return;
|
||||||
|
scaler.style.transform = 'none';
|
||||||
|
const nw = screen.offsetWidth, nh = screen.offsetHeight;
|
||||||
|
const aw = area.clientWidth - 40, ah = area.clientHeight - 40;
|
||||||
|
const scale = Math.min(aw / nw, ah / nh, 1);
|
||||||
|
scaler.style.transform = 'scale(' + scale + ')';
|
||||||
|
scaler.style.marginBottom = Math.round(nh * (scale - 1)) + 'px';
|
||||||
|
}
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorPick(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := PickFiles("Select HTML file", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
||||||
|
path := ""
|
||||||
|
if len(paths) > 0 {
|
||||||
|
path = paths[0]
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"path": path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorLoad(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" || !isHTMLPath(req.Path) {
|
||||||
|
writeJSON(w, map[string]any{"error": "Pick an HTML file."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
writeJSON(w, map[string]any{"error": "Expected an HTML file, got a folder."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"name": filepath.Base(req.Path),
|
||||||
|
"path": req.Path,
|
||||||
|
"content": string(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceSimulatorPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if path == "" || !isHTMLPath(path) {
|
||||||
|
http.Error(w, "Pick an HTML file.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
http.Error(w, "Expected an HTML file, got a folder.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := filepath.Dir(path)
|
||||||
|
assetName := filepath.ToSlash(r.URL.Path[len("/api/device-simulator/preview/"):])
|
||||||
|
targetPath := path
|
||||||
|
if assetName != "" && assetName != "index.html" {
|
||||||
|
cleanName := filepath.Clean(filepath.FromSlash(assetName))
|
||||||
|
targetPath = filepath.Join(baseDir, cleanName)
|
||||||
|
resolvedBase, err := filepath.Abs(baseDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolvedTarget, err := filepath.Abs(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resolvedTarget != resolvedBase && len(resolvedTarget) > len(resolvedBase) && resolvedTarget[:len(resolvedBase)+1] != resolvedBase+string(os.PathSeparator) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if targetPath == path {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
data = []byte(injectDeviceSimulatorMuteBridge(string(data)))
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = 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)
|
||||||
|
headIndex := strings.Index(lower, "<head")
|
||||||
|
if headIndex >= 0 {
|
||||||
|
closeIndex := strings.Index(lower[headIndex:], ">")
|
||||||
|
if closeIndex >= 0 {
|
||||||
|
insertAt := headIndex + closeIndex + 1
|
||||||
|
return html[:insertAt] + bridge + html[insertAt:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bridge + html
|
||||||
|
}
|
||||||
@@ -16,13 +16,16 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if n.Beta {
|
if n.Beta {
|
||||||
badge = ` <span class="beta-pill">Beta</span>`
|
badge = ` <span class="beta-pill">Beta</span>`
|
||||||
}
|
}
|
||||||
items.WriteString(`<button type="button" class="home-tool" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
items.WriteString(`<button type="button" class="home-tool" draggable="true" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := `
|
body := `
|
||||||
<header class="tool-header">
|
<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>
|
<h2 class="tool-title">HPL Toolbox</h2>
|
||||||
<p class="tool-description">Standalone build. Pick a tool to open.</p>
|
<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;">⚙</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="home-tools">
|
<div class="home-tools">
|
||||||
` + items.String() + `
|
` + items.String() + `
|
||||||
@@ -43,6 +46,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
font:inherit;
|
font:inherit;
|
||||||
}
|
}
|
||||||
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
||||||
|
.home-tool.dragging { opacity:0.55; }
|
||||||
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
||||||
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
@@ -25,30 +25,111 @@ const SharedCSS = `
|
|||||||
html { min-height: 100%; }
|
html { min-height: 100%; }
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: var(--sidebar-width, 236px) minmax(0, 1fr);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: #1e1e1e; color: #ddd;
|
background: #1e1e1e; color: #ddd;
|
||||||
margin: 0; padding: 0;
|
margin: 0; padding: 0;
|
||||||
}
|
}
|
||||||
.topbar {
|
body.sidebar-collapsed { --sidebar-width: 56px; }
|
||||||
display: flex; align-items: center; gap: 8px;
|
.sidebar {
|
||||||
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
background: #252526;
|
||||||
|
border-right: 1px solid #333;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.topbar .nav-link {
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 2px 6px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
.sidebar-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.45px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sidebar-toggle {
|
||||||
|
width: 32px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: transparent;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.sidebar-toggle:hover { background: #2a2d2e; }
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 5px 10px;
|
padding: 4px 8px 4px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link:hover { background: #2a2d2e; }
|
||||||
|
.sidebar .nav-link.active { background: #094771; color: #fff; }
|
||||||
|
.sidebar .nav-link.dragging { opacity: 0.55; }
|
||||||
|
.nav-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(128,128,128,0.12);
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.nav-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.topbar .nav-link:hover { background: #2a2d2e; }
|
|
||||||
.topbar .nav-link.active { background: #094771; color: #fff; }
|
|
||||||
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
|
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
|
||||||
.topbar-spacer { margin-left: auto; }
|
.app-shell {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
|
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
|
||||||
.app-footer {
|
.app-footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -78,6 +159,77 @@ const SharedCSS = `
|
|||||||
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
|
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
|
||||||
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
|
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
|
||||||
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
|
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
|
||||||
|
.app-footer-version-button { justify-self: center; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.75; text-align: center; white-space: nowrap; }
|
||||||
|
.app-footer-version-button:hover { background: transparent; opacity: 1; text-decoration: underline; }
|
||||||
|
.toast-box {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
z-index: 50;
|
||||||
|
max-width: min(420px, calc(100vw - 36px));
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #ddd;
|
||||||
|
background: #252526;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-left: 3px solid #007fd4;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 10px 28px rgba(0,0,0,0.35);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.toast-box.is-visible { display: flex; }
|
||||||
|
.toast-box.is-update { border-left-color: #ffd580; }
|
||||||
|
.toast-box.is-current { border-left-color: #89d185; }
|
||||||
|
.toast-box.is-error { border-left-color: #f48771; }
|
||||||
|
.toast-message { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
||||||
|
.toast-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #0e639c;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.toast-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: transparent;
|
||||||
|
color: #a7a7a7;
|
||||||
|
border: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.toast-close:hover { background: rgba(255,255,255,0.08); color: #fff; }
|
||||||
|
body.sidebar-collapsed .sidebar { align-items: stretch; }
|
||||||
|
body.sidebar-collapsed .sidebar-title,
|
||||||
|
body.sidebar-collapsed .nav-text,
|
||||||
|
body.sidebar-collapsed .beta-pill { display: none; }
|
||||||
|
body.sidebar-collapsed .sidebar .nav-link {
|
||||||
|
grid-template-columns: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
body.sidebar-collapsed .sidebar-header { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||||
|
body.sidebar-collapsed .sidebar-toggle { width: 38px; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
body { grid-template-columns: 1fr; }
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
max-height: 48vh;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
body.sidebar-collapsed .sidebar { max-height: 50px; }
|
||||||
|
}
|
||||||
h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
||||||
.tool-header { margin-bottom: var(--tool-gap-lg); }
|
.tool-header { margin-bottom: var(--tool-gap-lg); }
|
||||||
.tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
.tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
||||||
@@ -159,6 +311,27 @@ const SharedCSS = `
|
|||||||
.err { color: #f48771; white-space: pre-wrap; }
|
.err { color: #f48771; white-space: pre-wrap; }
|
||||||
.ok { color: #89d185; }
|
.ok { color: #89d185; }
|
||||||
.hint { font-size: 12px; opacity: 0.7; }
|
.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 = `
|
const SharedDropZoneScript = `
|
||||||
@@ -324,12 +497,15 @@ type navItem struct {
|
|||||||
|
|
||||||
var navItems = []navItem{
|
var navItems = []navItem{
|
||||||
{Path: "/", Label: "Home"},
|
{Path: "/", Label: "Home"},
|
||||||
|
{Path: "/project-init", Label: "Initialize Project", Description: "Download AGENTS.md and .gitignore templates from Google Drive"},
|
||||||
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
||||||
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
|
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
|
||||||
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
|
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
|
||||||
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
|
{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: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
|
||||||
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
{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"},
|
||||||
|
{Path: "/device-simulator", Label: "Device Simulator", Description: "Preview HTML playables inside mobile device frames"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
||||||
@@ -365,9 +541,42 @@ func betaToolCount() int {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func navInitials(label string) string {
|
||||||
|
switch label {
|
||||||
|
case "Initialize Project":
|
||||||
|
return "IP"
|
||||||
|
case "Base64 Scanner":
|
||||||
|
return "B64"
|
||||||
|
case "AppLovin Playable Preview":
|
||||||
|
return "APP"
|
||||||
|
case "Mintegral Checker":
|
||||||
|
return "MiC"
|
||||||
|
case "Device Simulator":
|
||||||
|
return "DS"
|
||||||
|
}
|
||||||
|
words := strings.Fields(label)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
if len(words) == 1 {
|
||||||
|
r := []rune(words[0])
|
||||||
|
if len(r) == 0 {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return strings.ToUpper(string(r[0]))
|
||||||
|
}
|
||||||
|
first := []rune(words[0])
|
||||||
|
second := []rune(words[1])
|
||||||
|
if len(first) == 0 || len(second) == 0 {
|
||||||
|
return strings.ToUpper(string([]rune(label)[0]))
|
||||||
|
}
|
||||||
|
return strings.ToUpper(string(first[0]) + string(second[0]))
|
||||||
|
}
|
||||||
|
|
||||||
func Page(activePath, title, body string) string {
|
func Page(activePath, title, body string) string {
|
||||||
cfg := LoadConfig()
|
cfg := LoadConfig()
|
||||||
betaToolsEnabled := cfg.BetaToolsEnabled
|
betaToolsEnabled := cfg.BetaToolsEnabled
|
||||||
|
randomizeUA := cfg.Applovin.RandomizeUserAgent == nil || *cfg.Applovin.RandomizeUserAgent
|
||||||
var tabs strings.Builder
|
var tabs strings.Builder
|
||||||
for _, n := range visibleNavItems(betaToolsEnabled) {
|
for _, n := range visibleNavItems(betaToolsEnabled) {
|
||||||
cls := ""
|
cls := ""
|
||||||
@@ -378,7 +587,7 @@ func Page(activePath, title, body string) string {
|
|||||||
if n.Beta {
|
if n.Beta {
|
||||||
label += `<span class="beta-pill">Beta</span>`
|
label += `<span class="beta-pill">Beta</span>`
|
||||||
}
|
}
|
||||||
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `">` + label + `</button>`)
|
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" draggable="` + boolAttr(n.Path != "/") + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
betaButtonLabel := "Show beta"
|
betaButtonLabel := "Show beta"
|
||||||
@@ -393,20 +602,102 @@ func Page(activePath, title, body string) string {
|
|||||||
<style>` + SharedCSS + `</style>
|
<style>` + SharedCSS + `</style>
|
||||||
<script>` + SharedDropZoneScript + `</script>
|
<script>` + SharedDropZoneScript + `</script>
|
||||||
</head><body>
|
</head><body>
|
||||||
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
|
<aside class="sidebar">
|
||||||
<div class="content">` + body + `</div>
|
<div class="sidebar-header">
|
||||||
<div class="app-footer">
|
<span class="sidebar-title">HPL Toolbox</span>
|
||||||
|
<button id="sidebarToggle" class="sidebar-toggle" title="Toggle sidebar" aria-label="Toggle sidebar">☰</button>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">` + tabs.String() + `</nav>
|
||||||
|
</aside>
|
||||||
|
<div class="app-shell">
|
||||||
|
<div class="content">` + body + `</div>
|
||||||
|
<div class="app-footer">
|
||||||
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
|
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
|
||||||
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
|
<button id="updateCheck" type="button" class="app-footer-version-button" title="Check for updates">` + AppVersion + ` - JJGC 00784</button>
|
||||||
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
|
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="toastBox" class="toast-box" role="status" aria-live="polite">
|
||||||
|
<span id="toastMessage" class="toast-message"></span>
|
||||||
|
<button id="toastAction" type="button" class="toast-action is-hidden">Open Repository</button>
|
||||||
|
<button id="toastClose" type="button" class="toast-close" aria-label="Close">x</button>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
if (localStorage.getItem('hplSidebarCollapsed') === 'true') {
|
||||||
|
document.body.classList.add('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
document.getElementById('sidebarToggle').addEventListener('click', () => {
|
||||||
|
const collapsed = document.body.classList.toggle('sidebar-collapsed');
|
||||||
|
localStorage.setItem('hplSidebarCollapsed', collapsed ? 'true' : 'false');
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-path]').forEach((element) => {
|
document.querySelectorAll('[data-path]').forEach((element) => {
|
||||||
element.addEventListener('click', () => {
|
element.addEventListener('click', () => {
|
||||||
|
if (element.dataset.dragging === 'true') {
|
||||||
|
element.dataset.dragging = 'false';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const path = element.dataset.path;
|
const path = element.dataset.path;
|
||||||
if (path) window.location.href = path;
|
if (path) window.location.href = path;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
|
||||||
|
function getToolOrder() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(TOOL_ORDER_KEY) || '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(path => typeof path === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setToolOrder(paths) {
|
||||||
|
localStorage.setItem(TOOL_ORDER_KEY, JSON.stringify(paths));
|
||||||
|
}
|
||||||
|
function applyStoredToolOrder(container, selector) {
|
||||||
|
if (!container) return;
|
||||||
|
const order = getToolOrder();
|
||||||
|
if (!order.length) return;
|
||||||
|
const elements = Array.from(container.querySelectorAll(selector));
|
||||||
|
const byPath = new Map(elements.map(el => [el.dataset.path, el]));
|
||||||
|
const home = byPath.get('/');
|
||||||
|
const ordered = order.map(path => byPath.get(path)).filter(Boolean);
|
||||||
|
const orderedSet = new Set(ordered);
|
||||||
|
const remaining = elements.filter(el => el !== home && !orderedSet.has(el));
|
||||||
|
if (home) container.appendChild(home);
|
||||||
|
ordered.concat(remaining).forEach(el => container.appendChild(el));
|
||||||
|
}
|
||||||
|
function setupToolReorder(container, selector) {
|
||||||
|
if (!container) return;
|
||||||
|
applyStoredToolOrder(container, selector);
|
||||||
|
let dragged = null;
|
||||||
|
container.querySelectorAll(selector).forEach(element => {
|
||||||
|
if (element.dataset.path === '/') return;
|
||||||
|
element.addEventListener('dragstart', event => {
|
||||||
|
dragged = element;
|
||||||
|
element.classList.add('dragging');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', element.dataset.path || '');
|
||||||
|
});
|
||||||
|
element.addEventListener('dragend', () => {
|
||||||
|
element.classList.remove('dragging');
|
||||||
|
element.dataset.dragging = 'true';
|
||||||
|
dragged = null;
|
||||||
|
const paths = Array.from(container.querySelectorAll(selector))
|
||||||
|
.map(el => el.dataset.path)
|
||||||
|
.filter(path => path && path !== '/');
|
||||||
|
setToolOrder(paths);
|
||||||
|
setTimeout(() => { element.dataset.dragging = 'false'; }, 0);
|
||||||
|
});
|
||||||
|
element.addEventListener('dragover', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dragged || dragged === element || element.dataset.path === '/') return;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const after = event.clientY > rect.top + rect.height / 2;
|
||||||
|
container.insertBefore(dragged, after ? element.nextSibling : element);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupToolReorder(document.querySelector('.sidebar-nav'), '.nav-link');
|
||||||
|
setupToolReorder(document.querySelector('.home-tools'), '.home-tool');
|
||||||
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
||||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
await fetch('/api/betaTools', {
|
await fetch('/api/betaTools', {
|
||||||
@@ -416,7 +707,201 @@ document.getElementById('betaToolsToggle').addEventListener('click', async (even
|
|||||||
});
|
});
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
const toastBox = document.getElementById('toastBox');
|
||||||
|
const toastMessage = document.getElementById('toastMessage');
|
||||||
|
const toastAction = document.getElementById('toastAction');
|
||||||
|
const toastClose = document.getElementById('toastClose');
|
||||||
|
let toastTimer = null;
|
||||||
|
let updateRepositoryUrl = '';
|
||||||
|
function showToast(message, status, repositoryUrl) {
|
||||||
|
window.clearTimeout(toastTimer);
|
||||||
|
updateRepositoryUrl = repositoryUrl || '';
|
||||||
|
toastMessage.textContent = message || '';
|
||||||
|
toastAction.classList.toggle('is-hidden', !updateRepositoryUrl);
|
||||||
|
toastBox.className = 'toast-box is-visible is-' + (status || 'current');
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toastBox.classList.remove('is-visible');
|
||||||
|
}, updateRepositoryUrl ? 9000 : 5000);
|
||||||
|
}
|
||||||
|
async function checkForUpdates(showCurrent) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/update/check');
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.status === 'current' && !showCurrent) return;
|
||||||
|
showToast(result.message || 'HPL Toolbox update check finished.', result.status, result.repositoryUrl);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('HPL Toolbox update check failed: ' + (error.message || error), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('updateCheck').addEventListener('click', () => {
|
||||||
|
checkForUpdates(true);
|
||||||
|
});
|
||||||
|
toastClose.addEventListener('click', () => {
|
||||||
|
window.clearTimeout(toastTimer);
|
||||||
|
toastBox.classList.remove('is-visible');
|
||||||
|
});
|
||||||
|
toastAction.addEventListener('click', async () => {
|
||||||
|
if (!updateRepositoryUrl) return;
|
||||||
|
await fetch('/api/open', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: updateRepositoryUrl }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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>
|
</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">×</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>`
|
</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,20 +107,31 @@ func buildMux() *http.ServeMux {
|
|||||||
}
|
}
|
||||||
HomePage(w, r)
|
HomePage(w, r)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("GET /project-init", ProjectInitPage)
|
||||||
mux.HandleFunc("GET /plec", PlecPage)
|
mux.HandleFunc("GET /plec", PlecPage)
|
||||||
mux.HandleFunc("GET /applovin", ApplovinPage)
|
mux.HandleFunc("GET /applovin", ApplovinPage)
|
||||||
mux.HandleFunc("GET /base64", Base64Page)
|
mux.HandleFunc("GET /base64", Base64Page)
|
||||||
mux.HandleFunc("GET /mraid", MraidPage)
|
mux.HandleFunc("GET /mraid", MraidPage)
|
||||||
|
mux.HandleFunc("GET /mintegral", MintegralPage)
|
||||||
mux.HandleFunc("GET /mobile", MobilePage)
|
mux.HandleFunc("GET /mobile", MobilePage)
|
||||||
mux.HandleFunc("GET /playworks", PlayworksPage)
|
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 /changelog", ChangelogPage)
|
||||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||||
|
|
||||||
|
// Initialize Project
|
||||||
|
mux.HandleFunc("GET /api/project-init/manifest", ProjectInitManifestEndpoint)
|
||||||
|
mux.HandleFunc("POST /api/project-init/pickFolder", ProjectInitPickFolderEndpoint)
|
||||||
|
mux.HandleFunc("POST /api/project-init/setManifestUrl", ProjectInitSetManifestURLEndpoint)
|
||||||
|
mux.HandleFunc("POST /api/project-init/download", ProjectInitDownloadEndpoint)
|
||||||
|
|
||||||
// Shared API
|
// Shared API
|
||||||
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
||||||
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
||||||
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
||||||
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
|
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
|
||||||
|
mux.HandleFunc("GET /api/update/check", UpdateCheckEndpoint)
|
||||||
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
|
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
|
||||||
|
|
||||||
// PLEC
|
// PLEC
|
||||||
@@ -145,6 +156,14 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles)
|
mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles)
|
||||||
mux.HandleFunc("POST /api/mraid/scan", MraidScan)
|
mux.HandleFunc("POST /api/mraid/scan", MraidScan)
|
||||||
|
|
||||||
|
// Mintegral Checker
|
||||||
|
mux.HandleFunc("POST /api/mintegral/pick", MintegralPick)
|
||||||
|
mux.HandleFunc("POST /api/mintegral/load", MintegralLoad)
|
||||||
|
mux.HandleFunc("GET /mintegral/runtime", MintegralRuntimePage)
|
||||||
|
mux.HandleFunc("GET /mintegral/ad", MintegralAd)
|
||||||
|
mux.HandleFunc("GET /mintegral/monitor.js", MintegralMonitor)
|
||||||
|
mux.HandleFunc("GET /mintegral/preview-util.js", MintegralPreviewUtil)
|
||||||
|
|
||||||
// Send To Mobile
|
// Send To Mobile
|
||||||
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
||||||
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)
|
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)
|
||||||
@@ -156,6 +175,16 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
||||||
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
||||||
|
|
||||||
|
// Device Simulator
|
||||||
|
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
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
545
standalone/mintegral.go
Normal file
545
standalone/mintegral.go
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/mintegral/preview-util.js
|
||||||
|
var mintegralPreviewUtil string
|
||||||
|
|
||||||
|
const (
|
||||||
|
mintegralZipLimit = 5 * 1024 * 1024
|
||||||
|
mintegralHTMLLimit = 5 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
type mintegralCheck struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mintegralZipEntry struct {
|
||||||
|
Name string
|
||||||
|
CompressedSize uint64
|
||||||
|
UncompressedSize uint64
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type mintegralRuntimePayload struct {
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
ZipSize int64 `json:"zipSize"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
Checks []mintegralCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var mintegralRuntime = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
payload *mintegralRuntimePayload
|
||||||
|
}{}
|
||||||
|
|
||||||
|
func MintegralPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Mintegral Checker</h2>
|
||||||
|
<p class="tool-description">Validates a Mintegral playable ad ZIP against PlayTurbo requirements. Runtime checks update live as you interact with the playable.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">Input</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-label">Playable ZIP</div>
|
||||||
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickBtn" class="secondary">Select File</button>
|
||||||
|
<button id="reloadBtn" class="secondary" style="display:none">Reload</button>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-text">or drop a Mintegral ZIP here</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="fileName" style="margin-top:8px;">(no file selected)</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tool-panel embedded-runtime" id="runtimePanel" style="display:none">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">Runtime Checker</h3></div>
|
||||||
|
<iframe id="runtimeFrame"></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.embedded-runtime { height: calc(100vh - 260px); min-height: 360px; }
|
||||||
|
.embedded-runtime iframe { width:100%; height:calc(100% - 39px); min-height:0; border:0; background:#1e1e1e; display:block; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const fileNameEl = document.getElementById('fileName');
|
||||||
|
const runtimePanel = document.getElementById('runtimePanel');
|
||||||
|
const runtimeFrame = document.getElementById('runtimeFrame');
|
||||||
|
const reloadBtn = document.getElementById('reloadBtn');
|
||||||
|
let currentPath = '';
|
||||||
|
function basename(p){ const i=Math.max(p.lastIndexOf('/'),p.lastIndexOf('\\')); return i>=0?p.slice(i+1):p; }
|
||||||
|
async function loadZip(path){
|
||||||
|
if(!path){ statusEl.textContent='Pick a ZIP first.'; return; }
|
||||||
|
currentPath = path;
|
||||||
|
statusEl.textContent = 'Reading ' + basename(path) + '...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
runtimeFrame.src = 'about:blank';
|
||||||
|
runtimePanel.style.display = 'none';
|
||||||
|
const r = await fetch('/api/mintegral/load', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path})});
|
||||||
|
const j = await r.json();
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
if(j.error){ statusEl.textContent = 'Error: ' + j.error; return; }
|
||||||
|
fileNameEl.textContent = j.fileName + ' (' + Math.round(j.zipSize / 1024) + ' KB)';
|
||||||
|
statusEl.textContent = '';
|
||||||
|
reloadBtn.style.display = '';
|
||||||
|
runtimePanel.style.display = 'block';
|
||||||
|
runtimeFrame.src = j.runtimeUrl;
|
||||||
|
}
|
||||||
|
document.getElementById('pickBtn').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/mintegral/pick', {method:'POST'});
|
||||||
|
const j = await r.json();
|
||||||
|
if(j.path) loadZip(j.path);
|
||||||
|
});
|
||||||
|
reloadBtn.addEventListener('click', () => loadZip(currentPath));
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('is-dragover'); });
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('is-dragover'));
|
||||||
|
dropZone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('is-dragover');
|
||||||
|
const paths = extractDroppedPaths(e.dataTransfer).filter(p => /\.zip$/i.test(p));
|
||||||
|
if(!paths.length){ statusEl.textContent = 'Drop a ZIP file.'; return; }
|
||||||
|
loadZip(paths[0]);
|
||||||
|
});
|
||||||
|
</script>`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/mintegral", "Mintegral Checker", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MintegralPick(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := PickFiles("Select Mintegral ZIP", []FileFilter{{Name: "ZIP", Extensions: []string{"zip"}}}, false, "")
|
||||||
|
path := ""
|
||||||
|
if len(paths) > 0 {
|
||||||
|
path = paths[0]
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"path": path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MintegralLoad(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := loadMintegralZip(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mintegralRuntime.Lock()
|
||||||
|
mintegralRuntime.payload = payload
|
||||||
|
mintegralRuntime.Unlock()
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"fileName": payload.FileName,
|
||||||
|
"zipSize": payload.ZipSize,
|
||||||
|
"runtimeUrl": "/mintegral/runtime",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MintegralRuntimePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mintegralRuntime.RLock()
|
||||||
|
payload := mintegralRuntime.payload
|
||||||
|
mintegralRuntime.RUnlock()
|
||||||
|
if payload == nil {
|
||||||
|
http.Error(w, "No Mintegral ZIP loaded.", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(mintegralRuntimeHTML(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MintegralAd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mintegralRuntime.RLock()
|
||||||
|
payload := mintegralRuntime.payload
|
||||||
|
mintegralRuntime.RUnlock()
|
||||||
|
if payload == nil {
|
||||||
|
http.Error(w, "No Mintegral ZIP loaded.", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(payload.HTML))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MintegralPreviewUtil(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(mintegralPreviewUtil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MintegralMonitor(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(mintegralMonitorScript))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMintegralZip(zipPath string) (*mintegralRuntimePayload, error) {
|
||||||
|
if zipPath == "" {
|
||||||
|
return nil, fmt.Errorf("Pick a ZIP first.")
|
||||||
|
}
|
||||||
|
info, err := os.Stat(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("Expected a ZIP file, got a folder.")
|
||||||
|
}
|
||||||
|
entries, err := readMintegralZip(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var index *mintegralZipEntry
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Name == "index.html" {
|
||||||
|
index = &entries[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
htmlText := ""
|
||||||
|
if index != nil {
|
||||||
|
htmlText = string(index.Data)
|
||||||
|
}
|
||||||
|
checks := runMintegralStaticChecks(htmlText, entries, info.Size())
|
||||||
|
return &mintegralRuntimePayload{
|
||||||
|
FileName: filepath.Base(zipPath),
|
||||||
|
ZipSize: info.Size(),
|
||||||
|
HTML: injectMintegralMonitoring(htmlText),
|
||||||
|
Checks: checks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMintegralZip(zipPath string) ([]mintegralZipEntry, error) {
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
entries := make([]mintegralZipEntry, 0, len(zr.File))
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if strings.HasSuffix(f.Name, "/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
_ = rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
data = nil
|
||||||
|
}
|
||||||
|
entries = append(entries, mintegralZipEntry{
|
||||||
|
Name: filepath.ToSlash(f.Name),
|
||||||
|
CompressedSize: f.CompressedSize64,
|
||||||
|
UncompressedSize: f.UncompressedSize64,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectMintegralMonitoring(htmlText string) string {
|
||||||
|
stub := `<script src="/mintegral/preview-util.js"></script>
|
||||||
|
<script src="/mintegral/monitor.js"></script>`
|
||||||
|
if regexp.MustCompile(`(?i)<head\b[^>]*>`).MatchString(htmlText) {
|
||||||
|
return regexp.MustCompile(`(?i)<head\b[^>]*>`).ReplaceAllStringFunc(htmlText, func(m string) string {
|
||||||
|
return m + "\n" + stub
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stub + "\n" + htmlText
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMintegralStaticChecks(htmlText string, entries []mintegralZipEntry, zipSize int64) []mintegralCheck {
|
||||||
|
scripts := mintegralExtractScripts(htmlText)
|
||||||
|
return []mintegralCheck{
|
||||||
|
mintegralCheckHTML(htmlText),
|
||||||
|
mintegralCheckCTA(scripts),
|
||||||
|
mintegralCheckGameEnd(scripts),
|
||||||
|
mintegralCheckGameReady(htmlText, scripts),
|
||||||
|
mintegralCheckGameStart(scripts),
|
||||||
|
mintegralCheckGameClose(scripts),
|
||||||
|
mintegralCheckFileHandling(htmlText),
|
||||||
|
mintegralCheckFileSpec(entries, zipSize),
|
||||||
|
mintegralCheckStorage(scripts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralExtractScripts(htmlText string) string {
|
||||||
|
re := regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||||
|
matches := re.FindAllStringSubmatch(htmlText, -1)
|
||||||
|
parts := make([]string, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
parts = append(parts, m[1])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckHTML(htmlText string) mintegralCheck {
|
||||||
|
issues := []string{}
|
||||||
|
if !regexp.MustCompile(`(?i)^\s*<!DOCTYPE\s+html\s*>`).MatchString(htmlText) {
|
||||||
|
issues = append(issues, "Missing <!DOCTYPE html>")
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`(?i)<meta\b[^>]*\bcharset\s*=`).MatchString(htmlText) {
|
||||||
|
issues = append(issues, "Missing charset meta tag")
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`(?i)<meta\b[^>]*\bname\s*=\s*["']viewport["']`).MatchString(htmlText) {
|
||||||
|
issues = append(issues, "Missing viewport meta tag")
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`(?i)<script\b[^>]*\btype\s*=\s*["']module["']`).MatchString(htmlText) {
|
||||||
|
issues = append(issues, `type="module" on script tag`)
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`(?i)<script\b[^>]*\bcrossorigin\b`).MatchString(htmlText) {
|
||||||
|
issues = append(issues, "crossorigin attribute on script tag")
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`(?i)</html\s*>`).MatchString(htmlText) {
|
||||||
|
issues = append(issues, "Missing </html> closing tag")
|
||||||
|
}
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return mintegralCheck{ID: "html-requirements", Status: "pass", Detail: "Valid HTML structure"}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "html-requirements", Status: "fail", Detail: strings.Join(issues, "; ")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckCTA(scripts string) mintegralCheck {
|
||||||
|
if regexp.MustCompile(`\bwindow\.install\b|\btypeof\s+window\.install`).MatchString(scripts) {
|
||||||
|
return mintegralCheck{ID: "cta-method", Status: "pass", Detail: "window.install() referenced in scripts"}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "cta-method", Status: "fail", Detail: "window.install() not found - Mintegral CTA requires window.install()"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckGameEnd(scripts string) mintegralCheck {
|
||||||
|
if regexp.MustCompile(`\bgameEnd\b`).MatchString(scripts) {
|
||||||
|
return mintegralCheck{ID: "game-end", Status: "pass", Detail: "gameEnd() found in scripts"}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "game-end", Status: "fail", Detail: "gameEnd() not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckGameReady(htmlText, scripts string) mintegralCheck {
|
||||||
|
inScript := regexp.MustCompile(`\bgameReady\b`).MatchString(scripts)
|
||||||
|
inOnload := regexp.MustCompile(`(?i)\bonload\s*=\s*["'][^"']*\bgameReady\b`).MatchString(htmlText)
|
||||||
|
if !inScript && !inOnload {
|
||||||
|
return mintegralCheck{ID: "game-ready", Status: "fail", Detail: "gameReady() not found"}
|
||||||
|
}
|
||||||
|
if !inOnload {
|
||||||
|
return mintegralCheck{ID: "game-ready", Status: "warn", Detail: `gameReady() found but not wired to body onload - Mintegral requires onload="gameReady()"`}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "game-ready", Status: "pass", Detail: "gameReady() wired to body onload"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckGameStart(scripts string) mintegralCheck {
|
||||||
|
if regexp.MustCompile(`\bgameStart\b`).MatchString(scripts) {
|
||||||
|
return mintegralCheck{ID: "game-start", Status: "pass", Detail: "gameStart() found in scripts"}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "game-start", Status: "fail", Detail: "gameStart() not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckGameClose(scripts string) mintegralCheck {
|
||||||
|
if regexp.MustCompile(`\bgameClose\b`).MatchString(scripts) {
|
||||||
|
return mintegralCheck{ID: "game-close", Status: "pass", Detail: "gameClose() found in scripts"}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "game-close", Status: "fail", Detail: "gameClose() not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckFileHandling(htmlText string) mintegralCheck {
|
||||||
|
re := regexp.MustCompile(`(?i)(?:src|href|action)\s*=\s*["'](https?://[^"']+)["']|url\s*\(\s*["']?(https?://[^)"'\s]+)`)
|
||||||
|
matches := re.FindAllStringSubmatch(htmlText, -1)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
found := []string{}
|
||||||
|
for _, m := range matches {
|
||||||
|
u := m[1]
|
||||||
|
if u == "" {
|
||||||
|
u = m[2]
|
||||||
|
}
|
||||||
|
if u != "" && !seen[u] {
|
||||||
|
seen[u] = true
|
||||||
|
found = append(found, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
return mintegralCheck{ID: "file-handling", Status: "pass", Detail: "All assets are inline - no external URLs detected"}
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("%d external URL(s): %s", len(found), strings.Join(found[:minInt(2, len(found))], ", "))
|
||||||
|
if len(found) > 2 {
|
||||||
|
detail += fmt.Sprintf(" (+%d more)", len(found)-2)
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "file-handling", Status: "fail", Detail: detail}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckFileSpec(entries []mintegralZipEntry, zipSize int64) mintegralCheck {
|
||||||
|
issues := []string{}
|
||||||
|
var index *mintegralZipEntry
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Name == "index.html" {
|
||||||
|
index = &entries[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == nil {
|
||||||
|
nested := ""
|
||||||
|
for _, e := range entries {
|
||||||
|
if regexp.MustCompile(`(?i)/index\.html$`).MatchString(e.Name) {
|
||||||
|
nested = e.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nested != "" {
|
||||||
|
issues = append(issues, fmt.Sprintf("index.html found at '%s' - must be at ZIP root", nested))
|
||||||
|
} else {
|
||||||
|
issues = append(issues, "index.html not found in ZIP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zipSize > mintegralZipLimit {
|
||||||
|
issues = append(issues, fmt.Sprintf("ZIP size %.2f MB exceeds 5 MB limit", float64(zipSize)/1024/1024))
|
||||||
|
}
|
||||||
|
if index != nil && index.UncompressedSize > mintegralHTMLLimit {
|
||||||
|
issues = append(issues, fmt.Sprintf("index.html %.2f MB uncompressed exceeds 5 MB", float64(index.UncompressedSize)/1024/1024))
|
||||||
|
}
|
||||||
|
if len(issues) > 0 {
|
||||||
|
return mintegralCheck{ID: "file-spec", Status: "fail", Detail: strings.Join(issues, "; ")}
|
||||||
|
}
|
||||||
|
extras := []string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name != "index.html" {
|
||||||
|
extras = append(extras, e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeStr := fmt.Sprintf("index.html %.0f KB, ZIP %.0f KB", float64(index.UncompressedSize)/1024, float64(zipSize)/1024)
|
||||||
|
if len(extras) > 0 {
|
||||||
|
return mintegralCheck{ID: "file-spec", Status: "warn", Detail: fmt.Sprintf("%s; %d extra file(s) in ZIP (%s)", sizeStr, len(extras), strings.Join(extras, ", "))}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "file-spec", Status: "pass", Detail: sizeStr}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralCheckStorage(scripts string) mintegralCheck {
|
||||||
|
issues := []string{}
|
||||||
|
if regexp.MustCompile(`\blocalStorage\s*\.\s*setItem\s*\(`).MatchString(scripts) {
|
||||||
|
issues = append(issues, "localStorage.setItem")
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`\bsessionStorage\s*\.\s*setItem\s*\(`).MatchString(scripts) {
|
||||||
|
issues = append(issues, "sessionStorage.setItem")
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`\bdocument\.cookie\s*=`).MatchString(scripts) {
|
||||||
|
issues = append(issues, "document.cookie write")
|
||||||
|
}
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return mintegralCheck{ID: "storage", Status: "pass", Detail: "No persistent storage writes detected"}
|
||||||
|
}
|
||||||
|
return mintegralCheck{ID: "storage", Status: "fail", Detail: "Forbidden storage write(s): " + strings.Join(issues, ", ")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintegralRuntimeHTML(payload *mintegralRuntimePayload) string {
|
||||||
|
boot, _ := json.Marshal(map[string]any{
|
||||||
|
"fileName": payload.FileName,
|
||||||
|
"zipSize": payload.ZipSize,
|
||||||
|
"checks": payload.Checks,
|
||||||
|
})
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Mintegral Runtime Checker</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark;--gap-xs:4px;--gap-sm:8px;--gap-md:12px;--radius:4px;--border:#333;--panel:#252526;--panel-soft:rgba(128,128,128,.08);--fg:#ddd;--muted:#a7a7a7;--ok:#89d185;--err:#f48771;--warn:#ffd580}
|
||||||
|
*{box-sizing:border-box}html,body{width:100%;height:100%;overflow:hidden}body{margin:0;background:#1e1e1e;color:var(--fg);font-family:Segoe UI,Arial,sans-serif;font-size:13px}
|
||||||
|
main{height:100vh;display:grid;grid-template-columns:minmax(360px,1fr) minmax(320px,420px);gap:var(--gap-md);padding:var(--gap-md);overflow:hidden}
|
||||||
|
.panel{border:1px solid var(--border);background:var(--panel-soft);border-radius:var(--radius);overflow:hidden}.head{display:flex;align-items:center;justify-content:space-between;gap:var(--gap-md);padding:10px 12px;border-bottom:1px solid var(--border);background:var(--panel)}
|
||||||
|
h1{margin:0;font-size:12px;font-weight:700;letter-spacing:.45px;text-transform:uppercase}.sub{color:var(--muted);font-size:12px;overflow-wrap:anywhere}.head-meta{display:flex;align-items:center;justify-content:flex-end;gap:6px;min-width:0;white-space:nowrap}.head-meta .sub{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.head-meta .sub+.sub::before{content:"|";margin-right:6px;color:var(--border)}
|
||||||
|
.title-actions{display:flex;gap:var(--gap-xs)}button{min-height:28px;padding:5px 12px;border:1px solid transparent;border-radius:var(--radius);background:#0e639c;color:#fff;font:inherit;cursor:pointer}.icon-btn{width:28px;height:28px;min-height:28px;padding:0;display:grid;place-items:center;font-size:15px;line-height:1;background:#3a3d41;color:#ddd;border-color:#444}.icon-btn[aria-pressed=true]{color:var(--ok)}
|
||||||
|
.runtime-side{display:grid;grid-template-rows:minmax(0,1fr);min-height:0}.playable-panel{min-height:0;display:flex;flex-direction:column}.playable-body{min-height:0;flex:1;display:flex;flex-direction:column;padding:10px}.preview-stage{min-height:0;flex:1;display:grid;place-items:center;overflow:hidden;container-type:size}.device{width:min(100cqw,calc(100cqh*.5625));max-width:100%;aspect-ratio:9/16;margin:auto}.screen{position:relative;width:100%;height:100%;background:#000;border-radius:4px;overflow:hidden}iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#000}
|
||||||
|
.tab-panel{min-height:0;display:flex;flex-direction:column}.tabs{display:flex;gap:var(--gap-xs);padding:8px 8px 0;background:var(--panel);border-bottom:1px solid var(--border)}.tab{min-height:28px;padding:4px 10px;background:#3a3d41;color:#ddd;border-color:#444;border-bottom:0;border-radius:var(--radius) var(--radius) 0 0}.tab.active{background:var(--panel-soft)}.tab-content{min-height:0;flex:1;display:none;overflow:auto}.tab-content.active{display:block}
|
||||||
|
ol{list-style:none;margin:0;padding:0}.check{display:flex;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border)}.num{flex:0 0 22px;height:22px;border:1px solid var(--border);border-radius:50%;display:grid;place-items:center;color:var(--muted);background:var(--panel);font-size:10px;font-weight:700}.label{font-size:12px;font-weight:600}.detail{margin-top:3px;color:var(--muted);font-size:11px;line-height:1.35;overflow-wrap:anywhere}.badge{display:inline-flex;min-height:18px;margin-left:6px;padding:1px 6px;border:1px solid var(--border);border-radius:999px;font-size:11px}.badge.pass{color:var(--ok);background:rgba(137,209,133,.12)}.badge.fail{color:var(--err);background:rgba(244,135,113,.14)}.badge.warn{color:var(--warn);background:rgba(210,153,34,.16)}.badge.waiting{color:var(--muted)}
|
||||||
|
ul.log{list-style:none;margin:0;padding:0;overflow:auto;font-family:Consolas,monospace;font-size:12px;height:100%}ul.log li{display:flex;gap:8px;padding:4px 8px;border-bottom:1px solid var(--border)}.time{color:var(--muted);flex:0 0 auto}.msg{white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}.level{font-weight:700}.pass{color:var(--ok)}.fail{color:var(--err)}.warn{color:var(--warn)}
|
||||||
|
@media(max-width:900px){html,body{overflow:auto}main{height:auto;min-height:100vh;grid-template-columns:1fr;overflow:visible}.playable-panel{height:min(72vh,640px);min-height:360px}.tab-panel{min-height:360px}}@media(max-height:700px) and (max-width:900px){.playable-panel{height:calc(100vh - 24px);min-height:300px}}
|
||||||
|
</style></head><body>
|
||||||
|
<main>
|
||||||
|
<section class="runtime-side"><section class="panel playable-panel"><div class="head"><h1>Playable</h1><div class="title-actions"><button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">🔇</button><button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">↻</button></div></div><div class="playable-body"><div class="preview-stage"><div class="device"><div class="screen"><iframe id="adFrame" src="/mintegral/ad?preview=true&loading=0&review=1"></iframe></div></div></div></div></section></section>
|
||||||
|
<section class="panel tab-panel"><div class="head"><h1>Checks</h1><div class="head-meta"><div class="sub" id="bridge">Starting...</div><div class="sub" id="timer">60s</div></div></div><div class="tabs"><button class="tab active" data-tab="checksTab">Checklist</button><button class="tab" data-tab="eventsTab">Events</button></div><ol class="tab-content active" id="checksTab"></ol><ul class="log tab-content" id="eventsTab"></ul></section>
|
||||||
|
</main>
|
||||||
|
<script>const BOOT=` + string(boot) + `;</script>
|
||||||
|
<script>` + mintegralRuntimeJS + `</script>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mintegralRuntimeJS = `
|
||||||
|
const CHECKS=[
|
||||||
|
{id:'html-requirements',num:1,label:'HTML Requirements',runtimeEvent:null},{id:'cta-method',num:2,label:'CTA Call Method',runtimeEvent:'install'},{id:'game-end',num:3,label:'Game End Call Method',runtimeEvent:'gameEnd'},{id:'game-ready',num:4,label:'Game Ready Call Method',runtimeEvent:'gameReady'},{id:'game-start',num:5,label:'Game Start Call Method',runtimeEvent:'gameStart'},{id:'game-close',num:6,label:'Game Close Call Method',runtimeEvent:'gameClose'},{id:'file-handling',num:7,label:'File Handling Method',runtimeEvent:null},{id:'file-spec',num:8,label:'File Spec',runtimeEvent:null},{id:'storage',num:9,label:'Storage Requirements',runtimeEvent:null},{id:'code-exception',num:10,label:'Code Exception',runtimeEvent:'exception'}];
|
||||||
|
const EVENT_TO_CHECK={};CHECKS.forEach(c=>{if(c.runtimeEvent)EVENT_TO_CHECK[c.runtimeEvent]=c.id;});
|
||||||
|
const SDK_TYPE_TO_CHECK={game_ready:'game-ready',game_end:'game-end',game_start:'game-start',game_close:'game-close',install:'cta-method',outer_chain:'file-handling',base64:'file-handling',code:'code-exception'};
|
||||||
|
const state={checks:{},seconds:0,muted:false,connected:false,timer:null,timeout:60,codeCleanAfter:5,hasSentNoRetry:false};
|
||||||
|
CHECKS.forEach(c=>state.checks[c.id]={status:'pending',detail:'',runtimeStatus:c.runtimeEvent?'waiting':null,runtimeDetail:''});
|
||||||
|
BOOT.checks.forEach(r=>{const ch=state.checks[r.id];if(ch){ch.status=r.status;ch.detail=r.detail;}});
|
||||||
|
state.checks['code-exception'].status='pass';state.checks['code-exception'].runtimeStatus='monitoring';
|
||||||
|
['cta-method','game-end','game-ready','game-start','game-close'].forEach(id=>{if(state.checks[id].status==='fail')state.checks[id].runtimeStatus=null;});
|
||||||
|
const checksEl=document.getElementById('checksTab'),eventLog=document.getElementById('eventsTab'),adFrame=document.getElementById('adFrame'),muteBtn=document.getElementById('muteBtn');
|
||||||
|
document.querySelectorAll('.tab').forEach(btn=>btn.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));document.querySelectorAll('.tab-content').forEach(p=>p.classList.remove('active'));btn.classList.add('active');document.getElementById(btn.dataset.tab).classList.add('active');}));
|
||||||
|
function resetChecks(){if(state.timer)clearInterval(state.timer);state.seconds=0;state.connected=false;state.hasSentNoRetry=false;CHECKS.forEach(c=>state.checks[c.id]={status:'pending',detail:'',runtimeStatus:c.runtimeEvent?'waiting':null,runtimeDetail:''});BOOT.checks.forEach(r=>{const ch=state.checks[r.id];if(ch){ch.status=r.status;ch.detail=r.detail;}});state.checks['code-exception'].status='pass';state.checks['code-exception'].runtimeStatus='monitoring';['cta-method','game-end','game-ready','game-start','game-close'].forEach(id=>{if(state.checks[id].status==='fail')state.checks[id].runtimeStatus=null;});eventLog.innerHTML='';document.getElementById('bridge').textContent='Starting...';document.getElementById('timer').textContent=state.timeout+'s';render();state.timer=startRuntimeTimer();}
|
||||||
|
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||||
|
function addLog(list,name,cls,msg){const d=new Date(),li=document.createElement('li');li.innerHTML='<span class="time">'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0')+'</span><span class="level '+(cls||'')+'">'+esc(name)+'</span><span class="msg">'+esc(msg||'')+'</span>';list.appendChild(li);list.scrollTop=list.scrollHeight;}
|
||||||
|
function render(){checksEl.innerHTML=CHECKS.map(c=>{const ch=state.checks[c.id];let s=ch.status,d=ch.detail;if(ch.runtimeStatus==='waiting'&&ch.status==='pass'){s='waiting';d=ch.detail+' - awaiting runtime confirmation';}else if(ch.runtimeStatus==='monitoring'){s='waiting';d='Monitoring for exceptions...';}else if(ch.runtimeStatus==='pass'){s='pass';d=(ch.detail||'')+(ch.runtimeDetail?' - '+ch.runtimeDetail:'');}else if(ch.runtimeStatus==='fail'){s='fail';d=ch.runtimeDetail||ch.detail;}else if(ch.runtimeStatus==='timeout'&&ch.status==='pass'){s='warn';d=ch.detail+' - not fired within '+state.timeout+'s';}else if(ch.runtimeStatus==='clean'){s='pass';d='No exceptions detected';}return '<li class="check"><span class="num">'+c.num+'</span><span><div class="label">'+esc(c.label)+' <span class="badge '+s+'">'+s.toUpperCase()+'</span></div><div class="detail">'+esc(d||'')+'</div></span></li>';}).join('');}
|
||||||
|
function sdkCheck(type,value,msg){const id=SDK_TYPE_TO_CHECK[type],ch=state.checks[id];if(!ch)return;const pass=value===1||value===true;if(type==='code'){ch.status='fail';ch.runtimeStatus='fail';ch.runtimeDetail=msg||'JS error detected';addLog(eventLog,'code error','fail',ch.runtimeDetail);}else if(type==='outer_chain'){ch.runtimeStatus=pass?'pass':'fail';ch.runtimeDetail=pass?'No external resources at runtime':'External resources detected at runtime';addLog(eventLog,type,pass?'pass':'fail',ch.runtimeDetail);}else if(type==='base64'){if(!pass){ch.status='fail';ch.runtimeStatus='fail';ch.runtimeDetail='Non-JS/HTML assets must be base64';addLog(eventLog,type,'warn',ch.runtimeDetail);}}else{if(pass&&(ch.runtimeStatus==='waiting'||ch.runtimeStatus===null)){ch.runtimeStatus='pass';ch.runtimeDetail='Confirmed by SDK at '+state.seconds+'s';addLog(eventLog,type,'pass','');}else if(!pass&&(ch.runtimeStatus==='waiting'||ch.runtimeStatus===null)){ch.runtimeStatus='fail';ch.runtimeDetail=type+' not defined';addLog(eventLog,type,'warn',ch.runtimeDetail);}}render();done();}
|
||||||
|
function inferAll(text){const s=String(text||''),out=[];if(/(^|[^a-z])gameReady([^a-z]|$)|previewer:gameReady|game_ready|\bDISPLAYED\b|\bLOADED\b/i.test(s))out.push('gameReady');if(/(^|[^a-z])gameStart([^a-z]|$)|previewer:gameStart|game_start|\bCHALLENGE_STARTED\b/i.test(s))out.push('gameStart');if(/(^|[^a-z])gameEnd([^a-z]|$)|previewer:gameEnd|game_end|\bCHALLENGE_SOLVED\b|\bENDCARD_SHOWN\b/i.test(s))out.push('gameEnd');if(/(^|[^a-z])gameClose([^a-z]|$)|previewer:gameClose|game_close/i.test(s))out.push('gameClose');if(/(^|[^a-z])install([^a-z]|$)|previewer:install|\bCTA_CLICKED\b/i.test(s))out.push('install');return out;}
|
||||||
|
function handle(msg){const name=msg.name,extra=msg.extra||{};if(name==='__ping__'){state.connected=true;document.getElementById('bridge').textContent='Monitor connected';addLog(eventLog,'Monitor connected','pass','');return;}if(name==='sdkCheck'){sdkCheck(extra.type,extra.value,extra.msg||'');return;}if(name==='console'){inferAll(extra.text||'').forEach(ev=>handle({name:ev,extra:{source:'console'}}));return;}if(name==='diagnostic'){addLog(eventLog,extra.label||'diagnostic','warn',extra.detail||'');return;}if(name==='audioContext'){addLog(eventLog,'Audio captured','pass',extra.routed?'master gain ready':'context only');return;}if(name==='exception'){const ch=state.checks['code-exception'];ch.status='fail';ch.runtimeStatus='fail';ch.runtimeDetail=extra.message||'Unhandled exception';addLog(eventLog,'exception','fail',ch.runtimeDetail);render();done();return;}const id=EVENT_TO_CHECK[name];if(id){const ch=state.checks[id];if(ch.runtimeStatus==='waiting'||ch.runtimeStatus===null){ch.runtimeStatus='pass';ch.runtimeDetail=(extra.source==='console'?'Seen in console at ':'Fired at ')+state.seconds+'s';addLog(eventLog,name,'pass',extra.source==='console'?'from console':'');render();done();}}}
|
||||||
|
window.__hplMtgNotify=(name,extra)=>handle({name,extra:extra||{}});
|
||||||
|
window.addEventListener('message',e=>{const m=e.data;if(!m)return;if(m.type==='lifecycle')handle(m);else if(typeof m.type==='string'&&m.type.startsWith('previewer:')){const map={'previewer:gameReady':'gameReady','previewer:gameStart':'gameStart','previewer:install':'install','previewer:gameEnd':'gameEnd','previewer:gameClose':'gameClose'};if(map[m.type])handle({name:map[m.type],extra:{}});if(m.type==='previewer:review'&&m.data&&Array.isArray(m.data.reviewResult)){if(!state.hasSentNoRetry){state.hasSentNoRetry=true;try{adFrame.contentWindow.postMessage({type:'previewer:review',data:{reviewResult:{no_game_retry:true}}},'*');}catch(e){}}m.data.reviewResult.forEach(r=>sdkCheck(r.type,r.value,r.msg||''));}}});
|
||||||
|
function applyMute(){try{const cw=adFrame.contentWindow;if(!cw)return;cw.__hplMuted=state.muted;if(typeof cw.__hplApplyMutedState==='function')cw.__hplApplyMutedState();(cw.__hplMasterGains||[]).forEach(g=>{try{if(g&&g.gain)g.gain.value=state.muted?0:1;}catch(e){}});(cw.__hplMediaElements||[]).forEach(el=>{try{el.muted=state.muted;el.volume=state.muted?0:1;}catch(e){}});}catch(e){}}
|
||||||
|
function renderMuteButton(){muteBtn.innerHTML=state.muted?'🔈':'🔇';muteBtn.title=state.muted?'Unmute':'Mute';muteBtn.setAttribute('aria-label',state.muted?'Unmute':'Mute');muteBtn.setAttribute('aria-pressed',state.muted?'true':'false');}
|
||||||
|
muteBtn.onclick=()=>{state.muted=!state.muted;renderMuteButton();applyMute();addLog(eventLog,state.muted?'Muted':'Unmuted','pass','');};
|
||||||
|
document.getElementById('reloadBtn').onclick=()=>{resetChecks();document.getElementById('bridge').textContent='Reloading...';adFrame.src='/mintegral/ad?preview=true&loading=0&review=1&t='+Date.now();};
|
||||||
|
['pointerdown','click','touchstart','keydown'].forEach(evt=>window.addEventListener(evt,()=>setTimeout(applyMute,0),true));
|
||||||
|
function markCodeClean(){const ch=state.checks['code-exception'];if(ch&&ch.runtimeStatus==='monitoring'){ch.runtimeStatus='clean';ch.runtimeDetail='No exceptions detected';addLog(eventLog,'code clean','pass',ch.runtimeDetail);render();}}
|
||||||
|
function done(){const othersDone=CHECKS.every(c=>c.id==='code-exception'||!c.runtimeEvent||state.checks[c.id].runtimeStatus!=='waiting');if(othersDone)markCodeClean();if(CHECKS.every(c=>!c.runtimeEvent||!['waiting','monitoring'].includes(state.checks[c.id].runtimeStatus))){clearInterval(state.timer);document.getElementById('timer').textContent='Runtime complete';}}
|
||||||
|
function startRuntimeTimer(){return setInterval(()=>{state.seconds++;document.getElementById('timer').textContent=Math.max(0,state.timeout-state.seconds)+'s';if(state.seconds===2&&!state.connected){document.getElementById('bridge').textContent='Monitor not connected';addLog(eventLog,'Monitor not connected','fail','Check browser DevTools console for blocked inline script/CSP errors.');}if(state.seconds>=state.codeCleanAfter)markCodeClean();done();if(state.seconds>=state.timeout){clearInterval(state.timer);CHECKS.forEach(c=>{const ch=state.checks[c.id];if(ch.runtimeStatus==='waiting')ch.runtimeStatus='timeout';if(ch.runtimeStatus==='monitoring'){ch.runtimeStatus='clean';ch.runtimeDetail='No exceptions detected';}});render();document.getElementById('timer').textContent='Runtime complete';}},1000);}
|
||||||
|
state.timer=startRuntimeTimer();renderMuteButton();render();
|
||||||
|
`
|
||||||
|
|
||||||
|
const mintegralMonitorScript = `
|
||||||
|
(function(){
|
||||||
|
window.__hplMtgEvents=window.__hplMtgEvents||[];
|
||||||
|
function notify(n,extra){window.__hplMtgEvents.push({name:n,extra:extra||{}});try{if(typeof parent.__hplMtgNotify==='function')parent.__hplMtgNotify(n,extra||{});}catch(e){}try{parent.postMessage({type:'lifecycle',name:n,extra:extra||{}},'*');}catch(e){}}
|
||||||
|
notify('__ping__');
|
||||||
|
function applyMutedState(){var muted=!!window.__hplMuted;try{(window.__hplMasterGains||[]).forEach(function(g){try{if(g&&g.gain)g.gain.value=muted?0:1;}catch(e){}});(window.__hplMediaElements||[]).forEach(function(el){try{el.muted=muted;el.volume=muted?0:1;}catch(e){}});}catch(e){}}
|
||||||
|
window.__hplApplyMutedState=applyMutedState;
|
||||||
|
try{(function(){var OrigAC=window.AudioContext||window.webkitAudioContext;if(!OrigAC||OrigAC.__hplProxy)return;window.__hplAudioContexts=[];window.__hplMasterGains=[];window.__hplAudioDestinations=[];function ACProxy(opts){var ctx=arguments.length?new OrigAC(opts):new OrigAC();var dest=ctx.destination,gain=null;try{gain=ctx.createGain();gain.gain.value=window.__hplMuted?0:1;gain.connect(dest);if(window.AudioNode&&window.AudioNode.prototype&&!window.AudioNode.prototype.connect.__hpl){var origConnect=window.AudioNode.prototype.connect;var connectProxy=function(target){var args=Array.prototype.slice.call(arguments);try{var idx=window.__hplAudioDestinations.indexOf(args[0]);if(idx>=0&&window.__hplMasterGains[idx])args[0]=window.__hplMasterGains[idx];}catch(e){}return origConnect.apply(this,args);};connectProxy.__hpl=true;window.AudioNode.prototype.connect=connectProxy;}}catch(e){}window.__hplAudioContexts.push(ctx);if(gain)window.__hplMasterGains.push(gain);if(gain)window.__hplAudioDestinations.push(dest);notify('audioContext',{state:ctx.state||'',routed:!!gain});return ctx;}ACProxy.prototype=OrigAC.prototype;Object.setPrototypeOf&&Object.setPrototypeOf(ACProxy,OrigAC);ACProxy.__hplProxy=true;window.AudioContext=ACProxy;window.webkitAudioContext=ACProxy;})();}catch(e){}
|
||||||
|
try{window.__hplMediaElements=[];var mediaPlay=window.HTMLMediaElement&&window.HTMLMediaElement.prototype&&window.HTMLMediaElement.prototype.play;if(mediaPlay&&!mediaPlay.__hpl){var playProxy=function(){window.__hplMediaElements.push(this);applyMutedState();notify('audioContext',{state:'media-play',routed:true});return mediaPlay.apply(this,arguments);};playProxy.__hpl=true;window.HTMLMediaElement.prototype.play=playProxy;}}catch(e){}
|
||||||
|
function patchMwInit(obj){if(!obj||obj.__hplMwPatched)return;obj.__hplMwPatched=true;obj._hasReview=function(){return true;};obj._isPreview=function(){return true;};var _oe=obj.emitCheckData;if(typeof _oe==='function'){obj.emitCheckData=function(d){_oe.call(obj,d);if(d&&d.type)notify('sdkCheck',{type:d.type,value:d.value,msg:d.msg||''});};}}
|
||||||
|
var _mwInit=window.MW_INIT||null;patchMwInit(_mwInit);try{Object.defineProperty(window,'MW_INIT',{configurable:true,enumerable:true,get:function(){return _mwInit;},set:function(obj){_mwInit=obj;patchMwInit(obj);}});}catch(e){}
|
||||||
|
var _owp=window.postMessage;window.postMessage=function(data,origin,transfer){if(data&&typeof data.type==='string'){var pm={'previewer:gameReady':'gameReady','previewer:gameStart':'gameStart','previewer:gameEnd':'gameEnd','previewer:gameClose':'gameClose','previewer:install':'install','previewer:gameRetry':'gameRetry'};if(pm[data.type])notify(pm[data.type]);}return _owp.apply(this,arguments);};
|
||||||
|
function mkAccess(name,onCall){var existing,_v;try{existing=window[name];Object.defineProperty(window,name,{configurable:true,enumerable:true,get:function(){return _v;},set:function(fn){var orig=fn;if(typeof orig==='function'){var w=function(){onCall();return orig.apply(this,arguments);};w.__hpl=true;_v=w;}else{_v=orig;}}});if(typeof existing==='function')window[name]=existing;}catch(e){}}
|
||||||
|
var _lh={gameReady:function(){notify('gameReady');setTimeout(function(){notify('sdkCheck',{type:'game_start',value:typeof window.gameStart==='function'?1:0});notify('sdkCheck',{type:'game_close',value:typeof window.gameClose==='function'?1:0});},100);},gameEnd:function(){notify('sdkCheck',{type:'game_end',value:1});},gameStart:function(){notify('sdkCheck',{type:'game_start',value:1});},gameClose:function(){notify('sdkCheck',{type:'game_close',value:1});},install:function(){notify('sdkCheck',{type:'install',value:1});}};
|
||||||
|
mkAccess('gameReady',_lh.gameReady);mkAccess('gameEnd',_lh.gameEnd);mkAccess('gameStart',_lh.gameStart);mkAccess('gameClose',_lh.gameClose);if(typeof window.install!=='function'){var _installW=function(){_lh.install();};_installW.__hpl=true;window.install=_installW;}
|
||||||
|
window.addEventListener('load',function(){Object.keys(_lh).forEach(function(name){var fn=window[name];if(typeof fn==='function'&&!fn.__hpl){var orig=fn,h=_lh[name];var w=function(){h();return orig.apply(this,arguments);};w.__hpl=true;try{window[name]=w;}catch(e){}}else if(typeof fn!=='function'){var ww=function(){_lh[name]();};ww.__hpl=true;try{window[name]=ww;}catch(e){}}});},true);
|
||||||
|
window.onerror=function(msg,src,line){notify('exception',{message:String(msg),line:line||0,source:src||''});return false;};
|
||||||
|
window.addEventListener('unhandledrejection',function(e){notify('exception',{message:String(e&&e.reason||'Unhandled promise rejection')});});
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
var _ = html.EscapeString
|
||||||
@@ -457,7 +457,7 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(s.files[0].buf)
|
_, _ = w.Write([]byte(mobilePreviewPage(s.files[0])))
|
||||||
case "/file":
|
case "/file":
|
||||||
fallthrough
|
fallthrough
|
||||||
case "/file/0":
|
case "/file/0":
|
||||||
@@ -481,7 +481,7 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
if parts[0] == "view" {
|
if parts[0] == "view" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(file.buf)
|
_, _ = w.Write([]byte(mobilePreviewPage(file)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
safeName := safeFilename(file.filename)
|
safeName := safeFilename(file.filename)
|
||||||
@@ -499,6 +499,35 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mobilePreviewPage(file sharedFile) string {
|
||||||
|
playableJSON, _ := json.Marshal(string(file.buf))
|
||||||
|
playableScriptValue := strings.ReplaceAll(string(playableJSON), "</script", "<\\/script")
|
||||||
|
playableScriptValue = strings.ReplaceAll(playableScriptValue, "</SCRIPT", "<\\/SCRIPT")
|
||||||
|
title := html.EscapeString(file.filename)
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>` + title + `</title>
|
||||||
|
<style>
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
|
||||||
|
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||||
|
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="boot" class="boot">Loading preview...</div>
|
||||||
|
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
|
||||||
|
<script>
|
||||||
|
const playableHtml = ` + playableScriptValue + `;
|
||||||
|
setTimeout(() => {
|
||||||
|
const frame = document.getElementById('playable');
|
||||||
|
document.getElementById('boot').style.display = 'none';
|
||||||
|
frame.srcdoc = playableHtml;
|
||||||
|
}, 750);
|
||||||
|
</script>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||||
|
|
||||||
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type PlecConfig struct {
|
|||||||
OriginUrl string `json:"originUrl"`
|
OriginUrl string `json:"originUrl"`
|
||||||
PreviewBase string `json:"previewBase"`
|
PreviewBase string `json:"previewBase"`
|
||||||
SkipExistsCheck bool `json:"skipExistsCheck"`
|
SkipExistsCheck bool `json:"skipExistsCheck"`
|
||||||
|
WpUsername string `json:"wpUsername"`
|
||||||
|
WpPassword string `json:"wpPassword"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApplovinConfig struct {
|
type ApplovinConfig struct {
|
||||||
@@ -28,12 +30,19 @@ type SendToMobileConfig struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeviceSimulatorConfig struct {
|
||||||
|
DevicesURL string `json:"devicesUrl"`
|
||||||
|
LanguagesURL string `json:"languagesUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
Plec PlecConfig `json:"plec"`
|
Plec PlecConfig `json:"plec"`
|
||||||
Applovin ApplovinConfig `json:"applovin"`
|
Applovin ApplovinConfig `json:"applovin"`
|
||||||
SendToMobile SendToMobileConfig `json:"sendToMobile"`
|
SendToMobile SendToMobileConfig `json:"sendToMobile"`
|
||||||
|
DeviceSimulator DeviceSimulatorConfig `json:"deviceSimulator"`
|
||||||
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
||||||
LastPickDir string `json:"lastPickDir,omitempty"`
|
LastPickDir string `json:"lastPickDir,omitempty"`
|
||||||
|
ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configMu sync.Mutex
|
var configMu sync.Mutex
|
||||||
@@ -62,9 +71,15 @@ func defaultConfig() AppConfig {
|
|||||||
OriginUrl: "http://167.99.227.249",
|
OriginUrl: "http://167.99.227.249",
|
||||||
PreviewBase: "https://playable.applovindemo.com/phaser/",
|
PreviewBase: "https://playable.applovindemo.com/phaser/",
|
||||||
SkipExistsCheck: false,
|
SkipExistsCheck: false,
|
||||||
|
WpUsername: "pm",
|
||||||
|
WpPassword: "nRLz#LV@y#)q@CW@z8fGEPyI",
|
||||||
},
|
},
|
||||||
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
||||||
SendToMobile: SendToMobileConfig{Port: 0},
|
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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
urlpkg "net/url"
|
urlpkg "net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,7 +24,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="tool-panel">
|
<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="panel-body">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<div id="dropZone" class="drop-zone">
|
<div id="dropZone" class="drop-zone">
|
||||||
@@ -54,10 +61,15 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
<style>
|
<style>
|
||||||
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
||||||
#rows input[type=text] { width: 100%; }
|
#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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const PAGE_SIZE = 5;
|
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 nextId = 1;
|
||||||
let rows = [];
|
let rows = [];
|
||||||
let page = 0;
|
let page = 0;
|
||||||
@@ -206,7 +218,7 @@ document.getElementById('upload').addEventListener('click', async () => {
|
|||||||
const res = await fetch('/api/plec/upload', {
|
const res = await fetch('/api/plec/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ rows }),
|
body: JSON.stringify({ rows, server: serverSelect.value }),
|
||||||
});
|
});
|
||||||
await readJSONStream(res, (msg) => {
|
await readJSONStream(res, (msg) => {
|
||||||
if (msg.type === 'status') {
|
if (msg.type === 'status') {
|
||||||
@@ -300,6 +312,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
send := newJSONStream(w)
|
send := newJSONStream(w)
|
||||||
var req struct {
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
send(map[string]any{"type": "error", "message": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
@@ -335,6 +348,14 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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")
|
stamp := time.Now().Format("20060102150405")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
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()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
|
iter := encodeURICompatible(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, " ", ""))
|
|
||||||
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
|
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
|
||||||
send(map[string]any{"type": "error", "message": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
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)})
|
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) {
|
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|||||||
328
standalone/project_init.go
Normal file
328
standalone/project_init.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultManifestURL = "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link"
|
||||||
|
|
||||||
|
type ManifestEntry struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reGDriveFileID = regexp.MustCompile(`/file/d/([a-zA-Z0-9_-]+)`)
|
||||||
|
var reGDriveIDParam = regexp.MustCompile(`[?&]id=([a-zA-Z0-9_-]+)`)
|
||||||
|
|
||||||
|
func gdriveToDirectURL(rawURL string) string {
|
||||||
|
if m := reGDriveFileID.FindStringSubmatch(rawURL); m != nil {
|
||||||
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
||||||
|
}
|
||||||
|
if m := reGDriveIDParam.FindStringSubmatch(rawURL); m != nil {
|
||||||
|
return "https://drive.google.com/uc?export=download&id=" + m[1]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchManifest(manifestURL string) ([]ManifestEntry, error) {
|
||||||
|
req, err := http.NewRequest("GET", gdriveToDirectURL(manifestURL), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
var entries []ManifestEntry
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||||
|
}
|
||||||
|
filtered := entries[:0]
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Filename != "" && e.URL != "" {
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadManifestFile(rawURL, destPath string) error {
|
||||||
|
req, err := http.NewRequest("GET", gdriveToDirectURL(rawURL), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
ext := strings.ToLower(filepath.Ext(destPath))
|
||||||
|
if strings.Contains(ct, "text/html") && ext != ".html" && ext != ".htm" {
|
||||||
|
return fmt.Errorf("got an HTML page instead of a file — make sure the Google Drive file is set to \"Anyone with the link can view\"")
|
||||||
|
}
|
||||||
|
return os.WriteFile(destPath, body, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getManifestURL() string {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
if cfg.ProjectInitManifestURL != "" {
|
||||||
|
return cfg.ProjectInitManifestURL
|
||||||
|
}
|
||||||
|
return defaultManifestURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Initialize Project</h2>
|
||||||
|
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<h3 class="panel-title">Files</h3>
|
||||||
|
<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()">↻</button>
|
||||||
|
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="openSettingsModal('initialize-project')">⚙</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div id="fileList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<p class="control-label">Download folder</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<input type="text" id="destFolder" value="" placeholder="(no folder selected)" readonly style="flex:1;" />
|
||||||
|
<button class="secondary" onclick="pickFolder()">Browse…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
|
||||||
|
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let files = [];
|
||||||
|
let allSelected = true;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setStatus('', '');
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest…</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/project-init/manifest');
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.error) { renderError(j.error); return; }
|
||||||
|
renderFiles(j.files || [], j.manifestUrl || '');
|
||||||
|
} catch (e) {
|
||||||
|
renderError('Failed to load manifest: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(msg) {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg) + '</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
document.getElementById('manifestSource').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(list, manifestUrl) {
|
||||||
|
files = list;
|
||||||
|
allSelected = true;
|
||||||
|
document.getElementById('manifestSource').textContent =
|
||||||
|
manifestUrl ? '(' + manifestUrl.replace(/^https?:\/\//, '').split('/')[0] + ')' : '';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
|
||||||
|
document.getElementById('initBtn').disabled = true;
|
||||||
|
document.getElementById('selectAllBtn').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.map((f, i) =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
|
||||||
|
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
|
||||||
|
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
|
||||||
|
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" onclick="openUrl(' + JSON.stringify(f.url) + ')">Open</button></td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
document.getElementById('fileList').innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th style="width:20px;"></th><th style="width:20%;">Filename</th><th style="width:70%;">Description</th><th style="width:10%;"></th></tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('initBtn').disabled = false;
|
||||||
|
document.getElementById('selectAllBtn').style.display = '';
|
||||||
|
document.getElementById('selectAllBtn').textContent = 'Deselect all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
allSelected = !allSelected;
|
||||||
|
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
|
||||||
|
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFolder() {
|
||||||
|
const r = await fetch('/api/project-init/pickFolder', { method: 'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) document.getElementById('destFolder').value = j.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUrl(url) {
|
||||||
|
await fetch('/api/open', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initProject() {
|
||||||
|
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
|
||||||
|
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
|
||||||
|
const destFolder = document.getElementById('destFolder').value.trim();
|
||||||
|
if (!destFolder) { setStatus('Please select a destination folder first.', 'err'); return; }
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
|
||||||
|
el.className = 'status-panel is-busy';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/project-init/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ files: checked, destFolder }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
setStatus(j.message, j.ok ? 'ok' : 'err');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Error: ' + (e.message || e), 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = 'status-panel' + (cls ? ' ' + cls : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/project-init", "Initialize Project", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitManifestEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
manifestURL := getManifestURL()
|
||||||
|
entries, err := fetchManifest(manifestURL)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"files": entries, "manifestUrl": manifestURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitPickFolderEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
folder := PickFolder("Select Destination Folder", cfg.LastPickDir)
|
||||||
|
if folder == "" {
|
||||||
|
writeJSON(w, map[string]any{"path": ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.LastPickDir = folder
|
||||||
|
_ = SaveConfig(cfg)
|
||||||
|
writeJSON(w, map[string]any{"path": folder})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectInitSetManifestURLEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := LoadConfig()
|
||||||
|
cfg.ProjectInitManifestURL = strings.TrimSpace(req.URL)
|
||||||
|
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 ProjectInitDownloadEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Files []ManifestEntry `json:"files"`
|
||||||
|
DestFolder string `json:"destFolder"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "Invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DestFolder == "" {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "No destination folder specified."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
for _, f := range req.Files {
|
||||||
|
if err := downloadManifestFile(f.URL, filepath.Join(req.DestFolder, f.Filename)); err != nil {
|
||||||
|
errs = append(errs, f.Filename+": "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := len(req.Files)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"message": fmt.Sprintf("%d/%d downloaded.\n\nErrors:\n%s", total-len(errs), total, strings.Join(errs, "\n")),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Done! %d file%s downloaded to: %s", total, pluralS(total), req.DestFolder),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
93
standalone/settings.go
Normal file
93
standalone/settings.go
Normal 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, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkedAttr(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "checked"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func intStr(n int) string {
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
127
standalone/update_checker.go
Normal file
127
standalone/update_checker.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const remotePackageJSONURL = "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox/raw/branch/main/package.json"
|
||||||
|
const updateRepositoryURL = "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox"
|
||||||
|
|
||||||
|
type updateCheckResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CurrentVersion string `json:"currentVersion"`
|
||||||
|
RemoteVersion string `json:"remoteVersion,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateCheckEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
remoteVersion, err := fetchRemoteVersion()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, updateCheckResponse{
|
||||||
|
OK: false,
|
||||||
|
Status: "error",
|
||||||
|
CurrentVersion: AppVersion,
|
||||||
|
Message: "HPL Toolbox update check failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comparison := compareVersions(remoteVersion, AppVersion)
|
||||||
|
if comparison > 0 {
|
||||||
|
writeJSON(w, updateCheckResponse{
|
||||||
|
OK: true,
|
||||||
|
Status: "update",
|
||||||
|
CurrentVersion: AppVersion,
|
||||||
|
RemoteVersion: remoteVersion,
|
||||||
|
Message: fmt.Sprintf("HPL Toolbox update available: %s (installed: %s).", remoteVersion, AppVersion),
|
||||||
|
RepositoryURL: updateRepositoryURL,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, updateCheckResponse{
|
||||||
|
OK: true,
|
||||||
|
Status: "current",
|
||||||
|
CurrentVersion: AppVersion,
|
||||||
|
RemoteVersion: remoteVersion,
|
||||||
|
Message: fmt.Sprintf("HPL Toolbox is up to date (%s).", AppVersion),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchRemoteVersion() (string, error) {
|
||||||
|
client := http.Client{Timeout: 10 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, remotePackageJSONURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("remote package.json returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var packageJSON struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&packageJSON); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(packageJSON.Version) == "" {
|
||||||
|
return "", fmt.Errorf("remote package.json is missing a version")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(packageJSON.Version), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareVersions(left, right string) int {
|
||||||
|
leftParts := parseVersionParts(left)
|
||||||
|
rightParts := parseVersionParts(right)
|
||||||
|
length := len(leftParts)
|
||||||
|
if len(rightParts) > length {
|
||||||
|
length = len(rightParts)
|
||||||
|
}
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
leftPart := 0
|
||||||
|
rightPart := 0
|
||||||
|
if i < len(leftParts) {
|
||||||
|
leftPart = leftParts[i]
|
||||||
|
}
|
||||||
|
if i < len(rightParts) {
|
||||||
|
rightPart = rightParts[i]
|
||||||
|
}
|
||||||
|
if leftPart > rightPart {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if leftPart < rightPart {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVersionParts(version string) []int {
|
||||||
|
fields := strings.FieldsFunc(version, func(r rune) bool {
|
||||||
|
return r == '.' || r == '-'
|
||||||
|
})
|
||||||
|
parts := make([]int, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
part, err := strconv.Atoi(field)
|
||||||
|
if err == nil {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
26
tools.json
26
tools.json
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": "project-init",
|
||||||
|
"title": "Initialize Project",
|
||||||
|
"description": "Download AGENTS.md and .gitignore templates from Google Drive",
|
||||||
|
"command": "hplToolbox.openProjectInit",
|
||||||
|
"path": "/project-init",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "plec",
|
"id": "plec",
|
||||||
"title": "PLEC Upload",
|
"title": "PLEC Upload",
|
||||||
@@ -45,6 +53,22 @@
|
|||||||
"description": "Convert Playworks HTML to per-network variants",
|
"description": "Convert Playworks HTML to per-network variants",
|
||||||
"command": "hplToolbox.openPlayworksConverter",
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
"path": "/playworks",
|
"path": "/playworks",
|
||||||
"beta": true
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mintegral",
|
||||||
|
"title": "Mintegral Checker",
|
||||||
|
"description": "Validate a Mintegral playable ZIP against PlayTurbo requirements",
|
||||||
|
"command": "hplToolbox.openMintegralChecker",
|
||||||
|
"path": "/mintegral",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device-simulator",
|
||||||
|
"title": "Device Simulator",
|
||||||
|
"description": "Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays",
|
||||||
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
|
"path": "/device-simulator",
|
||||||
|
"beta": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user