Compare commits
45 Commits
6be55255b2
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 331e49ea29 | |||
| c336f176e6 | |||
| 0e30ed0b36 | |||
| 8e5d56ab1d | |||
| 68d15aa208 | |||
| b254daf06a | |||
| 16de3cc116 | |||
| 5fa1ce949c | |||
| e50b4c2133 | |||
| cebb65fb35 | |||
| 7094e6c3b0 | |||
| 6c09beea43 | |||
| ad6403f3e4 | |||
| 700bb135ad | |||
| 986993a027 | |||
| 9e24342142 | |||
| b71e730482 | |||
| e3f454c5f2 | |||
| 88981d209b | |||
| 2524bacb5d | |||
| 9e919756fd | |||
| 1c445e2771 | |||
| a2d80d2660 | |||
| 1ee22b7155 | |||
| 2ca858cfa4 | |||
| 80f155dfe8 | |||
| 5625d94106 | |||
| a1d3fde774 | |||
| 7201bf9845 | |||
| 2921d4d384 | |||
| cce30c0729 | |||
| 4f17523f26 | |||
| 274bafe1cb | |||
| afbf504957 | |||
| 16847a78ca | |||
| ca9cc62b8c | |||
| 8e8d3d6da0 | |||
| 3f86aeaf0c | |||
| 92de00b52e | |||
| 78578cec16 | |||
| 75a506a505 | |||
| 698223234d | |||
| 9e82656eeb | |||
| c7ad1e14ca | |||
| d384c1be3a |
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 |
|
||||||
166
README.md
Normal file
166
README.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# HPL Toolbox
|
||||||
|
|
||||||
|
Bundled VS Code extension and standalone tools for playable ad workflows.
|
||||||
|
|
||||||
|
# Tools
|
||||||
|
|
||||||
|
- **PLEC Upload** - Upload selected HTML files to the internal PLEC server.
|
||||||
|
- **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** - Share selected HTML files to a phone over LAN with a QR code.
|
||||||
|
- **MRAID Checker** - Check HTML files against MRAID requirements and best practices.
|
||||||
|
- **Mintegral Checker** - Validate Mintegral playable ZIPs against PlayTurbo requirements.
|
||||||
|
- **Playworks Converter** - Convert Playworks HTML into per-network variants.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
**v0.1.2**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added local hosting for HTML files.
|
||||||
|
- Added the standalone app.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.1.3**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added QR regeneration for AppLovin previews that fail to render.
|
||||||
|
- Added Save QR and Save All QRs actions.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Updated AppLovin Demo Upload.
|
||||||
|
- Improved the file selection workflow.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.1.5**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added multi-file selection for PLEC Upload.
|
||||||
|
- Added automatic iteration name detection.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Improved PLEC Upload file selection.
|
||||||
|
- Moved standalone temporary files into the user temp folder.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.1.6**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added beta tools for further testing:
|
||||||
|
- Playworks Converter for converting Playworks UnityAds HTML into other network variants.
|
||||||
|
- MRAID Checker for scanning HTML files against MRAID requirements and best practices.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- Simplified the UI.
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.1.7**
|
||||||
|
```
|
||||||
|
Added
|
||||||
|
- Added standalone drag-and-drop file/folder selection for HTML inputs across supported tools.
|
||||||
|
- Added status throbber/progress updates for batch scans, uploads, sharing, and conversions.
|
||||||
|
- Added streamed standalone progress updates so long-running actions update the status bar before completion.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
- MRAID Checker is no longer marked as beta in extension or standalone.
|
||||||
|
- Playworks Converter source selection now matches the other file-selection UIs more closely.
|
||||||
|
- Playworks Converter default output folder now resolves to the input file location's Output folder.
|
||||||
|
- Progress text now follows the format: [throbber] Verb count file.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed standalone drag-and-drop handling for multiple files and multiple folders.
|
||||||
|
- 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
321
aiAssets/GUIDE.md
Normal file
321
aiAssets/GUIDE.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# AGENTS.md — Playable Ads
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps
|
||||||
|
Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt)
|
||||||
|
|
||||||
|
## Porting to a new project
|
||||||
|
1. Update naming fields (3LC, vendor, concept name) in [scripts/build-all.mjs](scripts/build-all.mjs) — drives the output filename
|
||||||
|
2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android)
|
||||||
|
3. Adjust the Depth Map below to match the project's layer needs
|
||||||
|
4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants
|
||||||
|
5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/BootScene.ts)
|
||||||
|
6. Rewrite `game/` modules for the new mechanic(s) — keep the single-responsibility split
|
||||||
|
7. Update this AGENTS.md's Depth Map and any project-specific pitfalls
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.ts # Bootstrap, resize, MRAID gating, lifecycle stubs on window
|
||||||
|
constants.ts # Design coords (1080×1920), store URLs
|
||||||
|
networks.ts # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX()
|
||||||
|
analytics.ts # trackEvent() → SDK
|
||||||
|
iteration.ts # A/B iteration config
|
||||||
|
utils/
|
||||||
|
responsive.ts # sx(), sy(), sd() — coordinate helpers
|
||||||
|
scenes/
|
||||||
|
BootScene.ts # Critical preload + deferred asset manifest
|
||||||
|
GameScene.ts # Orchestrator only — wires game/ modules, handles lifecycle
|
||||||
|
cta.ts # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA()
|
||||||
|
game/ # Single-responsibility modules (no direct ad-SDK calls)
|
||||||
|
# …one file per distinct mechanic or UI widget
|
||||||
|
Assets/
|
||||||
|
scripts/build-all.mjs # Single build → per-network HTML variants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsibility split
|
||||||
|
- **`game/`** modules each own a single concern (one mechanic, one widget, one system).
|
||||||
|
They receive the Phaser scene as a constructor arg and own their own game objects.
|
||||||
|
They never call ad-SDK functions (`triggerCTA`, `notifyGameX`, `trackEvent`).
|
||||||
|
- **`GameScene.ts`** is the wiring layer: creates modules, passes data between them,
|
||||||
|
and calls SDK helpers at the correct lifecycle moments.
|
||||||
|
- **`scenes/cta.ts`** owns end-card presentation and the store-redirect sequence.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- 60 FPS target; never below 30 on mid-range Android
|
||||||
|
- `dist/index.html` < 5 MB; WebP images, short audio
|
||||||
|
- No hardcoded px — `sx()`, `sy()`, `sd()` only
|
||||||
|
- No audio autoplay — unmute on first `pointerdown`
|
||||||
|
- `for` loops in game logic; pool objects; no GC
|
||||||
|
- `setDisplaySize(sd())` on images — never `setScale`
|
||||||
|
- Never dim via `body`/page background
|
||||||
|
|
||||||
|
## Phaser Config
|
||||||
|
```ts
|
||||||
|
{ type: Phaser.AUTO, transparent: true,
|
||||||
|
scale: { mode: Phaser.Scale.NONE, width: 1080, height: 1920 },
|
||||||
|
render: { antialias: true, pixelArt: false } }
|
||||||
|
```
|
||||||
|
`Scale.NONE` — manual sizing; CSS = viewport px; internal = viewport × DPR.
|
||||||
|
`antialias: true` — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to `false`.
|
||||||
|
|
||||||
|
## Responsive (1080×1920 ref)
|
||||||
|
```ts
|
||||||
|
const s = Math.min(viewW / 1080, viewH / 1920)
|
||||||
|
const offX = (viewW - 1080 * s) / 2
|
||||||
|
const offY = (viewH - 1920 * s) / 2
|
||||||
|
// sx/sy/sd apply offX, offY, s
|
||||||
|
```
|
||||||
|
- `update()` polls `visualViewport` per-frame for rotation
|
||||||
|
- On change: `game.scale.resize(vw*dpr, vh*dpr)` → `relayout()`
|
||||||
|
- `bindResponsiveResize()`: rAF debounce + 100/300/600ms retries
|
||||||
|
|
||||||
|
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||||
|
```
|
||||||
|
1. ExitApi.exit() → GoogleAds (gg)
|
||||||
|
2. FbPlayableAd.onCTAClick() → Facebook (fb) · Moloco (mo)
|
||||||
|
3. Luna.Unity.Playable → Unity (un)
|
||||||
|
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
||||||
|
5. window.install() → Mintegral (mtg)
|
||||||
|
6. window.openAppStore() → (runtime fallback)
|
||||||
|
7. window.clickTag → Moloco (mo) fallback
|
||||||
|
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
||||||
|
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
||||||
|
10. mraid.open(url) → Applovin (al) / Ironsource (is) / Unity (un fallback after Luna)
|
||||||
|
11. window.open(storeUrl) → Fallback (all others)
|
||||||
|
```
|
||||||
|
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
||||||
|
MRAID CTA calls must be guarded: require `typeof mraid.open === 'function'`; treat missing `mraid.getState()` as `ready`; if state is still `loading`, fall through to `window.open(storeUrl)`.
|
||||||
|
|
||||||
|
## Analytics — SDK Priority
|
||||||
|
```
|
||||||
|
ALPlayableAnalytics.trackEvent(e) → Applovin (al)
|
||||||
|
playableSDK.reportEvent(e) → (runtime fallback if SDK present)
|
||||||
|
console.log('[Analytics]', e) → All other networks
|
||||||
|
```
|
||||||
|
Events: `DISPLAYED` · `CTA_CLICKED` · `ENDCARD_SHOWN` · `CHALLENGE_STARTED` · `CHALLENGE_SOLVED`
|
||||||
|
|
||||||
|
## Game Lifecycle (all networks)
|
||||||
|
`main.ts` exposes stubs on `window` so every network's preview tool detects them.
|
||||||
|
Stubs only set if the SDK hasn't already provided its own (`typeof` guard).
|
||||||
|
```
|
||||||
|
gameReady() → stub in main.ts; Mintegral also gets onload body attr from build
|
||||||
|
gameStart() → notifyGameStart() in GameScene.startPlaying()
|
||||||
|
gameEnd() → notifyGameEnd() in GameScene.triggerFail() / triggerWin()
|
||||||
|
gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||||
|
| Network | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events; also shared MRAID exposure/viewability when `mraid.js` is present |
|
||||||
|
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||||
|
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||||
|
| Applovin / Ironsource / Unity | MRAID 3.0 `exposureChange`, MRAID 2.0 fallback `viewableChange` / `isViewable`, and `audioVolumeChange` (gated in `initMraid`) |
|
||||||
|
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
|
||||||
|
|
||||||
|
### MRAID 3.0 Requirements & Best Practices
|
||||||
|
MRAID networks are **Applovin (`al`)**, **Ironsource (`is`)**, and **Unity (`un`)**. All three must request `<script src="mraid.js"></script>` early in the generated HTML. Unity still uses Luna first for CTA/lifecycle where available, but MRAID must exist as the standards fallback.
|
||||||
|
|
||||||
|
Creative requirements from the MRAID 3.0 spec and Best Practices Guide:
|
||||||
|
- Request `mraid.js` as early as possible, exactly once, either with `<script src="mraid.js"></script>` or DOM insertion. Do not rely on the container to inject it automatically.
|
||||||
|
- Wait for both DOM readiness and MRAID readiness before starting rich media behavior. Use `document.readyState` / `DOMContentLoaded` or `load` for the DOM side.
|
||||||
|
- Use `mraid.getState()` together with `mraid.addEventListener('ready', ...)` so the creative still starts when the container fires `ready` before the listener attaches.
|
||||||
|
- Guard access to `mraid` and MRAID methods with `typeof` checks. Treat missing optional methods as unsupported and fall back gracefully.
|
||||||
|
- Use `mraid.open(url)` for MRAID clickthroughs. Avoid `<a href>`, `location.href` / `assign` / `replace`, and unguarded `window.open`.
|
||||||
|
- Include `<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">` or an equivalent mobile viewport tag.
|
||||||
|
- Do not assume nested iframes can access native MRAID. If nested iframe access is required, the outer frame must provide its own bridge.
|
||||||
|
- Use `exposureChange` as the MRAID 3.0 viewability signal, then degrade to `viewableChange` / `isViewable()` for MRAID 2.0 compatibility.
|
||||||
|
- Listen for `audioVolumeChange`; `null` is valid and must be ignored. Only apply volume math when the value is numeric.
|
||||||
|
- Listen for `error`; if the ad cannot run or assets fail, gracefully degrade or call `mraid.unload()` instead of showing a broken/blank ad.
|
||||||
|
- Call `setResizeProperties()` before `resize()`. Do not use `resize()` for full-screen takeovers; use `expand()` for that case.
|
||||||
|
- Avoid resizing or expanding interstitials. Check placement with `getPlacementType()` when one creative can serve inline and interstitial placements.
|
||||||
|
- Avoid `useCustomClose()` and custom close indicators in MRAID 3.0. The host provides the close control.
|
||||||
|
- Avoid two-part expandable ads (`expand(url)`); use self-contained one-part expandables.
|
||||||
|
- Call `supports()` before optional/native features such as `storePicture`, `createCalendarEvent`, `getLocation`, VPAID, SMS/tel, or inline video behavior.
|
||||||
|
- Use `playVideo()` for native video playback cases instead of `mraid.open(videoUrl)`. Use HTML5 `<video>` for inline playback when supported.
|
||||||
|
|
||||||
|
Implementation requirements for `networks.ts` / `networks.js`:
|
||||||
|
- Export `initMraid(timeoutMs = 2000, detectTimeoutMs = 500)` and call it at module load.
|
||||||
|
- If `window.mraid` is missing at module load, poll briefly (`detectTimeoutMs`, default 500 ms) for late container injection before resolving as no-MRAID.
|
||||||
|
- If MRAID exists and `mraid.getState() === 'loading'`, wait for `mraid.addEventListener('ready', ...)`; self-resolve after `timeoutMs` (default 2000 ms) so startup never hangs.
|
||||||
|
- `main` / boot must `await initMraid()` before constructing `new Phaser.Game(...)`.
|
||||||
|
- On setup, seed cached visibility from `mraid.isViewable()` when available.
|
||||||
|
- Register MRAID callbacks once, before any Phaser scene is ready:
|
||||||
|
- `mraid.addEventListener('error', (message, action) => ...)` — log or gracefully handle failed/unsupported MRAID calls.
|
||||||
|
- `mraid.addEventListener('stateChange', state => ...)` — required when using state-changing MRAID behavior such as `close`, `expand`, or `resize`.
|
||||||
|
- `mraid.addEventListener('exposureChange', exposedPercentage => ...)` — MRAID 3.0 primary viewability. Pause when `exposedPercentage <= 0`; resume when positive.
|
||||||
|
- `mraid.addEventListener('viewableChange', viewable => ...)` — MRAID 2.0 compatibility fallback.
|
||||||
|
- `mraid.addEventListener('audioVolumeChange', pct => ...)` — only apply `pct / 100` when `typeof pct === 'number'`.
|
||||||
|
- In `bindLifecycle(scene)`, apply cached hidden/non-exposed MRAID state immediately once a scene exists.
|
||||||
|
- If the creative renders its own close control, wire that control to a guarded `mraid.close()` helper and call `notifyGameClose()` before closing.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||||
|
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
||||||
|
MRAID networks (`al`, `is`, `un`) inject exactly one `<script src="mraid.js"></script>` request.
|
||||||
|
If built output contains `console.error` from Phaser or bundled node modules, prefer dropping it at bundle time instead of editing library source. Add this option to the shared Vite config, usually `vite.shared.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
esbuild: {
|
||||||
|
pure: ['console.error'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep project-authored error handling meaningful: use visible fallback UI, `console.warn`, or guarded debug logging where needed.
|
||||||
|
|
||||||
|
| Network | Tag | Injected | Zipped | Included |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Applovin | al | `mraid.js` | — | ✓ |
|
||||||
|
| Google | gg | `exitapi.js` | ✓ | ✓ |
|
||||||
|
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||||
|
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||||
|
| Facebook | fb | — | — | ✓ |
|
||||||
|
| Unity | un | `mraid.js` | — | ✓ |
|
||||||
|
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||||
|
| Moloco | mo | — | — | ✓ |
|
||||||
|
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||||
|
|
||||||
|
|
||||||
|
Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](scripts/build-all.mjs) writes a `.zip` containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via `zip: true` in the `NETWORKS` array.
|
||||||
|
|
||||||
|
Networks marked **Included** are built by default. Networks not marked are prepared for but not built. Toggle per-network via `included: true` in the `NETWORKS` array.
|
||||||
|
|
||||||
|
Output is grouped by **iteration length** (e.g. `2words/`, `4words/`, `full/`) then by **network** (e.g. `Applovin/`, `Google/`, `Mintegral/`), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder:
|
||||||
|
|
||||||
|
`dist/<length>/<3lc>_<creative-type>_<vendor>_<concept-name>_<concept-num>_<gameplay>_<ugc>_<seasonal>_<lang>_<length>_<size>_<network>`
|
||||||
|
|
||||||
|
Example layout:
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
2words/
|
||||||
|
Applovin/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||||
|
Google/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||||
|
Ironsource/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||||
|
…one HTML or ZIP per network…
|
||||||
|
4words/
|
||||||
|
full/
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| 3LC | 3-letter project code | `ws` (Wordscapes), `pp` (PeoplePuzzle) |
|
||||||
|
| Creative Type | Format identifier | `mip` |
|
||||||
|
| Vendor | Studio / vendor code | `grhpl` |
|
||||||
|
| Concept Name | Creative concept (append `var1`, `var2`… for variants) | `wbinspiredvar1`, `wbinspiredvar2`, `burglar` |
|
||||||
|
| Concept # | Iteration number, zero-padded | `01`, `02`, `03` |
|
||||||
|
| Gameplay | Gameplay style | `real`, `cartoon` |
|
||||||
|
| UGC | UGC presence | `na`, `ugc`, `nougc` |
|
||||||
|
| Seasonal | Seasonal tie-in | `noseason`, `xmas` |
|
||||||
|
| Language | Locale code | `en` |
|
||||||
|
| Length | Run length | `2words`, `4words`, `7words`, `full` (Wordscapes); `2clk`, `10clk`, `120sec` (other projects) |
|
||||||
|
| Size | Region / size code | `na` |
|
||||||
|
| Network | Network tag (last segment) | `al`, `gg`, `is`, `mtg`, `fb`, `un`, `vu`, `mo`, `tt` |
|
||||||
|
|
||||||
|
Examples (this project):
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html` (cta2, default theme)
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html` (full, default theme)
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html` (cta2, v1 theme)
|
||||||
|
|
||||||
|
Naming fields are defined in [scripts/build-all.mjs](scripts/build-all.mjs) — update them when porting.
|
||||||
|
|
||||||
|
## Asset Loading
|
||||||
|
- **Critical** (BootScene): first-frame assets, target < 200 KB
|
||||||
|
- **Deferred**: audio, overlays, secondary UI — loads during intro
|
||||||
|
- Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading
|
||||||
|
|
||||||
|
## Depth Map (adjust per project)
|
||||||
|
Define depths as named constants in [constants.ts](src/constants.ts) — never use magic numbers in scene code.
|
||||||
|
|
||||||
|
| Layer | Depth |
|
||||||
|
|---|---|
|
||||||
|
| Background | 0 |
|
||||||
|
| Game objects | 1–10 |
|
||||||
|
| Logo | 17 |
|
||||||
|
| Dim overlay | 20 |
|
||||||
|
| Fail/win UI | 21 |
|
||||||
|
| EndCard input | 25 |
|
||||||
|
|
||||||
|
## Ship Checklist
|
||||||
|
- [ ] < 5 MB, all WebP
|
||||||
|
- [ ] FPS ≥ 55 under CPU throttle
|
||||||
|
- [ ] Portrait + landscape pass
|
||||||
|
- [ ] MRAID ready before gameplay
|
||||||
|
- [ ] Audio muted by default
|
||||||
|
- [ ] CTA fires in fallback
|
||||||
|
- [ ] No `console.error`
|
||||||
|
- [ ] No `type="module"` or `crossorigin` on `<script>` in output HTML
|
||||||
|
- [ ] Lifecycle: gameReady / gameStart / gameEnd / gameClose detected by all network preview tools
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
| Issue | Fix |
|
||||||
|
|---|---|
|
||||||
|
| Rotation stuck | `Scale.NONE` + per-frame poll |
|
||||||
|
| Mobile aliasing | Canvas = viewport × DPR |
|
||||||
|
| FPS drop on resize | rAF debounce |
|
||||||
|
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||||
|
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||||
|
| Bundle > 5 MB | WebP q75, shorter audio |
|
||||||
|
| Built output contains library `console.error` | If `console.error` comes from Phaser or bundled node modules, add `esbuild: { pure: ['console.error'] }` in `vite.shared.js` so it is dropped at bundle time. Do not edit library source. |
|
||||||
|
| Audio blocked | Gate on `pointerdown` |
|
||||||
|
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||||
|
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||||
|
| Network preview checklist fail | Expose lifecycle stubs on `window` + call `notifyX()` with `typeof` guards |
|
||||||
|
| `playableSDK.reportEvent is not a function` | Applovin preview injects `playableSDK` without `reportEvent` — guard with `typeof playableSDK.reportEvent === 'function'`, not just `typeof playableSDK !== 'undefined'` |
|
||||||
|
| iPhone black screen (Android works) | **Only one `Phaser.Game` per page, ever.** iOS WKWebView — used by Applovin/Ironsource preview apps and most in-app ad containers — caps or outright rejects the 2nd WebGL context. If a background scene is spun up as a separate `new Phaser.Game()` synchronously at module load, it can throw before `await initMraid()` and before the font promise resolves, so the rest of `main.ts` never runs → black screen with no visible error. Render the background as a CSS `background-image` on `#bg-container` (cover-sized) instead — zero WebGL contexts for the background, and nothing to throw at module-load time. iOS-debug path: enable Safari → Advanced → Web Inspector on the iPhone, connect to a Mac, open Develop → iPhone → preview WebView, read the Console. Without a Mac, temporarily install top-of-`main.ts` `window.addEventListener('error',…)` and `unhandledrejection` handlers that write the error into `document.body.innerHTML` so the failure is visible on-screen. |
|
||||||
|
| iOS hangs on font load | `document.fonts.load()` silently stalls in WKWebView when `@font-face` uses base64 `data:` URLs. Race against a 1.5 s timeout: `Promise.race([Promise.all([…fonts]), new Promise(r=>setTimeout(r,1500))]).then(startGame)` |
|
||||||
|
| iOS hangs on MRAID ready | `mraid.addEventListener('ready', …)` can miss the event if the container fires it before the listener attaches. `initMraid()` must self-resolve after ~2 s so `startGame()` always runs; also check `mraid.getState() !== 'loading'` first. |
|
||||||
|
| External `src=` survives build | `vite-plugin-singlefile` only inlines assets *imported from JS*. A raw `<img src="src/assets/…">` in `index.html` ships as a literal relative path and fails in ad sandboxes, producing inconsistent per-device load failures. Leave DOM `<img>` elements with no `src`; assign via JS from `assets.ts` at boot (`setEndcardHtmlAssets()`). |
|
||||||
|
| Audio keeps playing when ad hides | Add `visibilitychange` listener in the sound module — mute Phaser's sound system and `suspend()` the `AudioContext` when `document.hidden`, resume on return. Spec requires audio stop on hide/close. |
|
||||||
|
| `mraid.open()` silently no-ops | Guard with `mraid.getState() !== 'loading'` before calling; fall through to `window.open()` if still loading. |
|
||||||
|
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed** — `document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
|
||||||
|
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
|
||||||
|
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top` → `window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
|
||||||
|
| MRAID network missing `mraid.js` | MRAID 3.0 creatives must request `mraid.js` early. Inject `<script src="mraid.js"></script>` exactly once for Applovin (`al`), Ironsource (`is`), and Unity (`un`), even if the container may also expose runtime MRAID objects. |
|
||||||
|
| MRAID listener wired too late | Previewers can probe MRAID before any Phaser scene is ready — if listeners are wired only inside `bindLifecycle` / scene `create()`, the creative can miss `ready`, `exposureChange`, or `viewableChange`. Fix: call `initMraid()` at `networks.ts` module-load time, guarded by `getState() === 'loading'` → `addEventListener('ready', setupMraid)` vs immediate call. Cache `_mraidViewable` / `_mraidExposed`; in `bindLifecycle` apply cached hidden/non-exposed state immediately. |
|
||||||
|
| MRAID 3.0 `exposureChange` not handled | MRAID 3.0 containers dispatch `exposureChange` with `exposedPercentage` directly instead of — or in addition to — `viewableChange`. If only listening to `viewableChange`, the ad may never receive pause signals on newer AL/IS/Unity builds. Register both: `mraid.addEventListener('exposureChange', exposedPercentage => onExposure(exposedPercentage > 0))` and `mraid.addEventListener('viewableChange', onViewable)` inside `initMraid()`. Seed initial state from `mraid.isViewable()` on ready. |
|
||||||
|
| MRAID 3.0 `audioVolumeChange` not handled | MRAID 3.0 containers fire `audioVolumeChange` with a volume level (0–100) or `null` when unavailable. Without a listener, the ad ignores host-level mute/unmute signals; if `null` is divided by 100, the ad can mute accidentally. Register `mraid.addEventListener('audioVolumeChange', v => { if (typeof v === 'number') scene.sound.setVolume(v / 100) })` in `initMraid()`. |
|
||||||
|
| MRAID `error` listener missing | Validators expect failed or unsupported MRAID calls to be observable. Register `mraid.addEventListener('error', (message, action) => console.warn('[MRAID error]', { message, action }))` during early MRAID setup. |
|
||||||
|
| MRAID `stateChange` listener missing | If the creative uses state-changing container behavior such as `mraid.close()`, `expand`, or `resize`, validators expect a `stateChange` listener. Register `mraid.addEventListener('stateChange', state => console.log('[MRAID stateChange]', state))` with the other early MRAID callbacks. |
|
||||||
|
| Custom close control not wired to MRAID | If the creative renders its own close button, the button must call a guarded close helper: `notifyGameClose()` then `mraid.close()` when `typeof mraid.close === 'function'` and `mraid.getState() !== 'loading'`. Do not use CTA/install buttons as close controls. |
|
||||||
|
| MRAID init not awaited before Phaser boot | If `new Phaser.Game()` is created before `initMraid()` resolves, the scene may fire `create()` before MRAID state is known — `bindLifecycle` then cannot apply a cached hidden/paused state. Make `boot()` async and `await initMraid()` before constructing the Phaser game. `initMraid()` must self-resolve after a timeout (~2 s) so a missing or non-firing MRAID container never blocks startup. |
|
||||||
|
| Late MRAID injection not handled | Some containers inject `window.mraid` after the script executes — a synchronous `typeof window.mraid !== 'undefined'` check at module load returns false even though MRAID will be present. After the immediate check fails, poll briefly (~500 ms) before concluding no-MRAID, then proceed. |
|
||||||
|
| Unsafe optional MRAID API calls | `useCustomClose()` was removed in MRAID 3.0, and APIs such as `expand`, `resize`, `storePicture`, and `createCalendarEvent` are optional/capability-dependent. Do not call them unless the project intentionally supports the feature and guards every call with `typeof mraid.method === 'function'` / `mraid.supports(...)` where relevant. |
|
||||||
|
|
||||||
|
## Ironsource — Runtime Analysis
|
||||||
|
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.
|
||||||
|
|
||||||
|
## Moloco — Upload Checklist & CTA Implementation
|
||||||
|
Creative specs: HTML5 (`.html` / `.htm`), < 5 MB, portrait + landscape.
|
||||||
|
|
||||||
|
Before uploading to Moloco Ads, confirm all three:
|
||||||
|
- [ ] No `XMLHttpRequest` anywhere in the ad (Moloco rejects it outright)
|
||||||
|
- [ ] CTA calls `FbPlayableAd.onCTAClick()` with no parameters — Moloco uses this for store redirect (not `window.clickTag`)
|
||||||
|
- [ ] No JavaScript redirects in ad file assets
|
||||||
|
|
||||||
|
### CTA implementation options
|
||||||
|
|
||||||
|
**Option 1 — event listener**
|
||||||
|
```html
|
||||||
|
<button id="ctaButton">Install</button>
|
||||||
|
<script>
|
||||||
|
document.getElementById('ctaButton').addEventListener('click', function() {
|
||||||
|
FbPlayableAd.onCTAClick();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 — inline onClick**
|
||||||
|
```html
|
||||||
|
<button onclick="FbPlayableAd.onCTAClick()">Install</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Moloco's validator requires `FbPlayableAd.onCTAClick()` — this is why it shares priority #2 in the CTA chain with Facebook. `window.clickTag` is kept as a fallback at priority #7.
|
||||||
|
|
||||||
|
***EOF***
|
||||||
|
[05/28/2026]
|
||||||
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.5.vsix
vendored
BIN
dist/hpl-toolbox-0.1.5.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
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"version": "0.1.0",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"version": "0.1.0",
|
"version": "0.1.6",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/vscode": "^1.85.0",
|
"@types/vscode": "^1.85.0",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -33,6 +35,53 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
77
package.json
77
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"displayName": "HPL Toolbox",
|
"displayName": "HPL Toolbox",
|
||||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, Daily Update. Local HTML Host.",
|
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||||
"version": "0.1.5",
|
"version": "1.0.1",
|
||||||
"publisher": "hesukastro",
|
"publisher": "hesukastro",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.85.0"
|
"vscode": "^1.85.0"
|
||||||
},
|
},
|
||||||
"categories": ["Other"],
|
"categories": [
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
"icon": "media/hpl.png",
|
"icon": "media/hpl.png",
|
||||||
"main": "./out/extension.js",
|
"main": "./out/extension.js",
|
||||||
"activationEvents": [],
|
"activationEvents": [],
|
||||||
@@ -24,19 +26,39 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "hplToolbox.openApplovinUpload",
|
"command": "hplToolbox.openApplovinUpload",
|
||||||
"title": "HPL Toolbox: Open AppLovin Demo Upload"
|
"title": "HPL Toolbox: Open AppLovin Playable Preview"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "hplToolbox.openBase64Scanner",
|
"command": "hplToolbox.openBase64Scanner",
|
||||||
"title": "HPL Toolbox: Open Base64 Scanner"
|
"title": "HPL Toolbox: Open Base64 Scanner"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "hplToolbox.openDailyUpdate",
|
"command": "hplToolbox.openMraidChecker",
|
||||||
"title": "HPL Toolbox: Open Daily Update"
|
"title": "HPL Toolbox: Open MRAID Checker"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "hplToolbox.openSendToMobile",
|
"command": "hplToolbox.openSendToMobile",
|
||||||
"title": "HPL Toolbox: Open Send To Mobile"
|
"title": "HPL Toolbox: Open Send To Mobile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
|
"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": {
|
||||||
@@ -53,7 +75,8 @@
|
|||||||
{
|
{
|
||||||
"type": "webview",
|
"type": "webview",
|
||||||
"id": "hplToolbox.launcher",
|
"id": "hplToolbox.launcher",
|
||||||
"name": "Tools"
|
"name": "",
|
||||||
|
"icon": "media/hpl.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -80,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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -113,17 +146,45 @@
|
|||||||
"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",
|
||||||
"@types/vscode": "^1.85.0",
|
"@types/vscode": "^1.85.0",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.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.
|
||||||
@@ -112,8 +112,14 @@ if ($Standalone) {
|
|||||||
|
|
||||||
$exeName = "hpl-toolbox-$version.exe"
|
$exeName = "hpl-toolbox-$version.exe"
|
||||||
$exeOut = Join-Path $distDir $exeName
|
$exeOut = Join-Path $distDir $exeName
|
||||||
go build -ldflags="-s -w -H windowsgui -X main.AppVersion=$version" -trimpath -o $exeOut .
|
$embeddedReadme = Join-Path (Get-Location) "README.md"
|
||||||
|
Copy-Item -Path (Join-Path $repoRoot "README.md") -Destination $embeddedReadme -Force
|
||||||
|
try {
|
||||||
|
go build -tags release -ldflags="-s -w -H windowsgui -X main.AppVersion=$version" -trimpath -o $exeOut .
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
|
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
|
||||||
|
} finally {
|
||||||
|
Remove-Item -Path $embeddedReadme -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
$summary += [pscustomobject]@{
|
$summary += [pscustomobject]@{
|
||||||
Artifact = $exeName
|
Artifact = $exeName
|
||||||
11
scripts/run-standalone.ps1
Normal file
11
scripts/run-standalone.ps1
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
$standalone = Join-Path $root 'standalone'
|
||||||
|
|
||||||
|
Push-Location $standalone
|
||||||
|
try {
|
||||||
|
go run .
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
222
src/changelogView.ts
Normal file
222
src/changelogView.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
export function openChangelog(context: vscode.ExtensionContext) {
|
||||||
|
const panel = vscode.window.createWebviewPanel(
|
||||||
|
'hplToolbox.changelog',
|
||||||
|
'HPL Toolbox Changelog',
|
||||||
|
vscode.ViewColumn.One,
|
||||||
|
{ enableScripts: false }
|
||||||
|
);
|
||||||
|
const readmePath = path.join(context.extensionPath, 'README.md');
|
||||||
|
let markdown = '# Changelog\n\nREADME.md was not found.';
|
||||||
|
try {
|
||||||
|
markdown = fs.readFileSync(readmePath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Keep the fallback text readable in packaged or development installs.
|
||||||
|
}
|
||||||
|
panel.webview.html = getHtml(renderMarkdown(markdown));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(content: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px;
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
font-size: var(--vscode-font-size);
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 840px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
margin: 6px 0 12px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: var(--vscode-editor-font-family, monospace);
|
||||||
|
font-size: 0.95em;
|
||||||
|
background: var(--vscode-textCodeBlock-background);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.changelog-block {
|
||||||
|
margin: 8px 0 18px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-textCodeBlock-background);
|
||||||
|
}
|
||||||
|
.change-section {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.changelog-block ul {
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
.changelog-block li.nested {
|
||||||
|
margin-left: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>${content}</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(markdown: string): string {
|
||||||
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const html: string[] = [];
|
||||||
|
let inList = false;
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let codeLines: string[] = [];
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (inList) {
|
||||||
|
html.push('</ul>');
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
if (rawLine.trim() === '```') {
|
||||||
|
closeList();
|
||||||
|
if (inCodeBlock) {
|
||||||
|
html.push(renderChangelogBlock(codeLines));
|
||||||
|
codeLines = [];
|
||||||
|
inCodeBlock = false;
|
||||||
|
} else {
|
||||||
|
inCodeBlock = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeLines.push(rawLine);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) {
|
||||||
|
closeList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||||||
|
if (heading) {
|
||||||
|
closeList();
|
||||||
|
const level = Math.min(heading[1].length, 2);
|
||||||
|
html.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const boldHeading = line.match(/^\*\*(.+)\*\*$/);
|
||||||
|
if (boldHeading) {
|
||||||
|
closeList();
|
||||||
|
html.push(`<h2>${escapeHtml(boldHeading[1])}</h2>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const listItem = line.match(/^-\s+(.+)$/);
|
||||||
|
if (listItem) {
|
||||||
|
if (!inList) {
|
||||||
|
html.push('<ul>');
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
html.push(`<li>${renderInlineMarkdown(listItem[1])}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
html.push(`<p>${renderInlineMarkdown(line)}</p>`);
|
||||||
|
}
|
||||||
|
if (inCodeBlock) {
|
||||||
|
html.push(renderChangelogBlock(codeLines));
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
return html.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChangelogBlock(lines: string[]): string {
|
||||||
|
const html: string[] = ['<div class="changelog-block">'];
|
||||||
|
let inList = false;
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (inList) {
|
||||||
|
html.push('</ul>');
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) {
|
||||||
|
closeList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const listItem = line.match(/^-\s+(.+)$/);
|
||||||
|
if (listItem) {
|
||||||
|
if (!inList) {
|
||||||
|
html.push('<ul>');
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
const nested = rawLine.match(/^\s{8,}-\s+/) ? ' class="nested"' : '';
|
||||||
|
html.push(`<li${nested}>${renderInlineMarkdown(listItem[1])}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
html.push(`<div class="change-section">${renderInlineMarkdown(line)}</div>`);
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
html.push('</div>');
|
||||||
|
return html.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineMarkdown(value: string): string {
|
||||||
|
return escapeHtml(value)
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>"']/g, ch => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[ch] ?? ch));
|
||||||
|
}
|
||||||
@@ -3,18 +3,32 @@ import { LauncherViewProvider } from './launcherView';
|
|||||||
import { openPlecUpload } from './tools/plecUpload';
|
import { openPlecUpload } from './tools/plecUpload';
|
||||||
import { openApplovinUpload } from './tools/applovinUpload';
|
import { openApplovinUpload } from './tools/applovinUpload';
|
||||||
import { openBase64Scanner } from './tools/base64Scanner';
|
import { openBase64Scanner } from './tools/base64Scanner';
|
||||||
import { openDailyUpdate } from './tools/dailyUpdate';
|
import { openMraidChecker } from './tools/mraidChecker';
|
||||||
import { openSendToMobile } from './tools/sendToMobile';
|
import { openSendToMobile } from './tools/sendToMobile';
|
||||||
|
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||||
|
import { openMintegralChecker } from './tools/mintegralChecker';
|
||||||
|
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(
|
||||||
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
|
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(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.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.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() {}
|
||||||
|
|||||||
@@ -1,72 +1,319 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
||||||
|
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
|
||||||
|
|
||||||
|
interface ToolDefinition {
|
||||||
|
id?: string;
|
||||||
|
command: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
beta?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
title: 'PLEC Upload',
|
||||||
|
description: 'Upload HTML to internal PLEC server',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openApplovinUpload',
|
||||||
|
title: 'AppLovin Playable Preview',
|
||||||
|
description: 'Upload to p.applov.in (QR preview)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openBase64Scanner',
|
||||||
|
title: 'Base64 Scanner',
|
||||||
|
description: 'Find non-base64 assets in HTML',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openSendToMobile',
|
||||||
|
title: 'Send To Mobile',
|
||||||
|
description: 'Share HTML to a phone via LAN + QR',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openMraidChecker',
|
||||||
|
title: 'MRAID Checker',
|
||||||
|
description: 'Check MRAID requirements and best practices',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openPlayworksConverter',
|
||||||
|
title: 'Playworks Converter',
|
||||||
|
description: 'Convert Playworks HTML to per-network variants',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
constructor(private readonly context: vscode.ExtensionContext) { }
|
||||||
|
|
||||||
resolveWebviewView(view: vscode.WebviewView) {
|
resolveWebviewView(view: vscode.WebviewView) {
|
||||||
view.webview.options = { enableScripts: true };
|
view.webview.options = { enableScripts: true };
|
||||||
const version = this.context.extension.packageJSON.version as string;
|
const version = this.context.extension.packageJSON.version as string;
|
||||||
view.webview.html = getHtml(version);
|
const tools = loadToolDefinitions(this.context.extensionPath);
|
||||||
view.webview.onDidReceiveMessage((msg) => {
|
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
||||||
|
const toolOrder = this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []);
|
||||||
|
view.webview.html = getHtml(version, betaToolsEnabled, tools, toolOrder);
|
||||||
|
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') {
|
||||||
|
vscode.commands.executeCommand('hplToolbox.openChangelog');
|
||||||
|
} else if (msg?.type === 'checkForUpdates') {
|
||||||
|
vscode.commands.executeCommand('hplToolbox.checkForUpdates');
|
||||||
|
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||||
|
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
||||||
|
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'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(version: string): string {
|
function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
|
||||||
|
const toolsPath = path.join(extensionPath, 'tools.json');
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(toolsPath, 'utf8'));
|
||||||
|
if (!Array.isArray(raw)) return FALLBACK_TOOLS;
|
||||||
|
const tools = raw
|
||||||
|
.filter((tool: any) => tool && typeof tool.command === 'string' && typeof tool.title === 'string')
|
||||||
|
.map((tool: any) => ({
|
||||||
|
id: typeof tool.id === 'string' ? tool.id : undefined,
|
||||||
|
command: tool.command,
|
||||||
|
title: tool.title,
|
||||||
|
description: typeof tool.description === 'string' ? tool.description : '',
|
||||||
|
beta: Boolean(tool.beta),
|
||||||
|
}));
|
||||||
|
return tools.length ? tools : FALLBACK_TOOLS;
|
||||||
|
} catch {
|
||||||
|
return FALLBACK_TOOLS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[], toolOrder: string[]): string {
|
||||||
|
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta), toolOrder);
|
||||||
|
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 8px; }
|
* { box-sizing: border-box; }
|
||||||
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 8px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
font-size: var(--vscode-font-size);
|
||||||
|
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
.tools {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.tool-btn {
|
.tool-btn {
|
||||||
display: block; width: 100%; text-align: left;
|
display: block;
|
||||||
padding: 8px 10px; margin-bottom: 6px;
|
width: 100%;
|
||||||
background: var(--vscode-button-secondaryBackground);
|
min-height: 52px;
|
||||||
color: var(--vscode-button-secondaryForeground);
|
padding: 8px 9px;
|
||||||
border: 1px solid var(--vscode-panel-border);
|
text-align: left;
|
||||||
border-radius: 3px; cursor: pointer; font-size: 13px;
|
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-button-secondaryBackground));
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
border: 1px solid var(--vscode-panel-border, transparent);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.tool-btn.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
.tool-btn:hover {
|
.tool-btn:hover {
|
||||||
background: var(--vscode-button-secondaryHoverBackground);
|
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
||||||
|
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
||||||
|
}
|
||||||
|
.tool-btn:focus {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.tool-btn:active {
|
||||||
|
background: var(--vscode-list-activeSelectionBackground, var(--vscode-button-background));
|
||||||
|
color: var(--vscode-list-activeSelectionForeground, var(--vscode-button-foreground));
|
||||||
|
}
|
||||||
|
.tool-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.tool-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.beta-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
.footer-action {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
#betaToggle {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.footer-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.footer-version {
|
||||||
|
opacity: 0.75;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
.changelog-link {
|
||||||
|
justify-self: end;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h3>HPL Toolbox ${version}</h3>
|
<div class="tools">
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openPlecUpload">
|
${toolButtons}
|
||||||
PLEC Upload
|
</div>
|
||||||
<span class="tool-desc">Upload HTML to internal PLEC server</span>
|
<div class="footer">
|
||||||
</button>
|
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openApplovinUpload">
|
<button id="updateCheck" class="footer-action footer-version" title="Check for updates">${version} - JJGC 00784</button>
|
||||||
AppLovin Demo Upload
|
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
|
||||||
<span class="tool-desc">Upload to p.applov.in (QR preview)</span>
|
</div>
|
||||||
</button>
|
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openBase64Scanner">
|
|
||||||
Base64 Scanner
|
|
||||||
<span class="tool-desc">Find non-base64 assets in HTML</span>
|
|
||||||
</button>
|
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openDailyUpdate">
|
|
||||||
Daily Update
|
|
||||||
<span class="tool-desc">Compose & copy a daily status</span>
|
|
||||||
</button>
|
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openSendToMobile">
|
|
||||||
Send To Mobile
|
|
||||||
<span class="tool-desc">Share HTML to a phone via LAN + QR</span>
|
|
||||||
</button>
|
|
||||||
<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) => {
|
||||||
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
|
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||||
|
});
|
||||||
|
document.getElementById('changelogLink').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'openChangelog' });
|
||||||
|
});
|
||||||
|
document.getElementById('updateCheck').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'checkForUpdates' });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortToolsForDisplay(tools: ToolDefinition[], toolOrder: string[]): ToolDefinition[] {
|
||||||
|
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 {
|
||||||
|
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-desc">${escapeHtml(tool.description)}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolId(tool: ToolDefinition): string {
|
||||||
|
return tool.id || tool.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>"']/g, ch => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[ch] ?? ch));
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import * as vscode from 'vscode';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { File } from 'node:buffer';
|
import { File } from 'node:buffer';
|
||||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||||
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Demo Upload');
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Playable Preview');
|
||||||
if (!isNew) return;
|
if (!isNew) return;
|
||||||
|
|
||||||
panel.webview.html = getHtml();
|
panel.webview.html = getHtml();
|
||||||
@@ -15,6 +15,24 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
|||||||
try {
|
try {
|
||||||
if (handleClipboardAndOpen(msg)) return;
|
if (handleClipboardAndOpen(msg)) return;
|
||||||
|
|
||||||
|
if (msg.type === 'pickFolder') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Folder',
|
||||||
|
defaultUri: getPickerDefaultUri(),
|
||||||
|
});
|
||||||
|
if (picked && picked[0]) {
|
||||||
|
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||||
|
panel.webview.postMessage({
|
||||||
|
type: 'filesSelected',
|
||||||
|
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'pickFiles') {
|
if (msg.type === 'pickFiles') {
|
||||||
const picked = await vscode.window.showOpenDialog({
|
const picked = await vscode.window.showOpenDialog({
|
||||||
canSelectMany: true,
|
canSelectMany: true,
|
||||||
@@ -116,11 +134,11 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
|||||||
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
|
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
|
||||||
|
|
||||||
if (i > 0 && delayMs > 0) {
|
if (i > 0 && delayMs > 0) {
|
||||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` });
|
panel.webview.postMessage({ type: 'status', message: `Waiting ${idx}/${paths.length} ${fileName}` });
|
||||||
await sleep(delayMs);
|
await sleep(delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` });
|
panel.webview.postMessage({ type: 'status', message: `Validating ${idx}/${paths.length} ${fileName}` });
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +154,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
|||||||
reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue;
|
reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Uploading ${fileName}...` });
|
panel.webview.postMessage({ type: 'status', message: `Uploading ${idx}/${paths.length} ${fileName}` });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
res = await fetch('https://p.applov.in/getCachedAdURL', {
|
res = await fetch('https://p.applov.in/getCachedAdURL', {
|
||||||
@@ -175,6 +193,30 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
|||||||
return vscode.Uri.file(root);
|
return vscode.Uri.file(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const stack: string[] = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||||
|
stack.push(full);
|
||||||
|
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function qrBaseName(name: string): string {
|
function qrBaseName(name: string): string {
|
||||||
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
|
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
|
||||||
}
|
}
|
||||||
@@ -243,54 +285,87 @@ function getHtml(): string {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
${getToolWebviewStyles()}
|
||||||
h2 { margin-top: 0; }
|
.selected-files { margin-top: 10px; }
|
||||||
button {
|
.qr-cell { text-align: center; }
|
||||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
.qr-cell img {
|
||||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
.result-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
button.secondary {
|
.qr-cell img { cursor: zoom-in; }
|
||||||
background: var(--vscode-button-secondaryBackground);
|
.qr-modal {
|
||||||
color: var(--vscode-button-secondaryForeground);
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
.qr-modal.open { display: flex; }
|
||||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
.qr-modal img {
|
||||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
width: min(70vw, 520px);
|
||||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
height: min(70vw, 520px);
|
||||||
#status { margin-top: 12px; min-height: 20px; }
|
padding: 18px;
|
||||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
border-radius: var(--tool-radius);
|
||||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
background: #fff;
|
||||||
.result {
|
|
||||||
padding: 10px;
|
|
||||||
background: var(--vscode-textBlockQuote-background);
|
|
||||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
.qr-results:not(.has-errors) .error-col { display: none; }
|
||||||
#results { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill,minmax(280px,1fr)); gap: 12px; }
|
.remove-selected {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
padding: 3px 0;
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>AppLovin Playable Preview (QR)</h2>
|
<main class="tool-page">
|
||||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app (iOS/Android).</p>
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">AppLovin Playable Preview</h2>
|
||||||
|
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="file-cell" style="margin-bottom:8px;">
|
<section class="tool-panel input-panel">
|
||||||
<button id="pick" class="secondary">Add files...</button>
|
<div class="panel-header">
|
||||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
<h3 class="panel-title">Inputs</h3>
|
||||||
<span class="file-name" id="fileName">(no files)</span>
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
|
<button id="pick" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
|
||||||
|
<div id="fileList" class="selected-files"></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="upload">Upload</button>
|
||||||
|
<button id="saveAll" class="secondary" disabled>Save All</button>
|
||||||
|
<button id="regenAll" class="secondary" disabled>Regenerate</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="outputPanel" class="is-hidden">
|
||||||
|
<div id="results" class="results-panel"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
<div id="qrModal" class="qr-modal" title="Click to close"><img alt="Expanded QR" /></div>
|
||||||
<div class="actions">
|
</main>
|
||||||
<button id="upload">Upload to AppLovin</button>
|
|
||||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
|
||||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
|
||||||
</div>
|
|
||||||
<div id="status"></div>
|
|
||||||
<div id="results"></div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
|
const pickFolderBtn = document.getElementById('pickFolder');
|
||||||
const pickBtn = document.getElementById('pick');
|
const pickBtn = document.getElementById('pick');
|
||||||
const clearBtn = document.getElementById('clear');
|
const clearBtn = document.getElementById('clear');
|
||||||
const uploadBtn = document.getElementById('upload');
|
const uploadBtn = document.getElementById('upload');
|
||||||
@@ -298,9 +373,13 @@ function getHtml(): string {
|
|||||||
const fileListEl = document.getElementById('fileList');
|
const fileListEl = document.getElementById('fileList');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
|
const outputPanel = document.getElementById('outputPanel');
|
||||||
|
const qrModal = document.getElementById('qrModal');
|
||||||
const saveAllBtn = document.getElementById('saveAll');
|
const saveAllBtn = document.getElementById('saveAll');
|
||||||
const regenAllBtn = document.getElementById('regenAll');
|
const regenAllBtn = document.getElementById('regenAll');
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
let selectedPaths = [];
|
let selectedPaths = [];
|
||||||
|
let selectedPage = 0;
|
||||||
let resultItems = [];
|
let resultItems = [];
|
||||||
|
|
||||||
function basename(p) {
|
function basename(p) {
|
||||||
@@ -308,15 +387,51 @@ function getHtml(): string {
|
|||||||
return i >= 0 ? p.slice(i + 1) : p;
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
}
|
}
|
||||||
function renderSelection() {
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
if (!selectedPaths.length) {
|
if (!selectedPaths.length) {
|
||||||
fileNameEl.textContent = '(no files)';
|
fileNameEl.textContent = '(no files selected)';
|
||||||
fileListEl.innerHTML = '';
|
fileListEl.innerHTML = '';
|
||||||
clearBtn.style.display = 'none';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||||
clearBtn.style.display = '';
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const start = selectedPage * PAGE_SIZE;
|
||||||
|
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
|
||||||
|
fileListEl.innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||||
|
'<tbody>' +
|
||||||
|
visiblePaths.map((p, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return (
|
||||||
|
'<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td>' +
|
||||||
|
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
|
||||||
|
);
|
||||||
|
}).join('') +
|
||||||
|
'</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedPaths.splice(Number(btn.dataset.index), 1);
|
||||||
|
renderSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (selectedPaths.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML =
|
||||||
|
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
fileListEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAllBtn.addEventListener('click', () => {
|
saveAllBtn.addEventListener('click', () => {
|
||||||
@@ -331,24 +446,33 @@ function getHtml(): string {
|
|||||||
img.src = base + '&_=' + Date.now();
|
img.src = base + '&_=' + Date.now();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
qrModal.addEventListener('click', () => {
|
||||||
|
qrModal.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
|
||||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
|
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
|
||||||
clearBtn.addEventListener('click', () => {
|
clearBtn.addEventListener('click', () => {
|
||||||
selectedPaths = [];
|
selectedPaths = [];
|
||||||
|
selectedPage = 0;
|
||||||
renderSelection();
|
renderSelection();
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadBtn.addEventListener('click', () => {
|
uploadBtn.addEventListener('click', () => {
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
if (!selectedPaths.length) {
|
if (!selectedPaths.length) {
|
||||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
|
statusEl.textContent = 'Uploading...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
resultItems = [];
|
resultItems = [];
|
||||||
saveAllBtn.style.display = 'none';
|
saveAllBtn.disabled = true;
|
||||||
regenAllBtn.style.display = 'none';
|
regenAllBtn.disabled = true;
|
||||||
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -356,21 +480,36 @@ function getHtml(): string {
|
|||||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureResultsTable() {
|
||||||
|
let tbody = resultsEl.querySelector('tbody');
|
||||||
|
if (tbody) return tbody;
|
||||||
|
resultsEl.innerHTML =
|
||||||
|
'<table class="data-table qr-results">' +
|
||||||
|
'<thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead>' +
|
||||||
|
'<tbody></tbody>' +
|
||||||
|
'</table>';
|
||||||
|
outputPanel.classList.remove('is-hidden');
|
||||||
|
return resultsEl.querySelector('tbody');
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (m.type === 'filesSelected') {
|
if (m.type === 'filesSelected') {
|
||||||
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||||
selectedPaths = selectedPaths.concat(added);
|
selectedPaths = selectedPaths.concat(added);
|
||||||
|
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||||
renderSelection();
|
renderSelection();
|
||||||
} else if (m.type === 'start') {
|
} else if (m.type === 'start') {
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
resultItems = [];
|
resultItems = [];
|
||||||
saveAllBtn.style.display = 'none';
|
saveAllBtn.disabled = true;
|
||||||
regenAllBtn.style.display = 'none';
|
regenAllBtn.disabled = true;
|
||||||
} else if (m.type === 'status') {
|
} else if (m.type === 'status') {
|
||||||
statusEl.textContent = m.message;
|
statusEl.textContent = m.message;
|
||||||
statusEl.className = '';
|
statusEl.className = 'status-panel is-busy';
|
||||||
} else if (m.type === 'error') {
|
} else if (m.type === 'error') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'err';
|
div.className = 'err';
|
||||||
div.textContent = m.message;
|
div.textContent = m.message;
|
||||||
@@ -378,20 +517,32 @@ function getHtml(): string {
|
|||||||
statusEl.appendChild(div);
|
statusEl.appendChild(div);
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
} else if (m.type === 'fileError') {
|
} else if (m.type === 'fileError') {
|
||||||
const wrap = document.createElement('div');
|
const tbody = ensureResultsTable();
|
||||||
wrap.className = 'result';
|
resultsEl.querySelector('table').classList.add('has-errors');
|
||||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
const row = document.createElement('tr');
|
||||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
row.innerHTML =
|
||||||
resultsEl.appendChild(wrap);
|
'<td class="mono wrap">' + escapeHtml(m.name) + '</td>' +
|
||||||
|
'<td class="muted">-</td>' +
|
||||||
|
'<td class="muted">-</td>' +
|
||||||
|
'<td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
|
||||||
|
tbody.appendChild(row);
|
||||||
} else if (m.type === 'fileResult') {
|
} else if (m.type === 'fileResult') {
|
||||||
const wrap = document.createElement('div');
|
const tbody = ensureResultsTable();
|
||||||
wrap.className = 'result';
|
const row = document.createElement('tr');
|
||||||
wrap.innerHTML =
|
const file = document.createElement('td');
|
||||||
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
file.className = 'mono wrap';
|
||||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
file.textContent = m.name;
|
||||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
const qr = document.createElement('td');
|
||||||
'</div>' +
|
qr.className = 'qr-cell';
|
||||||
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
|
||||||
|
qr.querySelector('img').addEventListener('click', (event) => {
|
||||||
|
qrModal.querySelector('img').src = event.currentTarget.src;
|
||||||
|
qrModal.classList.add('open');
|
||||||
|
});
|
||||||
|
const actionCell = document.createElement('td');
|
||||||
|
const error = document.createElement('td');
|
||||||
|
error.className = 'error-col';
|
||||||
|
error.innerHTML = '<span class="muted">-</span>';
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'result-actions';
|
actions.className = 'result-actions';
|
||||||
const openPreview = document.createElement('button');
|
const openPreview = document.createElement('button');
|
||||||
@@ -404,14 +555,14 @@ function getHtml(): string {
|
|||||||
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
||||||
actions.appendChild(openPreview);
|
actions.appendChild(openPreview);
|
||||||
actions.appendChild(saveQr);
|
actions.appendChild(saveQr);
|
||||||
wrap.appendChild(actions);
|
actionCell.appendChild(actions);
|
||||||
resultsEl.appendChild(wrap);
|
row.append(file, qr, actionCell, error);
|
||||||
|
tbody.appendChild(row);
|
||||||
resultItems.push({ name: m.name, url: m.qrImg });
|
resultItems.push({ name: m.name, url: m.qrImg });
|
||||||
if (resultItems.length >= 2) {
|
saveAllBtn.disabled = resultItems.length === 0;
|
||||||
saveAllBtn.style.display = '';
|
regenAllBtn.disabled = resultItems.length === 0;
|
||||||
regenAllBtn.style.display = '';
|
|
||||||
}
|
|
||||||
} else if (m.type === 'done') {
|
} else if (m.type === 'done') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { singletonPanel } from './shared';
|
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||||
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Asset Scanner');
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Scanner');
|
||||||
if (!isNew) return;
|
if (!isNew) return;
|
||||||
|
|
||||||
panel.webview.html = getHtml();
|
panel.webview.html = getHtml();
|
||||||
@@ -16,7 +16,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
|||||||
if (ws) {
|
if (ws) {
|
||||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||||
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
const files = await collectHtmlFiles(distPath);
|
||||||
|
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'pickFolder') {
|
} else if (msg.type === 'pickFolder') {
|
||||||
@@ -27,7 +28,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
|||||||
openLabel: 'Select Folder',
|
openLabel: 'Select Folder',
|
||||||
});
|
});
|
||||||
if (picked && picked[0]) {
|
if (picked && picked[0]) {
|
||||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||||
|
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'pickFiles') {
|
} else if (msg.type === 'pickFiles') {
|
||||||
const picked = await vscode.window.showOpenDialog({
|
const picked = await vscode.window.showOpenDialog({
|
||||||
@@ -56,7 +58,9 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const results: { file: string; ok: boolean; assets: string[] }[] = [];
|
const results: { file: string; ok: boolean; assets: string[] }[] = [];
|
||||||
for (const file of targets) {
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
const file = targets[i];
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` });
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(file, 'utf8');
|
const content = fs.readFileSync(file, 'utf8');
|
||||||
const offenders = findNonBase64Assets(content);
|
const offenders = findNonBase64Assets(content);
|
||||||
@@ -144,46 +148,125 @@ function getHtml(): string {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
${getToolWebviewStyles()}
|
||||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
.asset-list { margin: 0; padding-left: 16px; }
|
||||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
.asset-list li + li { margin-top: 3px; }
|
||||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
.selected-list { margin-top: 8px; }
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
.remove-selected {
|
||||||
.row-result { display: flex; gap: 8px; padding: 2px 0; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
min-width: 28px;
|
||||||
.mark { width: 14px; flex-shrink: 0; font-weight: bold; }
|
width: 28px;
|
||||||
.ok { color: #3fb950; }
|
padding: 3px 0;
|
||||||
.bad { color: #f85149; }
|
color: var(--vscode-errorForeground);
|
||||||
.file-name { word-break: break-all; }
|
font-size: 16px;
|
||||||
.assets { color: var(--vscode-descriptionForeground); margin-left: 6px; word-break: break-all; }
|
line-height: 1;
|
||||||
.summary { margin-top: 12px; opacity: 0.8; }
|
}
|
||||||
|
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>Base64 Asset Scanner</h2>
|
<main class="tool-page">
|
||||||
<div class="row">
|
<header class="tool-header">
|
||||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
<h2 class="tool-title">Base64 Scanner</h2>
|
||||||
<button id="pick">Browse Folder...</button>
|
<p class="tool-description">Scan folders or selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||||
<button id="pickFiles">Pick Files...</button>
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Inputs</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="folder" type="hidden" />
|
||||||
|
<button id="pick" class="secondary">Select Folder</button>
|
||||||
|
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="selection" class="selected-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
<button id="scan">Scan</button>
|
<button id="scan">Scan</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="selection" class="summary"></div>
|
<div id="status" class="status-panel"></div>
|
||||||
<div id="status"></div>
|
</div>
|
||||||
<div id="results"></div>
|
</section>
|
||||||
|
|
||||||
|
<div id="outputPanel" class="is-hidden">
|
||||||
|
<div id="results" class="results-panel"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
vscode.postMessage({ type: 'ready' });
|
vscode.postMessage({ type: 'ready' });
|
||||||
const folderEl = document.getElementById('folder');
|
const folderEl = document.getElementById('folder');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
|
const outputPanel = document.getElementById('outputPanel');
|
||||||
const selectionEl = document.getElementById('selection');
|
const selectionEl = document.getElementById('selection');
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
let pickedFolder = '';
|
||||||
let pickedFiles = [];
|
let pickedFiles = [];
|
||||||
|
let selectedPage = 0;
|
||||||
|
|
||||||
function clearFiles() {
|
function basename(p) {
|
||||||
pickedFiles = [];
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||||
selectionEl.textContent = '';
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
}
|
}
|
||||||
|
|
||||||
folderEl.addEventListener('input', clearFiles);
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
const items = pickedFiles.length
|
||||||
|
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
|
||||||
|
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
|
||||||
|
if (!items.length) {
|
||||||
|
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
|
||||||
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
|
||||||
|
selectionEl.innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||||
|
'<tbody>' +
|
||||||
|
visibleItems.map(item =>
|
||||||
|
'<tr><td class="mono wrap">' + escapeHtml(item.label) + '</td>' +
|
||||||
|
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
|
||||||
|
).join('') +
|
||||||
|
'</tbody></table>' +
|
||||||
|
'</div>';
|
||||||
|
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.type === 'folder') {
|
||||||
|
pickedFolder = '';
|
||||||
|
folderEl.value = '';
|
||||||
|
} else {
|
||||||
|
pickedFiles.splice(Number(btn.dataset.index), 1);
|
||||||
|
}
|
||||||
|
renderSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (items.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML =
|
||||||
|
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
selectionEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSelection();
|
||||||
|
|
||||||
document.getElementById('pick').addEventListener('click', () => {
|
document.getElementById('pick').addEventListener('click', () => {
|
||||||
vscode.postMessage({ type: 'pickFolder' });
|
vscode.postMessage({ type: 'pickFolder' });
|
||||||
@@ -191,9 +274,22 @@ function getHtml(): string {
|
|||||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||||
vscode.postMessage({ type: 'pickFiles' });
|
vscode.postMessage({ type: 'pickFiles' });
|
||||||
});
|
});
|
||||||
|
document.getElementById('clear').addEventListener('click', () => {
|
||||||
|
folderEl.value = '';
|
||||||
|
pickedFiles = [];
|
||||||
|
pickedFolder = '';
|
||||||
|
selectedPage = 0;
|
||||||
|
renderSelection();
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
});
|
||||||
document.getElementById('scan').addEventListener('click', () => {
|
document.getElementById('scan').addEventListener('click', () => {
|
||||||
statusEl.textContent = 'Scanning...';
|
statusEl.textContent = 'Scanning...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
if (pickedFiles.length) {
|
if (pickedFiles.length) {
|
||||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||||
} else {
|
} else {
|
||||||
@@ -207,35 +303,68 @@ function getHtml(): string {
|
|||||||
const msg = event.data;
|
const msg = event.data;
|
||||||
if (msg.type === 'folderPicked') {
|
if (msg.type === 'folderPicked') {
|
||||||
folderEl.value = msg.path;
|
folderEl.value = msg.path;
|
||||||
clearFiles();
|
pickedFolder = msg.path;
|
||||||
|
pickedFiles = [];
|
||||||
|
selectedPage = 0;
|
||||||
|
renderSelection();
|
||||||
} else if (msg.type === 'filesPicked') {
|
} else if (msg.type === 'filesPicked') {
|
||||||
pickedFiles = msg.paths;
|
const existing = new Set(pickedFiles);
|
||||||
|
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
|
||||||
folderEl.value = '';
|
folderEl.value = '';
|
||||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
pickedFolder = '';
|
||||||
|
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
renderSelection();
|
||||||
|
} else if (msg.type === 'status') {
|
||||||
|
statusEl.textContent = msg.message;
|
||||||
|
statusEl.className = 'status-panel is-busy';
|
||||||
} else if (msg.type === 'results') {
|
} else if (msg.type === 'results') {
|
||||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
if (msg.error) {
|
||||||
const total = msg.results.length;
|
statusEl.classList.remove('is-busy');
|
||||||
const flagged = msg.results.filter(r => !r.ok).length;
|
statusEl.textContent = 'Error: ' + msg.error;
|
||||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
outputPanel.classList.add('is-hidden');
|
||||||
resultsEl.innerHTML = '';
|
return;
|
||||||
for (const r of msg.results) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'row-result';
|
|
||||||
const mark = document.createElement('span');
|
|
||||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
|
||||||
mark.textContent = r.ok ? '✓' : '✗';
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'file-name';
|
|
||||||
name.textContent = r.file;
|
|
||||||
row.appendChild(mark);
|
|
||||||
row.appendChild(name);
|
|
||||||
if (!r.ok) {
|
|
||||||
const a = document.createElement('span');
|
|
||||||
a.className = 'assets';
|
|
||||||
a.textContent = '— ' + r.assets.join(', ');
|
|
||||||
row.appendChild(a);
|
|
||||||
}
|
}
|
||||||
resultsEl.appendChild(row);
|
const total = msg.results.length;
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
const flagged = msg.results.filter(r => !r.ok).length;
|
||||||
|
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||||
|
if (!total) {
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outputPanel.classList.remove('is-hidden');
|
||||||
|
resultsEl.innerHTML =
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead>' +
|
||||||
|
'<tbody></tbody>' +
|
||||||
|
'</table>';
|
||||||
|
const tbody = resultsEl.querySelector('tbody');
|
||||||
|
for (const r of msg.results) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const status = document.createElement('td');
|
||||||
|
status.innerHTML = r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>';
|
||||||
|
const name = document.createElement('td');
|
||||||
|
name.className = 'mono wrap';
|
||||||
|
name.textContent = r.file;
|
||||||
|
const count = document.createElement('td');
|
||||||
|
count.textContent = String(r.assets.length);
|
||||||
|
const assets = document.createElement('td');
|
||||||
|
assets.className = 'wrap';
|
||||||
|
if (!r.ok) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'asset-list mono';
|
||||||
|
r.assets.forEach(asset => {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.textContent = asset;
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
assets.appendChild(list);
|
||||||
|
} else {
|
||||||
|
assets.innerHTML = '<span class="muted">None</span>';
|
||||||
|
}
|
||||||
|
row.append(status, name, count, assets);
|
||||||
|
tbody.appendChild(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import * as vscode from 'vscode';
|
|
||||||
import { singletonPanel } from './shared';
|
|
||||||
|
|
||||||
const PROJECT_KEY = 'hplToolbox.dailyUpdate.lastProject';
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
|
||||||
|
|
||||||
export function openDailyUpdate(context: vscode.ExtensionContext) {
|
|
||||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.dailyUpdate', 'Daily Update');
|
|
||||||
if (!isNew) return;
|
|
||||||
|
|
||||||
const lastProject = context.globalState.get<string>(PROJECT_KEY, '');
|
|
||||||
panel.webview.html = getHtml(lastProject);
|
|
||||||
|
|
||||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
|
||||||
if (msg.type === 'submit') {
|
|
||||||
const text = formatMessage(msg.payload);
|
|
||||||
await vscode.env.clipboard.writeText(text);
|
|
||||||
await context.globalState.update(PROJECT_KEY, msg.payload.project);
|
|
||||||
vscode.window.showInformationMessage('Daily update copied to clipboard.');
|
|
||||||
panel.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessage(p: { date: string; status: string; project: string; remarks: string }): string {
|
|
||||||
const bullets = p.remarks
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((l) => l.replace(/^\s*[-*]\s*/, '').trim())
|
|
||||||
.filter((l) => l.length > 0)
|
|
||||||
.map((l) => `- ${l}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`Date: ${formatDate(p.date)}`,
|
|
||||||
`Status: ${p.status}`,
|
|
||||||
`Project: ${p.project}`,
|
|
||||||
`Remarks:`,
|
|
||||||
bullets,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (p.status === 'For OT') {
|
|
||||||
lines.push('@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace');
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
||||||
if (!m) return iso;
|
|
||||||
return `${m[2]}/${m[3]}/${m[1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttr(s: string): string {
|
|
||||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHtml(lastProject: string): string {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
|
||||||
<style>
|
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; max-width: 520px; }
|
|
||||||
label { display: block; margin-top: 12px; font-size: 12px; opacity: 0.85; }
|
|
||||||
input, select, textarea {
|
|
||||||
width: 100%; box-sizing: border-box; margin-top: 4px; padding: 6px 8px;
|
|
||||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
|
||||||
border: 1px solid var(--vscode-input-border, transparent); font-family: inherit; font-size: 13px;
|
|
||||||
}
|
|
||||||
textarea { min-height: 96px; resize: vertical; }
|
|
||||||
button {
|
|
||||||
margin-top: 16px; padding: 8px 16px; cursor: pointer;
|
|
||||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
|
||||||
border: none; font-size: 13px;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2 style="margin:0 0 4px 0;">Daily Update</h2>
|
|
||||||
<div style="font-size:12px;opacity:0.7;">Submit copies the formatted text to your clipboard.</div>
|
|
||||||
|
|
||||||
<label for="date">Date</label>
|
|
||||||
<input type="date" id="date" value="${today}" />
|
|
||||||
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status">
|
|
||||||
<option>Started</option>
|
|
||||||
<option>Progress</option>
|
|
||||||
<option>Finished</option>
|
|
||||||
<option>Client Feedback</option>
|
|
||||||
<option>For OT</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="project">Project</label>
|
|
||||||
<input type="text" id="project" placeholder="Project name" value="${escapeAttr(lastProject)}" />
|
|
||||||
|
|
||||||
<label for="remarks">Remarks</label>
|
|
||||||
<textarea id="remarks" placeholder="What did you do?"></textarea>
|
|
||||||
|
|
||||||
<button id="submit">Submit</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const vscode = acquireVsCodeApi();
|
|
||||||
document.getElementById('submit').addEventListener('click', () => {
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'submit',
|
|
||||||
payload: {
|
|
||||||
date: document.getElementById('date').value,
|
|
||||||
status: document.getElementById('status').value,
|
|
||||||
project: document.getElementById('project').value.trim(),
|
|
||||||
remarks: document.getElementById('remarks').value.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
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
895
src/tools/mraidChecker.ts
Normal file
895
src/tools/mraidChecker.ts
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
type Severity = 'requirement' | 'best-practice';
|
||||||
|
|
||||||
|
interface MraidIssue {
|
||||||
|
id: string;
|
||||||
|
severity: Severity;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
line?: number;
|
||||||
|
reference: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MraidResult {
|
||||||
|
file: string;
|
||||||
|
ok: boolean;
|
||||||
|
issues: MraidIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MraidRule {
|
||||||
|
id: string;
|
||||||
|
severity: Severity;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
reference: string;
|
||||||
|
test: (ctx: ScanContext) => MraidIssue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanContext {
|
||||||
|
filePath: string;
|
||||||
|
html: string;
|
||||||
|
scriptText: string;
|
||||||
|
lowerHtml: string;
|
||||||
|
lowerScript: string;
|
||||||
|
mraidCallLines: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||||
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.mraidChecker', 'MRAID Checker');
|
||||||
|
if (!isNew) return;
|
||||||
|
|
||||||
|
panel.webview.html = getHtml();
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
const ws = vscode.workspace.workspaceFolders?.[0];
|
||||||
|
if (ws) {
|
||||||
|
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||||
|
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||||
|
const files = (await collectHtmlFiles(distPath)).filter(isInSupportedMraidFolderPath);
|
||||||
|
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'pickFolder') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Folder',
|
||||||
|
});
|
||||||
|
if (picked && picked[0]) {
|
||||||
|
const files = (await collectHtmlFiles(picked[0].fsPath)).filter(isInSupportedMraidFolderPath);
|
||||||
|
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'pickFiles') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectMany: true,
|
||||||
|
openLabel: 'Select Files',
|
||||||
|
filters: { 'HTML': ['html', 'htm'] },
|
||||||
|
});
|
||||||
|
if (picked && picked.length) {
|
||||||
|
panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) });
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'scan') {
|
||||||
|
const folder: string | undefined = msg.folder;
|
||||||
|
const files: string[] | undefined = msg.files;
|
||||||
|
let targets: string[] = [];
|
||||||
|
let baseDir = '';
|
||||||
|
let skipped = 0;
|
||||||
|
let skippedReason = '';
|
||||||
|
let noTargetsError = 'No HTML files were found.';
|
||||||
|
if (files && files.length) {
|
||||||
|
const existing = files.filter(f => fs.existsSync(f));
|
||||||
|
targets = existing;
|
||||||
|
skipped = files.length - existing.length;
|
||||||
|
skippedReason = 'missing file(s).';
|
||||||
|
noTargetsError = 'No selected HTML files were found.';
|
||||||
|
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||||
|
} else if (folder && fs.existsSync(folder)) {
|
||||||
|
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||||
|
targets = allHtmlFiles.filter(isInSupportedMraidFolderPath);
|
||||||
|
skipped = allHtmlFiles.length - targets.length;
|
||||||
|
skippedReason = 'HTML file(s) outside AppLovin/ironSource/Unity folders.';
|
||||||
|
noTargetsError = 'No AppLovin, ironSource, or Unity folder HTML files were found.';
|
||||||
|
baseDir = folder;
|
||||||
|
} else {
|
||||||
|
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
panel.webview.postMessage({
|
||||||
|
type: 'results',
|
||||||
|
results: [],
|
||||||
|
skipped,
|
||||||
|
skippedReason,
|
||||||
|
error: noTargetsError,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: MraidResult[] = [];
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
const file = targets[i];
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` });
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(file, 'utf8');
|
||||||
|
const issues = checkMraid(content, file);
|
||||||
|
const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
|
||||||
|
results.push({ file: display, ok: issues.length === 0, issues });
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable files, matching the scanner pattern used elsewhere.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panel.webview.postMessage({ type: 'results', results, skipped, skippedReason });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonBaseDir(files: string[]): string {
|
||||||
|
if (files.length === 0) return '';
|
||||||
|
if (files.length === 1) return path.dirname(files[0]);
|
||||||
|
const split = files.map(f => path.dirname(f).split(path.sep));
|
||||||
|
const first = split[0];
|
||||||
|
let i = 0;
|
||||||
|
while (i < first.length && split.every(parts => parts[i] === first[i])) i++;
|
||||||
|
return first.slice(0, i).join(path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const stack: string[] = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||||
|
stack.push(full);
|
||||||
|
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkMraid(html: string, filePath = ''): MraidIssue[] {
|
||||||
|
const scriptText = extractScriptText(html);
|
||||||
|
const ctx: ScanContext = {
|
||||||
|
filePath,
|
||||||
|
html,
|
||||||
|
scriptText,
|
||||||
|
lowerHtml: html.toLowerCase(),
|
||||||
|
lowerScript: scriptText.toLowerCase(),
|
||||||
|
mraidCallLines: collectMraidCallLines(html),
|
||||||
|
};
|
||||||
|
|
||||||
|
return MRAID_RULES
|
||||||
|
.map(rule => rule.test(ctx))
|
||||||
|
.filter((issue): issue is MraidIssue => issue !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScriptText(html: string): string {
|
||||||
|
const scripts: string[] = [];
|
||||||
|
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = scriptRegex.exec(html)) !== null) {
|
||||||
|
scripts.push(m[1]);
|
||||||
|
}
|
||||||
|
return scripts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMraidCallLines(html: string): Map<string, number> {
|
||||||
|
const calls = new Map<string, number>();
|
||||||
|
const lines = html.split(/\r?\n/);
|
||||||
|
const callRegex = /\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(/g;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = callRegex.exec(lines[i])) !== null) {
|
||||||
|
if (!calls.has(m[1])) calls.set(m[1], i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInSupportedMraidFolderPath(filePath: string): boolean {
|
||||||
|
const folderParts = path.dirname(filePath)
|
||||||
|
.split(/[\\/]+/)
|
||||||
|
.map(part => part.toLowerCase());
|
||||||
|
return folderParts.some(part => [
|
||||||
|
'al',
|
||||||
|
'applovin',
|
||||||
|
'app-lovin',
|
||||||
|
'is',
|
||||||
|
'ironsource',
|
||||||
|
'iron-source',
|
||||||
|
'iron_source',
|
||||||
|
'un',
|
||||||
|
'unity',
|
||||||
|
'unityads',
|
||||||
|
'unity-ads',
|
||||||
|
'unity_ads',
|
||||||
|
].includes(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidReference(ctx: ScanContext): boolean {
|
||||||
|
return /\bmraid\b/i.test(ctx.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMraidJsReferences(ctx: ScanContext): number {
|
||||||
|
return (ctx.html.match(/\bmraid\.js\b/gi) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasViewportMeta(ctx: ScanContext): boolean {
|
||||||
|
return /<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>/i.test(ctx.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidCall(ctx: ScanContext, method: string): boolean {
|
||||||
|
return ctx.mraidCallLines.has(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mraidLine(ctx: ScanContext, method: string): number | undefined {
|
||||||
|
return ctx.mraidCallLines.get(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issue(rule: MraidRule, line?: number): MraidIssue {
|
||||||
|
return {
|
||||||
|
id: rule.id,
|
||||||
|
severity: rule.severity,
|
||||||
|
title: rule.title,
|
||||||
|
detail: rule.detail,
|
||||||
|
line,
|
||||||
|
reference: rule.reference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReadyGate(ctx: ScanContext): boolean {
|
||||||
|
return (
|
||||||
|
/mraid\s*\.\s*addEventListener\s*\(\s*['"]ready['"]/i.test(ctx.scriptText) ||
|
||||||
|
/mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]/i.test(ctx.scriptText) ||
|
||||||
|
/['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDomReadyGate(ctx: ScanContext): boolean {
|
||||||
|
return (
|
||||||
|
/document\s*\.\s*readyState/i.test(ctx.scriptText) ||
|
||||||
|
/DOMContentLoaded/i.test(ctx.scriptText) ||
|
||||||
|
/\bwindow\s*\.\s*(addEventListener\s*\(\s*['"]load['"]|onload\b)/i.test(ctx.scriptText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReadyListenerAndStateFallback(ctx: ScanContext): boolean {
|
||||||
|
return hasEventListener(ctx, 'ready') && hasMraidCall(ctx, 'getState');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidTypeofGuard(ctx: ScanContext): boolean {
|
||||||
|
return /typeof\s+(?:window\s*\.\s*)?mraid\s*!={1,2}\s*['"]undefined['"]/i.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidFunctionGuard(ctx: ScanContext, method: string): boolean {
|
||||||
|
const escaped = method.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return new RegExp(`typeof\\s+(?:window\\s*\\.\\s*)?mraid\\s*\\.\\s*${escaped}\\s*={2,3}\\s*['"]function['"]|typeof\\s+(?:window\\s*\\.\\s*)?mraid\\s*\\.\\s*${escaped}\\s*!={1,2}\\s*['"]undefined['"]`, 'i').test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidReadyTimeout(ctx: ScanContext): boolean {
|
||||||
|
return /setTimeout\s*\(/i.test(ctx.scriptText) && /\bmraid\b/i.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLateMraidDetection(ctx: ScanContext): boolean {
|
||||||
|
return /setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\(/i.test(ctx.scriptText) && /window\s*\.\s*mraid|\bmraid\b/i.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidOpenGuard(ctx: ScanContext): boolean {
|
||||||
|
return (
|
||||||
|
/typeof\s+(?:window\s*\.\s*)?mraid\s*\.\s*open\s*={2,3}\s*['"]function['"]/i.test(ctx.scriptText) ||
|
||||||
|
/typeof\s+(?:window\s*\.\s*)?mraid\s*\.\s*open\s*!={1,2}\s*['"]undefined['"]/i.test(ctx.scriptText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidOpenLoadingGuard(ctx: ScanContext): boolean {
|
||||||
|
return (
|
||||||
|
/mraid\s*\.\s*getState\s*\(\s*\)\s*!={1,2}\s*['"]loading['"]/i.test(ctx.scriptText) ||
|
||||||
|
/['"]loading['"]\s*!={1,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText) ||
|
||||||
|
/typeof\s+(?:window\s*\.\s*)?mraid\s*\.\s*getState\s*!={1,2}\s*['"]function['"]/i.test(ctx.scriptText) ||
|
||||||
|
(hasMraidCall(ctx, 'getState') && /\b\w+\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*\w+\b/i.test(ctx.scriptText)) ||
|
||||||
|
(hasMraidCall(ctx, 'getState') && /\b\w+\s*={2,3}\s*['"]loading['"][\s\S]{0,500}\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAudioVolumeNullSafeHandling(ctx: ScanContext): boolean {
|
||||||
|
return hasEventListener(ctx, 'audioVolumeChange') && (/typeof\s+\w+\s*={2,3}\s*['"]number['"]/i.test(ctx.scriptText) || /!=\s*null|!==\s*null/i.test(ctx.scriptText));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLifecycleSignal(ctx: ScanContext, name: string): boolean {
|
||||||
|
return new RegExp(`\\b${name}\\b`, 'i').test(ctx.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnsupportedHyperlink(ctx: ScanContext): boolean {
|
||||||
|
return /<a\b[^>]*\bhref\s*=/i.test(ctx.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTwoPartExpand(ctx: ScanContext): boolean {
|
||||||
|
return /mraid\s*\.\s*expand\s*\(\s*[^)\s]/i.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function opensVideoUrl(ctx: ScanContext): boolean {
|
||||||
|
return /mraid\s*\.\s*open\s*\(\s*[^)]*\.(mp4|m4v|mov|webm|avi|m3u8)(?:['"`?#)]|$)/i.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInMraidNetworkPath(ctx: ScanContext): boolean {
|
||||||
|
if (!ctx.filePath) return true;
|
||||||
|
return isInSupportedMraidFolderPath(ctx.filePath) || /_(al|is|un)\.html?$/i.test(path.basename(ctx.filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEventListener(ctx: ScanContext, eventName: string): boolean {
|
||||||
|
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
|
||||||
|
return pattern.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
|
||||||
|
const pattern = new RegExp(`mraid\\s*\\.\\s*supports\\s*\\(\\s*['"]${feature}['"]`, 'i');
|
||||||
|
return pattern.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
|
||||||
|
return /\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesLocationNavigation(ctx: ScanContext): boolean {
|
||||||
|
return /\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MRAID_REFERENCE = 'GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide.';
|
||||||
|
|
||||||
|
const MRAID_RULES: MraidRule[] = [
|
||||||
|
{
|
||||||
|
id: 'mraid-js-missing',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'mraid.js reference not found',
|
||||||
|
detail: 'MRAID creatives for AppLovin, ironSource, and Unity must request mraid.js early in the generated HTML.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => isInMraidNetworkPath(ctx) && countMraidJsReferences(ctx) === 0 ? issue(MRAID_RULES_BY_ID['mraid-js-missing']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mraid-js-duplicate',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'Multiple mraid.js references found',
|
||||||
|
detail: 'Request mraid.js exactly once; duplicate references can inject multiple MRAID libraries and slow or destabilize the ad.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => countMraidJsReferences(ctx) > 1 ? issue(MRAID_RULES_BY_ID['mraid-js-duplicate']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mraid-reference',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'MRAID API reference not found',
|
||||||
|
detail: 'Playable HTML should use the injected mraid object when it depends on an MRAID container.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'viewport-meta',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'Viewport meta tag not found',
|
||||||
|
detail: 'MRAID creatives should include a mobile viewport meta tag so the ad scales predictably on mobile WebViews.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasViewportMeta(ctx) ? null : issue(MRAID_RULES_BY_ID['viewport-meta']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mraid-typeof-guard',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'MRAID object is not guarded',
|
||||||
|
detail: 'Guard MRAID access with typeof mraid !== "undefined" or an equivalent window.mraid guard before making MRAID calls.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidReference(ctx) && !hasMraidTypeofGuard(ctx) ? issue(MRAID_RULES_BY_ID['mraid-typeof-guard']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dom-ready-gate',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'DOM readiness is not guarded',
|
||||||
|
detail: 'MRAID startup should wait for DOM readiness as well as MRAID readiness before building or binding rich media content.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasDomReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['dom-ready-gate']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ready-gate',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'MRAID ready state is not guarded',
|
||||||
|
detail: 'Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ready-state-fallback',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'MRAID ready listener/state fallback pair not found',
|
||||||
|
detail: 'Use both mraid.addEventListener("ready", ...) and mraid.getState() so startup works when ready fires before the listener attaches.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasReadyListenerAndStateFallback(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-state-fallback']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ready-timeout-fallback',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'MRAID ready timeout fallback not found',
|
||||||
|
detail: 'A timeout fallback prevents startup hangs when a container never fires ready.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasMraidReadyTimeout(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-timeout-fallback']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'late-mraid-detection',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'Late MRAID injection detection not found',
|
||||||
|
detail: 'Poll or retry briefly for late window.mraid injection before deciding the creative is running without MRAID.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasLateMraidDetection(ctx) ? null : issue(MRAID_RULES_BY_ID['late-mraid-detection']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'error-listener',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No MRAID error listener found',
|
||||||
|
detail: 'Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasEventListener(ctx, 'error') ? null : issue(MRAID_RULES_BY_ID['error-listener']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'viewability',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'MRAID viewability fallback chain not found',
|
||||||
|
detail: 'Use exposureChange for MRAID 3.0 and keep viewableChange or isViewable() as MRAID 2.0 fallback before starting animation, audio, video, or timers.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const hasVisibilitySignal = hasEventListener(ctx, 'exposureChange') && (hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange'));
|
||||||
|
return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audio-volume-change',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'audioVolumeChange handling not found',
|
||||||
|
detail: 'Listen for audioVolumeChange and ignore null values; only apply volume math when the value is numeric.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasAudioVolumeNullSafeHandling(ctx) ? null : issue(MRAID_RULES_BY_ID['audio-volume-change']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-api',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'Browser navigation used instead of mraid.open',
|
||||||
|
detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container; avoid hyperlinks, location redirects, and unguarded browser navigation.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasUnsupportedHyperlink(ctx) || usesLocationNavigation(ctx) || (usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open')) ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-guarded-fallback',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'mraid.open guarded fallback not found',
|
||||||
|
detail: 'Guard mraid.open with function/state checks and provide a window.open fallback when MRAID is unavailable or still loading.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'open') && (!hasMraidOpenGuard(ctx) || !hasMraidOpenLoadingGuard(ctx) || !usesDirectBrowserNavigation(ctx)) ? issue(MRAID_RULES_BY_ID['open-guarded-fallback'], mraidLine(ctx, 'open')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-video-url',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'mraid.open appears to target video media',
|
||||||
|
detail: 'Use mraid.playVideo(url) for native video playback cases instead of mraid.open(videoUrl).',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => opensVideoUrl(ctx) && !hasMraidCall(ctx, 'playVideo') ? issue(MRAID_RULES_BY_ID['open-video-url'], mraidLine(ctx, 'open')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'close-api',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No mraid.close call found',
|
||||||
|
detail: 'If the creative renders its own close control, wire that control to mraid.close().',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const appearsToHaveCloseUi = /\b(close|dismiss|skip)\b/i.test(ctx.html);
|
||||||
|
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unload-fallback',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No mraid.unload graceful failure path found',
|
||||||
|
detail: 'Use mraid.unload() when the ad cannot run or refuses to show, instead of leaving a broken or blank creative.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidReference(ctx) && !hasMraidCall(ctx, 'unload') ? issue(MRAID_RULES_BY_ID['unload-fallback']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resize-properties',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'mraid.resize used without setResizeProperties',
|
||||||
|
detail: 'Call mraid.setResizeProperties(...) before mraid.resize(); otherwise the container should emit an error.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasMraidCall(ctx, 'setResizeProperties') ? issue(MRAID_RULES_BY_ID['resize-properties'], mraidLine(ctx, 'resize')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'use-custom-close',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'Deprecated useCustomClose found',
|
||||||
|
detail: 'MRAID 3.0 ignores useCustomClose; the host provides the mandatory close control.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'useCustomClose') || /useCustomClose/i.test(ctx.scriptText) ? issue(MRAID_RULES_BY_ID['use-custom-close'], mraidLine(ctx, 'useCustomClose')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'two-part-expand',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'Two-part expand appears to be used',
|
||||||
|
detail: 'Two-part expandable ads are deprecated in MRAID 3.0; use self-contained one-part expandables.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasTwoPartExpand(ctx) ? issue(MRAID_RULES_BY_ID['two-part-expand'], mraidLine(ctx, 'expand')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expand-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'mraid.expand used without function guard',
|
||||||
|
detail: 'Guard mraid.expand with a function check before using expand behavior.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'expand') && !hasMraidFunctionGuard(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resize-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'mraid.resize used without function guard',
|
||||||
|
detail: 'Guard mraid.resize with a function check before using resize behavior.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasMraidFunctionGuard(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'store-picture-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'storePicture used without supports check',
|
||||||
|
detail: 'Call mraid.supports("storePicture") before mraid.storePicture(...).',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'storePicture') && !hasSupportCheck(ctx, 'storePicture') ? issue(MRAID_RULES_BY_ID['store-picture-support'], mraidLine(ctx, 'storePicture')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'calendar-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'createCalendarEvent used without supports check',
|
||||||
|
detail: 'Call mraid.supports("calendar") before mraid.createCalendarEvent(...).',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'createCalendarEvent') && !hasSupportCheck(ctx, 'calendar') ? issue(MRAID_RULES_BY_ID['calendar-support'], mraidLine(ctx, 'createCalendarEvent')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'location-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'getLocation used without supports check',
|
||||||
|
detail: 'Call mraid.supports("location") before mraid.getLocation(). Do not use HTML5 geolocation as a substitute in MRAID.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'getLocation') && !hasSupportCheck(ctx, 'location') ? issue(MRAID_RULES_BY_ID['location-support'], mraidLine(ctx, 'getLocation')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vpaid-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'VPAID integration used without supports check',
|
||||||
|
detail: 'Call mraid.supports("vpaid") before mraid.initVpaid(...).',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'initVpaid') && !hasSupportCheck(ctx, 'vpaid') ? issue(MRAID_RULES_BY_ID['vpaid-support'], mraidLine(ctx, 'initVpaid')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orientation-support',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'Orientation properties set without function guard',
|
||||||
|
detail: 'Guard mraid.setOrientationProperties with a function check before requiring orientation behavior.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasMraidFunctionGuard(ctx, 'setOrientationProperties') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'size-change',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No sizeChange listener found',
|
||||||
|
detail: 'Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const dependsOnSize = hasMraidCall(ctx, 'getMaxSize') || hasMraidCall(ctx, 'getScreenSize') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'setOrientationProperties');
|
||||||
|
return dependsOnSize && !hasEventListener(ctx, 'sizeChange') ? issue(MRAID_RULES_BY_ID['size-change']) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'state-change',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No stateChange listener found',
|
||||||
|
detail: 'Listen for stateChange when using expand, resize, close, or other state-changing container behavior.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const changesState = hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'close');
|
||||||
|
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'output-script-hygiene',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'Rejected script attributes found',
|
||||||
|
detail: 'Playable output should not contain type="module" or crossorigin on script tags; ad networks can reject ES modules/CORS attributes.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => /<script\b[^>]*(\btype\s*=\s*['"]module['"]|\bcrossorigin\b)/i.test(ctx.html) ? issue(MRAID_RULES_BY_ID['output-script-hygiene']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'console-error',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'console.error found',
|
||||||
|
detail: 'The ship checklist expects no console.error calls in output; use guarded logging or remove debug error logs before submission.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => /\bconsole\s*\.\s*error\s*\(/i.test(ctx.scriptText) ? issue(MRAID_RULES_BY_ID['console-error']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifecycle-stubs',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'Playable lifecycle signals not found',
|
||||||
|
detail: 'Expose or call gameReady, gameStart, gameEnd, and gameClose so network preview tools can detect the playable lifecycle.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const missing = ['gameReady', 'gameStart', 'gameEnd', 'gameClose'].filter(name => !hasLifecycleSignal(ctx, name));
|
||||||
|
if (!missing.length) return null;
|
||||||
|
const rule = MRAID_RULES_BY_ID['lifecycle-stubs'];
|
||||||
|
return { ...issue(rule), detail: `${rule.detail} Missing: ${missing.join(', ')}.` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
|
||||||
|
acc[rule.id] = rule;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
${getToolWebviewStyles()}
|
||||||
|
.issues { margin: 0; padding: 0; }
|
||||||
|
.issue { list-style: none; margin: 0 0 8px; }
|
||||||
|
.issue:last-child { margin-bottom: 0; }
|
||||||
|
.severity-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 82px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.requirement { color: var(--vscode-errorForeground); background: var(--vscode-inputValidation-errorBackground, rgba(248,81,73,0.14)); }
|
||||||
|
.best-practice { color: var(--vscode-editorWarning-foreground, #d29922); background: var(--vscode-inputValidation-warningBackground, rgba(210,153,34,0.14)); }
|
||||||
|
.issue-title { font-weight: 600; }
|
||||||
|
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||||
|
.selected-list { margin-top: 8px; }
|
||||||
|
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="tool-page">
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">MRAID Checker</h2>
|
||||||
|
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity HTML builds based on GUIDE.md plus the MRAID 3.0 specification and Best Practices Guide.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Inputs</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="folder" type="hidden" />
|
||||||
|
<button id="pick" class="secondary">Select Folder</button>
|
||||||
|
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="selection" class="selected-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="scan">Scan</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="outputPanel" class="is-hidden">
|
||||||
|
<div id="results" class="results-panel"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
vscode.postMessage({ type: 'ready' });
|
||||||
|
const folderEl = document.getElementById('folder');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
const outputPanel = document.getElementById('outputPanel');
|
||||||
|
const selectionEl = document.getElementById('selection');
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
let pickedFolder = '';
|
||||||
|
let pickedFiles = [];
|
||||||
|
let selectedPage = 0;
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||||
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
const items = pickedFiles.length
|
||||||
|
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
|
||||||
|
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
|
||||||
|
if (!items.length) {
|
||||||
|
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
|
||||||
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
|
||||||
|
selectionEl.innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||||
|
'<tbody>' +
|
||||||
|
visibleItems.map(item =>
|
||||||
|
'<tr><td class="mono wrap">' + escapeText(item.label) + '</td>' +
|
||||||
|
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
|
||||||
|
).join('') +
|
||||||
|
'</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.type === 'folder') {
|
||||||
|
pickedFolder = '';
|
||||||
|
folderEl.value = '';
|
||||||
|
} else {
|
||||||
|
pickedFiles.splice(Number(btn.dataset.index), 1);
|
||||||
|
}
|
||||||
|
renderSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (items.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML =
|
||||||
|
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
selectionEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSelection();
|
||||||
|
|
||||||
|
document.getElementById('pick').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'pickFolder' });
|
||||||
|
});
|
||||||
|
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'pickFiles' });
|
||||||
|
});
|
||||||
|
document.getElementById('clear').addEventListener('click', () => {
|
||||||
|
folderEl.value = '';
|
||||||
|
pickedFolder = '';
|
||||||
|
pickedFiles = [];
|
||||||
|
selectedPage = 0;
|
||||||
|
renderSelection();
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
});
|
||||||
|
document.getElementById('scan').addEventListener('click', () => {
|
||||||
|
statusEl.textContent = 'Scanning...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
if (pickedFiles.length) {
|
||||||
|
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||||
|
} else {
|
||||||
|
const folder = folderEl.value.trim();
|
||||||
|
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||||
|
vscode.postMessage({ type: 'scan', folder });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (msg.type === 'folderPicked') {
|
||||||
|
folderEl.value = msg.path;
|
||||||
|
pickedFolder = msg.path;
|
||||||
|
pickedFiles = [];
|
||||||
|
selectedPage = 0;
|
||||||
|
renderSelection();
|
||||||
|
} else if (msg.type === 'filesPicked') {
|
||||||
|
const existing = new Set(pickedFiles);
|
||||||
|
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
|
||||||
|
folderEl.value = '';
|
||||||
|
pickedFolder = '';
|
||||||
|
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
renderSelection();
|
||||||
|
} else if (msg.type === 'status') {
|
||||||
|
statusEl.textContent = msg.message;
|
||||||
|
statusEl.className = 'status-panel is-busy';
|
||||||
|
} else if (msg.type === 'results') {
|
||||||
|
if (msg.error) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = 'Error: ' + msg.error;
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = msg.results.length;
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
const withIssues = msg.results.filter(r => !r.ok).length;
|
||||||
|
const skipped = Number(msg.skipped || 0);
|
||||||
|
const skippedReason = msg.skippedReason || 'file(s).';
|
||||||
|
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||||
|
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||||
|
if (!total) {
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outputPanel.classList.remove('is-hidden');
|
||||||
|
resultsEl.innerHTML =
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th>File</th><th>Results</th></tr></thead>' +
|
||||||
|
'<tbody></tbody>' +
|
||||||
|
'</table>';
|
||||||
|
const tbody = resultsEl.querySelector('tbody');
|
||||||
|
for (const r of msg.results) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const file = document.createElement('td');
|
||||||
|
file.className = 'mono wrap';
|
||||||
|
file.textContent = r.file;
|
||||||
|
const issues = document.createElement('td');
|
||||||
|
issues.className = 'wrap';
|
||||||
|
if (!r.ok) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'issues';
|
||||||
|
for (const i of r.issues) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'issue';
|
||||||
|
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||||
|
item.innerHTML =
|
||||||
|
'<span class="severity-badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
|
||||||
|
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
|
||||||
|
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
issues.appendChild(list);
|
||||||
|
} else {
|
||||||
|
issues.innerHTML = '<span class="badge ok">OK</span>';
|
||||||
|
}
|
||||||
|
row.append(file, issues);
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
671
src/tools/playworksConverter.ts
Normal file
671
src/tools/playworksConverter.ts
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as cp from 'child_process';
|
||||||
|
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
interface ConvertOptions {
|
||||||
|
sourcePath: string;
|
||||||
|
outputDir: string;
|
||||||
|
baseName?: string;
|
||||||
|
networks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkResult {
|
||||||
|
network: string;
|
||||||
|
file: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||||
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
|
||||||
|
if (!isNew) return;
|
||||||
|
|
||||||
|
panel.webview.html = getHtml();
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'pickSource': {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
filters: { HTML: ['html', 'htm'] },
|
||||||
|
openLabel: 'Select Playworks HTML',
|
||||||
|
});
|
||||||
|
if (!picked?.[0]) return;
|
||||||
|
const srcPath = picked[0].fsPath;
|
||||||
|
const dir = path.dirname(srcPath);
|
||||||
|
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir, baseName: sourceBaseName(srcPath) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pickOutput': {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Output Folder',
|
||||||
|
});
|
||||||
|
if (!picked?.[0]) return;
|
||||||
|
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'convert': {
|
||||||
|
const opts: ConvertOptions = msg.opts;
|
||||||
|
if (!fs.existsSync(opts.sourcePath)) {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(opts.outputDir)) {
|
||||||
|
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
try {
|
||||||
|
panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' });
|
||||||
|
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||||
|
} catch (e: any) {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: NetworkResult[] = [];
|
||||||
|
try {
|
||||||
|
panel.webview.postMessage({ type: 'status', message: 'Converting Unity...' });
|
||||||
|
results.push({ network: 'un', file: copyUnityInput(opts), ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push({ network: 'un', file: '', ok: false, error: e.message });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < opts.networks.length; i++) {
|
||||||
|
const network = opts.networks[i];
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Converting ${i + 1}/${opts.networks.length} ${network}` });
|
||||||
|
try {
|
||||||
|
const filePath = await convertNetwork(html, opts, network);
|
||||||
|
results.push({ network, file: filePath, ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push({ network, file: '', ok: false, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'openOutput': {
|
||||||
|
vscode.env.openExternal(vscode.Uri.file(msg.dir));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZIPPED_NETWORKS = new Set(['gg', 'ap', 'vu', 'mtg']);
|
||||||
|
const SUPPORTED_NETWORKS = new Set(['al', 'is', 'fb', 'gg', 'ap', 'mo', 'vu', 'mtg', 'tt']);
|
||||||
|
const MRAID_NETWORKS = new Set(['al', 'is']);
|
||||||
|
const EXITAPI_NETWORKS = new Set(['gg', 'ap']);
|
||||||
|
const NETWORK_OUTPUT_FOLDERS: Record<string, string> = {
|
||||||
|
al: 'Applovin',
|
||||||
|
is: 'Ironsource',
|
||||||
|
fb: 'Facebook',
|
||||||
|
gg: 'GoogleAds',
|
||||||
|
ap: 'Appreciate',
|
||||||
|
mo: 'Moloco',
|
||||||
|
vu: 'Vungle',
|
||||||
|
mtg: 'Mintegral',
|
||||||
|
tt: 'TikTok',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceBaseName(sourcePath: string): string {
|
||||||
|
return path.basename(sourcePath, path.extname(sourcePath))
|
||||||
|
.replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputBaseName(opts: ConvertOptions): string {
|
||||||
|
const rawBaseName = opts.baseName?.trim() || sourceBaseName(opts.sourcePath);
|
||||||
|
const withoutExtension = rawBaseName.replace(/\.html?$/i, '');
|
||||||
|
const safeBaseName = path.basename(withoutExtension).replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||||
|
return safeBaseName || sourceBaseName(opts.sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
|
||||||
|
if (!SUPPORTED_NETWORKS.has(network)) {
|
||||||
|
throw new Error(`Unsupported Playworks target network: ${network}`);
|
||||||
|
}
|
||||||
|
const transformed = transformHtml(html, network);
|
||||||
|
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||||
|
const baseName = outputBaseName(opts);
|
||||||
|
const htmlFileName = `${baseName}_${network}.html`;
|
||||||
|
const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
if (needsZip) {
|
||||||
|
const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||||
|
const zipPath = path.join(outputDir, `${baseName}_${network}.zip`);
|
||||||
|
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||||
|
try {
|
||||||
|
await createZip(tmpPath, 'index.html', zipPath);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = path.join(outputDir, htmlFileName);
|
||||||
|
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUnityInput(opts: ConvertOptions): string {
|
||||||
|
const baseName = outputBaseName(opts);
|
||||||
|
const outputDir = path.join(opts.outputDir, 'Unity');
|
||||||
|
const outPath = path.join(outputDir, `${baseName}_un.html`);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
if (path.resolve(opts.sourcePath) === path.resolve(outPath)) {
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(opts.sourcePath, outPath);
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformHtml(html: string, network: string): string {
|
||||||
|
let result = sanitizePlayworksHtml(html);
|
||||||
|
|
||||||
|
// Inject into </head>: lifecycle stubs only (must register luna:build listener
|
||||||
|
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
|
||||||
|
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
|
||||||
|
result = removeNetworkSdkScripts(result);
|
||||||
|
let headInject = buildLifecycleScript();
|
||||||
|
if (MRAID_NETWORKS.has(network)) {
|
||||||
|
headInject += '<script src="mraid.js"></script>' + buildMraidComplianceScript();
|
||||||
|
} else if (EXITAPI_NETWORKS.has(network)) {
|
||||||
|
headInject += '<script src="exitapi.js"></script>';
|
||||||
|
}
|
||||||
|
result = injectBeforeHeadClose(result, headInject);
|
||||||
|
|
||||||
|
// Body-level flags
|
||||||
|
if (network === 'vu') {
|
||||||
|
result = injectAfterBodyOpen(result, '<script>window.__VUNGLE__=true;</script>');
|
||||||
|
} else if (network === 'mtg') {
|
||||||
|
result = addBodyOnload(result, 'gameReady()');
|
||||||
|
} else if (network === 'tt') {
|
||||||
|
result = injectAfterBodyOpen(result, '<script>window.__TIKTOK__=true;</script>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the CTA script
|
||||||
|
result = replaceCTAScript(result, network);
|
||||||
|
|
||||||
|
return finalizeNetworkHtml(result, network);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePlayworksHtml(html: string): string {
|
||||||
|
let result = cleanupPlayworksHtml(html);
|
||||||
|
return removeNetworkSdkScripts(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPlayworksHtml(html: string): string {
|
||||||
|
let result = html;
|
||||||
|
result = trimAfterFirstHtmlClose(result);
|
||||||
|
result = result.replace(/<script\b[^>]*>[\s\S]*?insertYourRemoteDebuggingTokenHere[\s\S]*?<\/script>\s*/gi, '');
|
||||||
|
result = result.replace(/https:\/\/mrdoob\.github\.io\/stats\.js\/build\/stats\.min\.js/gi, 'data:text/javascript,');
|
||||||
|
result = result.replace(/\s+crossorigin(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
|
||||||
|
result = result.replace(/\s+type\s*=\s*["']module["']/gi, '');
|
||||||
|
result = result.replace(/\bconsole\.error\s*\(/g, 'console.warn(');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimAfterFirstHtmlClose(html: string): string {
|
||||||
|
const idx = html.search(/<\/html\s*>/i);
|
||||||
|
if (idx === -1) return html;
|
||||||
|
const close = html.match(/<\/html\s*>/i);
|
||||||
|
return close ? html.slice(0, idx + close[0].length) : html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeNetworkHtml(html: string, network: string): string {
|
||||||
|
let result = cleanupPlayworksHtml(html);
|
||||||
|
result = dedupeScriptSrc(result, 'mraid.js', MRAID_NETWORKS.has(network));
|
||||||
|
result = dedupeScriptSrc(result, 'exitapi.js', EXITAPI_NETWORKS.has(network));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNetworkSdkScripts(html: string): string {
|
||||||
|
let result = html;
|
||||||
|
for (const src of ['mraid.js', 'exitapi.js']) {
|
||||||
|
const escaped = src.replace('.', '\\.');
|
||||||
|
result = result.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), '');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeScriptSrc(html: string, src: string, shouldExist: boolean): string {
|
||||||
|
const escaped = src.replace('.', '\\.');
|
||||||
|
let seen = false;
|
||||||
|
let result = html.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), (match) => {
|
||||||
|
if (shouldExist && !seen) {
|
||||||
|
seen = true;
|
||||||
|
return `<script src="${src}"></script>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
if (shouldExist && !seen) {
|
||||||
|
result = injectBeforeHeadClose(result, `<script src="${src}"></script>`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectBeforeHeadClose(html: string, injection: string): string {
|
||||||
|
if (/<\/head>/i.test(html)) {
|
||||||
|
return html.replace(/<\/head>/i, `${injection}\n</head>`);
|
||||||
|
}
|
||||||
|
return `${injection}\n${html}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectAfterBodyOpen(html: string, injection: string): string {
|
||||||
|
if (/<body\b[^>]*>/i.test(html)) {
|
||||||
|
return html.replace(/<body\b[^>]*>/i, match => `${match}\n${injection}`);
|
||||||
|
}
|
||||||
|
return `${injection}\n${html}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBodyOnload(html: string, handler: string): string {
|
||||||
|
if (!/<body\b[^>]*>/i.test(html)) {
|
||||||
|
return `<body onload="${handler}">\n${html}`;
|
||||||
|
}
|
||||||
|
return html.replace(/<body\b([^>]*)>/i, (match, attrs: string) => {
|
||||||
|
const onloadMatch = attrs.match(/\bonload\s*=\s*(["'])(.*?)\1/i);
|
||||||
|
if (!onloadMatch) return `<body${attrs} onload="${handler}">`;
|
||||||
|
if (onloadMatch[2].includes(handler)) return match;
|
||||||
|
const updated = `${onloadMatch[2]};${handler}`;
|
||||||
|
return match.replace(onloadMatch[0], `onload=${onloadMatch[1]}${updated}${onloadMatch[1]}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCTAScript(html: string, network: string): string {
|
||||||
|
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||||
|
const markerIdx = html.lastIndexOf(marker);
|
||||||
|
if (markerIdx === -1) return html;
|
||||||
|
|
||||||
|
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
|
||||||
|
if (scriptOpenIdx === -1) return html;
|
||||||
|
|
||||||
|
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
|
||||||
|
if (scriptCloseIdx === -1) return html;
|
||||||
|
|
||||||
|
const before = html.slice(0, scriptOpenIdx);
|
||||||
|
const after = html.slice(scriptCloseIdx + '</script>'.length);
|
||||||
|
|
||||||
|
// Console restore runs after the bundle (which overrides console), before CTA
|
||||||
|
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores native console methods overridden by the Playworks bundle,
|
||||||
|
// using a temporary iframe to get a fresh un-patched console reference.
|
||||||
|
function buildConsoleRestoreScript(): string {
|
||||||
|
return [
|
||||||
|
'<script>(function(){',
|
||||||
|
'try{',
|
||||||
|
'var f=document.createElement("iframe");',
|
||||||
|
'f.style.display="none";',
|
||||||
|
'document.documentElement.appendChild(f);',
|
||||||
|
'var nc=f.contentWindow.console;',
|
||||||
|
'document.documentElement.removeChild(f);',
|
||||||
|
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
|
||||||
|
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
|
||||||
|
'}catch(e){}',
|
||||||
|
'})();</script>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
|
||||||
|
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
|
||||||
|
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
|
||||||
|
// One-time guards prevent double-calls:
|
||||||
|
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
|
||||||
|
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
|
||||||
|
function buildLifecycleScript(): string {
|
||||||
|
return [
|
||||||
|
'<script>(function(){',
|
||||||
|
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
|
||||||
|
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
|
||||||
|
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
|
||||||
|
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
|
||||||
|
'var _grOnce=false,_gsOnce=false;',
|
||||||
|
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
|
||||||
|
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
|
||||||
|
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
|
||||||
|
'})();</script>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMraidComplianceScript(): string {
|
||||||
|
return [
|
||||||
|
'<script>(function(){',
|
||||||
|
'var m=null,scene=null,viewable=true,exposed=true,volume=null;',
|
||||||
|
'function get(){return window.mraid||m;}',
|
||||||
|
'function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}',
|
||||||
|
'function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}',
|
||||||
|
'function onViewable(v){viewable=!!v;apply();}',
|
||||||
|
'function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}',
|
||||||
|
'function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}',
|
||||||
|
'function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}',
|
||||||
|
'function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}',
|
||||||
|
'window.__hplMraidUnload=unload;',
|
||||||
|
'function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}',
|
||||||
|
'function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}',
|
||||||
|
'window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};',
|
||||||
|
'if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}',
|
||||||
|
'setTimeout(setup,2000);',
|
||||||
|
'})();</script>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCTAScript(network: string): string {
|
||||||
|
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
|
||||||
|
// gameClose must fire before every redirect
|
||||||
|
const closeCall = `try{window.gameClose();}catch(e){}`;
|
||||||
|
|
||||||
|
let ctaLogic: string;
|
||||||
|
switch (network) {
|
||||||
|
case 'al':
|
||||||
|
case 'is':
|
||||||
|
// MRAID — mraid.js injected in <head>
|
||||||
|
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'fb':
|
||||||
|
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'gg':
|
||||||
|
case 'ap':
|
||||||
|
// Google — exitapi.js injected in <head>
|
||||||
|
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'mo':
|
||||||
|
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'vu':
|
||||||
|
// Vungle — window.__VUNGLE__ flag set in body
|
||||||
|
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
|
||||||
|
break;
|
||||||
|
case 'mtg':
|
||||||
|
// Mintegral — onload="gameReady()" set on body
|
||||||
|
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'tt':
|
||||||
|
// TikTok — window.__TIKTOK__ flag set in body
|
||||||
|
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ctaLogic = `${closeCall}window.open(o,"_blank");`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Remove existing zip if present (Compress-Archive would fail otherwise)
|
||||||
|
if (fs.existsSync(destZipPath)) {
|
||||||
|
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const psScript = [
|
||||||
|
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
|
||||||
|
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
|
||||||
|
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
|
||||||
|
'$zip.Dispose()',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stderr = '';
|
||||||
|
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Webview UI ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
const networks = [
|
||||||
|
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||||
|
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||||
|
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
||||||
|
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
||||||
|
{ tag: 'ap', label: 'Appreciate', note: 'ZIP + exitapi.js in head' },
|
||||||
|
{ tag: 'mo', label: 'Moloco', note: 'HTML + FbPlayableAd CTA' },
|
||||||
|
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
|
||||||
|
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
||||||
|
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const checkboxRows = networks.map(n => `
|
||||||
|
<tr>
|
||||||
|
<td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
|
||||||
|
<td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
|
||||||
|
<td><span class="net-note">${n.note}</span></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
${getToolWebviewStyles()}
|
||||||
|
.net-label { min-width: 100px; font-weight: 500; }
|
||||||
|
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||||
|
.net-note { font-size: 11px; opacity: 0.6; }
|
||||||
|
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="tool-page">
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Playworks Converter</h2>
|
||||||
|
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tool-panel input-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Inputs</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Source HTML (Playworks export)</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="src" type="hidden" />
|
||||||
|
<button id="pickSrc" class="secondary">Select File</button>
|
||||||
|
<span id="srcName" class="file-name">(no file)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Base Filename</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="baseName" type="text" placeholder="Base filename..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Output Folder</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||||
|
<button id="pickOut" class="secondary">Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Networks to Generate</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<button id="selectAll" class="secondary">Select All</button>
|
||||||
|
<button id="selectNone" class="secondary">Select None</button>
|
||||||
|
</div>
|
||||||
|
<div class="results-panel" style="margin-top:0;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th style="width:70px;">Use</th><th style="width:160px;">Network</th><th>Output behavior</th></tr></thead>
|
||||||
|
<tbody>${checkboxRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="convert" disabled>Convert</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="outputPanel" class="is-hidden">
|
||||||
|
<div id="results" class="results-panel"></div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let outputDir = '';
|
||||||
|
|
||||||
|
const srcEl = document.getElementById('src');
|
||||||
|
const srcNameEl = document.getElementById('srcName');
|
||||||
|
const outEl = document.getElementById('outDir');
|
||||||
|
const baseNameEl = document.getElementById('baseName');
|
||||||
|
const convertBtn = document.getElementById('convert');
|
||||||
|
const openOutBtn = document.getElementById('openOut');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
const outputPanel = document.getElementById('outputPanel');
|
||||||
|
|
||||||
|
function updateConvertBtn() {
|
||||||
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||||
|
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOutputDir(dir) {
|
||||||
|
return dir ? dir.replace(/[\\\\/]+$/, '') + '\\\\Output' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||||
|
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||||
|
document.getElementById('selectAll').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.getElementById('selectNone').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||||
|
outEl.addEventListener('input', updateConvertBtn);
|
||||||
|
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||||
|
|
||||||
|
openOutBtn.addEventListener('click', () => {
|
||||||
|
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||||
|
});
|
||||||
|
|
||||||
|
function outputDisplayName(file) {
|
||||||
|
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||||
|
return parts.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('convert').addEventListener('click', () => {
|
||||||
|
const networks = [...document.querySelectorAll('.net-cb')]
|
||||||
|
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||||
|
statusEl.textContent = 'Converting...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
openOutBtn.style.display = 'none';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
convertBtn.disabled = true;
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'convert',
|
||||||
|
opts: {
|
||||||
|
sourcePath: srcEl.value,
|
||||||
|
outputDir: outEl.value,
|
||||||
|
baseName: baseNameEl.value,
|
||||||
|
networks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (msg.type === 'sourcePicked') {
|
||||||
|
srcEl.value = msg.path;
|
||||||
|
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
|
||||||
|
baseNameEl.value = msg.baseName || '';
|
||||||
|
if (!outEl.value && msg.dir) {
|
||||||
|
outEl.value = defaultOutputDir(msg.dir);
|
||||||
|
outputDir = outEl.value;
|
||||||
|
}
|
||||||
|
updateConvertBtn();
|
||||||
|
} else if (msg.type === 'outputPicked') {
|
||||||
|
outEl.value = msg.path;
|
||||||
|
outputDir = msg.path;
|
||||||
|
updateConvertBtn();
|
||||||
|
} else if (msg.type === 'status') {
|
||||||
|
statusEl.textContent = msg.message;
|
||||||
|
statusEl.className = 'status-panel is-busy';
|
||||||
|
} else if (msg.type === 'convertDone') {
|
||||||
|
convertBtn.disabled = false;
|
||||||
|
updateConvertBtn();
|
||||||
|
if (msg.error) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = 'Error: ' + msg.error;
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = msg.results || [];
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
const ok = results.filter(r => r.ok).length;
|
||||||
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||||
|
if (!results.length) {
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outputPanel.classList.remove('is-hidden');
|
||||||
|
resultsEl.innerHTML =
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th style="width:90px;">Output</th><th>Path</th><th>Error</th></tr></thead>' +
|
||||||
|
'<tbody></tbody>' +
|
||||||
|
'</table>';
|
||||||
|
const tbody = resultsEl.querySelector('tbody');
|
||||||
|
for (const r of results) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const net = document.createElement('td');
|
||||||
|
net.textContent = r.network;
|
||||||
|
const status = document.createElement('td');
|
||||||
|
status.innerHTML = r.ok ? '<span class="badge ok">Done</span>' : '<span class="badge err">Failed</span>';
|
||||||
|
const outputType = document.createElement('td');
|
||||||
|
const detail = document.createElement('td');
|
||||||
|
detail.className = 'mono wrap';
|
||||||
|
const error = document.createElement('td');
|
||||||
|
error.className = 'err wrap';
|
||||||
|
if (r.ok) {
|
||||||
|
outputType.textContent = /\\.zip$/i.test(r.file) ? 'ZIP' : 'HTML';
|
||||||
|
detail.textContent = outputDisplayName(r.file);
|
||||||
|
} else {
|
||||||
|
outputType.textContent = '-';
|
||||||
|
detail.innerHTML = '<span class="muted">-</span>';
|
||||||
|
error.textContent = r.error || 'Unknown error';
|
||||||
|
}
|
||||||
|
if (r.ok) error.innerHTML = '<span class="muted">-</span>';
|
||||||
|
row.append(net, status, outputType, detail, error);
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
if (ok > 0) {
|
||||||
|
outputDir = outEl.value;
|
||||||
|
openOutBtn.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -2,19 +2,47 @@ import * as vscode from 'vscode';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { File } from 'node:buffer';
|
import { File } from 'node:buffer';
|
||||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||||
|
|
||||||
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') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Folder',
|
||||||
|
defaultUri: getPickerDefaultUri(),
|
||||||
|
});
|
||||||
|
if (picked && picked[0]) {
|
||||||
|
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||||
|
panel.webview.postMessage({
|
||||||
|
type: 'filesSelected',
|
||||||
|
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'pickFile') {
|
if (msg.type === 'pickFile') {
|
||||||
const picked = await vscode.window.showOpenDialog({
|
const picked = await vscode.window.showOpenDialog({
|
||||||
canSelectMany: true,
|
canSelectMany: true,
|
||||||
@@ -33,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) {
|
||||||
@@ -48,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)) {
|
||||||
@@ -74,20 +98,32 @@ 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];
|
||||||
const idx = i + 1;
|
const idx = i + 1;
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${path.basename(r.path)}` });
|
||||||
const buf = fs.readFileSync(r.path);
|
const buf = fs.readFileSync(r.path);
|
||||||
const file = new File([buf], stampedNames[i], { type: 'text/html' });
|
const file = new File([buf], stampedNames[i], { type: 'text/html' });
|
||||||
form.append(`fileUpload_${idx}`, file as any);
|
form.append(`fileUpload_${idx}`, file as any);
|
||||||
form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, '')));
|
form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, '')));
|
||||||
}
|
}
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
@@ -109,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,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 (
|
||||||
@@ -159,78 +337,115 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
|||||||
return vscode.Uri.file(root);
|
return vscode.Uri.file(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const stack: string[] = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||||
|
stack.push(full);
|
||||||
|
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function getHtml(): string {
|
function getHtml(): string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
${getToolWebviewStyles()}
|
||||||
h2 { margin-top: 0; }
|
#rows input[type=text] { width: 100%; }
|
||||||
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
.file-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
th, td { padding: 6px 8px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--vscode-panel-border); }
|
|
||||||
th { font-weight: 600; font-size: 12px; text-transform: uppercase; opacity: 0.7; }
|
|
||||||
input[type=text] {
|
|
||||||
width: 100%; padding: 4px 6px;
|
|
||||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
|
||||||
border: 1px solid var(--vscode-input-border, transparent);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
|
||||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
|
||||||
button.secondary {
|
|
||||||
background: var(--vscode-button-secondaryBackground);
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
}
|
|
||||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
|
||||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
|
||||||
.row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; }
|
.row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; }
|
||||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
.pick { min-width: 82px; }
|
||||||
#status { margin-top: 12px; min-height: 20px; }
|
.remove-btn {
|
||||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
min-width: 28px;
|
||||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
width: 28px;
|
||||||
.result {
|
padding: 3px 0;
|
||||||
margin-top: 12px; padding: 10px;
|
color: var(--vscode-errorForeground);
|
||||||
background: var(--vscode-textBlockQuote-background);
|
font-size: 16px;
|
||||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
line-height: 1;
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
.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; }
|
||||||
.remove-btn { background: transparent; color: var(--vscode-foreground); padding: 4px 8px; }
|
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||||
|
.panel-header { display: flex; align-items: center; }
|
||||||
|
#serverSelect {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--vscode-dropdown-background);
|
||||||
|
color: var(--vscode-dropdown-foreground);
|
||||||
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>PLEC Upload</h2>
|
<main class="tool-page">
|
||||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">PLEC Upload</h2>
|
||||||
|
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<table id="rows">
|
<section class="tool-panel input-panel">
|
||||||
<thead>
|
<div class="panel-header">
|
||||||
<tr>
|
<h3 class="panel-title">Inputs</h3>
|
||||||
<th style="width:45%">HTML File</th>
|
<select id="serverSelect">
|
||||||
<th style="width:45%">Iteration Name</th>
|
<option value="legacy">167.99.227.249 (Default)</option>
|
||||||
<th></th>
|
<option value="wordpress">20.255.60.183 (Backup)</option>
|
||||||
</tr>
|
</select>
|
||||||
</thead>
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
|
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||||
|
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
|
||||||
|
<table id="rows" class="data-table">
|
||||||
|
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div class="actions">
|
</div>
|
||||||
<button id="addRow" class="secondary">+ Add Row</button>
|
<div class="action-row">
|
||||||
<button id="upload">Upload</button>
|
<button id="upload">Upload</button>
|
||||||
</div>
|
<button id="openResult" class="secondary" disabled>Open</button>
|
||||||
|
<button id="copyResult" class="secondary" disabled>Copy Link</button>
|
||||||
<div id="status"></div>
|
</div>
|
||||||
<div id="results"></div>
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
let rows = [];
|
||||||
|
let page = 0;
|
||||||
|
let previewUrl = '';
|
||||||
const tbody = document.querySelector('#rows tbody');
|
const tbody = document.querySelector('#rows tbody');
|
||||||
|
const rowsPanel = document.getElementById('rowsPanel');
|
||||||
|
const emptySelection = document.getElementById('emptySelection');
|
||||||
|
|
||||||
function parseIterationName(filename) {
|
function parseIterationName(filename) {
|
||||||
const base = filename.replace(/\\.html?$/i, '');
|
const base = filename.replace(/\\.html?$/i, '');
|
||||||
@@ -241,135 +456,175 @@ function getHtml(): string {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const openResultBtn = document.getElementById('openResult');
|
||||||
|
const copyResultBtn = document.getElementById('copyResult');
|
||||||
|
|
||||||
function addRow() {
|
function escapeHtml(s) {
|
||||||
const id = 'r' + (nextId++);
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.dataset.id = id;
|
|
||||||
tr.innerHTML = \`
|
|
||||||
<td>
|
|
||||||
<div class="file-cell">
|
|
||||||
<button class="pick secondary">Choose...</button>
|
|
||||||
<span class="file-name" data-name>(no file)</span>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" data-path />
|
|
||||||
<div class="row-error" data-error></div>
|
|
||||||
</td>
|
|
||||||
<td><input type="text" data-iter placeholder="iteration name" /></td>
|
|
||||||
<td><button class="remove-btn" title="Remove">×</button></td>
|
|
||||||
\`;
|
|
||||||
tr.querySelector('.pick').addEventListener('click', () => {
|
|
||||||
vscode.postMessage({ type: 'pickFile', rowId: id });
|
|
||||||
});
|
|
||||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
|
||||||
tr.remove();
|
|
||||||
if (!tbody.children.length) addRow();
|
|
||||||
});
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addRow').addEventListener('click', addRow);
|
function addFiles(files) {
|
||||||
|
const existing = new Set(rows.map(r => r.path));
|
||||||
|
files.forEach(f => {
|
||||||
|
if (existing.has(f.path)) return;
|
||||||
|
rows.push({
|
||||||
|
id: 'r' + (nextId++),
|
||||||
|
path: f.path,
|
||||||
|
name: f.name,
|
||||||
|
iteration: parseIterationName(f.name),
|
||||||
|
error: ''
|
||||||
|
});
|
||||||
|
existing.add(f.path);
|
||||||
|
});
|
||||||
|
normalizeDuplicateIterations(files.length);
|
||||||
|
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDuplicateIterations(addedCount) {
|
||||||
|
if (addedCount <= 1) return;
|
||||||
|
const added = rows.slice(Math.max(0, rows.length - addedCount));
|
||||||
|
if (!added.length) return;
|
||||||
|
const first = added[0].iteration;
|
||||||
|
if (first && added.every(r => r.iteration === first)) {
|
||||||
|
added.forEach((r, i) => {
|
||||||
|
r.iteration = String(i + 1).padStart(2, '0') + '_' + first;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||||
|
page = Math.min(page, maxPage);
|
||||||
|
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
|
||||||
|
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = page * PAGE_SIZE;
|
||||||
|
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||||||
|
tbody.innerHTML = pageRows.map((row, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return \`
|
||||||
|
<tr data-id="\${row.id}">
|
||||||
|
<td>
|
||||||
|
<span class="mono wrap">\${escapeHtml(row.name)}</span>
|
||||||
|
<div class="row-error" data-error>\${escapeHtml(row.error || '')}</div>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" data-iter data-index="\${i}" value="\${escapeHtml(row.iteration)}" /></td>
|
||||||
|
<td><button class="remove-btn danger" data-remove="\${i}" title="Remove">×</button></td>
|
||||||
|
</tr>
|
||||||
|
\`;
|
||||||
|
}).join('');
|
||||||
|
tbody.querySelectorAll('[data-iter]').forEach(input => {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
rows[Number(input.dataset.index)].iteration = input.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('[data-remove]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
rows.splice(Number(btn.dataset.remove), 1);
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (rows.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML =
|
||||||
|
'<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
document.querySelector('#rows').parentElement.after(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreview(url) {
|
||||||
|
previewUrl = url || '';
|
||||||
|
openResultBtn.disabled = !previewUrl;
|
||||||
|
copyResultBtn.disabled = !previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('serverSelect').addEventListener('change', (e) => {
|
||||||
|
vscode.postMessage({ type: 'saveServer', value: e.target.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pickFolder').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'pickFolder' });
|
||||||
|
});
|
||||||
|
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'pickFile' });
|
||||||
|
});
|
||||||
|
document.getElementById('clear').addEventListener('click', () => {
|
||||||
|
rows = [];
|
||||||
|
page = 0;
|
||||||
|
setPreview('');
|
||||||
|
renderRows();
|
||||||
|
statusEl.textContent = '';
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('upload').addEventListener('click', () => {
|
document.getElementById('upload').addEventListener('click', () => {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
resultsEl.innerHTML = '';
|
statusEl.classList.remove('is-busy');
|
||||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
setPreview('');
|
||||||
id: tr.dataset.id,
|
|
||||||
path: tr.querySelector('[data-path]').value,
|
|
||||||
iteration: tr.querySelector('[data-iter]').value,
|
|
||||||
})).filter(r => r.path || r.iteration);
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
|
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
vscode.postMessage({ type: 'upload', rows });
|
statusEl.textContent = 'Uploading...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
vscode.postMessage({ type: 'upload', rows, server: document.getElementById('serverSelect').value });
|
||||||
|
});
|
||||||
|
openResultBtn.addEventListener('click', () => {
|
||||||
|
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
|
||||||
|
});
|
||||||
|
copyResultBtn.addEventListener('click', () => {
|
||||||
|
if (previewUrl) vscode.postMessage({ type: 'copy', text: previewUrl });
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearErrors() {
|
function clearErrors() {
|
||||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
rows.forEach(row => row.error = '');
|
||||||
|
renderRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
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') {
|
||||||
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
addFiles(m.files || []);
|
||||||
const affected = [];
|
|
||||||
m.files.forEach(function(f, i) {
|
|
||||||
let tr;
|
|
||||||
if (i === 0) {
|
|
||||||
tr = firstTr;
|
|
||||||
} else {
|
|
||||||
addRow();
|
|
||||||
tr = tbody.lastElementChild;
|
|
||||||
}
|
|
||||||
if (!tr) return;
|
|
||||||
tr.querySelector('[data-path]').value = f.path;
|
|
||||||
tr.querySelector('[data-name]').textContent = f.name;
|
|
||||||
const iter = tr.querySelector('[data-iter]');
|
|
||||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
|
||||||
affected.push(tr);
|
|
||||||
});
|
|
||||||
if (affected.length > 1) {
|
|
||||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
|
||||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
|
||||||
affected.forEach((r, i) => {
|
|
||||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (m.type === 'status') {
|
} else if (m.type === 'status') {
|
||||||
statusEl.textContent = m.message;
|
statusEl.textContent = m.message;
|
||||||
statusEl.className = '';
|
statusEl.className = 'status-panel is-busy';
|
||||||
} else if (m.type === 'error') {
|
} else if (m.type === 'error') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '';
|
statusEl.innerHTML = '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'err';
|
div.className = 'err';
|
||||||
div.textContent = m.message;
|
div.textContent = m.message;
|
||||||
statusEl.appendChild(div);
|
statusEl.appendChild(div);
|
||||||
} else if (m.type === 'rowError') {
|
} else if (m.type === 'rowError') {
|
||||||
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
statusEl.classList.remove('is-busy');
|
||||||
if (tr) tr.querySelector('[data-error]').textContent = m.message;
|
const row = rows.find(r => r.id === m.rowId);
|
||||||
|
if (row) row.error = m.message;
|
||||||
|
renderRows();
|
||||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||||
} else if (m.type === 'result') {
|
} else if (m.type === 'result') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||||
const wrap = document.createElement('div');
|
setPreview(m.preview);
|
||||||
wrap.className = 'result';
|
|
||||||
if (m.preview) {
|
|
||||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + m.preview + '">' + m.preview + '</a></div>';
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'result-actions';
|
|
||||||
const copy = document.createElement('button');
|
|
||||||
copy.textContent = 'Copy';
|
|
||||||
copy.onclick = () => vscode.postMessage({ type: 'copy', text: m.preview });
|
|
||||||
const open = document.createElement('button');
|
|
||||||
open.textContent = 'Open';
|
|
||||||
open.className = 'secondary';
|
|
||||||
open.onclick = () => vscode.postMessage({ type: 'open', text: m.preview });
|
|
||||||
const showRaw = document.createElement('button');
|
|
||||||
showRaw.textContent = 'Show server response';
|
|
||||||
showRaw.className = 'secondary';
|
|
||||||
showRaw.onclick = () => {
|
|
||||||
const pre = document.createElement('pre');
|
|
||||||
pre.style.marginTop = '8px';
|
|
||||||
pre.style.whiteSpace = 'pre-wrap';
|
|
||||||
pre.textContent = m.rawText || JSON.stringify(m.raw, null, 2);
|
|
||||||
wrap.appendChild(pre);
|
|
||||||
showRaw.disabled = true;
|
|
||||||
};
|
|
||||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
|
||||||
wrap.appendChild(actions);
|
|
||||||
} else {
|
|
||||||
wrap.innerHTML = '<div>No preview field in response. Raw JSON:</div><pre>' +
|
|
||||||
(m.rawText || JSON.stringify(m.raw, null, 2)).replace(/</g, '<') + '</pre>';
|
|
||||||
}
|
|
||||||
resultsEl.innerHTML = '';
|
|
||||||
resultsEl.appendChild(wrap);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
addRow();
|
renderRows();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { handleClipboardAndOpen } from './shared';
|
import { getToolWebviewStyles, handleClipboardAndOpen } from './shared';
|
||||||
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
@@ -12,7 +12,11 @@ interface ActiveShare {
|
|||||||
server: http.Server;
|
server: http.Server;
|
||||||
port: number;
|
port: number;
|
||||||
token: string;
|
token: string;
|
||||||
fileBuf: Buffer;
|
files: SharedFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedFile {
|
||||||
|
buf: Buffer;
|
||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,19 +61,35 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
|||||||
try {
|
try {
|
||||||
if (handleClipboardAndOpen(msg)) return;
|
if (handleClipboardAndOpen(msg)) return;
|
||||||
|
|
||||||
|
if (msg.type === 'pickFolder') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Folder',
|
||||||
|
defaultUri: getPickerDefaultUri(),
|
||||||
|
});
|
||||||
|
if (picked && picked[0]) {
|
||||||
|
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||||
|
panel.webview.postMessage({
|
||||||
|
type: 'fileSelected',
|
||||||
|
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'pickFile') {
|
if (msg.type === 'pickFile') {
|
||||||
const picked = await vscode.window.showOpenDialog({
|
const picked = await vscode.window.showOpenDialog({
|
||||||
canSelectMany: false,
|
canSelectMany: true,
|
||||||
filters: { HTML: ['html', 'htm'] },
|
filters: { HTML: ['html', 'htm'] },
|
||||||
openLabel: 'Select',
|
openLabel: 'Select',
|
||||||
defaultUri: getPickerDefaultUri(),
|
defaultUri: getPickerDefaultUri(),
|
||||||
});
|
});
|
||||||
if (picked && picked[0]) {
|
if (picked && picked.length) {
|
||||||
const fp = picked[0].fsPath;
|
|
||||||
panel.webview.postMessage({
|
panel.webview.postMessage({
|
||||||
type: 'fileSelected',
|
type: 'fileSelected',
|
||||||
path: fp,
|
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
|
||||||
name: path.basename(fp),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -77,13 +97,22 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
if (msg.type === 'startShare') {
|
if (msg.type === 'startShare') {
|
||||||
stopActive();
|
stopActive();
|
||||||
const filePath: string = msg.path;
|
const filePaths: string[] = Array.isArray(msg.paths) ? msg.paths : (msg.path ? [msg.path] : []);
|
||||||
|
if (!filePaths.length) {
|
||||||
|
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const files: SharedFile[] = [];
|
||||||
|
for (let i = 0; i < filePaths.length; i++) {
|
||||||
|
const filePath = filePaths[i];
|
||||||
if (!filePath || !fs.existsSync(filePath)) {
|
if (!filePath || !fs.existsSync(filePath)) {
|
||||||
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fileBuf = fs.readFileSync(filePath);
|
panel.webview.postMessage({ type: 'status', message: `Loading ${i + 1}/${filePaths.length} ${path.basename(filePath)}` });
|
||||||
const filename = path.basename(filePath);
|
files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) });
|
||||||
|
}
|
||||||
|
panel.webview.postMessage({ type: 'status', message: 'Starting server...' });
|
||||||
const token = crypto.randomBytes(6).toString('base64url');
|
const token = crypto.randomBytes(6).toString('base64url');
|
||||||
|
|
||||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
|
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
|
||||||
@@ -93,8 +122,7 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
|||||||
server: http.createServer(),
|
server: http.createServer(),
|
||||||
port: 0,
|
port: 0,
|
||||||
token,
|
token,
|
||||||
fileBuf,
|
files,
|
||||||
filename,
|
|
||||||
};
|
};
|
||||||
share.server.on('request', (req, res) => handleRequest(req, res, share));
|
share.server.on('request', (req, res) => handleRequest(req, res, share));
|
||||||
share.server.on('error', (err) => {
|
share.server.on('error', (err) => {
|
||||||
@@ -125,7 +153,11 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
|||||||
iface: ip.iface,
|
iface: ip.iface,
|
||||||
url: `http://${ip.address}:${share.port}/s/${token}/`,
|
url: `http://${ip.address}:${share.port}/s/${token}/`,
|
||||||
}));
|
}));
|
||||||
panel.webview.postMessage({ type: 'sharing', urls, filename });
|
panel.webview.postMessage({
|
||||||
|
type: 'sharing',
|
||||||
|
urls,
|
||||||
|
filename: files.length === 1 ? files[0].filename : `${files.length} files`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,6 +173,30 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const stack: string[] = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||||
|
stack.push(full);
|
||||||
|
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) {
|
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) {
|
||||||
if (req.method !== 'GET' || !req.url) {
|
if (req.method !== 'GET' || !req.url) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
@@ -156,7 +212,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
|||||||
const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
|
const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
|
||||||
|
|
||||||
if (rest === '' || rest === '/') {
|
if (rest === '' || rest === '/') {
|
||||||
const body = chooserPage(share.filename);
|
const body = chooserPage(share.files);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
@@ -165,24 +221,40 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rest === '/view') {
|
const viewMatch = rest.match(/^\/view\/(\d+)$/);
|
||||||
|
if (rest === '/view' || viewMatch) {
|
||||||
|
const index = viewMatch ? Number(viewMatch[1]) : 0;
|
||||||
|
const file = share.files[index];
|
||||||
|
if (!file) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
});
|
});
|
||||||
res.end(share.fileBuf);
|
res.end(mobilePreviewPage(file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rest === '/file') {
|
const fileMatch = rest.match(/^\/file\/(\d+)$/);
|
||||||
const safeName = share.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
if (rest === '/file' || fileMatch) {
|
||||||
|
const index = fileMatch ? Number(fileMatch[1]) : 0;
|
||||||
|
const file = share.files[index];
|
||||||
|
if (!file) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safeName = file.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'Content-Disposition': `attachment; filename="${safeName}"`,
|
'Content-Disposition': `attachment; filename="${safeName}"`,
|
||||||
'Content-Length': String(share.fileBuf.length),
|
'Content-Length': String(file.buf.length),
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
});
|
});
|
||||||
res.end(share.fileBuf);
|
res.end(file.buf);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,10 +262,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
|||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooserPage(filename: string): string {
|
function chooserPage(files: SharedFile[]): string {
|
||||||
const escName = filename.replace(/[&<>"]/g, (c) =>
|
const esc = (value: string) => value.replace(/[&<>"]/g, (c) =>
|
||||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)
|
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)
|
||||||
);
|
);
|
||||||
|
const items = files.map((file, index) => {
|
||||||
|
const escName = esc(file.filename);
|
||||||
|
return `<div class="item">
|
||||||
|
<div class="file">${escName}</div>
|
||||||
|
<a class="btn view" href="view/${index}">View in browser</a>
|
||||||
|
<a class="btn dl" href="file/${index}" download="${escName}">Download .html</a>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html><head>
|
<html><head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -203,7 +283,8 @@ function chooserPage(filename: string): string {
|
|||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
.item { margin-bottom: 26px; }
|
||||||
|
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
|
||||||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||||
a.view { background: #2d7dff; color: white; }
|
a.view { background: #2d7dff; color: white; }
|
||||||
@@ -211,9 +292,36 @@ function chooserPage(filename: string): string {
|
|||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<h1>HPL Toolbox</h1>
|
<h1>HPL Toolbox</h1>
|
||||||
<div class="file">${escName}</div>
|
${items}
|
||||||
<a class="btn view" href="view">View in browser</a>
|
</body></html>`;
|
||||||
<a class="btn dl" href="file" download="${escName}">Download .html</a>
|
}
|
||||||
|
|
||||||
|
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>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,26 +358,8 @@ function getHtml(qrScriptUri: string): string {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
${getToolWebviewStyles()}
|
||||||
h2 { margin-top: 0; }
|
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: var(--tool-radius); }
|
||||||
.row { margin-bottom: 14px; }
|
|
||||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
|
||||||
button {
|
|
||||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
|
||||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
|
||||||
button.secondary {
|
|
||||||
background: var(--vscode-button-secondaryBackground);
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
}
|
|
||||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.iface-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
|
||||||
.iface-list label { font-size: 12px; cursor: pointer; }
|
|
||||||
.iface-list .meta { opacity: 0.6; margin-left: 6px; }
|
|
||||||
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: 4px; }
|
|
||||||
#qr svg, #qr img { display: block; }
|
#qr svg, #qr img { display: block; }
|
||||||
.url {
|
.url {
|
||||||
margin-top: 10px; padding: 8px;
|
margin-top: 10px; padding: 8px;
|
||||||
@@ -277,99 +367,170 @@ function getHtml(qrScriptUri: string): string {
|
|||||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||||
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
|
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
|
||||||
}
|
}
|
||||||
.url-actions { margin-top: 6px; display: flex; gap: 6px; }
|
.url-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
#status { margin-top: 12px; min-height: 18px; }
|
.share-panel { padding: 12px; }
|
||||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
.iface-radio { text-align: center; }
|
||||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
#qr { margin-top: 0; }
|
||||||
.hint { font-size: 12px; opacity: 0.7; margin-top: 8px; }
|
.selected-list { margin-top: 10px; }
|
||||||
|
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||||
</style>
|
</style>
|
||||||
<script src="${qrScriptUri}"></script>
|
<script src="${qrScriptUri}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>Send To Mobile</h2>
|
<main class="tool-page">
|
||||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">
|
<header class="tool-header">
|
||||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
<h2 class="tool-title">Send To Mobile</h2>
|
||||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
<p class="tool-description">Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. The phone gets View and Download options.</p>
|
||||||
</p>
|
</header>
|
||||||
|
|
||||||
<div class="row">
|
<section class="tool-panel input-panel">
|
||||||
<div class="file-cell">
|
<div class="panel-header">
|
||||||
<button id="pick" class="secondary">Choose...</button>
|
<h3 class="panel-title">Inputs</h3>
|
||||||
<span class="file-name" id="fileName">(no file)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="filePath" />
|
<div class="panel-body">
|
||||||
</div>
|
<div class="control-group">
|
||||||
|
<div class="file-row">
|
||||||
<div class="row">
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
<button id="start">Start sharing</button>
|
<button id="pick" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
|
||||||
|
<div id="fileList" class="selected-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="start">Start</button>
|
||||||
<button id="stop" class="secondary" disabled>Stop</button>
|
<button id="stop" class="secondary" disabled>Stop</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ifaceWrap" class="row" style="display:none;">
|
|
||||||
<div style="font-size:12px;opacity:0.8;">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
|
||||||
<div class="iface-list" id="ifaceList"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="shareInfo" style="display:none;">
|
|
||||||
<div id="qr"></div>
|
|
||||||
<div class="url" id="urlText"></div>
|
|
||||||
<div class="url-actions">
|
|
||||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
|
||||||
<button id="openUrl" class="secondary">Open in browser</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">
|
<div id="status" class="status-panel"></div>
|
||||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="outputPanel" class="is-hidden">
|
||||||
|
<div id="ifaceWrap" class="control-group" style="display:none;">
|
||||||
|
<div class="control-label">Network Interfaces</div>
|
||||||
|
<p class="section-description">Pick the interface your phone can reach.</p>
|
||||||
|
<div class="iface-list" id="ifaceList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="shareInfo" class="result-card share-panel" style="display:none;">
|
||||||
|
<div id="qr"></div>
|
||||||
|
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||||||
|
First run on Windows may show a firewall prompt - allow access for "Private networks".
|
||||||
Sharing stops automatically when this panel is closed.
|
Sharing stops automatically when this panel is closed.
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
<div id="status"></div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
|
const pickFolderBtn = document.getElementById('pickFolder');
|
||||||
const pickBtn = document.getElementById('pick');
|
const pickBtn = document.getElementById('pick');
|
||||||
|
const clearBtn = document.getElementById('clear');
|
||||||
const startBtn = document.getElementById('start');
|
const startBtn = document.getElementById('start');
|
||||||
const stopBtn = document.getElementById('stop');
|
const stopBtn = document.getElementById('stop');
|
||||||
const fileNameEl = document.getElementById('fileName');
|
const fileNameEl = document.getElementById('fileName');
|
||||||
const filePathEl = document.getElementById('filePath');
|
const fileListEl = document.getElementById('fileList');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
const outputPanel = document.getElementById('outputPanel');
|
||||||
const shareInfo = document.getElementById('shareInfo');
|
const shareInfo = document.getElementById('shareInfo');
|
||||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||||
const ifaceList = document.getElementById('ifaceList');
|
const ifaceList = document.getElementById('ifaceList');
|
||||||
const qrEl = document.getElementById('qr');
|
const qrEl = document.getElementById('qr');
|
||||||
const urlTextEl = document.getElementById('urlText');
|
|
||||||
|
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
let currentUrls = [];
|
let currentUrls = [];
|
||||||
let selectedIdx = 0;
|
let selectedIdx = 0;
|
||||||
|
let selectedPaths = [];
|
||||||
|
let selectedFiles = [];
|
||||||
|
let selectedPage = 0;
|
||||||
|
|
||||||
|
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
|
||||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' }));
|
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' }));
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
selectedPaths = [];
|
||||||
|
selectedFiles = [];
|
||||||
|
selectedPage = 0;
|
||||||
|
renderSelection();
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
});
|
||||||
|
|
||||||
startBtn.addEventListener('click', () => {
|
startBtn.addEventListener('click', () => {
|
||||||
statusEl.innerHTML = '';
|
statusEl.innerHTML = '';
|
||||||
const path = filePathEl.value;
|
outputPanel.classList.add('is-hidden');
|
||||||
if (!path) {
|
if (!selectedPaths.length) {
|
||||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startBtn.disabled = true;
|
startBtn.disabled = true;
|
||||||
statusEl.textContent = 'Starting server...';
|
statusEl.textContent = 'Starting server...';
|
||||||
vscode.postMessage({ type: 'startShare', path });
|
statusEl.classList.add('is-busy');
|
||||||
|
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
|
||||||
});
|
});
|
||||||
|
|
||||||
stopBtn.addEventListener('click', () => {
|
stopBtn.addEventListener('click', () => {
|
||||||
vscode.postMessage({ type: 'stopShare' });
|
vscode.postMessage({ type: 'stopShare' });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
function escapeHtml(s) {
|
||||||
vscode.postMessage({ type: 'copy', text: currentUrls[selectedIdx]?.url || '' });
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
if (!selectedFiles.length) {
|
||||||
|
fileNameEl.textContent = '(no files selected)';
|
||||||
|
fileListEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||||
|
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const start = selectedPage * PAGE_SIZE;
|
||||||
|
const visibleFiles = selectedFiles.slice(start, start + PAGE_SIZE);
|
||||||
|
fileListEl.innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||||
|
'<tbody>' +
|
||||||
|
visibleFiles.map((file, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return (
|
||||||
|
'<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td>' +
|
||||||
|
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
|
||||||
|
);
|
||||||
|
}).join('') +
|
||||||
|
'</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const index = Number(btn.dataset.index);
|
||||||
|
selectedFiles.splice(index, 1);
|
||||||
|
selectedPaths.splice(index, 1);
|
||||||
|
renderSelection();
|
||||||
});
|
});
|
||||||
document.getElementById('openUrl').addEventListener('click', () => {
|
|
||||||
vscode.postMessage({ type: 'open', text: currentUrls[selectedIdx]?.url || '' });
|
|
||||||
});
|
});
|
||||||
|
if (selectedFiles.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML =
|
||||||
|
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
fileListEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSelection();
|
||||||
|
|
||||||
function renderQr(url) {
|
function renderQr(url) {
|
||||||
qrEl.innerHTML = '';
|
qrEl.innerHTML = '';
|
||||||
urlTextEl.textContent = url;
|
|
||||||
try {
|
try {
|
||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
qr.addData(url);
|
qr.addData(url);
|
||||||
@@ -389,41 +550,62 @@ function getHtml(qrScriptUri: string): string {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ifaceWrap.style.display = '';
|
ifaceWrap.style.display = '';
|
||||||
|
ifaceList.innerHTML =
|
||||||
|
'<div class="results-panel" style="margin-top:0;">' +
|
||||||
|
'<table class="data-table">' +
|
||||||
|
'<thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead>' +
|
||||||
|
'<tbody></tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
const tbody = ifaceList.querySelector('tbody');
|
||||||
urls.forEach((u, i) => {
|
urls.forEach((u, i) => {
|
||||||
const id = 'iface_' + i;
|
const id = 'iface_' + i;
|
||||||
const label = document.createElement('label');
|
const row = document.createElement('tr');
|
||||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
row.innerHTML =
|
||||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
'<td class="iface-radio"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||||
u.ip + '<span class="meta">(' + u.iface + ')</span>';
|
'<td class="mono wrap">' + u.ip + '</td>' +
|
||||||
label.querySelector('input').addEventListener('change', () => {
|
'<td class="wrap">' + u.iface + '</td>';
|
||||||
|
row.querySelector('input').addEventListener('change', () => {
|
||||||
selectedIdx = i;
|
selectedIdx = i;
|
||||||
renderQr(urls[i].url);
|
renderQr(urls[i].url);
|
||||||
});
|
});
|
||||||
ifaceList.appendChild(label);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (m.type === 'fileSelected') {
|
if (m.type === 'fileSelected') {
|
||||||
filePathEl.value = m.path;
|
const existing = new Set(selectedFiles.map(f => f.path));
|
||||||
fileNameEl.textContent = m.name;
|
selectedFiles = selectedFiles.concat((m.files || []).filter(f => !existing.has(f.path)));
|
||||||
|
selectedPaths = selectedFiles.map(f => f.path);
|
||||||
|
selectedPage = 0;
|
||||||
|
renderSelection();
|
||||||
|
} else if (m.type === 'status') {
|
||||||
|
statusEl.textContent = m.message;
|
||||||
|
statusEl.className = 'status-panel is-busy';
|
||||||
} else if (m.type === 'sharing') {
|
} else if (m.type === 'sharing') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
currentUrls = m.urls;
|
currentUrls = m.urls;
|
||||||
selectedIdx = 0;
|
selectedIdx = 0;
|
||||||
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
|
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
|
||||||
|
outputPanel.classList.remove('is-hidden');
|
||||||
shareInfo.style.display = '';
|
shareInfo.style.display = '';
|
||||||
renderIfaces(m.urls);
|
renderIfaces(m.urls);
|
||||||
renderQr(m.urls[0].url);
|
renderQr(m.urls[0].url);
|
||||||
startBtn.disabled = true;
|
startBtn.disabled = true;
|
||||||
stopBtn.disabled = false;
|
stopBtn.disabled = false;
|
||||||
} else if (m.type === 'stopped') {
|
} else if (m.type === 'stopped') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.textContent = 'Stopped.';
|
statusEl.textContent = 'Stopped.';
|
||||||
shareInfo.style.display = 'none';
|
shareInfo.style.display = 'none';
|
||||||
|
ifaceWrap.style.display = 'none';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
currentUrls = [];
|
currentUrls = [];
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
stopBtn.disabled = true;
|
stopBtn.disabled = true;
|
||||||
} else if (m.type === 'error') {
|
} else if (m.type === 'error') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '<') + '</span>';
|
statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '<') + '</span>';
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,306 @@ export function handleClipboardAndOpen(msg: any): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getToolWebviewStyles(): string {
|
||||||
|
return `
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--tool-gap-xs: 4px;
|
||||||
|
--tool-gap-sm: 8px;
|
||||||
|
--tool-gap-md: 12px;
|
||||||
|
--tool-gap-lg: 16px;
|
||||||
|
--tool-radius: 4px;
|
||||||
|
--tool-border: var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
|
||||||
|
--tool-panel: var(--vscode-editorWidget-background, var(--vscode-sideBar-background));
|
||||||
|
--tool-panel-soft: var(--vscode-textBlockQuote-background, rgba(128, 128, 128, 0.08));
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
font-size: var(--vscode-font-size);
|
||||||
|
}
|
||||||
|
.tool-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.tool-header {
|
||||||
|
margin-bottom: var(--tool-gap-lg);
|
||||||
|
}
|
||||||
|
.tool-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tool-description,
|
||||||
|
.section-description,
|
||||||
|
.muted {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
.tool-description {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
max-width: 780px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.tool-section {
|
||||||
|
margin-bottom: var(--tool-gap-lg);
|
||||||
|
}
|
||||||
|
.tool-panel {
|
||||||
|
margin-bottom: var(--tool-gap-lg);
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: var(--tool-panel-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.is-hidden,
|
||||||
|
.tool-panel.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--tool-gap-md);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--tool-border);
|
||||||
|
background: var(--tool-panel);
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.45px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.panel-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.control-group + .control-group {
|
||||||
|
margin-top: var(--tool-gap-md);
|
||||||
|
}
|
||||||
|
.control-label {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.section-description {
|
||||||
|
margin: -2px 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.field-row,
|
||||||
|
.file-row,
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--tool-gap-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.field-row + .field-row {
|
||||||
|
margin-top: var(--tool-gap-sm);
|
||||||
|
}
|
||||||
|
input[type=text],
|
||||||
|
input[type=date],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border: 1px solid var(--vscode-input-border, var(--tool-border));
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input[type=text]:focus,
|
||||||
|
input[type=date]:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder);
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
.field-row input[type=text] {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border-color: var(--tool-border);
|
||||||
|
}
|
||||||
|
button.secondary:hover {
|
||||||
|
background: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
border-color: var(--tool-border);
|
||||||
|
}
|
||||||
|
button.danger:hover {
|
||||||
|
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.16));
|
||||||
|
}
|
||||||
|
.remove-selected {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
padding: 3px 0;
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.action-row {
|
||||||
|
margin-top: var(--tool-gap-md);
|
||||||
|
}
|
||||||
|
.status-panel,
|
||||||
|
.results-panel,
|
||||||
|
.result-card {
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
border-radius: var(--tool-radius);
|
||||||
|
background: var(--tool-panel-soft);
|
||||||
|
}
|
||||||
|
.status-panel {
|
||||||
|
min-height: 30px;
|
||||||
|
margin-top: var(--tool-gap-md);
|
||||||
|
padding: 8px 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.status-panel.is-busy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.status-panel.is-busy::before {
|
||||||
|
content: "";
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent);
|
||||||
|
border-top-color: var(--vscode-foreground);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: status-spin 800ms linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes status-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.status-panel:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.results-panel {
|
||||||
|
margin-top: var(--tool-gap-md);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.results-panel:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.output-panel .results-panel {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.result-card {
|
||||||
|
padding: 10px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid var(--tool-border);
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--tool-panel);
|
||||||
|
}
|
||||||
|
.data-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: var(--vscode-editor-font-family, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
color: var(--vscode-testing-iconPassed, #3fb950);
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
color: var(--vscode-errorForeground, #f85149);
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
color: var(--vscode-editorWarning-foreground, #d29922);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--tool-border);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.ok {
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #3fb950) 45%, transparent);
|
||||||
|
background: rgba(63, 185, 80, 0.12);
|
||||||
|
}
|
||||||
|
.badge.err {
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f85149) 45%, transparent);
|
||||||
|
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.12));
|
||||||
|
}
|
||||||
|
.badge.warn {
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground, #d29922) 45%, transparent);
|
||||||
|
background: var(--vscode-inputValidation-warningBackground, rgba(210, 153, 34, 0.12));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function singletonPanel(
|
export function singletonPanel(
|
||||||
store: { panel: vscode.WebviewPanel | null },
|
store: { panel: vscode.WebviewPanel | null },
|
||||||
viewType: string,
|
viewType: string,
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
@@ -35,31 +35,53 @@ func pickApplovinUserAgent(randomize bool) string {
|
|||||||
|
|
||||||
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
||||||
body := `
|
body := `
|
||||||
<h2>AppLovin Playable Preview (QR)</h2>
|
<header class="tool-header">
|
||||||
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
<h2 class="tool-title">AppLovin Playable Preview</h2>
|
||||||
|
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
<section class="tool-panel">
|
||||||
<button id="pick" class="secondary">Add files...</button>
|
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
<div class="panel-body">
|
||||||
<span id="fileName" class="hint">(no files)</span>
|
<div class="control-group">
|
||||||
</div>
|
<div id="dropZone" class="drop-zone">
|
||||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
<div class="file-row">
|
||||||
<div style="display:flex;gap:8px;">
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
<button id="upload">Upload to AppLovin</button>
|
<button id="pick" class="secondary">Select File(s)</button>
|
||||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
<button id="clear" class="secondary">Clear</button>
|
||||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
</div>
|
||||||
</div>
|
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||||
|
</div>
|
||||||
|
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||||
|
<div id="fileList" class="selected-files"></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="upload">Upload</button>
|
||||||
|
<button id="saveAll" class="secondary" disabled>Save All</button>
|
||||||
|
<button id="regenAll" class="secondary" disabled>Regenerate</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
<div id="results" class="results-panel"></div>
|
||||||
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
.qr-cell { text-align:center; }
|
||||||
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
|
.qr-cell img { width:104px; height:104px; max-width:100%; padding:6px; border-radius:4px; background:#fff; cursor:zoom-in; }
|
||||||
|
.result-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||||
|
.qr-results:not(.has-errors) .error-col { display:none; }
|
||||||
|
.qr-modal { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:24px; background:rgba(0,0,0,0.72); z-index:10; }
|
||||||
|
.qr-modal.open { display:flex; }
|
||||||
|
.qr-modal img { width:min(70vw,520px); height:min(70vw,520px); padding:18px; border-radius:4px; background:#fff; }
|
||||||
</style>
|
</style>
|
||||||
|
<div id="qrModal" class="qr-modal"><img alt="Expanded QR" /></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
let selectedPage = 0;
|
||||||
const pickBtn = document.getElementById('pick');
|
const pickBtn = document.getElementById('pick');
|
||||||
|
const pickFolderBtn = document.getElementById('pickFolder');
|
||||||
const clearBtn = document.getElementById('clear');
|
const clearBtn = document.getElementById('clear');
|
||||||
const uploadBtn = document.getElementById('upload');
|
const uploadBtn = document.getElementById('upload');
|
||||||
const fileNameEl = document.getElementById('fileName');
|
const fileNameEl = document.getElementById('fileName');
|
||||||
@@ -68,6 +90,7 @@ const statusEl = document.getElementById('status');
|
|||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
const saveAllBtn = document.getElementById('saveAll');
|
const saveAllBtn = document.getElementById('saveAll');
|
||||||
const regenAllBtn = document.getElementById('regenAll');
|
const regenAllBtn = document.getElementById('regenAll');
|
||||||
|
const qrModal = document.getElementById('qrModal');
|
||||||
let selectedPaths = [];
|
let selectedPaths = [];
|
||||||
let resultItems = [];
|
let resultItems = [];
|
||||||
|
|
||||||
@@ -76,15 +99,38 @@ function basename(p) {
|
|||||||
return i >= 0 ? p.slice(i + 1) : p;
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
}
|
}
|
||||||
function renderSelection() {
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
if (!selectedPaths.length) {
|
if (!selectedPaths.length) {
|
||||||
fileNameEl.textContent = '(no files)';
|
fileNameEl.textContent = '(no files selected)';
|
||||||
fileListEl.innerHTML = '';
|
fileListEl.innerHTML = '';
|
||||||
clearBtn.style.display = 'none';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||||
clearBtn.style.display = '';
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const start = selectedPage * PAGE_SIZE;
|
||||||
|
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
|
||||||
|
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||||
|
visiblePaths.map((p, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||||
|
}).join('') + '</tbody></table></div>';
|
||||||
|
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => { selectedPaths.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||||
|
});
|
||||||
|
if (selectedPaths.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
fileListEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAllBtn.addEventListener('click', async () => {
|
saveAllBtn.addEventListener('click', async () => {
|
||||||
@@ -111,33 +157,57 @@ regenAllBtn.addEventListener('click', () => {
|
|||||||
img.src = base + '&_=' + Date.now();
|
img.src = base + '&_=' + Date.now();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
if (j.files && j.files.length) {
|
||||||
|
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||||
|
selectedPaths = selectedPaths.concat(added);
|
||||||
|
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||||
|
});
|
||||||
|
|
||||||
clearBtn.addEventListener('click', () => {
|
clearBtn.addEventListener('click', () => {
|
||||||
selectedPaths = [];
|
selectedPaths = [];
|
||||||
|
selectedPage = 0;
|
||||||
renderSelection();
|
renderSelection();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pickFolderBtn.addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/applovin/pickFolder', { method: 'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.files && j.files.length) {
|
||||||
|
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||||
|
selectedPaths = selectedPaths.concat(added);
|
||||||
|
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
pickBtn.addEventListener('click', async () => {
|
pickBtn.addEventListener('click', async () => {
|
||||||
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.files && j.files.length) {
|
if (j.files && j.files.length) {
|
||||||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||||
selectedPaths = selectedPaths.concat(added);
|
selectedPaths = selectedPaths.concat(added);
|
||||||
|
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||||
renderSelection();
|
renderSelection();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadBtn.addEventListener('click', async () => {
|
uploadBtn.addEventListener('click', async () => {
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
if (!selectedPaths.length) {
|
if (!selectedPaths.length) {
|
||||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
|
statusEl.textContent = 'Uploading...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
resultItems = [];
|
resultItems = [];
|
||||||
saveAllBtn.style.display = 'none';
|
saveAllBtn.disabled = true;
|
||||||
regenAllBtn.style.display = 'none';
|
regenAllBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/applovin/upload', {
|
const res = await fetch('/api/applovin/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -145,6 +215,7 @@ uploadBtn.addEventListener('click', async () => {
|
|||||||
body: JSON.stringify({ paths: selectedPaths }),
|
body: JSON.stringify({ paths: selectedPaths }),
|
||||||
});
|
});
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
|
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
@@ -164,6 +235,7 @@ uploadBtn.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||||
} finally {
|
} finally {
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
@@ -174,24 +246,41 @@ function escapeHtml(s){
|
|||||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureResultsTable() {
|
||||||
|
let tbody = resultsEl.querySelector('tbody');
|
||||||
|
if (tbody) return tbody;
|
||||||
|
resultsEl.innerHTML = '<table class="data-table qr-results"><thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead><tbody></tbody></table>';
|
||||||
|
return resultsEl.querySelector('tbody');
|
||||||
|
}
|
||||||
|
qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
|
||||||
|
|
||||||
function handle(m) {
|
function handle(m) {
|
||||||
if (m.type === 'status') {
|
if (m.type === 'status') {
|
||||||
statusEl.textContent = m.message;
|
statusEl.textContent = m.message;
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
} else if (m.type === 'fileError') {
|
} else if (m.type === 'fileError') {
|
||||||
const wrap = document.createElement('div');
|
const tbody = ensureResultsTable();
|
||||||
wrap.className = 'result';
|
resultsEl.querySelector('table').classList.add('has-errors');
|
||||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
const row = document.createElement('tr');
|
||||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
row.innerHTML = '<td class="mono wrap">' + escapeHtml(m.name) + '</td><td class="muted">-</td><td class="muted">-</td><td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
|
||||||
resultsEl.appendChild(wrap);
|
tbody.appendChild(row);
|
||||||
} else if (m.type === 'fileResult') {
|
} else if (m.type === 'fileResult') {
|
||||||
const wrap = document.createElement('div');
|
const tbody = ensureResultsTable();
|
||||||
wrap.className = 'result';
|
const row = document.createElement('tr');
|
||||||
wrap.innerHTML =
|
const file = document.createElement('td');
|
||||||
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
file.className = 'mono wrap';
|
||||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
file.textContent = m.name;
|
||||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
const qr = document.createElement('td');
|
||||||
'</div>' +
|
qr.className = 'qr-cell';
|
||||||
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
|
||||||
|
qr.querySelector('img').addEventListener('click', (event) => {
|
||||||
|
qrModal.querySelector('img').src = event.currentTarget.src;
|
||||||
|
qrModal.classList.add('open');
|
||||||
|
});
|
||||||
|
const actionCell = document.createElement('td');
|
||||||
|
const error = document.createElement('td');
|
||||||
|
error.className = 'error-col';
|
||||||
|
error.innerHTML = '<span class="muted">-</span>';
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'result-actions';
|
actions.className = 'result-actions';
|
||||||
const openBtn = document.createElement('button');
|
const openBtn = document.createElement('button');
|
||||||
@@ -218,21 +307,21 @@ function handle(m) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
||||||
wrap.appendChild(actions);
|
actionCell.appendChild(actions);
|
||||||
resultsEl.appendChild(wrap);
|
row.append(file, qr, actionCell, error);
|
||||||
|
tbody.appendChild(row);
|
||||||
resultItems.push({ name: m.name, url: m.qrImg });
|
resultItems.push({ name: m.name, url: m.qrImg });
|
||||||
if (resultItems.length >= 2) {
|
saveAllBtn.disabled = resultItems.length === 0;
|
||||||
saveAllBtn.style.display = '';
|
regenAllBtn.disabled = resultItems.length === 0;
|
||||||
regenAllBtn.style.display = '';
|
|
||||||
}
|
|
||||||
} else if (m.type === 'done') {
|
} else if (m.type === 'done') {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
`
|
`
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body)))
|
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Playable Preview", body)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -253,6 +342,23 @@ func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]any{"files": files})
|
writeJSON(w, map[string]any{"files": files})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ApplovinPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
dir := PickFolder("Select folder", cfg.LastPickDir)
|
||||||
|
if dir == "" {
|
||||||
|
writeJSON(w, map[string]any{"files": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.LastPickDir = dir
|
||||||
|
_ = SaveConfig(cfg)
|
||||||
|
paths := collectHTMLFiles(dir)
|
||||||
|
files := make([]map[string]string, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"files": files})
|
||||||
|
}
|
||||||
|
|
||||||
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
@@ -345,11 +451,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if i > 0 && delayMs > 0 {
|
if i > 0 && delayMs > 0 {
|
||||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Waiting %dms before %s...", idx, len(req.Paths), delayMs, fileName)})
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Waiting %d/%d %s", idx, len(req.Paths), fileName)})
|
||||||
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)})
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Validating %d/%d %s", idx, len(req.Paths), fileName)})
|
||||||
|
|
||||||
body, ct, err := buildForm(filePath)
|
body, ct, err := buildForm(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -374,7 +480,7 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)})
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d/%d %s", idx, len(req.Paths), fileName)})
|
||||||
|
|
||||||
body, ct, err = buildForm(filePath)
|
body, ct, err = buildForm(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,83 +13,164 @@ import (
|
|||||||
|
|
||||||
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
||||||
body := `
|
body := `
|
||||||
<h2>Base64 Asset Scanner</h2>
|
<header class="tool-header">
|
||||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
<h2 class="tool-title">Base64 Scanner</h2>
|
||||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
<p class="tool-description">Scan selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||||
<button id="pickFolder">Browse Folder...</button>
|
</header>
|
||||||
<button id="pickFiles">Pick Files...</button>
|
|
||||||
<button id="scan">Scan</button>
|
<section class="tool-panel">
|
||||||
</div>
|
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||||
<div id="selection" class="hint"></div>
|
<div class="panel-body">
|
||||||
<div id="status" style="margin-top:8px;"></div>
|
<div class="control-group">
|
||||||
<div id="results" style="margin-top:8px;"></div>
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
|
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||||
|
</div>
|
||||||
|
<div id="selection" class="selected-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row"><button id="scan">Scan</button></div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="results" class="results-panel"></div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
|
.asset-list { margin:0; padding-left:16px; }
|
||||||
.mark { width:14px; flex-shrink:0; font-weight:bold; }
|
.asset-list li + li { margin-top:3px; }
|
||||||
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
|
||||||
.file-name { word-break:break-all; }
|
|
||||||
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
|
|
||||||
.summary { opacity:0.8; margin-bottom:6px; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const folderEl = document.getElementById('folder');
|
const PAGE_SIZE = 5;
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
const selectionEl = document.getElementById('selection');
|
const selectionEl = document.getElementById('selection');
|
||||||
let pickedFiles = [];
|
let pickedFiles = [];
|
||||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
let selectedPage = 0;
|
||||||
folderEl.addEventListener('input', clearFiles);
|
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
|
||||||
|
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
if (!pickedFiles.length) {
|
||||||
|
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const start = selectedPage * PAGE_SIZE;
|
||||||
|
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
|
||||||
|
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||||
|
visible.map((p, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||||
|
}).join('') + '</tbody></table></div>';
|
||||||
|
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||||
|
});
|
||||||
|
if (pickedFiles.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
selectionEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addPaths(paths) {
|
||||||
|
const existing = new Set(pickedFiles);
|
||||||
|
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
|
||||||
|
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
async function readJSONStream(response, handle) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newline;
|
||||||
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, newline);
|
||||||
|
buffer = buffer.slice(newline + 1);
|
||||||
|
if (line.trim()) handle(JSON.parse(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||||
|
}
|
||||||
|
renderSelection();
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
addPaths(j.paths || []);
|
||||||
|
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||||
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
addPaths(j.paths || []);
|
||||||
});
|
});
|
||||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||||
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.paths && j.paths.length) {
|
addPaths(j.paths || []);
|
||||||
pickedFiles = j.paths;
|
});
|
||||||
folderEl.value = '';
|
document.getElementById('clear').addEventListener('click', () => {
|
||||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
pickedFiles = [];
|
||||||
}
|
selectedPage = 0;
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
renderSelection();
|
||||||
});
|
});
|
||||||
document.getElementById('scan').addEventListener('click', async () => {
|
document.getElementById('scan').addEventListener('click', async () => {
|
||||||
statusEl.textContent = 'Scanning...';
|
statusEl.textContent = 'Scanning...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
let payload;
|
if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
|
||||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
const payload = { files: pickedFiles };
|
||||||
else {
|
let j = null;
|
||||||
const folder = folderEl.value.trim();
|
try {
|
||||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
|
||||||
payload = { folder };
|
|
||||||
}
|
|
||||||
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||||
const j = await r.json();
|
await readJSONStream(r, (msg) => {
|
||||||
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
if (msg.type === 'status') {
|
||||||
|
statusEl.textContent = msg.message;
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
} else if (msg.type === 'done') {
|
||||||
|
j = msg;
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
j = { error: msg.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = 'Error: ' + (e.message || e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
|
||||||
|
if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
|
||||||
const total = j.results.length;
|
const total = j.results.length;
|
||||||
const flagged = j.results.filter(r => !r.ok).length;
|
const flagged = j.results.filter(r => !r.ok).length;
|
||||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
statusEl.classList.remove('is-busy');
|
||||||
resultsEl.innerHTML = '';
|
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||||
|
if (!total) { resultsEl.innerHTML = ''; return; }
|
||||||
|
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead><tbody></tbody></table>';
|
||||||
|
const tbody = resultsEl.querySelector('tbody');
|
||||||
for (const rr of j.results) {
|
for (const rr of j.results) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('tr');
|
||||||
row.className = 'row-result';
|
const assets = rr.ok ? '<span class="muted">None</span>' : '<ul class="asset-list mono">' + rr.assets.map(a => '<li>' + escapeHtml(a) + '</li>').join('') + '</ul>';
|
||||||
const mark = document.createElement('span');
|
row.innerHTML = '<td>' + (rr.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>') + '</td>' +
|
||||||
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
|
'<td class="mono wrap">' + escapeHtml(rr.file) + '</td><td>' + rr.assets.length + '</td><td class="wrap">' + assets + '</td>';
|
||||||
mark.textContent = rr.ok ? '✓' : '✗';
|
tbody.appendChild(row);
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'file-name';
|
|
||||||
name.textContent = rr.file;
|
|
||||||
row.appendChild(mark); row.appendChild(name);
|
|
||||||
if (!rr.ok) {
|
|
||||||
const a = document.createElement('span');
|
|
||||||
a.className = 'assets';
|
|
||||||
a.textContent = '— ' + rr.assets.join(', ');
|
|
||||||
row.appendChild(a);
|
|
||||||
}
|
|
||||||
resultsEl.appendChild(row);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -99,7 +181,11 @@ document.getElementById('scan').addEventListener('click', async () => {
|
|||||||
|
|
||||||
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
|
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
p := PickFolder("Select folder to scan", "")
|
p := PickFolder("Select folder to scan", "")
|
||||||
writeJSON(w, map[string]any{"path": p})
|
if p == "" {
|
||||||
|
writeJSON(w, map[string]any{"paths": []string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"paths": collectHTMLFiles(p)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
|
func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -118,12 +204,13 @@ type scanResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
send := newJSONStream(w)
|
||||||
var req struct {
|
var req struct {
|
||||||
Folder string `json:"folder"`
|
Folder string `json:"folder"`
|
||||||
Files []string `json:"files"`
|
Files []string `json:"files"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,12 +230,13 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
|||||||
targets = collectHTMLFiles(req.Folder)
|
targets = collectHTMLFiles(req.Folder)
|
||||||
baseDir = req.Folder
|
baseDir = req.Folder
|
||||||
} else {
|
} else {
|
||||||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"})
|
send(map[string]any{"type": "error", "message": "Pick a folder or files first"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
results := []scanResult{}
|
results := []scanResult{}
|
||||||
for _, file := range targets {
|
for i, file := range targets {
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
|
||||||
data, err := os.ReadFile(file)
|
data, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -169,7 +257,7 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
|||||||
Assets: offenders,
|
Assets: offenders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{"results": results})
|
send(map[string]any{"type": "done", "results": results})
|
||||||
}
|
}
|
||||||
|
|
||||||
func commonBaseDir(files []string) string {
|
func commonBaseDir(files []string) string {
|
||||||
|
|||||||
229
standalone/changelog.go
Normal file
229
standalone/changelog.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChangelogPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markdown := readChangelog()
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Changelog</h2>
|
||||||
|
<p class="tool-description">Loaded from README.md.</p>
|
||||||
|
</header>
|
||||||
|
<section class="tool-panel changelog-panel">
|
||||||
|
<div class="panel-body changelog-content">
|
||||||
|
` + renderChangelogMarkdown(markdown) + `
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<style>
|
||||||
|
.changelog-panel { max-width: 840px; }
|
||||||
|
.changelog-content h1 { margin: 0 0 14px; font-size: 20px; line-height: 1.25; }
|
||||||
|
.changelog-content h2 { margin: 18px 0 8px; padding-top: 12px; border-top: 1px solid #333; font-size: 15px; line-height: 1.3; }
|
||||||
|
.changelog-content h3 { margin: 14px 0 6px; font-size: 13px; line-height: 1.3; }
|
||||||
|
.changelog-content ul { margin: 6px 0 12px; padding-left: 20px; }
|
||||||
|
.changelog-content li { margin: 4px 0; line-height: 1.45; }
|
||||||
|
.changelog-content li.nested { margin-left: 18px; }
|
||||||
|
.changelog-content p { margin: 8px 0; line-height: 1.5; }
|
||||||
|
.changelog-content code { font-family: Consolas, monospace; font-size: 0.95em; background: rgba(128,128,128,0.12); padding: 1px 4px; border-radius: 3px; }
|
||||||
|
.changelog-block { margin: 8px 0 18px; padding: 10px 12px; border: 1px solid #333; border-radius: 4px; background: rgba(128,128,128,0.08); }
|
||||||
|
.change-section { margin: 8px 0 4px; font-weight: 600; }
|
||||||
|
.changelog-block ul { margin: 4px 0 10px; }
|
||||||
|
</style>`
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/changelog", "Changelog", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readChangelog() string {
|
||||||
|
if embeddedReadme != "" {
|
||||||
|
return embeddedReadme
|
||||||
|
}
|
||||||
|
for _, start := range changelogSearchRoots() {
|
||||||
|
for _, candidate := range changelogCandidates(start) {
|
||||||
|
data, err := os.ReadFile(candidate)
|
||||||
|
if err == nil {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "# Changelog\n\nREADME.md was not found."
|
||||||
|
}
|
||||||
|
|
||||||
|
func changelogSearchRoots() []string {
|
||||||
|
roots := []string{}
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
roots = append(roots, wd)
|
||||||
|
}
|
||||||
|
roots = append(roots, appDir())
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
func changelogCandidates(start string) []string {
|
||||||
|
candidates := []string{}
|
||||||
|
dir := filepath.Clean(start)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
candidates = append(candidates, filepath.Join(dir, "README.md"))
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderChangelogMarkdown(markdown string) string {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n")
|
||||||
|
var out strings.Builder
|
||||||
|
inList := false
|
||||||
|
inCodeBlock := false
|
||||||
|
codeLines := []string{}
|
||||||
|
closeList := func() {
|
||||||
|
if inList {
|
||||||
|
out.WriteString("</ul>")
|
||||||
|
inList = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rawLine := range lines {
|
||||||
|
if strings.TrimSpace(rawLine) == "```" {
|
||||||
|
closeList()
|
||||||
|
if inCodeBlock {
|
||||||
|
out.WriteString(renderChangelogBlock(codeLines))
|
||||||
|
codeLines = []string{}
|
||||||
|
inCodeBlock = false
|
||||||
|
} else {
|
||||||
|
inCodeBlock = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inCodeBlock {
|
||||||
|
codeLines = append(codeLines, rawLine)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" {
|
||||||
|
closeList()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "### ") {
|
||||||
|
closeList()
|
||||||
|
out.WriteString("<h2>" + renderInlineMarkdown(strings.TrimSpace(line[4:])) + "</h2>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "## ") {
|
||||||
|
closeList()
|
||||||
|
out.WriteString("<h2>" + renderInlineMarkdown(strings.TrimSpace(line[3:])) + "</h2>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "# ") {
|
||||||
|
closeList()
|
||||||
|
out.WriteString("<h1>" + renderInlineMarkdown(strings.TrimSpace(line[2:])) + "</h1>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "**") && strings.HasSuffix(line, "**") && len(line) > 4 {
|
||||||
|
closeList()
|
||||||
|
out.WriteString("<h2>" + html.EscapeString(strings.TrimSuffix(strings.TrimPrefix(line, "**"), "**")) + "</h2>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "- ") {
|
||||||
|
if !inList {
|
||||||
|
out.WriteString("<ul>")
|
||||||
|
inList = true
|
||||||
|
}
|
||||||
|
out.WriteString("<li>" + renderInlineMarkdown(strings.TrimSpace(line[2:])) + "</li>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
out.WriteString("<p>" + renderInlineMarkdown(line) + "</p>")
|
||||||
|
}
|
||||||
|
if inCodeBlock {
|
||||||
|
out.WriteString(renderChangelogBlock(codeLines))
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderChangelogBlock(lines []string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
inList := false
|
||||||
|
closeList := func() {
|
||||||
|
if inList {
|
||||||
|
out.WriteString("</ul>")
|
||||||
|
inList = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.WriteString(`<div class="changelog-block">`)
|
||||||
|
for _, rawLine := range lines {
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" {
|
||||||
|
closeList()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "- ") {
|
||||||
|
if !inList {
|
||||||
|
out.WriteString("<ul>")
|
||||||
|
inList = true
|
||||||
|
}
|
||||||
|
className := ""
|
||||||
|
if leadingSpaceCount(rawLine) >= 8 {
|
||||||
|
className = ` class="nested"`
|
||||||
|
}
|
||||||
|
out.WriteString("<li" + className + ">" + renderInlineMarkdown(strings.TrimSpace(line[2:])) + "</li>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
out.WriteString(`<div class="change-section">` + renderInlineMarkdown(line) + `</div>`)
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
out.WriteString("</div>")
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderInlineMarkdown(value string) string {
|
||||||
|
escaped := html.EscapeString(value)
|
||||||
|
escaped = replaceDelimited(escaped, "**", "strong")
|
||||||
|
escaped = replaceDelimited(escaped, "`", "code")
|
||||||
|
return escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceDelimited(value, marker, tag string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
for {
|
||||||
|
start := strings.Index(value, marker)
|
||||||
|
if start < 0 {
|
||||||
|
out.WriteString(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := strings.Index(value[start+len(marker):], marker)
|
||||||
|
if end < 0 {
|
||||||
|
out.WriteString(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end += start + len(marker)
|
||||||
|
out.WriteString(value[:start])
|
||||||
|
out.WriteString("<" + tag + ">")
|
||||||
|
out.WriteString(value[start+len(marker) : end])
|
||||||
|
out.WriteString("</" + tag + ">")
|
||||||
|
value = value[end+len(marker):]
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func leadingSpaceCount(value string) int {
|
||||||
|
count := 0
|
||||||
|
for _, ch := range value {
|
||||||
|
if ch != ' ' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DailyPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg := LoadConfig()
|
|
||||||
today := time.Now().Format("2006-01-02")
|
|
||||||
lastProject := html.EscapeString(cfg.DailyUpdate.LastProject)
|
|
||||||
|
|
||||||
body := `
|
|
||||||
<h2>Daily Update</h2>
|
|
||||||
<div class="hint" style="margin-bottom:12px;">Submit copies the formatted text to your clipboard.</div>
|
|
||||||
|
|
||||||
<div style="max-width:520px;">
|
|
||||||
<label style="display:block;margin-top:8px;font-size:12px;opacity:0.85;">Date</label>
|
|
||||||
<input type="date" id="date" value="` + today + `" style="width:100%;" />
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Status</label>
|
|
||||||
<select id="status" style="width:100%;">
|
|
||||||
<option>Started</option><option>Progress</option><option>Finished</option>
|
|
||||||
<option>Client Feedback</option><option>For OT</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Project</label>
|
|
||||||
<input type="text" id="project" placeholder="Project name" value="` + lastProject + `" style="width:100%;" />
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Remarks</label>
|
|
||||||
<textarea id="remarks" placeholder="What did you do?" style="width:100%;min-height:96px;"></textarea>
|
|
||||||
|
|
||||||
<button id="submit" style="margin-top:16px;">Submit</button>
|
|
||||||
<div id="status-msg" style="margin-top:12px;min-height:18px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.getElementById('submit').addEventListener('click', async () => {
|
|
||||||
const payload = {
|
|
||||||
date: document.getElementById('date').value,
|
|
||||||
status: document.getElementById('status').value,
|
|
||||||
project: document.getElementById('project').value.trim(),
|
|
||||||
remarks: document.getElementById('remarks').value.trim(),
|
|
||||||
};
|
|
||||||
const statusEl = document.getElementById('status-msg');
|
|
||||||
statusEl.textContent = 'Copying...';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/daily/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const j = await res.json();
|
|
||||||
if (j.ok) {
|
|
||||||
statusEl.innerHTML = '<span class="ok">Copied to clipboard.</span>';
|
|
||||||
} else {
|
|
||||||
statusEl.innerHTML = '<span class="err">' + (j.error || 'Failed') + '</span>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
`
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_, _ = w.Write([]byte(Page("/daily", "Daily Update", body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type dailySubmitReq struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Project string `json:"project"`
|
|
||||||
Remarks string `json:"remarks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func DailySubmit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req dailySubmitReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text := formatDailyMessage(req)
|
|
||||||
if err := CopyToClipboard(text); err != nil {
|
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := LoadConfig()
|
|
||||||
cfg.DailyUpdate.LastProject = req.Project
|
|
||||||
_ = SaveConfig(cfg)
|
|
||||||
writeJSON(w, map[string]any{"ok": true, "text": text})
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDailyMessage(p dailySubmitReq) string {
|
|
||||||
bullets := []string{}
|
|
||||||
for _, line := range strings.Split(p.Remarks, "\n") {
|
|
||||||
t := strings.TrimRight(line, "\r")
|
|
||||||
t = regexp.MustCompile(`^\s*[-*]\s*`).ReplaceAllString(t, "")
|
|
||||||
t = strings.TrimSpace(t)
|
|
||||||
if t != "" {
|
|
||||||
bullets = append(bullets, "- "+t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines := []string{
|
|
||||||
"Date: " + formatDailyDate(p.Date),
|
|
||||||
"Status: " + p.Status,
|
|
||||||
"Project: " + p.Project,
|
|
||||||
"Remarks:",
|
|
||||||
strings.Join(bullets, "\n"),
|
|
||||||
}
|
|
||||||
if p.Status == "For OT" {
|
|
||||||
lines = append(lines, "@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace")
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDailyDate(iso string) string {
|
|
||||||
t, err := time.Parse("2006-01-02", iso)
|
|
||||||
if err != nil {
|
|
||||||
return iso
|
|
||||||
}
|
|
||||||
return t.Format("01/02/2006")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -1,18 +1,55 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
func HomePage(w http.ResponseWriter, r *http.Request) {
|
func HomePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
var items strings.Builder
|
||||||
|
for _, n := range visibleNavItems(cfg.BetaToolsEnabled) {
|
||||||
|
if n.Path == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
badge := ""
|
||||||
|
if n.Beta {
|
||||||
|
badge = ` <span class="beta-pill">Beta</span>`
|
||||||
|
}
|
||||||
|
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 := `
|
||||||
<h2>HPL Toolbox</h2>
|
<header class="tool-header" style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;">
|
||||||
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
<div>
|
||||||
<ul style="line-height:1.9;">
|
<h2 class="tool-title">HPL Toolbox</h2>
|
||||||
<li><a href="/plec" style="color:#9cdcfe;">PLEC Upload</a> — Upload HTML to the internal PLEC server</li>
|
<p class="tool-description">Standalone build. Pick a tool to open.</p>
|
||||||
<li><a href="/applovin" style="color:#9cdcfe;">AppLovin Demo Upload</a> — Upload to p.applov.in (QR preview)</li>
|
</div>
|
||||||
<li><a href="/base64" style="color:#9cdcfe;">Base64 Scanner</a> — Find non-base64 assets in HTML</li>
|
<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>
|
||||||
<li><a href="/daily" style="color:#9cdcfe;">Daily Update</a> — Compose & copy a daily status</li>
|
</header>
|
||||||
<li><a href="/mobile" style="color:#9cdcfe;">Send To Mobile</a> — Share HTML to a phone via LAN + QR</li>
|
<div class="home-tools">
|
||||||
</ul>
|
` + items.String() + `
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.home-tools { display:flex; flex-direction:column; gap:8px; max-width:620px; }
|
||||||
|
.home-tool {
|
||||||
|
display:block;
|
||||||
|
width:100%;
|
||||||
|
min-height:52px;
|
||||||
|
padding:9px 11px;
|
||||||
|
color:#ddd;
|
||||||
|
text-align:left;
|
||||||
|
background:#2a2d2e;
|
||||||
|
border:1px solid #333;
|
||||||
|
border-radius:5px;
|
||||||
|
cursor:pointer;
|
||||||
|
font:inherit;
|
||||||
|
}
|
||||||
|
.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-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
||||||
|
</style>
|
||||||
`
|
`
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write([]byte(Page("/", "Home", body)))
|
_, _ = w.Write([]byte(Page("/", "Home", body)))
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
36
standalone/json.go
Normal file
36
standalone/json.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJSONStream(w http.ResponseWriter) func(any) {
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
return func(v any) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
data = []byte(fmt.Sprintf(`{"type":"error","message":%q}`, err.Error()))
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
_, _ = w.Write([]byte("\n"))
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralS(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
1049
standalone/layout.go
1049
standalone/layout.go
File diff suppressed because it is too large
Load Diff
@@ -107,27 +107,41 @@ 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 /daily", DailyPage)
|
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 /device-simulator", DeviceSimulatorPage)
|
||||||
|
mux.HandleFunc("GET /settings", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) })
|
||||||
|
mux.HandleFunc("GET /changelog", ChangelogPage)
|
||||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
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)
|
||||||
// Daily Update
|
mux.HandleFunc("GET /api/update/check", UpdateCheckEndpoint)
|
||||||
mux.HandleFunc("POST /api/daily/submit", DailySubmit)
|
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
|
||||||
|
|
||||||
// PLEC
|
// PLEC
|
||||||
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
||||||
|
mux.HandleFunc("POST /api/plec/pickFolder", PlecPickFolder)
|
||||||
mux.HandleFunc("POST /api/plec/upload", PlecUpload)
|
mux.HandleFunc("POST /api/plec/upload", PlecUpload)
|
||||||
|
|
||||||
// AppLovin
|
// AppLovin
|
||||||
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
|
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
|
||||||
|
mux.HandleFunc("POST /api/applovin/pickFolder", ApplovinPickFolder)
|
||||||
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
|
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
|
||||||
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
|
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
|
||||||
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
|
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
|
||||||
@@ -137,11 +151,40 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
||||||
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
||||||
|
|
||||||
|
// MRAID Checker
|
||||||
|
mux.HandleFunc("POST /api/mraid/pickFolder", MraidPickFolder)
|
||||||
|
mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles)
|
||||||
|
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/start", MobileStart)
|
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
||||||
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
||||||
|
|
||||||
|
// Playworks Converter
|
||||||
|
mux.HandleFunc("POST /api/playworks/pickSource", PlayworksPickSource)
|
||||||
|
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
||||||
|
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
|
||||||
@@ -2,10 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"net"
|
"net"
|
||||||
@@ -27,7 +27,11 @@ type activeShare struct {
|
|||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
token string
|
token string
|
||||||
fileBuf []byte
|
files []sharedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
type sharedFile struct {
|
||||||
|
buf []byte
|
||||||
filename string
|
filename string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,89 +58,198 @@ func MobileQrScript(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func MobilePage(w http.ResponseWriter, r *http.Request) {
|
func MobilePage(w http.ResponseWriter, r *http.Request) {
|
||||||
body := `
|
body := `
|
||||||
<h2>Send To Mobile</h2>
|
<header class="tool-header">
|
||||||
<p class="hint" style="margin-top:0;">
|
<h2 class="tool-title">Send To Mobile</h2>
|
||||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
<p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
|
||||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
</header>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style="margin-bottom:14px;">
|
<section class="tool-panel">
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||||
<button id="pick" class="secondary">Choose...</button>
|
<div class="panel-body">
|
||||||
<span id="fileName" class="hint">(no file)</span>
|
<div class="control-group">
|
||||||
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
|
<button id="pick" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="filePath" />
|
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||||
<div style="margin-bottom:14px;">
|
<div id="fileList" class="selected-list"></div>
|
||||||
<button id="start">Start sharing</button>
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="start">Start</button>
|
||||||
<button id="stop" class="secondary" disabled>Stop</button>
|
<button id="stop" class="secondary" disabled>Stop</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
|
|
||||||
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
|
||||||
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="shareInfo" style="display:none;">
|
|
||||||
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
|
||||||
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
|
|
||||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
|
||||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
|
||||||
<button id="openUrl" class="secondary">Open in browser</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" style="margin-top:8px;">
|
<div id="status" class="status-panel"></div>
|
||||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="ifaceWrap" class="control-group" style="display:none;margin-bottom:14px;">
|
||||||
|
<div class="control-label">Network Interfaces</div>
|
||||||
|
<div id="ifaceList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="shareInfo" class="result-card" style="display:none;">
|
||||||
|
<div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||||
|
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||||||
|
First run on Windows may show a firewall prompt - allow access for Private networks.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="status" style="margin-top:12px;min-height:18px;"></div>
|
|
||||||
|
|
||||||
<script src="/assets/qrcode.min.js"></script>
|
<script src="/assets/qrcode.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
const pickFolderBtn = document.getElementById('pickFolder');
|
||||||
const pickBtn = document.getElementById('pick');
|
const pickBtn = document.getElementById('pick');
|
||||||
|
const clearBtn = document.getElementById('clear');
|
||||||
const startBtn = document.getElementById('start');
|
const startBtn = document.getElementById('start');
|
||||||
const stopBtn = document.getElementById('stop');
|
const stopBtn = document.getElementById('stop');
|
||||||
const fileNameEl = document.getElementById('fileName');
|
const fileNameEl = document.getElementById('fileName');
|
||||||
const filePathEl = document.getElementById('filePath');
|
const fileListEl = document.getElementById('fileList');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const shareInfo = document.getElementById('shareInfo');
|
const shareInfo = document.getElementById('shareInfo');
|
||||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||||
const ifaceList = document.getElementById('ifaceList');
|
const ifaceList = document.getElementById('ifaceList');
|
||||||
const qrEl = document.getElementById('qr');
|
const qrEl = document.getElementById('qr');
|
||||||
const urlTextEl = document.getElementById('urlText');
|
|
||||||
|
|
||||||
let currentUrls = [];
|
let currentUrls = [];
|
||||||
let selectedIdx = 0;
|
let selectedIdx = 0;
|
||||||
|
let selectedFiles = [];
|
||||||
|
let selectedPaths = [];
|
||||||
|
let selectedPage = 0;
|
||||||
|
|
||||||
|
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
if (!selectedFiles.length) {
|
||||||
|
fileNameEl.textContent = '(no files selected)';
|
||||||
|
fileListEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||||
|
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const start = selectedPage * PAGE_SIZE;
|
||||||
|
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
|
||||||
|
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||||
|
visible.map((file, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||||
|
}).join('') + '</tbody></table></div>';
|
||||||
|
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const index = Number(btn.dataset.index);
|
||||||
|
selectedFiles.splice(index, 1);
|
||||||
|
selectedPaths.splice(index, 1);
|
||||||
|
renderSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (selectedFiles.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
fileListEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addFiles(files) {
|
||||||
|
const existing = new Set(selectedFiles.map(f => f.path));
|
||||||
|
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
|
||||||
|
selectedPaths = selectedFiles.map(f => f.path);
|
||||||
|
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
async function readJSONStream(response, handle) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newline;
|
||||||
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, newline);
|
||||||
|
buffer = buffer.slice(newline + 1);
|
||||||
|
if (line.trim()) handle(JSON.parse(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||||
|
}
|
||||||
|
renderSelection();
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
addFiles(j.files || []);
|
||||||
|
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||||
|
});
|
||||||
|
|
||||||
|
pickFolderBtn.addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
addFiles(j.files || []);
|
||||||
|
});
|
||||||
pickBtn.addEventListener('click', async () => {
|
pickBtn.addEventListener('click', async () => {
|
||||||
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.path) {
|
addFiles(j.files || []);
|
||||||
filePathEl.value = j.path;
|
});
|
||||||
fileNameEl.textContent = j.name;
|
clearBtn.addEventListener('click', () => {
|
||||||
}
|
selectedFiles = [];
|
||||||
|
selectedPaths = [];
|
||||||
|
selectedPage = 0;
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
renderSelection();
|
||||||
});
|
});
|
||||||
|
|
||||||
startBtn.addEventListener('click', async () => {
|
startBtn.addEventListener('click', async () => {
|
||||||
statusEl.innerHTML = '';
|
statusEl.innerHTML = '';
|
||||||
if (!filePathEl.value) {
|
if (!selectedPaths.length) {
|
||||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startBtn.disabled = true;
|
startBtn.disabled = true;
|
||||||
statusEl.textContent = 'Starting server...';
|
statusEl.textContent = 'Starting server...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
let j = null;
|
||||||
|
try {
|
||||||
const r = await fetch('/api/mobile/start', {
|
const r = await fetch('/api/mobile/start', {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ path: filePathEl.value }),
|
body: JSON.stringify({ paths: selectedPaths }),
|
||||||
});
|
});
|
||||||
const j = await r.json();
|
await readJSONStream(r, (msg) => {
|
||||||
|
if (msg.type === 'status') {
|
||||||
|
statusEl.textContent = msg.message;
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
} else if (msg.type === 'done') {
|
||||||
|
j = msg;
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
j = { error: msg.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||||
|
startBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; startBtn.disabled = false; return; }
|
||||||
if (j.error) {
|
if (j.error) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentUrls = j.urls;
|
currentUrls = j.urls;
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
selectedIdx = 0;
|
selectedIdx = 0;
|
||||||
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
|
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
|
||||||
shareInfo.style.display = '';
|
shareInfo.style.display = '';
|
||||||
@@ -147,6 +260,7 @@ startBtn.addEventListener('click', async () => {
|
|||||||
|
|
||||||
stopBtn.addEventListener('click', async () => {
|
stopBtn.addEventListener('click', async () => {
|
||||||
await fetch('/api/mobile/stop', { method:'POST' });
|
await fetch('/api/mobile/stop', { method:'POST' });
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.textContent = 'Stopped.';
|
statusEl.textContent = 'Stopped.';
|
||||||
shareInfo.style.display = 'none';
|
shareInfo.style.display = 'none';
|
||||||
currentUrls = [];
|
currentUrls = [];
|
||||||
@@ -154,18 +268,8 @@ stopBtn.addEventListener('click', async () => {
|
|||||||
stopBtn.disabled = true;
|
stopBtn.disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
|
||||||
const url = currentUrls[selectedIdx]?.url || '';
|
|
||||||
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
|
|
||||||
});
|
|
||||||
document.getElementById('openUrl').addEventListener('click', () => {
|
|
||||||
const url = currentUrls[selectedIdx]?.url || '';
|
|
||||||
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderQr(url) {
|
function renderQr(url) {
|
||||||
qrEl.innerHTML = '';
|
qrEl.innerHTML = '';
|
||||||
urlTextEl.textContent = url;
|
|
||||||
try {
|
try {
|
||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
qr.addData(url);
|
qr.addData(url);
|
||||||
@@ -182,19 +286,18 @@ function renderIfaces(urls) {
|
|||||||
ifaceList.innerHTML = '';
|
ifaceList.innerHTML = '';
|
||||||
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
||||||
ifaceWrap.style.display = '';
|
ifaceWrap.style.display = '';
|
||||||
|
ifaceList.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table"><thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead><tbody></tbody></table></div>';
|
||||||
|
const tbody = ifaceList.querySelector('tbody');
|
||||||
urls.forEach((u, i) => {
|
urls.forEach((u, i) => {
|
||||||
const id = 'iface_' + i;
|
const id = 'iface_' + i;
|
||||||
const label = document.createElement('label');
|
const row = document.createElement('tr');
|
||||||
label.style.fontSize = '12px';
|
row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||||
label.style.cursor = 'pointer';
|
'<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
|
||||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
row.querySelector('input').addEventListener('change', () => {
|
||||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
|
||||||
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
|
|
||||||
label.querySelector('input').addEventListener('change', () => {
|
|
||||||
selectedIdx = i;
|
selectedIdx = i;
|
||||||
renderQr(urls[i].url);
|
renderQr(urls[i].url);
|
||||||
});
|
});
|
||||||
ifaceList.appendChild(label);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -207,48 +310,81 @@ func MobilePick(w http.ResponseWriter, r *http.Request) {
|
|||||||
picked := PickFiles(
|
picked := PickFiles(
|
||||||
"Select HTML",
|
"Select HTML",
|
||||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||||
false, "",
|
true, "",
|
||||||
)
|
)
|
||||||
if len(picked) == 0 {
|
if len(picked) == 0 {
|
||||||
writeJSON(w, map[string]any{"path": nil})
|
writeJSON(w, map[string]any{"files": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
|
files := make([]map[string]string, 0, len(picked))
|
||||||
|
for _, p := range picked {
|
||||||
|
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"files": files})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MobilePickFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dir := PickFolder("Select folder", "")
|
||||||
|
if dir == "" {
|
||||||
|
writeJSON(w, map[string]any{"files": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paths := collectHTMLFiles(dir)
|
||||||
|
files := make([]map[string]string, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"files": files})
|
||||||
}
|
}
|
||||||
|
|
||||||
func MobileStart(w http.ResponseWriter, r *http.Request) {
|
func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||||
stopActiveShare()
|
stopActiveShare()
|
||||||
|
send := newJSONStream(w)
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Paths []string `json:"paths"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Path == "" || !fileExists(req.Path) {
|
paths := req.Paths
|
||||||
writeJSON(w, map[string]any{"error": "File missing."})
|
if len(paths) == 0 && req.Path != "" {
|
||||||
|
paths = []string{req.Path}
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
send(map[string]any{"type": "error", "message": "File missing."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fileBuf, err := os.ReadFile(req.Path)
|
files := make([]sharedFile, 0, len(paths))
|
||||||
|
for i, p := range paths {
|
||||||
|
if p == "" || !fileExists(p) {
|
||||||
|
send(map[string]any{"type": "error", "message": "File missing."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Loading %d/%d %s", i+1, len(paths), filepath.Base(p))})
|
||||||
|
fileBuf, err := os.ReadFile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
filename := filepath.Base(req.Path)
|
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
|
||||||
|
}
|
||||||
|
|
||||||
// 6 random bytes -> base64url, no padding
|
// 6 random bytes -> base64url, no padding
|
||||||
tokenBytes := make([]byte, 6)
|
tokenBytes := make([]byte, 6)
|
||||||
if _, err := rand.Read(tokenBytes); err != nil {
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()})
|
send(map[string]any{"type": "error", "message": "Token gen failed: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
|
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
|
||||||
|
|
||||||
cfgPort := LoadConfig().SendToMobile.Port
|
cfgPort := LoadConfig().SendToMobile.Port
|
||||||
|
send(map[string]any{"type": "status", "message": "Starting server..."})
|
||||||
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
|
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()})
|
send(map[string]any{"type": "error", "message": "Failed to bind: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
port := listener.Addr().(*net.TCPAddr).Port
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
@@ -257,8 +393,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
listener: listener,
|
listener: listener,
|
||||||
port: port,
|
port: port,
|
||||||
token: token,
|
token: token,
|
||||||
fileBuf: fileBuf,
|
files: files,
|
||||||
filename: filename,
|
|
||||||
}
|
}
|
||||||
share.server = &http.Server{Handler: shareHandler(share)}
|
share.server = &http.Server{Handler: shareHandler(share)}
|
||||||
|
|
||||||
@@ -267,7 +402,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
ips := getLanIPs()
|
ips := getLanIPs()
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
_ = share.server.Close()
|
_ = share.server.Close()
|
||||||
writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"})
|
send(map[string]any{"type": "error", "message": "No non-loopback IPv4 interface found. Are you on a network?"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +423,11 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
|
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
|
filename := fmt.Sprintf("%d files", len(files))
|
||||||
|
if len(files) == 1 {
|
||||||
|
filename = files[0].filename
|
||||||
|
}
|
||||||
|
send(map[string]any{"type": "done", "urls": urls, "filename": filename})
|
||||||
}
|
}
|
||||||
|
|
||||||
func MobileStop(w http.ResponseWriter, r *http.Request) {
|
func MobileStop(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -308,30 +447,99 @@ func shareHandler(s *activeShare) http.Handler {
|
|||||||
case "", "/":
|
case "", "/":
|
||||||
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([]byte(chooserPage(s.filename)))
|
_, _ = w.Write([]byte(chooserPage(s.files)))
|
||||||
case "/view":
|
case "/view":
|
||||||
|
fallthrough
|
||||||
|
case "/view/0":
|
||||||
|
if len(s.files) == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
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.fileBuf)
|
_, _ = w.Write([]byte(mobilePreviewPage(s.files[0])))
|
||||||
case "/file":
|
case "/file":
|
||||||
safeName := safeFilename(s.filename)
|
fallthrough
|
||||||
|
case "/file/0":
|
||||||
|
if len(s.files) == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
safeName := safeFilename(s.files[0].filename)
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf)))
|
w.Header().Set("Content-Length", strconv.Itoa(len(s.files[0].buf)))
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(s.fileBuf)
|
_, _ = w.Write(s.files[0].buf)
|
||||||
default:
|
default:
|
||||||
|
if strings.HasPrefix(rest, "/view/") || strings.HasPrefix(rest, "/file/") {
|
||||||
|
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
idx, err := strconv.Atoi(parts[1])
|
||||||
|
if err == nil && idx >= 0 && idx < len(s.files) {
|
||||||
|
file := s.files[idx]
|
||||||
|
if parts[0] == "view" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(mobilePreviewPage(file)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
safeName := safeFilename(file.filename)
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(file.buf)))
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write(file.buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, "_") }
|
||||||
|
|
||||||
func chooserPage(filename string) string {
|
func chooserPage(files []sharedFile) string {
|
||||||
escName := html.EscapeString(filename)
|
var items strings.Builder
|
||||||
|
for i, file := range files {
|
||||||
|
escName := html.EscapeString(file.filename)
|
||||||
|
items.WriteString(`<div class="item"><div class="file">` + escName + `</div>
|
||||||
|
<a class="btn view" href="view/` + strconv.Itoa(i) + `">View in browser</a>
|
||||||
|
<a class="btn dl" href="file/` + strconv.Itoa(i) + `" download="` + escName + `">Download .html</a></div>`)
|
||||||
|
}
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html><head>
|
<html><head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -341,7 +549,8 @@ func chooserPage(filename string) string {
|
|||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
.item { margin-bottom: 26px; }
|
||||||
|
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
|
||||||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||||
a.view { background: #2d7dff; color: white; }
|
a.view { background: #2d7dff; color: white; }
|
||||||
@@ -349,9 +558,7 @@ func chooserPage(filename string) string {
|
|||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<h1>HPL Toolbox</h1>
|
<h1>HPL Toolbox</h1>
|
||||||
<div class="file">` + escName + `</div>
|
` + items.String() + `
|
||||||
<a class="btn view" href="view">View in browser</a>
|
|
||||||
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
|
|
||||||
</body></html>`
|
</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
725
standalone/mraid.go
Normal file
725
standalone/mraid.go
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mraidIssue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidResult struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Issues []mraidIssue `json:"issues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidRule struct {
|
||||||
|
ID string
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
Reference string
|
||||||
|
Test func(mraidContext, mraidRule) *mraidIssue
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidContext struct {
|
||||||
|
FilePath string
|
||||||
|
HTML string
|
||||||
|
ScriptText string
|
||||||
|
MraidCallLines map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">MRAID Checker</h2>
|
||||||
|
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity HTML builds based on GUIDE.md plus the MRAID 3.0 specification and Best Practices Guide.</p>
|
||||||
|
</header>
|
||||||
|
<section class="tool-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
|
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||||
|
</div>
|
||||||
|
<div id="selection" class="selected-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row"><button id="scan">Scan</button></div>
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="results" class="results-panel"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-result { padding:8px 0; border-bottom:1px solid #333; }
|
||||||
|
.file-head { display:flex; gap:8px; align-items:baseline; font-family:Consolas, monospace; font-size:12px; }
|
||||||
|
.mark { width:20px; flex-shrink:0; font-weight:bold; }
|
||||||
|
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||||
|
.file-name { word-break:break-all; }
|
||||||
|
.counts { opacity:0.7; margin-left:auto; white-space:nowrap; }
|
||||||
|
.issues { margin:6px 0 0 28px; padding:0; }
|
||||||
|
.issue { list-style:none; margin:6px 0; }
|
||||||
|
.badge { display:inline-block; min-width:82px; margin-right:6px; padding:1px 5px; border-radius:3px; font-size:11px; text-align:center; }
|
||||||
|
.requirement { color:#ffb4ad; background:rgba(248,81,73,0.18); }
|
||||||
|
.best-practice { color:#ffd580; background:rgba(210,153,34,0.18); }
|
||||||
|
.issue-title { font-weight:600; }
|
||||||
|
.issue-detail { opacity:0.72; margin-top:2px; }
|
||||||
|
.summary { opacity:0.85; margin-bottom:6px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const folderEl = document.getElementById('folder');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
const selectionEl = document.getElementById('selection');
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
let pickedFiles = [];
|
||||||
|
let selectedPage = 0;
|
||||||
|
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); }
|
||||||
|
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
|
||||||
|
function renderSelection() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
if (!pickedFiles.length) {
|
||||||
|
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
selectedPage = Math.min(selectedPage, maxPage);
|
||||||
|
const start = selectedPage * PAGE_SIZE;
|
||||||
|
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
|
||||||
|
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||||
|
visible.map((p, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return '<tr><td class="mono wrap">' + escapeText(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||||
|
}).join('') + '</tbody></table></div>';
|
||||||
|
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||||
|
});
|
||||||
|
if (pickedFiles.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
selectionEl.appendChild(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addPaths(paths) {
|
||||||
|
const existing = new Set(pickedFiles);
|
||||||
|
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
|
||||||
|
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
async function readJSONStream(response, handle) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newline;
|
||||||
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, newline);
|
||||||
|
buffer = buffer.slice(newline + 1);
|
||||||
|
if (line.trim()) handle(JSON.parse(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||||
|
}
|
||||||
|
renderSelection();
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
addPaths(j.paths || []);
|
||||||
|
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
addPaths(j.paths || []);
|
||||||
|
});
|
||||||
|
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
addPaths(j.paths || []);
|
||||||
|
});
|
||||||
|
document.getElementById('clear').addEventListener('click', () => {
|
||||||
|
pickedFiles = [];
|
||||||
|
selectedPage = 0;
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
renderSelection();
|
||||||
|
});
|
||||||
|
document.getElementById('scan').addEventListener('click', async () => {
|
||||||
|
statusEl.textContent = 'Scanning...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
|
||||||
|
const payload = { files: pickedFiles };
|
||||||
|
let j = null;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||||
|
await readJSONStream(r, (msg) => {
|
||||||
|
if (msg.type === 'status') {
|
||||||
|
statusEl.textContent = msg.message;
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
} else if (msg.type === 'done') {
|
||||||
|
j = msg;
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
j = { error: msg.message, results: msg.results || [], skipped: msg.skipped, skippedReason: msg.skippedReason };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = 'Error: ' + (e.message || e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
|
||||||
|
if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
|
||||||
|
const total = j.results.length;
|
||||||
|
const withIssues = j.results.filter(r => !r.ok).length;
|
||||||
|
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||||
|
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||||
|
const skipped = Number(j.skipped || 0);
|
||||||
|
const skippedReason = j.skippedReason || 'file(s).';
|
||||||
|
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||||
|
if (!total) { resultsEl.innerHTML = ''; return; }
|
||||||
|
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
|
||||||
|
const tbody = resultsEl.querySelector('tbody');
|
||||||
|
for (const rr of j.results) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const file = document.createElement('td');
|
||||||
|
file.className = 'mono wrap';
|
||||||
|
file.textContent = rr.file;
|
||||||
|
const result = document.createElement('td');
|
||||||
|
result.className = 'wrap';
|
||||||
|
if (!rr.ok) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'issues';
|
||||||
|
for (const i of rr.issues) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'issue';
|
||||||
|
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||||
|
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
result.appendChild(list);
|
||||||
|
} else {
|
||||||
|
result.innerHTML = '<span class="badge ok">OK</span>';
|
||||||
|
}
|
||||||
|
row.append(file, result);
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/mraid", "MRAID Checker", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := PickFolder("Select folder to scan", "")
|
||||||
|
if p == "" {
|
||||||
|
writeJSON(w, map[string]any{"paths": []string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files := []string{}
|
||||||
|
for _, f := range collectHTMLFiles(p) {
|
||||||
|
if isInSupportedMraidFolderPath(f) {
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"paths": files})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := PickFiles("Select HTML files", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, true, "")
|
||||||
|
writeJSON(w, map[string]any{"paths": paths})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
send := newJSONStream(w)
|
||||||
|
var req struct {
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": err.Error(), "results": []mraidResult{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []string
|
||||||
|
var baseDir string
|
||||||
|
skipped := 0
|
||||||
|
skippedReason := ""
|
||||||
|
noTargetsError := "No HTML files were found."
|
||||||
|
if len(req.Files) > 0 {
|
||||||
|
var existing []string
|
||||||
|
for _, f := range req.Files {
|
||||||
|
if fileExists(f) {
|
||||||
|
existing = append(existing, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets = existing
|
||||||
|
skipped = len(req.Files) - len(existing)
|
||||||
|
skippedReason = "missing file(s)."
|
||||||
|
noTargetsError = "No selected HTML files were found."
|
||||||
|
if len(targets) > 0 {
|
||||||
|
baseDir = commonBaseDir(targets)
|
||||||
|
}
|
||||||
|
} else if req.Folder != "" && fileExists(req.Folder) {
|
||||||
|
all := collectHTMLFiles(req.Folder)
|
||||||
|
for _, f := range all {
|
||||||
|
if isInSupportedMraidFolderPath(f) {
|
||||||
|
targets = append(targets, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipped = len(all) - len(targets)
|
||||||
|
skippedReason = "HTML file(s) outside AppLovin/ironSource/Unity folders."
|
||||||
|
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
|
||||||
|
baseDir = req.Folder
|
||||||
|
} else {
|
||||||
|
send(map[string]any{"type": "error", "message": "Pick a folder or files first", "results": []mraidResult{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
send(map[string]any{"type": "error", "message": noTargetsError, "results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []mraidResult{}
|
||||||
|
for i, file := range targets {
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
|
||||||
|
data, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
issues := checkMraid(string(data), file)
|
||||||
|
display := file
|
||||||
|
if baseDir != "" {
|
||||||
|
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
|
||||||
|
display = rel
|
||||||
|
} else {
|
||||||
|
display = filepath.Base(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
|
||||||
|
}
|
||||||
|
send(map[string]any{"type": "done", "results": results, "skipped": skipped, "skippedReason": skippedReason})
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||||
|
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||||
|
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||||
|
mraidJsRx = regexp.MustCompile(`(?i)\bmraid\.js\b`)
|
||||||
|
mraidViewportRx = regexp.MustCompile(`(?i)<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>`)
|
||||||
|
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||||
|
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||||
|
mraidHyperlinkRx = regexp.MustCompile(`(?i)<a\b[^>]*\bhref\s*=`)
|
||||||
|
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||||
|
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||||
|
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||||
|
mraidDomReadyRx = regexp.MustCompile(`(?i)document\s*\.\s*readyState|DOMContentLoaded|\bwindow\s*\.\s*(addEventListener\s*\(\s*['"]load['"]|onload\b)`)
|
||||||
|
mraidTypeofGuardRx = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*!={1,2}\s*['"]undefined['"]`)
|
||||||
|
mraidReadyTimeoutRx = regexp.MustCompile(`(?is)setTimeout\s*\(.*mraid|mraid.*setTimeout\s*\(`)
|
||||||
|
mraidLateDetectionRx = regexp.MustCompile(`(?i)(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\().*(window\s*\.\s*mraid|\bmraid\b)|(window\s*\.\s*mraid|\bmraid\b).*(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\()`)
|
||||||
|
mraidOpenGuardRx = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*!={1,2}\s*['"]undefined['"]`)
|
||||||
|
mraidOpenLoadingGuardRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*mraid\s*\.\s*getState\s*\(\s*\)|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*getState\s*!={1,2}\s*['"]function['"]`)
|
||||||
|
mraidVariableLoadingGuardRx = regexp.MustCompile(`(?i)\b\w+\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*\w+\b`)
|
||||||
|
mraidLoadingFallbackRx = regexp.MustCompile(`(?is)\b\w+\s*={2,3}\s*['"]loading['"].{0,500}\bwindow\s*\.\s*open\s*\(`)
|
||||||
|
mraidAudioSafeRx = regexp.MustCompile(`(?i)typeof\s+\w+\s*={2,3}\s*['"]number['"]|!=\s*null|!==\s*null`)
|
||||||
|
mraidTwoPartExpandRx = regexp.MustCompile(`(?i)mraid\s*\.\s*expand\s*\(\s*[^)\s]`)
|
||||||
|
mraidVideoOpenRx = regexp.MustCompile(`(?i)mraid\s*\.\s*open\s*\(\s*[^)]*\.(mp4|m4v|mov|webm|avi|m3u8)(['"` + "`" + `?#)]|$)`)
|
||||||
|
mraidRejectedScriptAttrsRx = regexp.MustCompile(`(?i)<script\b[^>]*(\btype\s*=\s*['"]module['"]|\bcrossorigin\b)`)
|
||||||
|
mraidConsoleErrorRx = regexp.MustCompile(`(?i)\bconsole\s*\.\s*error\s*\(`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const mraidReference = "GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide."
|
||||||
|
|
||||||
|
func isInSupportedMraidFolderPath(filePath string) bool {
|
||||||
|
supported := map[string]bool{
|
||||||
|
"al": true,
|
||||||
|
"applovin": true,
|
||||||
|
"app-lovin": true,
|
||||||
|
"is": true,
|
||||||
|
"ironsource": true,
|
||||||
|
"iron-source": true,
|
||||||
|
"iron_source": true,
|
||||||
|
"un": true,
|
||||||
|
"unity": true,
|
||||||
|
"unityads": true,
|
||||||
|
"unity-ads": true,
|
||||||
|
"unity_ads": true,
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(strings.ToLower(filepath.Dir(filePath)), func(r rune) bool {
|
||||||
|
return r == '/' || r == '\\'
|
||||||
|
})
|
||||||
|
for _, part := range parts {
|
||||||
|
if supported[part] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMraid(htmlSrc string, filePath ...string) []mraidIssue {
|
||||||
|
sourcePath := ""
|
||||||
|
if len(filePath) > 0 {
|
||||||
|
sourcePath = filePath[0]
|
||||||
|
}
|
||||||
|
ctx := mraidContext{
|
||||||
|
FilePath: sourcePath,
|
||||||
|
HTML: htmlSrc,
|
||||||
|
ScriptText: extractMraidScriptText(htmlSrc),
|
||||||
|
MraidCallLines: collectMraidCallLines(htmlSrc),
|
||||||
|
}
|
||||||
|
var out []mraidIssue
|
||||||
|
for _, rule := range mraidRules {
|
||||||
|
if found := rule.Test(ctx, rule); found != nil {
|
||||||
|
out = append(out, *found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []mraidIssue{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMraidScriptText(htmlSrc string) string {
|
||||||
|
var parts []string
|
||||||
|
for _, m := range mraidScriptRx.FindAllStringSubmatch(htmlSrc, -1) {
|
||||||
|
parts = append(parts, m[1])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMraidCallLines(htmlSrc string) map[string]int {
|
||||||
|
out := map[string]int{}
|
||||||
|
for idx, line := range strings.Split(htmlSrc, "\n") {
|
||||||
|
for _, m := range mraidCallRx.FindAllStringSubmatch(line, -1) {
|
||||||
|
if _, ok := out[m[1]]; !ok {
|
||||||
|
out[m[1]] = idx + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
|
||||||
|
func mraidCountJSReferences(ctx mraidContext) int {
|
||||||
|
return len(mraidJsRx.FindAllStringIndex(ctx.HTML, -1))
|
||||||
|
}
|
||||||
|
func mraidHasViewport(ctx mraidContext) bool { return mraidViewportRx.MatchString(ctx.HTML) }
|
||||||
|
func mraidIsNetworkTarget(ctx mraidContext) bool {
|
||||||
|
if ctx.FilePath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
name := strings.ToLower(filepath.Base(ctx.FilePath))
|
||||||
|
return isInSupportedMraidFolderPath(ctx.FilePath) ||
|
||||||
|
strings.HasSuffix(name, "_al.html") || strings.HasSuffix(name, "_is.html") || strings.HasSuffix(name, "_un.html") ||
|
||||||
|
strings.HasSuffix(name, "_al.htm") || strings.HasSuffix(name, "_is.htm") || strings.HasSuffix(name, "_un.htm")
|
||||||
|
}
|
||||||
|
func mraidHasCall(ctx mraidContext, method string) bool {
|
||||||
|
_, ok := ctx.MraidCallLines[method]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
|
||||||
|
|
||||||
|
func mraidHasFunctionGuard(ctx mraidContext, method string) bool {
|
||||||
|
escaped := regexp.QuoteMeta(method)
|
||||||
|
rx := regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*!={1,2}\s*['"]undefined['"]`)
|
||||||
|
return rx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
|
||||||
|
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
|
||||||
|
return rx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasSupportCheck(ctx mraidContext, feature string) bool {
|
||||||
|
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*supports\s*\(\s*['"]` + regexp.QuoteMeta(feature) + `['"]`)
|
||||||
|
return rx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasReadyGate(ctx mraidContext) bool {
|
||||||
|
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasDomReadyGate(ctx mraidContext) bool { return mraidDomReadyRx.MatchString(ctx.ScriptText) }
|
||||||
|
func mraidHasReadyStateFallback(ctx mraidContext) bool {
|
||||||
|
return mraidHasEventListener(ctx, "ready") && mraidHasCall(ctx, "getState")
|
||||||
|
}
|
||||||
|
func mraidHasTypeofGuard(ctx mraidContext) bool {
|
||||||
|
return mraidTypeofGuardRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
func mraidHasReadyTimeout(ctx mraidContext) bool {
|
||||||
|
return mraidReadyTimeoutRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
func mraidHasLateDetection(ctx mraidContext) bool {
|
||||||
|
return mraidLateDetectionRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
func mraidHasOpenGuard(ctx mraidContext) bool { return mraidOpenGuardRx.MatchString(ctx.ScriptText) }
|
||||||
|
func mraidHasOpenLoadingGuard(ctx mraidContext) bool {
|
||||||
|
return mraidOpenLoadingGuardRx.MatchString(ctx.ScriptText) ||
|
||||||
|
(mraidHasCall(ctx, "getState") && mraidVariableLoadingGuardRx.MatchString(ctx.ScriptText)) ||
|
||||||
|
(mraidHasCall(ctx, "getState") && mraidLoadingFallbackRx.MatchString(ctx.ScriptText))
|
||||||
|
}
|
||||||
|
func mraidUsesBrowserNavigation(ctx mraidContext) bool {
|
||||||
|
return mraidWindowOpenRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
func mraidUsesLocationNavigation(ctx mraidContext) bool {
|
||||||
|
return mraidLocationNavRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
func mraidHasAudioVolumeSafeHandling(ctx mraidContext) bool {
|
||||||
|
return mraidHasEventListener(ctx, "audioVolumeChange") && mraidAudioSafeRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
func mraidHasLifecycleSignal(ctx mraidContext, name string) bool {
|
||||||
|
return regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\b`).MatchString(ctx.HTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||||
|
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
|
||||||
|
if line > 0 {
|
||||||
|
found.Line = line
|
||||||
|
}
|
||||||
|
return &found
|
||||||
|
}
|
||||||
|
|
||||||
|
var mraidRules = []mraidRule{
|
||||||
|
{ID: "mraid-js-missing", Severity: "requirement", Title: "mraid.js reference not found", Detail: "MRAID creatives for AppLovin, ironSource, and Unity must request mraid.js early in the generated HTML.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidIsNetworkTarget(ctx) && mraidCountJSReferences(ctx) == 0 {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "mraid-js-duplicate", Severity: "requirement", Title: "Multiple mraid.js references found", Detail: "Request mraid.js exactly once; duplicate references can inject multiple MRAID libraries and slow or destabilize the ad.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidCountJSReferences(ctx) > 1 {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "mraid-reference", Severity: "requirement", Title: "MRAID API reference not found", Detail: "Playable HTML should use the injected mraid object when it depends on an MRAID container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasReference(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "viewport-meta", Severity: "best-practice", Title: "Viewport meta tag not found", Detail: "MRAID creatives should include a mobile viewport meta tag so the ad scales predictably on mobile WebViews.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasViewport(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "mraid-typeof-guard", Severity: "requirement", Title: "MRAID object is not guarded", Detail: "Guard MRAID access with typeof mraid !== \"undefined\" or an equivalent window.mraid guard before making MRAID calls.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasReference(ctx) && !mraidHasTypeofGuard(ctx) {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "dom-ready-gate", Severity: "requirement", Title: "DOM readiness is not guarded", Detail: "MRAID startup should wait for DOM readiness as well as MRAID readiness before building or binding rich media content.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasDomReadyGate(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "ready-gate", Severity: "requirement", Title: "MRAID ready state is not guarded", Detail: "Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "ready-state-fallback", Severity: "requirement", Title: "MRAID ready listener/state fallback pair not found", Detail: "Use both mraid.addEventListener(\"ready\", ...) and mraid.getState() so startup works when ready fires before the listener attaches.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasReadyStateFallback(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "ready-timeout-fallback", Severity: "best-practice", Title: "MRAID ready timeout fallback not found", Detail: "A timeout fallback prevents startup hangs when a container never fires ready.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasReadyTimeout(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "late-mraid-detection", Severity: "best-practice", Title: "Late MRAID injection detection not found", Detail: "Poll or retry briefly for late window.mraid injection before deciding the creative is running without MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasLateDetection(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "error-listener", Severity: "best-practice", Title: "No MRAID error listener found", Detail: "Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "viewability", Severity: "best-practice", Title: "MRAID viewability fallback chain not found", Detail: "Use exposureChange for MRAID 3.0 and keep viewableChange or isViewable() as MRAID 2.0 fallback before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
hasVisibility := mraidHasEventListener(ctx, "exposureChange") && (mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange"))
|
||||||
|
if !mraidHasReference(ctx) || hasVisibility {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "audio-volume-change", Severity: "best-practice", Title: "audioVolumeChange handling not found", Detail: "Listen for audioVolumeChange and ignore null values; only apply volume math when the value is numeric.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if !mraidHasReference(ctx) || mraidHasAudioVolumeSafeHandling(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}},
|
||||||
|
{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container; avoid hyperlinks, location redirects, and unguarded browser navigation.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHyperlinkRx.MatchString(ctx.HTML) || mraidUsesLocationNavigation(ctx) || (mraidUsesBrowserNavigation(ctx) && !mraidHasCall(ctx, "open")) {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "open-guarded-fallback", Severity: "requirement", Title: "mraid.open guarded fallback not found", Detail: "Guard mraid.open with function/state checks and provide a window.open fallback when MRAID is unavailable or still loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "open") && (!mraidHasOpenGuard(ctx) || !mraidHasOpenLoadingGuard(ctx) || !mraidUsesBrowserNavigation(ctx)) {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "open"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "open-video-url", Severity: "best-practice", Title: "mraid.open appears to target video media", Detail: "Use mraid.playVideo(url) for native video playback cases instead of mraid.open(videoUrl).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidVideoOpenRx.MatchString(ctx.ScriptText) && !mraidHasCall(ctx, "playVideo") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "open"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "close-api", Severity: "best-practice", Title: "No mraid.close call found", Detail: "If the creative renders its own close control, wire that control to mraid.close().", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "unload-fallback", Severity: "best-practice", Title: "No mraid.unload graceful failure path found", Detail: "Use mraid.unload() when the ad cannot run or refuses to show, instead of leaving a broken or blank creative.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasReference(ctx) && !mraidHasCall(ctx, "unload") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "resize-properties", Severity: "requirement", Title: "mraid.resize used without setResizeProperties", Detail: "Call mraid.setResizeProperties(...) before mraid.resize(); otherwise the container should emit an error.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "resize") && !mraidHasCall(ctx, "setResizeProperties") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "use-custom-close", Severity: "best-practice", Title: "Deprecated useCustomClose found", Detail: "MRAID 3.0 ignores useCustomClose; the host provides the mandatory close control.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "useCustomClose") || regexp.MustCompile(`(?i)useCustomClose`).MatchString(ctx.ScriptText) {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "useCustomClose"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "two-part-expand", Severity: "best-practice", Title: "Two-part expand appears to be used", Detail: "Two-part expandable ads are deprecated in MRAID 3.0; use self-contained one-part expandables.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidTwoPartExpandRx.MatchString(ctx.ScriptText) {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without function guard", Detail: "Guard mraid.expand with a function check before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "expand") && !mraidHasFunctionGuard(ctx, "expand") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without function guard", Detail: "Guard mraid.resize with a function check before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "resize") && !mraidHasFunctionGuard(ctx, "resize") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "store-picture-support", Severity: "requirement", Title: "storePicture used without supports check", Detail: "Call mraid.supports(\"storePicture\") before mraid.storePicture(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "storePicture") && !mraidHasSupportCheck(ctx, "storePicture") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "storePicture"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "calendar-support", Severity: "requirement", Title: "createCalendarEvent used without supports check", Detail: "Call mraid.supports(\"calendar\") before mraid.createCalendarEvent(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "createCalendarEvent") && !mraidHasSupportCheck(ctx, "calendar") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "createCalendarEvent"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "location-support", Severity: "requirement", Title: "getLocation used without supports check", Detail: "Call mraid.supports(\"location\") before mraid.getLocation(). Do not use HTML5 geolocation as a substitute in MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "getLocation") && !mraidHasSupportCheck(ctx, "location") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "getLocation"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "vpaid-support", Severity: "requirement", Title: "VPAID integration used without supports check", Detail: "Call mraid.supports(\"vpaid\") before mraid.initVpaid(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "initVpaid") && !mraidHasSupportCheck(ctx, "vpaid") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "initVpaid"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without function guard", Detail: "Guard mraid.setOrientationProperties with a function check before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasFunctionGuard(ctx, "setOrientationProperties") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "size-change", Severity: "best-practice", Title: "No sizeChange listener found", Detail: "Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
depends := mraidHasCall(ctx, "getMaxSize") || mraidHasCall(ctx, "getScreenSize") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "setOrientationProperties")
|
||||||
|
if depends && !mraidHasEventListener(ctx, "sizeChange") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "state-change", Severity: "best-practice", Title: "No stateChange listener found", Detail: "Listen for stateChange when using expand, resize, close, or other state-changing container behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
changes := mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "close")
|
||||||
|
if changes && !mraidHasEventListener(ctx, "stateChange") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "output-script-hygiene", Severity: "requirement", Title: "Rejected script attributes found", Detail: "Playable output should not contain type=\"module\" or crossorigin on script tags; ad networks can reject ES modules/CORS attributes.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidRejectedScriptAttrsRx.MatchString(ctx.HTML) {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "console-error", Severity: "best-practice", Title: "console.error found", Detail: "The ship checklist expects no console.error calls in output; use guarded logging or remove debug error logs before submission.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
if mraidConsoleErrorRx.MatchString(ctx.ScriptText) {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "lifecycle-stubs", Severity: "requirement", Title: "Playable lifecycle signals not found", Detail: "Expose or call gameReady, gameStart, gameEnd, and gameClose so network preview tools can detect the playable lifecycle.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
missing := []string{}
|
||||||
|
for _, name := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} {
|
||||||
|
if !mraidHasLifecycleSignal(ctx, name) {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found := mraidIssueFrom(rule, 0)
|
||||||
|
found.Detail = found.Detail + " Missing: " + strings.Join(missing, ", ") + "."
|
||||||
|
return found
|
||||||
|
}},
|
||||||
|
}
|
||||||
93
standalone/mraid_dogcat3_test.go
Normal file
93
standalone/mraid_dogcat3_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDogCat3MraidChecker(t *testing.T) {
|
||||||
|
root := filepath.Join("..", "aiAssets", "DogCat3")
|
||||||
|
files, err := collectDogCat3MraidTargets(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("collect targets: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
t.Fatalf("no DogCat3 MRAID targets found")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
t.Run(filepath.Base(file), func(t *testing.T) {
|
||||||
|
html, displayPath, err := readDogCat3HTML(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read target: %v", err)
|
||||||
|
}
|
||||||
|
issues := checkMraid(html, displayPath)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, issue := range issues {
|
||||||
|
parts = append(parts, issue.ID+": "+issue.Title+" ("+issue.Severity+")")
|
||||||
|
}
|
||||||
|
t.Fatalf("MRAID checker issues:\n%s", strings.Join(parts, "\n"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDogCat3MraidTargets(root string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, folder := range []string{"Applovin", "Ironsource", "Unity"} {
|
||||||
|
dir := filepath.Join(root, folder)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.ToLower(entry.Name())
|
||||||
|
if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".htm") || strings.HasSuffix(name, ".zip") {
|
||||||
|
out = append(out, filepath.Join(dir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDogCat3HTML(file string) (html string, displayPath string, err error) {
|
||||||
|
if strings.HasSuffix(strings.ToLower(file), ".zip") {
|
||||||
|
zr, err := zip.OpenReader(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
for _, entry := range zr.File {
|
||||||
|
if strings.EqualFold(entry.Name, "index.html") {
|
||||||
|
rc, err := entry.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return string(data), file + "#index.html", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return string(data), file, nil
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PlecConfig struct {
|
type PlecConfig struct {
|
||||||
@@ -15,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 {
|
||||||
@@ -27,16 +30,19 @@ type SendToMobileConfig struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyUpdateConfig struct {
|
type DeviceSimulatorConfig struct {
|
||||||
LastProject string `json:"lastProject"`
|
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"`
|
||||||
DailyUpdate DailyUpdateConfig `json:"dailyUpdate"`
|
DeviceSimulator DeviceSimulatorConfig `json:"deviceSimulator"`
|
||||||
|
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
|
||||||
@@ -65,10 +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},
|
||||||
DailyUpdate: DailyUpdateConfig{LastProject: ""},
|
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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +129,12 @@ func SaveConfig(cfg AppConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func OpenInBrowser(url string) {
|
func OpenInBrowser(url string) {
|
||||||
// `start` is a cmd builtin. Quoted empty arg is the window title.
|
cmd := hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url)
|
||||||
cmd := exec.Command("cmd", "/c", "start", "", url)
|
|
||||||
_ = cmd.Start()
|
_ = cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyToClipboard(text string) error {
|
func CopyToClipboard(text string) error {
|
||||||
cmd := exec.Command("clip")
|
cmd := hiddenCommand("clip")
|
||||||
cmd.Stdin = strings.NewReader(text)
|
cmd.Stdin = strings.NewReader(text)
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
@@ -136,6 +146,15 @@ type FileFilter struct {
|
|||||||
|
|
||||||
func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||||
|
|
||||||
|
func hiddenCommand(name string, args ...string) *exec.Cmd {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000,
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string {
|
func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string {
|
||||||
filterStr := "All files|*.*"
|
filterStr := "All files|*.*"
|
||||||
if len(filters) > 0 {
|
if len(filters) > 0 {
|
||||||
@@ -164,7 +183,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
|||||||
$dlg.FileNames | ForEach-Object { Write-Output $_ }
|
$dlg.FileNames | ForEach-Object { Write-Output $_ }
|
||||||
}
|
}
|
||||||
`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
|
`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
|
||||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -203,7 +222,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
|||||||
Write-Output $dlg.FileName
|
Write-Output $dlg.FileName
|
||||||
}
|
}
|
||||||
`, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir))
|
`, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir))
|
||||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -220,7 +239,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
|||||||
Write-Output $dlg.SelectedPath
|
Write-Output $dlg.SelectedPath
|
||||||
}
|
}
|
||||||
`, psEscape(title), psEscape(initialDir), psEscape(initialDir))
|
`, psEscape(title), psEscape(initialDir), psEscape(initialDir))
|
||||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
728
standalone/playworks.go
Normal file
728
standalone/playworks.go
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type playworksConvertOptions struct {
|
||||||
|
SourcePath string `json:"sourcePath"`
|
||||||
|
OutputDir string `json:"outputDir"`
|
||||||
|
BaseName string `json:"baseName"`
|
||||||
|
Networks []string `json:"networks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type playworksNetworkResult struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
File string `json:"file"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := `
|
||||||
|
<header class="tool-header">
|
||||||
|
<h2 class="tool-title">Playworks Converter</h2>
|
||||||
|
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.net-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:4px 12px; }
|
||||||
|
.net-row { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; user-select:none; border:1px solid #333; border-radius:4px; background:rgba(128,128,128,0.06); }
|
||||||
|
.net-label { min-width:100px; font-weight:500; }
|
||||||
|
.net-tag { font-size:10px; opacity:0.6; font-weight:400; }
|
||||||
|
.net-note { font-size:11px; opacity:0.6; }
|
||||||
|
.toggle-row { display:flex; gap:8px; margin-bottom:6px; }
|
||||||
|
.toggle-row button { font-size:11px; padding:2px 8px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="tool-panel">
|
||||||
|
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Source HTML (Playworks export)</div>
|
||||||
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickSrc" class="secondary">Select File</button>
|
||||||
|
<button id="clearSrc" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-text">or drop a Playworks HTML file here</div>
|
||||||
|
</div>
|
||||||
|
<input id="src" type="hidden" />
|
||||||
|
<div id="sourceSelection" class="selected-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Base Filename</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Output Folder</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
||||||
|
<button id="pickOut" class="secondary">Select Folder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">Networks to generate</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<button id="selectAll" class="secondary">All</button>
|
||||||
|
<button id="selectNone" class="secondary">None</button>
|
||||||
|
</div>
|
||||||
|
<div class="net-grid">
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="is" checked /><span class="net-label">Ironsource <span class="net-tag">is</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="fb" checked /><span class="net-label">Facebook <span class="net-tag">fb</span></span><span class="net-note">HTML</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="gg" checked /><span class="net-label">Google Ads <span class="net-tag">gg</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="ap" checked /><span class="net-label">Appreciate <span class="net-tag">ap</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML + FbPlayableAd CTA</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
|
||||||
|
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<button id="convert" disabled>Convert</button>
|
||||||
|
<button id="openOut" class="secondary" disabled>Open Output Folder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="results" class="results-panel"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let outputDir = '';
|
||||||
|
const srcEl = document.getElementById('src');
|
||||||
|
const outEl = document.getElementById('outDir');
|
||||||
|
const baseNameEl = document.getElementById('baseName');
|
||||||
|
const convertBtn = document.getElementById('convert');
|
||||||
|
const openOutBtn = document.getElementById('openOut');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
const sourceSelectionEl = document.getElementById('sourceSelection');
|
||||||
|
|
||||||
|
async function readJSONStream(response, handle) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newline;
|
||||||
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, newline);
|
||||||
|
buffer = buffer.slice(newline + 1);
|
||||||
|
if (line.trim()) handle(JSON.parse(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConvertBtn() {
|
||||||
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||||
|
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||||
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s){
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSourceSelection() {
|
||||||
|
if (!srcEl.value) {
|
||||||
|
sourceSelectionEl.innerHTML = '<span class="file-name">(no file selected)</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceSelectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||||
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||||
|
'<tr><td class="mono wrap">' + escapeHtml(basename(srcEl.value)) + '</td><td><button id="removeSource" class="remove-selected danger" title="Remove">×</button></td></tr>' +
|
||||||
|
'</tbody></table></div>';
|
||||||
|
document.getElementById('removeSource').addEventListener('click', clearSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSource(path, baseName, dir) {
|
||||||
|
srcEl.value = path || '';
|
||||||
|
baseNameEl.value = baseName || '';
|
||||||
|
if (!outEl.value && dir) { outEl.value = defaultOutputDir(dir); outputDir = outEl.value; }
|
||||||
|
renderSourceSelection();
|
||||||
|
updateConvertBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOutputDir(dir) {
|
||||||
|
return dir ? dir.replace(/[\\\/]+$/, '') + '\\Output' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSource() {
|
||||||
|
srcEl.value = '';
|
||||||
|
baseNameEl.value = '';
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
renderSourceSelection();
|
||||||
|
updateConvertBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pickSrc').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/playworks/pickSource', { method:'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) {
|
||||||
|
setSource(j.path, j.baseName || '', j.dir || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('clearSrc').addEventListener('click', clearSource);
|
||||||
|
document.getElementById('pickOut').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
|
||||||
|
});
|
||||||
|
document.getElementById('selectAll').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.getElementById('selectNone').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||||
|
outEl.addEventListener('input', updateConvertBtn);
|
||||||
|
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||||
|
openOutBtn.addEventListener('click', () => {
|
||||||
|
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
function outputDisplayName(file) {
|
||||||
|
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||||
|
return parts.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
const paths = j.paths || [];
|
||||||
|
if (!paths.length) {
|
||||||
|
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourcePath = paths[0];
|
||||||
|
const droppedBase = basename(sourcePath).replace(/\\.html?$/i, '').replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/i, '');
|
||||||
|
const i = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\\\'));
|
||||||
|
setSource(sourcePath, droppedBase, i >= 0 ? sourcePath.slice(0, i) : '');
|
||||||
|
const skipped = Number(j.skipped || 0) + Math.max(0, paths.length - 1);
|
||||||
|
if (skipped) statusEl.textContent = 'Using first dropped HTML file. Skipped ' + skipped + ' other item(s).';
|
||||||
|
});
|
||||||
|
renderSourceSelection();
|
||||||
|
|
||||||
|
document.getElementById('convert').addEventListener('click', async () => {
|
||||||
|
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
||||||
|
statusEl.textContent = 'Converting...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
openOutBtn.disabled = true;
|
||||||
|
convertBtn.disabled = true;
|
||||||
|
const res = await fetch('/api/playworks/convert', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, baseName: baseNameEl.value, networks })
|
||||||
|
});
|
||||||
|
let msg = null;
|
||||||
|
try {
|
||||||
|
await readJSONStream(res, (event) => {
|
||||||
|
if (event.type === 'status') {
|
||||||
|
statusEl.textContent = event.message;
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
} else if (event.type === 'done') {
|
||||||
|
msg = event;
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
msg = { error: event.message, results: event.results || [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
msg = { error: e.message || e, results: [] };
|
||||||
|
}
|
||||||
|
convertBtn.disabled = false;
|
||||||
|
updateConvertBtn();
|
||||||
|
if (!msg) { msg = { error: 'No response.', results: [] }; }
|
||||||
|
if (msg.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||||
|
const results = msg.results || [];
|
||||||
|
const ok = results.filter(r => r.ok).length;
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||||
|
if (!results.length) { resultsEl.innerHTML = ''; return; }
|
||||||
|
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th>Output Path</th><th>Error</th></tr></thead><tbody></tbody></table>';
|
||||||
|
const tbody = resultsEl.querySelector('tbody');
|
||||||
|
for (const r of results) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = '<td class="mono">' + r.network + '</td>' +
|
||||||
|
'<td>' + (r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Error</span>') + '</td>' +
|
||||||
|
'<td class="mono wrap">' + (r.ok ? outputDisplayName(r.file) : '<span class="muted">-</span>') + '</td>' +
|
||||||
|
'<td class="err wrap">' + (r.ok ? '' : (r.error || 'Unknown error')) + '</td>';
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
if (ok > 0) { outputDir = outEl.value; openOutBtn.disabled = false; }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/playworks", "Playworks Converter", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
picked := PickFiles("Select Playworks HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
||||||
|
if len(picked) == 0 {
|
||||||
|
writeJSON(w, map[string]any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0]), "baseName": playworksSourceBaseName(picked[0])})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{"path": PickFolder("Select Output Folder", "")})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
send := newJSONStream(w)
|
||||||
|
var opts playworksConvertOptions
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": err.Error(), "results": []playworksNetworkResult{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fileExists(opts.SourcePath) {
|
||||||
|
send(map[string]any{"type": "error", "message": "Source file not found.", "results": []playworksNetworkResult{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": "Could not create output folder.", "results": []playworksNetworkResult{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(map[string]any{"type": "status", "message": "Reading source HTML..."})
|
||||||
|
data, err := os.ReadFile(opts.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
send(map[string]any{"type": "error", "message": "Could not read source: " + err.Error(), "results": []playworksNetworkResult{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []playworksNetworkResult{}
|
||||||
|
send(map[string]any{"type": "status", "message": "Converting Unity..."})
|
||||||
|
unityPath, err := copyPlayworksUnityInput(data, opts)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, playworksNetworkResult{Network: "un", OK: false, Error: err.Error()})
|
||||||
|
} else {
|
||||||
|
results = append(results, playworksNetworkResult{Network: "un", File: unityPath, OK: true})
|
||||||
|
}
|
||||||
|
for i, network := range opts.Networks {
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Converting %d/%d %s", i+1, len(opts.Networks), network)})
|
||||||
|
filePath, err := convertPlayworksNetwork(string(data), opts, network)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
|
||||||
|
}
|
||||||
|
send(map[string]any{"type": "done", "results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$`)
|
||||||
|
var playworksMraidScriptRx = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']mraid\.js["'][^>]*>\s*</script>\s*`)
|
||||||
|
var playworksExitapiScriptRx = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']exitapi\.js["'][^>]*>\s*</script>\s*`)
|
||||||
|
var playworksHeadCloseRx = regexp.MustCompile(`(?i)</head>`)
|
||||||
|
var playworksBodyOpenRx = regexp.MustCompile(`(?i)<body\b[^>]*>`)
|
||||||
|
var playworksBodyTagRx = regexp.MustCompile(`(?i)<body\b([^>]*)>`)
|
||||||
|
var playworksHtmlCloseRx = regexp.MustCompile(`(?i)</html\s*>`)
|
||||||
|
var playworksRemoteDebugScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?insertYourRemoteDebuggingTokenHere.*?</script>\s*`)
|
||||||
|
var playworksStatsURLRx = regexp.MustCompile(`(?i)https://mrdoob\.github\.io/stats\.js/build/stats\.min\.js`)
|
||||||
|
var playworksCrossoriginRx = regexp.MustCompile(`(?i)\s+crossorigin(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?`)
|
||||||
|
var playworksTypeModuleRx = regexp.MustCompile(`(?i)\s+type\s*=\s*["']module["']`)
|
||||||
|
var playworksConsoleErrorRx = regexp.MustCompile(`\bconsole\.error\s*\(`)
|
||||||
|
|
||||||
|
func playworksSourceBaseName(sourcePath string) string {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||||
|
return playworksSuffixRx.ReplaceAllString(base, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksOutputBaseName(opts playworksConvertOptions) string {
|
||||||
|
baseName := strings.TrimSpace(opts.BaseName)
|
||||||
|
if baseName == "" {
|
||||||
|
baseName = playworksSourceBaseName(opts.SourcePath)
|
||||||
|
}
|
||||||
|
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||||
|
baseName = filepath.Base(baseName)
|
||||||
|
baseName = strings.Map(func(r rune) rune {
|
||||||
|
switch r {
|
||||||
|
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
|
||||||
|
return '_'
|
||||||
|
default:
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}, baseName)
|
||||||
|
baseName = strings.TrimSpace(baseName)
|
||||||
|
if baseName == "" {
|
||||||
|
return playworksSourceBaseName(opts.SourcePath)
|
||||||
|
}
|
||||||
|
return baseName
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
||||||
|
if !playworksIsSupportedNetwork(network) {
|
||||||
|
return "", fmt.Errorf("unsupported Playworks target network: %s", network)
|
||||||
|
}
|
||||||
|
transformed := transformPlayworksHTML(htmlSrc, network)
|
||||||
|
baseName := playworksOutputBaseName(opts)
|
||||||
|
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
||||||
|
outputDir := filepath.Join(opts.OutputDir, playworksNetworkOutputFolder(network))
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if playworksNeedsZip(network) {
|
||||||
|
zipPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
|
||||||
|
if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return zipPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(outputDir, htmlFileName)
|
||||||
|
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return outPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPlayworksUnityInput(data []byte, opts playworksConvertOptions) (string, error) {
|
||||||
|
baseName := playworksOutputBaseName(opts)
|
||||||
|
outputDir := filepath.Join(opts.OutputDir, "Unity")
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
outPath := filepath.Join(outputDir, fmt.Sprintf("%s_un.html", baseName))
|
||||||
|
if err := os.WriteFile(outPath, data, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return outPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksNetworkOutputFolder(network string) string {
|
||||||
|
switch network {
|
||||||
|
case "al":
|
||||||
|
return "Applovin"
|
||||||
|
case "is":
|
||||||
|
return "Ironsource"
|
||||||
|
case "fb":
|
||||||
|
return "Facebook"
|
||||||
|
case "gg":
|
||||||
|
return "GoogleAds"
|
||||||
|
case "ap":
|
||||||
|
return "Appreciate"
|
||||||
|
case "mo":
|
||||||
|
return "Moloco"
|
||||||
|
case "vu":
|
||||||
|
return "Vungle"
|
||||||
|
case "mtg":
|
||||||
|
return "Mintegral"
|
||||||
|
case "tt":
|
||||||
|
return "TikTok"
|
||||||
|
default:
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksNeedsZip(network string) bool {
|
||||||
|
return network == "gg" || network == "ap" || network == "vu" || network == "mtg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksIsMraidNetwork(network string) bool {
|
||||||
|
return network == "al" || network == "is"
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksIsExitapiNetwork(network string) bool {
|
||||||
|
return network == "gg" || network == "ap"
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksIsSupportedNetwork(network string) bool {
|
||||||
|
switch network {
|
||||||
|
case "al", "is", "fb", "gg", "ap", "mo", "vu", "mtg", "tt":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformPlayworksHTML(htmlSrc, network string) string {
|
||||||
|
result := sanitizePlayworksHTML(htmlSrc)
|
||||||
|
headInject := buildPlayworksLifecycleScript()
|
||||||
|
if playworksIsMraidNetwork(network) {
|
||||||
|
headInject += `<script src="mraid.js"></script>` + buildPlayworksMraidComplianceScript()
|
||||||
|
} else if playworksIsExitapiNetwork(network) {
|
||||||
|
headInject += `<script src="exitapi.js"></script>`
|
||||||
|
}
|
||||||
|
result = injectPlayworksBeforeHeadClose(result, headInject)
|
||||||
|
|
||||||
|
if network == "vu" {
|
||||||
|
result = injectPlayworksAfterBodyOpen(result, `<script>window.__VUNGLE__=true;</script>`)
|
||||||
|
} else if network == "mtg" {
|
||||||
|
result = addPlayworksBodyOnload(result, "gameReady()")
|
||||||
|
} else if network == "tt" {
|
||||||
|
result = injectPlayworksAfterBodyOpen(result, `<script>window.__TIKTOK__=true;</script>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = replacePlayworksCTAScript(result, network)
|
||||||
|
return finalizePlayworksNetworkHTML(result, network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizePlayworksHTML(htmlSrc string) string {
|
||||||
|
return removePlayworksNetworkScripts(cleanupPlayworksHTML(htmlSrc))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupPlayworksHTML(htmlSrc string) string {
|
||||||
|
result := trimPlayworksAfterFirstHTMLClose(htmlSrc)
|
||||||
|
result = playworksRemoteDebugScriptRx.ReplaceAllString(result, "")
|
||||||
|
result = playworksStatsURLRx.ReplaceAllString(result, "data:text/javascript,")
|
||||||
|
result = playworksCrossoriginRx.ReplaceAllString(result, "")
|
||||||
|
result = playworksTypeModuleRx.ReplaceAllString(result, "")
|
||||||
|
result = playworksConsoleErrorRx.ReplaceAllString(result, "console.warn(")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimPlayworksAfterFirstHTMLClose(htmlSrc string) string {
|
||||||
|
loc := playworksHtmlCloseRx.FindStringIndex(htmlSrc)
|
||||||
|
if loc == nil {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
return htmlSrc[:loc[1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizePlayworksNetworkHTML(htmlSrc, network string) string {
|
||||||
|
result := cleanupPlayworksHTML(htmlSrc)
|
||||||
|
result = dedupePlayworksScriptSrc(result, "mraid.js", playworksIsMraidNetwork(network))
|
||||||
|
result = dedupePlayworksScriptSrc(result, "exitapi.js", playworksIsExitapiNetwork(network))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePlayworksNetworkScripts(htmlSrc string) string {
|
||||||
|
result := playworksMraidScriptRx.ReplaceAllString(htmlSrc, "")
|
||||||
|
result = playworksExitapiScriptRx.ReplaceAllString(result, "")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupePlayworksScriptSrc(htmlSrc, src string, shouldExist bool) string {
|
||||||
|
rx := regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']` + regexp.QuoteMeta(src) + `["'][^>]*>\s*</script>\s*`)
|
||||||
|
seen := false
|
||||||
|
result := rx.ReplaceAllStringFunc(htmlSrc, func(match string) string {
|
||||||
|
if shouldExist && !seen {
|
||||||
|
seen = true
|
||||||
|
return `<script src="` + src + `"></script>`
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
if shouldExist && !seen {
|
||||||
|
result = injectPlayworksBeforeHeadClose(result, `<script src="`+src+`"></script>`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string {
|
||||||
|
loc := playworksHeadCloseRx.FindStringIndex(htmlSrc)
|
||||||
|
if loc != nil {
|
||||||
|
return htmlSrc[:loc[0]] + injection + "\n" + htmlSrc[loc[0]:]
|
||||||
|
}
|
||||||
|
return injection + "\n" + htmlSrc
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectPlayworksAfterBodyOpen(htmlSrc, injection string) string {
|
||||||
|
loc := playworksBodyOpenRx.FindStringIndex(htmlSrc)
|
||||||
|
if loc == nil {
|
||||||
|
return injection + "\n" + htmlSrc
|
||||||
|
}
|
||||||
|
return htmlSrc[:loc[1]] + "\n" + injection + htmlSrc[loc[1]:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPlayworksBodyOnload(htmlSrc, handler string) string {
|
||||||
|
loc := playworksBodyTagRx.FindStringSubmatchIndex(htmlSrc)
|
||||||
|
if loc == nil {
|
||||||
|
return `<body onload="` + handler + `">` + "\n" + htmlSrc
|
||||||
|
}
|
||||||
|
attrs := htmlSrc[loc[2]:loc[3]]
|
||||||
|
onloadStart, onloadEnd, quote, valueStart, valueEnd := findPlayworksOnloadAttr(attrs)
|
||||||
|
if onloadStart == -1 {
|
||||||
|
updated := `<body` + attrs + ` onload="` + handler + `">`
|
||||||
|
return htmlSrc[:loc[0]] + updated + htmlSrc[loc[1]:]
|
||||||
|
}
|
||||||
|
value := attrs[valueStart:valueEnd]
|
||||||
|
if strings.Contains(value, handler) {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
replacement := `onload=` + quote + value + `;` + handler + quote
|
||||||
|
updatedAttrs := attrs[:onloadStart] + replacement + attrs[onloadEnd:]
|
||||||
|
updatedTag := `<body` + updatedAttrs + `>`
|
||||||
|
return htmlSrc[:loc[0]] + updatedTag + htmlSrc[loc[1]:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPlayworksOnloadAttr(attrs string) (start int, end int, quote string, valueStart int, valueEnd int) {
|
||||||
|
lower := strings.ToLower(attrs)
|
||||||
|
idx := strings.Index(lower, "onload")
|
||||||
|
for idx != -1 {
|
||||||
|
if idx > 0 {
|
||||||
|
prev := lower[idx-1]
|
||||||
|
if (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '-' || prev == '_' {
|
||||||
|
next := strings.Index(lower[idx+len("onload"):], "onload")
|
||||||
|
if next == -1 {
|
||||||
|
return -1, -1, "", -1, -1
|
||||||
|
}
|
||||||
|
idx += len("onload") + next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos := idx + len("onload")
|
||||||
|
for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') {
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
if pos >= len(attrs) || attrs[pos] != '=' {
|
||||||
|
return -1, -1, "", -1, -1
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') {
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
if pos >= len(attrs) || (attrs[pos] != '"' && attrs[pos] != '\'') {
|
||||||
|
return -1, -1, "", -1, -1
|
||||||
|
}
|
||||||
|
q := attrs[pos]
|
||||||
|
valueStart = pos + 1
|
||||||
|
valueEnd = strings.IndexByte(attrs[valueStart:], q)
|
||||||
|
if valueEnd == -1 {
|
||||||
|
return -1, -1, "", -1, -1
|
||||||
|
}
|
||||||
|
valueEnd += valueStart
|
||||||
|
return idx, valueEnd + 1, string(q), valueStart, valueEnd
|
||||||
|
}
|
||||||
|
return -1, -1, "", -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePlayworksCTAScript(htmlSrc, network string) string {
|
||||||
|
marker := "Luna.Unity.Playable.InstallFullGame=function"
|
||||||
|
markerIdx := strings.LastIndex(htmlSrc, marker)
|
||||||
|
if markerIdx == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptOpenIdx := strings.LastIndex(htmlSrc[:markerIdx], "<script>")
|
||||||
|
if scriptOpenIdx == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptCloseRel := strings.Index(htmlSrc[markerIdx:], "</script>")
|
||||||
|
if scriptCloseRel == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptCloseIdx := markerIdx + scriptCloseRel
|
||||||
|
return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len("</script>"):]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksConsoleRestoreScript() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
`<script>(function(){`,
|
||||||
|
`try{`,
|
||||||
|
`var f=document.createElement("iframe");`,
|
||||||
|
`f.style.display="none";`,
|
||||||
|
`document.documentElement.appendChild(f);`,
|
||||||
|
`var nc=f.contentWindow.console;`,
|
||||||
|
`document.documentElement.removeChild(f);`,
|
||||||
|
`var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];`,
|
||||||
|
`methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});`,
|
||||||
|
`}catch(e){}`,
|
||||||
|
`})();</script>`,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksLifecycleScript() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
`<script>(function(){`,
|
||||||
|
`if(typeof window.gameReady!=="function")window.gameReady=function(){};`,
|
||||||
|
`if(typeof window.gameStart!=="function")window.gameStart=function(){};`,
|
||||||
|
`if(typeof window.gameEnd!=="function")window.gameEnd=function(){};`,
|
||||||
|
`if(typeof window.gameClose!=="function")window.gameClose=function(){};`,
|
||||||
|
`var _grOnce=false,_gsOnce=false;`,
|
||||||
|
`window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});`,
|
||||||
|
`window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});`,
|
||||||
|
`window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});`,
|
||||||
|
`})();</script>`,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksMraidComplianceScript() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
`<script>(function(){`,
|
||||||
|
`var m=null,scene=null,viewable=true,exposed=true,volume=null;`,
|
||||||
|
`function get(){return window.mraid||m;}`,
|
||||||
|
`function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}`,
|
||||||
|
`function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}`,
|
||||||
|
`function onViewable(v){viewable=!!v;apply();}`,
|
||||||
|
`function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}`,
|
||||||
|
`function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}`,
|
||||||
|
`function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}`,
|
||||||
|
`function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}`,
|
||||||
|
`window.__hplMraidUnload=unload;`,
|
||||||
|
`function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}`,
|
||||||
|
`function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}`,
|
||||||
|
`window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};`,
|
||||||
|
`if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}`,
|
||||||
|
`setTimeout(setup,2000);`,
|
||||||
|
`})();</script>`,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksCTAScript(network string) string {
|
||||||
|
urlSetup := `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`
|
||||||
|
closeCall := `try{window.gameClose();}catch(e){}`
|
||||||
|
ctaLogic := closeCall + `window.open(o,"_blank");`
|
||||||
|
switch network {
|
||||||
|
case "al", "is":
|
||||||
|
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`
|
||||||
|
case "fb":
|
||||||
|
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
|
||||||
|
case "gg", "ap":
|
||||||
|
ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`
|
||||||
|
case "mo":
|
||||||
|
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`
|
||||||
|
case "vu":
|
||||||
|
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
|
||||||
|
case "mtg":
|
||||||
|
ctaLogic = closeCall + `if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`
|
||||||
|
case "tt":
|
||||||
|
ctaLogic = closeCall + `if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`
|
||||||
|
}
|
||||||
|
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),` + urlSetup + ctaLogic + `}}))}));</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSingleFileZip(data []byte, nameInZip, destZipPath string) error {
|
||||||
|
_ = os.Remove(destZipPath)
|
||||||
|
out, err := os.Create(destZipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(out)
|
||||||
|
entry, err := zw.Create(nameInZip)
|
||||||
|
if err != nil {
|
||||||
|
_ = zw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(data); err != nil {
|
||||||
|
_ = zw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
36
standalone/playworks_export_test.go
Normal file
36
standalone/playworks_export_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportPlayworksNetworksForRuntimeCheck(t *testing.T) {
|
||||||
|
outDir := os.Getenv("PLAYWORKS_EXPORT_DIR")
|
||||||
|
if outDir == "" {
|
||||||
|
t.Skip("PLAYWORKS_EXPORT_DIR not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := filepath.Join("..", "aiAssets", "DogCat3_Full.html")
|
||||||
|
data, err := os.ReadFile(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := playworksConvertOptions{
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
OutputDir: outDir,
|
||||||
|
BaseName: "wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na",
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := copyPlayworksUnityInput(data, opts); err != nil {
|
||||||
|
t.Fatalf("copy unity input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, network := range []string{"al", "ap", "fb", "gg", "is", "mo", "mtg", "vu"} {
|
||||||
|
if _, err := convertPlayworksNetwork(string(data), opts, network); err != nil {
|
||||||
|
t.Fatalf("convert %s: %v", network, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
standalone/playworks_test.go
Normal file
75
standalone/playworks_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTransformPlayworksHTMLNetworkRequirements(t *testing.T) {
|
||||||
|
sourcePath := filepath.Join("..", "aiAssets", "DogCat3_Full.html")
|
||||||
|
data, err := os.ReadFile(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
network string
|
||||||
|
wantMraid int
|
||||||
|
wantExitapi int
|
||||||
|
}{
|
||||||
|
{network: "al", wantMraid: 1},
|
||||||
|
{network: "is", wantMraid: 1},
|
||||||
|
{network: "gg", wantExitapi: 1},
|
||||||
|
{network: "ap", wantExitapi: 1},
|
||||||
|
{network: "fb"},
|
||||||
|
{network: "mo"},
|
||||||
|
{network: "vu"},
|
||||||
|
{network: "mtg"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.network, func(t *testing.T) {
|
||||||
|
html := transformPlayworksHTML(string(data), tt.network)
|
||||||
|
assertCount(t, html, `src="mraid.js"`, tt.wantMraid)
|
||||||
|
assertCount(t, html, `src="exitapi.js"`, tt.wantExitapi)
|
||||||
|
assertCount(t, strings.ToLower(html), "</html>", 1)
|
||||||
|
assertCount(t, strings.ToLower(html), "</head>", 1)
|
||||||
|
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
`type="module"`,
|
||||||
|
`crossorigin`,
|
||||||
|
`console.error(`,
|
||||||
|
`stats.min.js`,
|
||||||
|
`insertYourRemoteDebuggingTokenHere`,
|
||||||
|
`console.re/connector.js`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(html, forbidden) {
|
||||||
|
t.Fatalf("forbidden content remains: %s", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} {
|
||||||
|
if !strings.Contains(html, required) {
|
||||||
|
t.Fatalf("missing lifecycle symbol: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantMraid == 1 {
|
||||||
|
for _, required := range []string{"exposureChange", "viewableChange", "audioVolumeChange", "[MRAID error]", "stateChange"} {
|
||||||
|
if !strings.Contains(html, required) {
|
||||||
|
t.Fatalf("missing MRAID requirement marker: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCount(t *testing.T, haystack, needle string, want int) {
|
||||||
|
t.Helper()
|
||||||
|
if got := strings.Count(haystack, needle); got != want {
|
||||||
|
t.Fatalf("%q count = %d, want %d", needle, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -17,42 +18,68 @@ import (
|
|||||||
|
|
||||||
func PlecPage(w http.ResponseWriter, r *http.Request) {
|
func PlecPage(w http.ResponseWriter, r *http.Request) {
|
||||||
body := `
|
body := `
|
||||||
<h2>PLEC Upload</h2>
|
<header class="tool-header">
|
||||||
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
|
<h2 class="tool-title">PLEC Upload</h2>
|
||||||
|
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;">
|
<section class="tool-panel">
|
||||||
<thead><tr>
|
<div class="panel-header">
|
||||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
|
<h3 class="panel-title">Inputs</h3>
|
||||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
|
<select id="serverSelect">
|
||||||
<th></th>
|
<option value="legacy">167.99.227.249 (Default)</option>
|
||||||
</tr></thead>
|
<option value="wordpress">20.255.60.183 (Backup)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
|
<div id="dropZone" class="drop-zone">
|
||||||
|
<div class="file-row">
|
||||||
|
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||||
|
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||||
|
<button id="clear" class="secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||||
|
</div>
|
||||||
|
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||||
|
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
|
||||||
|
<table id="rows" class="data-table">
|
||||||
|
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div style="display:flex;gap:8px;">
|
</div>
|
||||||
<button id="addRow" class="secondary">+ Add Row</button>
|
<div class="action-row">
|
||||||
<button id="upload">Upload</button>
|
<button id="upload">Upload</button>
|
||||||
</div>
|
<button id="openResult" class="secondary" disabled>Open</button>
|
||||||
|
<button id="copyResult" class="secondary" disabled>Copy Link</button>
|
||||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
</div>
|
||||||
<div id="results"></div>
|
<div id="status" class="status-panel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
|
|
||||||
.file-cell { display:flex; align-items:center; gap:8px; }
|
|
||||||
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
|
|
||||||
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
||||||
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
#rows input[type=text] { width: 100%; }
|
||||||
.result a { color:#9cdcfe; }
|
.panel-header { display: flex; align-items: center; }
|
||||||
.result-actions { margin-top:8px; display:flex; gap:8px; }
|
#serverSelect { margin-left:auto; padding:3px 6px; font-size:12px; cursor:pointer; }
|
||||||
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
const serverSelect = document.getElementById('serverSelect');
|
||||||
|
serverSelect.value = localStorage.getItem('plec.lastServer') || 'legacy';
|
||||||
|
serverSelect.addEventListener('change', () => localStorage.setItem('plec.lastServer', serverSelect.value));
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
let rows = [];
|
||||||
|
let page = 0;
|
||||||
|
let previewUrl = '';
|
||||||
const tbody = document.querySelector('#rows tbody');
|
const tbody = document.querySelector('#rows tbody');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const rowsPanel = document.getElementById('rowsPanel');
|
||||||
|
const emptySelection = document.getElementById('emptySelection');
|
||||||
|
const openResultBtn = document.getElementById('openResult');
|
||||||
|
const copyResultBtn = document.getElementById('copyResult');
|
||||||
|
|
||||||
function parseIterationName(filename) {
|
function parseIterationName(filename) {
|
||||||
const base = filename.replace(/\.html?$/i, '');
|
const base = filename.replace(/\.html?$/i, '');
|
||||||
@@ -63,121 +90,175 @@ function parseIterationName(filename) {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRow() {
|
function escapeHtml(s){
|
||||||
const id = 'r' + (nextId++);
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const tr = document.createElement('tr');
|
}
|
||||||
tr.dataset.id = id;
|
function addFiles(files) {
|
||||||
tr.innerHTML = ` + "`" + `
|
const existing = new Set(rows.map(r => r.path));
|
||||||
<td>
|
const before = rows.length;
|
||||||
<div class="file-cell">
|
(files || []).forEach(f => {
|
||||||
<button class="pick secondary">Choose...</button>
|
if (existing.has(f.path)) return;
|
||||||
<span class="file-name" data-name>(no file)</span>
|
rows.push({ id:'r' + (nextId++), path:f.path, name:f.name, iteration:parseIterationName(f.name), error:'' });
|
||||||
</div>
|
existing.add(f.path);
|
||||||
<input type="hidden" data-path />
|
});
|
||||||
<div class="row-error" data-error></div>
|
const added = rows.length - before;
|
||||||
</td>
|
if (added > 1) {
|
||||||
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
|
const batch = rows.slice(rows.length - added);
|
||||||
<td><button class="remove-btn" title="Remove">×</button></td>
|
const first = batch[0].iteration;
|
||||||
` + "`" + `;
|
if (first && batch.every(r => r.iteration === first)) {
|
||||||
tr.querySelector('.pick').addEventListener('click', async () => {
|
batch.forEach((r, i) => r.iteration = String(i + 1).padStart(2, '0') + '_' + first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
|
async function readJSONStream(response, handle) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let newline;
|
||||||
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, newline);
|
||||||
|
buffer = buffer.slice(newline + 1);
|
||||||
|
if (line.trim()) handle(JSON.parse(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||||
|
}
|
||||||
|
function renderRows() {
|
||||||
|
const oldPager = document.getElementById('selectedPager');
|
||||||
|
if (oldPager) oldPager.remove();
|
||||||
|
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
|
||||||
|
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
|
||||||
|
if (!rows.length) { tbody.innerHTML = ''; return; }
|
||||||
|
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||||
|
page = Math.min(page, maxPage);
|
||||||
|
const start = page * PAGE_SIZE;
|
||||||
|
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||||||
|
tbody.innerHTML = pageRows.map((row, offset) => {
|
||||||
|
const i = start + offset;
|
||||||
|
return '<tr data-id="' + row.id + '">' +
|
||||||
|
'<td><span class="mono wrap">' + escapeHtml(row.name) + '</span><div class="row-error">' + escapeHtml(row.error || '') + '</div></td>' +
|
||||||
|
'<td><input type="text" data-iter data-index="' + i + '" value="' + escapeHtml(row.iteration) + '" /></td>' +
|
||||||
|
'<td><button class="remove-btn danger" data-remove="' + i + '" title="Remove">×</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
tbody.querySelectorAll('[data-iter]').forEach(input => {
|
||||||
|
input.addEventListener('input', () => rows[Number(input.dataset.index)].iteration = input.value);
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('[data-remove]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => { rows.splice(Number(btn.dataset.remove), 1); renderRows(); });
|
||||||
|
});
|
||||||
|
if (rows.length > PAGE_SIZE) {
|
||||||
|
const pager = document.createElement('div');
|
||||||
|
pager.id = 'selectedPager';
|
||||||
|
pager.className = 'pager';
|
||||||
|
pager.innerHTML = '<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||||
|
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||||
|
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||||
|
rowsPanel.after(pager);
|
||||||
|
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
|
||||||
|
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setPreview(url) {
|
||||||
|
previewUrl = url || '';
|
||||||
|
openResultBtn.disabled = !previewUrl;
|
||||||
|
copyResultBtn.disabled = !previewUrl;
|
||||||
|
}
|
||||||
|
setupDropZone('dropZone', statusEl, (j) => {
|
||||||
|
addFiles(j.files || []);
|
||||||
|
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||||
|
const res = await fetch('/api/plec/pickFolder', { method: 'POST' });
|
||||||
|
const j = await res.json();
|
||||||
|
addFiles(j.files || []);
|
||||||
|
});
|
||||||
|
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (j.files && j.files.length) {
|
addFiles(j.files || []);
|
||||||
const affected = [];
|
});
|
||||||
j.files.forEach(function(f, i) {
|
document.getElementById('clear').addEventListener('click', () => {
|
||||||
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
|
rows = [];
|
||||||
target.querySelector('[data-path]').value = f.path;
|
page = 0;
|
||||||
target.querySelector('[data-name]').textContent = f.name;
|
setPreview('');
|
||||||
const iter = target.querySelector('[data-iter]');
|
statusEl.textContent = '';
|
||||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
statusEl.classList.remove('is-busy');
|
||||||
affected.push(target);
|
renderRows();
|
||||||
});
|
});
|
||||||
if (affected.length > 1) {
|
openResultBtn.addEventListener('click', () => {
|
||||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
if (previewUrl) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: previewUrl }) });
|
||||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
});
|
||||||
affected.forEach((r, i) => {
|
copyResultBtn.addEventListener('click', () => {
|
||||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
if (previewUrl) fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: previewUrl }) });
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
|
||||||
tr.remove();
|
|
||||||
if (!tbody.children.length) addRow();
|
|
||||||
});
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('addRow').addEventListener('click', addRow);
|
|
||||||
|
|
||||||
document.getElementById('upload').addEventListener('click', async () => {
|
document.getElementById('upload').addEventListener('click', async () => {
|
||||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
rows.forEach(row => row.error = '');
|
||||||
|
renderRows();
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
resultsEl.innerHTML = '';
|
statusEl.classList.remove('is-busy');
|
||||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
setPreview('');
|
||||||
id: tr.dataset.id,
|
|
||||||
path: tr.querySelector('[data-path]').value,
|
|
||||||
iteration: tr.querySelector('[data-iter]').value,
|
|
||||||
})).filter(r => r.path || r.iteration);
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
|
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
statusEl.textContent = 'Uploading...';
|
statusEl.textContent = 'Uploading...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
|
let j = null;
|
||||||
|
try {
|
||||||
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 }),
|
||||||
});
|
});
|
||||||
const j = await res.json();
|
await readJSONStream(res, (msg) => {
|
||||||
if (j.rowErrors) {
|
if (msg.type === 'status') {
|
||||||
for (const re of j.rowErrors) {
|
statusEl.textContent = msg.message;
|
||||||
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
|
statusEl.classList.add('is-busy');
|
||||||
if (tr) tr.querySelector('[data-error]').textContent = re.message;
|
} else if (msg.type === 'done') {
|
||||||
|
j = msg;
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
j = { error: msg.message };
|
||||||
|
} else if (msg.type === 'rowErrors') {
|
||||||
|
j = { rowErrors: msg.rowErrors };
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; return; }
|
||||||
|
if (j.rowErrors) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
|
for (const re of j.rowErrors) {
|
||||||
|
const row = rows.find(r => r.id === re.id);
|
||||||
|
if (row) row.error = re.message;
|
||||||
|
}
|
||||||
|
renderRows();
|
||||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (j.error) {
|
if (j.error) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||||
const wrap = document.createElement('div');
|
setPreview(j.preview);
|
||||||
wrap.className = 'result';
|
|
||||||
if (j.preview) {
|
|
||||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'result-actions';
|
|
||||||
const copy = document.createElement('button');
|
|
||||||
copy.textContent = 'Copy';
|
|
||||||
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
|
|
||||||
const open = document.createElement('button');
|
|
||||||
open.textContent = 'Open';
|
|
||||||
open.className = 'secondary';
|
|
||||||
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
|
|
||||||
const showRaw = document.createElement('button');
|
|
||||||
showRaw.textContent = 'Show server response';
|
|
||||||
showRaw.className = 'secondary';
|
|
||||||
showRaw.onclick = () => {
|
|
||||||
const pre = document.createElement('pre');
|
|
||||||
pre.style.marginTop = '8px';
|
|
||||||
pre.style.whiteSpace = 'pre-wrap';
|
|
||||||
pre.textContent = j.rawText;
|
|
||||||
wrap.appendChild(pre);
|
|
||||||
showRaw.disabled = true;
|
|
||||||
};
|
|
||||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
|
||||||
wrap.appendChild(actions);
|
|
||||||
} else {
|
|
||||||
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'<') + '</pre>';
|
|
||||||
}
|
|
||||||
resultsEl.innerHTML = '';
|
|
||||||
resultsEl.appendChild(wrap);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
addRow();
|
renderRows();
|
||||||
</script>
|
</script>
|
||||||
`
|
`
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -204,6 +285,23 @@ func PlecPick(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]any{"files": files})
|
writeJSON(w, map[string]any{"files": files})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PlecPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
dir := PickFolder("Select folder", cfg.LastPickDir)
|
||||||
|
if dir == "" {
|
||||||
|
writeJSON(w, map[string]any{"files": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.LastPickDir = dir
|
||||||
|
_ = SaveConfig(cfg)
|
||||||
|
paths := collectHTMLFiles(dir)
|
||||||
|
files := make([]map[string]string, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"files": files})
|
||||||
|
}
|
||||||
|
|
||||||
type plecRow struct {
|
type plecRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -211,11 +309,13 @@ type plecRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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 {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg := LoadConfig().Plec
|
cfg := LoadConfig().Plec
|
||||||
@@ -240,51 +340,59 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(rowErrors) > 0 {
|
if len(rowErrors) > 0 {
|
||||||
writeJSON(w, map[string]any{"rowErrors": rowErrors})
|
send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(valid) == 0 {
|
if len(valid) == 0 {
|
||||||
writeJSON(w, map[string]any{"error": "Nothing to upload."})
|
send(map[string]any{"type": "error", "message": "Nothing to upload."})
|
||||||
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
|
||||||
mw := multipart.NewWriter(&buf)
|
mw := multipart.NewWriter(&buf)
|
||||||
for i, row := range valid {
|
for i, row := range valid {
|
||||||
idx := i + 1
|
idx := i + 1
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filepath.Base(row.Path))})
|
||||||
data, err := os.ReadFile(row.Path)
|
data, err := os.ReadFile(row.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
|
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
|
||||||
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
|
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := fw.Write(data); err != nil {
|
if _, err := fw.Write(data); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": 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 {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := mw.Close(); err != nil {
|
if err := mw.Close(); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
|
||||||
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
|
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
send(map[string]any{"type": "error", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
|
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
@@ -296,18 +404,18 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()})
|
send(map[string]any{"type": "error", "message": "Network error: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
|
send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var parsed map[string]any
|
var parsed map[string]any
|
||||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
|
send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var preview any
|
var preview any
|
||||||
@@ -316,7 +424,172 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else if v, ok := parsed["previewURL"]; ok {
|
} else if v, ok := parsed["previewURL"]; ok {
|
||||||
preview = v
|
preview = v
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{"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) {
|
||||||
|
|||||||
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
5
standalone/readme_embed_dev.go
Normal file
5
standalone/readme_embed_dev.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !release
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
const embeddedReadme = ""
|
||||||
8
standalone/readme_embed_release.go
Normal file
8
standalone/readme_embed_release.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build release
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed README.md
|
||||||
|
var embeddedReadme string
|
||||||
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
|
||||||
|
}
|
||||||
74
tools.json
Normal file
74
tools.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"title": "PLEC Upload",
|
||||||
|
"description": "Upload HTML to internal PLEC server",
|
||||||
|
"command": "hplToolbox.openPlecUpload",
|
||||||
|
"path": "/plec",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "applovin",
|
||||||
|
"title": "AppLovin Playable Preview",
|
||||||
|
"description": "Upload to p.applov.in (QR preview)",
|
||||||
|
"command": "hplToolbox.openApplovinUpload",
|
||||||
|
"path": "/applovin",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "base64",
|
||||||
|
"title": "Base64 Scanner",
|
||||||
|
"description": "Find non-base64 assets in HTML",
|
||||||
|
"command": "hplToolbox.openBase64Scanner",
|
||||||
|
"path": "/base64",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mobile",
|
||||||
|
"title": "Send To Mobile",
|
||||||
|
"description": "Share HTML to a phone via LAN + QR",
|
||||||
|
"command": "hplToolbox.openSendToMobile",
|
||||||
|
"path": "/mobile",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mraid",
|
||||||
|
"title": "MRAID Checker",
|
||||||
|
"description": "Check MRAID requirements and best practices",
|
||||||
|
"command": "hplToolbox.openMraidChecker",
|
||||||
|
"path": "/mraid",
|
||||||
|
"beta": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "playworks",
|
||||||
|
"title": "Playworks Converter",
|
||||||
|
"description": "Convert Playworks HTML to per-network variants",
|
||||||
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
|
"path": "/playworks",
|
||||||
|
"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