Compare commits
44 Commits
d384c1be3a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 331e49ea29 | |||
| c336f176e6 | |||
| 0e30ed0b36 | |||
| 8e5d56ab1d | |||
| 68d15aa208 | |||
| b254daf06a | |||
| 16de3cc116 | |||
| 5fa1ce949c | |||
| e50b4c2133 | |||
| cebb65fb35 | |||
| 7094e6c3b0 | |||
| 6c09beea43 | |||
| ad6403f3e4 | |||
| 700bb135ad | |||
| 986993a027 | |||
| 9e24342142 | |||
| b71e730482 | |||
| e3f454c5f2 | |||
| 88981d209b | |||
| 2524bacb5d | |||
| 9e919756fd | |||
| 1c445e2771 | |||
| a2d80d2660 | |||
| 1ee22b7155 | |||
| 2ca858cfa4 | |||
| 80f155dfe8 | |||
| 5625d94106 | |||
| a1d3fde774 | |||
| 7201bf9845 | |||
| 2921d4d384 | |||
| cce30c0729 | |||
| 4f17523f26 | |||
| 274bafe1cb | |||
| afbf504957 | |||
| 16847a78ca | |||
| ca9cc62b8c | |||
| 8e8d3d6da0 | |||
| 3f86aeaf0c | |||
| 92de00b52e | |||
| 78578cec16 | |||
| 75a506a505 | |||
| 698223234d | |||
| 9e82656eeb | |||
| c7ad1e14ca |
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.
|
||||||
@@ -73,18 +73,19 @@ const offY = (viewH - 1920 * s) / 2
|
|||||||
## CTA — SDK Priority (triggerCTA fallback chain)
|
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||||
```
|
```
|
||||||
1. ExitApi.exit() → GoogleAds (gg)
|
1. ExitApi.exit() → GoogleAds (gg)
|
||||||
2. FbPlayableAd.onCTAClick() → Facebook (fb)
|
2. FbPlayableAd.onCTAClick() → Facebook (fb) · Moloco (mo)
|
||||||
3. Luna.Unity.Playable → Unity (un)
|
3. Luna.Unity.Playable → Unity (un)
|
||||||
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
||||||
5. window.install() → Mintegral (mtg)
|
5. window.install() → Mintegral (mtg)
|
||||||
6. window.openAppStore() → (runtime fallback)
|
6. window.openAppStore() → (runtime fallback)
|
||||||
7. window.clickTag → Moloco (mo)
|
7. window.clickTag → Moloco (mo) fallback
|
||||||
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
||||||
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
||||||
10. mraid.open(url) → Applovin (al) / Ironsource (is)
|
10. mraid.open(url) → Applovin (al) / Ironsource (is) / Unity (un fallback after Luna)
|
||||||
11. window.open(storeUrl) → Fallback (all others)
|
11. window.open(storeUrl) → Fallback (all others)
|
||||||
```
|
```
|
||||||
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
`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
|
## Analytics — SDK Priority
|
||||||
```
|
```
|
||||||
@@ -107,15 +108,63 @@ gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCT
|
|||||||
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||||
| Network | Mechanism |
|
| Network | Mechanism |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
|
| 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` |
|
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||||
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||||
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
|
| 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 |
|
| 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
|
## Build
|
||||||
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||||
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
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 |
|
| Network | Tag | Injected | Zipped | Included |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
@@ -124,7 +173,7 @@ Build script strips `type="module"` and `crossorigin` — ad networks reject ES
|
|||||||
| Ironsource | is | `mraid.js` | — | ✓ |
|
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||||
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||||
| Facebook | fb | — | — | ✓ |
|
| Facebook | fb | — | — | ✓ |
|
||||||
| Unity | un | — | — | ✓ |
|
| Unity | un | `mraid.js` | — | ✓ |
|
||||||
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||||
| Moloco | mo | — | — | ✓ |
|
| Moloco | mo | — | — | ✓ |
|
||||||
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||||
@@ -146,7 +195,6 @@ dist/
|
|||||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||||
Google/
|
Google/
|
||||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||||
index.zip
|
|
||||||
Ironsource/
|
Ironsource/
|
||||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||||
…one HTML or ZIP per network…
|
…one HTML or ZIP per network…
|
||||||
@@ -213,6 +261,7 @@ Define depths as named constants in [constants.ts](src/constants.ts) — never u
|
|||||||
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||||
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||||
| Bundle > 5 MB | WebP q75, shorter audio |
|
| 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` |
|
| Audio blocked | Gate on `pointerdown` |
|
||||||
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||||
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||||
@@ -227,6 +276,46 @@ Define depths as named constants in [constants.ts](src/constants.ts) — never u
|
|||||||
| 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. |
|
| 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. |
|
| 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. |
|
| 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 — 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.
|
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]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
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",
|
||||||
|
|||||||
73
package.json
73
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,15 +26,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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",
|
||||||
@@ -41,6 +43,22 @@
|
|||||||
{
|
{
|
||||||
"command": "hplToolbox.openPlayworksConverter",
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
"title": "HPL Toolbox: Open Playworks Converter"
|
"title": "HPL Toolbox: Open Playworks Converter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openMintegralChecker",
|
||||||
|
"title": "HPL Toolbox: Open Mintegral Checker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openDeviceSimulator",
|
||||||
|
"title": "HPL Toolbox: Open Device Simulator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.checkForUpdates",
|
||||||
|
"title": "HPL Toolbox: Check for Updates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openProjectInit",
|
||||||
|
"title": "HPL Toolbox: Initialize Project"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
@@ -57,7 +75,8 @@
|
|||||||
{
|
{
|
||||||
"type": "webview",
|
"type": "webview",
|
||||||
"id": "hplToolbox.launcher",
|
"id": "hplToolbox.launcher",
|
||||||
"name": "Tools"
|
"name": "",
|
||||||
|
"icon": "media/hpl.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -84,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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -117,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,20 +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 { 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.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,76 +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>
|
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openPlayworksConverter">
|
|
||||||
Playworks Converter
|
|
||||||
<span class="tool-desc">Convert Playworks HTML to per-network variants</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>`;
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@ 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 * as cp from 'child_process';
|
import * as cp from 'child_process';
|
||||||
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 };
|
||||||
|
|
||||||
interface ConvertOptions {
|
interface ConvertOptions {
|
||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
|
baseName?: string;
|
||||||
networks: string[];
|
networks: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
|||||||
if (!picked?.[0]) return;
|
if (!picked?.[0]) return;
|
||||||
const srcPath = picked[0].fsPath;
|
const srcPath = picked[0].fsPath;
|
||||||
const dir = path.dirname(srcPath);
|
const dir = path.dirname(srcPath);
|
||||||
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir });
|
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir, baseName: sourceBaseName(srcPath) });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'pickOutput': {
|
case 'pickOutput': {
|
||||||
@@ -67,6 +68,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
let html: string;
|
let html: string;
|
||||||
try {
|
try {
|
||||||
|
panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' });
|
||||||
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||||
@@ -74,7 +76,15 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results: NetworkResult[] = [];
|
const results: NetworkResult[] = [];
|
||||||
for (const network of opts.networks) {
|
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 {
|
try {
|
||||||
const filePath = await convertNetwork(html, opts, network);
|
const filePath = await convertNetwork(html, opts, network);
|
||||||
results.push({ network, file: filePath, ok: true });
|
results.push({ network, file: filePath, ok: true });
|
||||||
@@ -93,22 +103,48 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
|
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 {
|
function sourceBaseName(sourcePath: string): string {
|
||||||
return path.basename(sourcePath, path.extname(sourcePath))
|
return path.basename(sourcePath, path.extname(sourcePath))
|
||||||
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
|
.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> {
|
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 transformed = transformHtml(html, network);
|
||||||
const needsZip = ZIPPED_NETWORKS.has(network);
|
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||||
const baseName = sourceBaseName(opts.sourcePath);
|
const baseName = outputBaseName(opts);
|
||||||
const htmlFileName = `${baseName}_${network}.html`;
|
const htmlFileName = `${baseName}_${network}.html`;
|
||||||
|
const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
if (needsZip) {
|
if (needsZip) {
|
||||||
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
|
const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||||
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
|
const zipPath = path.join(outputDir, `${baseName}_${network}.zip`);
|
||||||
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||||
try {
|
try {
|
||||||
await createZip(tmpPath, 'index.html', zipPath);
|
await createZip(tmpPath, 'index.html', zipPath);
|
||||||
@@ -118,45 +154,135 @@ async function convertNetwork(html: string, opts: ConvertOptions, network: strin
|
|||||||
return zipPath;
|
return zipPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outPath = path.join(opts.outputDir, htmlFileName);
|
const outPath = path.join(outputDir, htmlFileName);
|
||||||
fs.writeFileSync(outPath, transformed, 'utf8');
|
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||||
return outPath;
|
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 {
|
function transformHtml(html: string, network: string): string {
|
||||||
let result = html;
|
let result = sanitizePlayworksHtml(html);
|
||||||
|
|
||||||
// Inject into </head>: lifecycle stubs only (must register luna:build listener
|
// 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.
|
// 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.
|
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
|
||||||
|
result = removeNetworkSdkScripts(result);
|
||||||
let headInject = buildLifecycleScript();
|
let headInject = buildLifecycleScript();
|
||||||
if (network === 'al' || network === 'is') {
|
if (MRAID_NETWORKS.has(network)) {
|
||||||
headInject += '<script src="mraid.js"></script>';
|
headInject += '<script src="mraid.js"></script>' + buildMraidComplianceScript();
|
||||||
} else if (network === 'gg') {
|
} else if (EXITAPI_NETWORKS.has(network)) {
|
||||||
headInject += '<script src="exitapi.js"></script>';
|
headInject += '<script src="exitapi.js"></script>';
|
||||||
}
|
}
|
||||||
result = result.replace('</head>', headInject + '\n</head>');
|
result = injectBeforeHeadClose(result, headInject);
|
||||||
|
|
||||||
// Body-level flags
|
// Body-level flags
|
||||||
if (network === 'vu') {
|
if (network === 'vu') {
|
||||||
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
|
result = injectAfterBodyOpen(result, '<script>window.__VUNGLE__=true;</script>');
|
||||||
} else if (network === 'mtg') {
|
} else if (network === 'mtg') {
|
||||||
result = result.replace('<body>', '<body onload="gameReady()">');
|
result = addBodyOnload(result, 'gameReady()');
|
||||||
} else if (network === 'tt') {
|
} else if (network === 'tt') {
|
||||||
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>');
|
result = injectAfterBodyOpen(result, '<script>window.__TIKTOK__=true;</script>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the CTA script
|
// Replace the CTA script
|
||||||
result = replaceCTAScript(result, network);
|
result = replaceCTAScript(result, network);
|
||||||
|
|
||||||
// Unity: rewrite window.top → window.self (Luna static scan requirement)
|
return finalizeNetworkHtml(result, network);
|
||||||
if (network === 'un') {
|
}
|
||||||
result = result.split('window.top').join('window.self');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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;
|
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 {
|
function replaceCTAScript(html: string, network: string): string {
|
||||||
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||||
const markerIdx = html.lastIndexOf(marker);
|
const markerIdx = html.lastIndexOf(marker);
|
||||||
@@ -214,6 +340,28 @@ function buildLifecycleScript(): string {
|
|||||||
].join('');
|
].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 {
|
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;`;
|
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
|
// gameClose must fire before every redirect
|
||||||
@@ -224,21 +372,18 @@ function buildCTAScript(network: string): string {
|
|||||||
case 'al':
|
case 'al':
|
||||||
case 'is':
|
case 'is':
|
||||||
// MRAID — mraid.js injected in <head>
|
// MRAID — mraid.js injected in <head>
|
||||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`;
|
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 'un':
|
|
||||||
// Unity Ads — MRAID-based; window.top already rewritten separately
|
|
||||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
|
|
||||||
break;
|
break;
|
||||||
case 'fb':
|
case 'fb':
|
||||||
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||||
break;
|
break;
|
||||||
case 'gg':
|
case 'gg':
|
||||||
|
case 'ap':
|
||||||
// Google — exitapi.js injected in <head>
|
// Google — exitapi.js injected in <head>
|
||||||
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
||||||
break;
|
break;
|
||||||
case 'mo':
|
case 'mo':
|
||||||
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
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;
|
break;
|
||||||
case 'vu':
|
case 'vu':
|
||||||
// Vungle — window.__VUNGLE__ flag set in body
|
// Vungle — window.__VUNGLE__ flag set in body
|
||||||
@@ -293,131 +438,122 @@ function getHtml(): string {
|
|||||||
const networks = [
|
const networks = [
|
||||||
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||||
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||||
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
|
|
||||||
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
||||||
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
||||||
{ tag: 'mo', label: 'Moloco', note: 'HTML' },
|
{ 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: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
|
||||||
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
||||||
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const checkboxRows = networks.map(n => `
|
const checkboxRows = networks.map(n => `
|
||||||
<label class="net-row">
|
<tr>
|
||||||
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
|
<td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
|
||||||
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
|
<td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
|
||||||
<span class="net-note">${n.note}</span>
|
<td><span class="net-note">${n.note}</span></td>
|
||||||
</label>`).join('');
|
</tr>`).join('');
|
||||||
|
|
||||||
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 10px; font-size: 13px; }
|
${getToolWebviewStyles()}
|
||||||
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
|
|
||||||
.section { margin-bottom: 14px; }
|
|
||||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
|
|
||||||
.row { display: flex; gap: 6px; align-items: center; }
|
|
||||||
input[type=text] {
|
|
||||||
flex: 1; padding: 4px 7px;
|
|
||||||
background: var(--vscode-input-background);
|
|
||||||
color: var(--vscode-input-foreground);
|
|
||||||
border: 1px solid var(--vscode-input-border, #555);
|
|
||||||
border-radius: 2px; font-size: 12px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
|
|
||||||
background: var(--vscode-button-secondaryBackground);
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
border: 1px solid var(--vscode-panel-border, #444);
|
|
||||||
}
|
|
||||||
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
|
||||||
button.primary {
|
|
||||||
background: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
border: none; padding: 6px 18px; font-size: 13px;
|
|
||||||
}
|
|
||||||
button.primary:hover { background: var(--vscode-button-hoverBackground); }
|
|
||||||
button:disabled { opacity: 0.45; cursor: default; }
|
|
||||||
.net-grid { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
|
|
||||||
.net-label { min-width: 100px; font-weight: 500; }
|
.net-label { min-width: 100px; font-weight: 500; }
|
||||||
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||||
.net-note { font-size: 11px; opacity: 0.6; }
|
.net-note { font-size: 11px; opacity: 0.6; }
|
||||||
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||||
.toggle-row button { font-size: 11px; padding: 2px 8px; }
|
|
||||||
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
|
|
||||||
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
|
|
||||||
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
|
|
||||||
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
|
|
||||||
.ok { color: #3fb950; }
|
|
||||||
.bad { color: #f85149; }
|
|
||||||
.res-net { min-width: 70px; font-weight: 500; }
|
|
||||||
.res-file { opacity: 0.65; word-break: break-all; }
|
|
||||||
.res-err { color: #f85149; opacity: 0.9; }
|
|
||||||
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>Playworks Converter</h2>
|
<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>
|
||||||
|
|
||||||
<div class="section">
|
<section class="tool-panel input-panel">
|
||||||
<div class="section-label">Source HTML (Playworks export)</div>
|
<div class="panel-header">
|
||||||
<div class="row">
|
<h3 class="panel-title">Inputs</h3>
|
||||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
|
|
||||||
<button id="pickSrc">Browse...</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="panel-body">
|
||||||
|
<div class="control-group">
|
||||||
<div class="section">
|
<div class="control-label">Source HTML (Playworks export)</div>
|
||||||
<div class="section-label">Output Folder</div>
|
<div class="field-row">
|
||||||
<div class="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..." />
|
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||||
<button id="pickOut">Browse...</button>
|
<button id="pickOut" class="secondary">Browse...</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
<hr />
|
<div class="control-label">Networks to Generate</div>
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-label">Networks to generate</div>
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<button id="selectAll">All</button>
|
<button id="selectAll" class="secondary">Select All</button>
|
||||||
<button id="selectNone">None</button>
|
<button id="selectNone" class="secondary">Select None</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="net-grid">${checkboxRows}</div>
|
<div class="results-panel" style="margin-top:0;">
|
||||||
</div>
|
<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 class="actions">
|
<div id="outputPanel" class="is-hidden">
|
||||||
<button class="primary" id="convert" disabled>Convert</button>
|
<div id="results" class="results-panel"></div>
|
||||||
<button id="openOut" style="display:none">Open Output Folder</button>
|
<div class="action-row">
|
||||||
|
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
<div id="status"></div>
|
|
||||||
<div class="results" id="results"></div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
let outputDir = '';
|
let outputDir = '';
|
||||||
|
|
||||||
const srcEl = document.getElementById('src');
|
const srcEl = document.getElementById('src');
|
||||||
|
const srcNameEl = document.getElementById('srcName');
|
||||||
const outEl = document.getElementById('outDir');
|
const outEl = document.getElementById('outDir');
|
||||||
|
const baseNameEl = document.getElementById('baseName');
|
||||||
const convertBtn = document.getElementById('convert');
|
const convertBtn = document.getElementById('convert');
|
||||||
const openOutBtn = document.getElementById('openOut');
|
const openOutBtn = document.getElementById('openOut');
|
||||||
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');
|
||||||
|
|
||||||
function updateConvertBtn() {
|
function updateConvertBtn() {
|
||||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||||
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
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('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||||
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||||
document.getElementById('selectAll').addEventListener('click', () => {
|
document.getElementById('selectAll').addEventListener('click', () => {
|
||||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||||
updateConvertBtn();
|
updateConvertBtn();
|
||||||
});
|
});
|
||||||
document.getElementById('selectNone').addEventListener('click', () => {
|
document.getElementById('selectNone').addEventListener('click', () => {
|
||||||
@@ -426,23 +562,32 @@ function getHtml(): string {
|
|||||||
});
|
});
|
||||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||||
outEl.addEventListener('input', updateConvertBtn);
|
outEl.addEventListener('input', updateConvertBtn);
|
||||||
|
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||||
|
|
||||||
openOutBtn.addEventListener('click', () => {
|
openOutBtn.addEventListener('click', () => {
|
||||||
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
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', () => {
|
document.getElementById('convert').addEventListener('click', () => {
|
||||||
const networks = [...document.querySelectorAll('.net-cb')]
|
const networks = [...document.querySelectorAll('.net-cb')]
|
||||||
.filter(c => c.checked).map(c => c.dataset.tag);
|
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||||
statusEl.textContent = 'Converting...';
|
statusEl.textContent = 'Converting...';
|
||||||
|
statusEl.classList.add('is-busy');
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
openOutBtn.style.display = 'none';
|
openOutBtn.style.display = 'none';
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
convertBtn.disabled = true;
|
convertBtn.disabled = true;
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'convert',
|
type: 'convert',
|
||||||
opts: {
|
opts: {
|
||||||
sourcePath: srcEl.value,
|
sourcePath: srcEl.value,
|
||||||
outputDir: outEl.value,
|
outputDir: outEl.value,
|
||||||
|
baseName: baseNameEl.value,
|
||||||
networks,
|
networks,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -452,45 +597,67 @@ function getHtml(): string {
|
|||||||
const msg = event.data;
|
const msg = event.data;
|
||||||
if (msg.type === 'sourcePicked') {
|
if (msg.type === 'sourcePicked') {
|
||||||
srcEl.value = msg.path;
|
srcEl.value = msg.path;
|
||||||
|
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
|
||||||
|
baseNameEl.value = msg.baseName || '';
|
||||||
if (!outEl.value && msg.dir) {
|
if (!outEl.value && msg.dir) {
|
||||||
outEl.value = msg.dir;
|
outEl.value = defaultOutputDir(msg.dir);
|
||||||
outputDir = msg.dir;
|
outputDir = outEl.value;
|
||||||
}
|
}
|
||||||
updateConvertBtn();
|
updateConvertBtn();
|
||||||
} else if (msg.type === 'outputPicked') {
|
} else if (msg.type === 'outputPicked') {
|
||||||
outEl.value = msg.path;
|
outEl.value = msg.path;
|
||||||
outputDir = msg.path;
|
outputDir = msg.path;
|
||||||
updateConvertBtn();
|
updateConvertBtn();
|
||||||
|
} else if (msg.type === 'status') {
|
||||||
|
statusEl.textContent = msg.message;
|
||||||
|
statusEl.className = 'status-panel is-busy';
|
||||||
} else if (msg.type === 'convertDone') {
|
} else if (msg.type === 'convertDone') {
|
||||||
convertBtn.disabled = false;
|
convertBtn.disabled = false;
|
||||||
updateConvertBtn();
|
updateConvertBtn();
|
||||||
if (msg.error) {
|
if (msg.error) {
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
statusEl.textContent = 'Error: ' + msg.error;
|
statusEl.textContent = 'Error: ' + msg.error;
|
||||||
|
outputPanel.classList.add('is-hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const results = msg.results || [];
|
const results = msg.results || [];
|
||||||
|
statusEl.classList.remove('is-busy');
|
||||||
const ok = results.filter(r => r.ok).length;
|
const ok = results.filter(r => r.ok).length;
|
||||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||||
|
if (!results.length) {
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
for (const r of results) {
|
outputPanel.classList.add('is-hidden');
|
||||||
const row = document.createElement('div');
|
return;
|
||||||
row.className = 'res-row';
|
|
||||||
const mark = document.createElement('span');
|
|
||||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
|
||||||
mark.textContent = r.ok ? '✓' : '✗';
|
|
||||||
const net = document.createElement('span');
|
|
||||||
net.className = 'res-net';
|
|
||||||
net.textContent = r.network;
|
|
||||||
const detail = document.createElement('span');
|
|
||||||
if (r.ok) {
|
|
||||||
detail.className = 'res-file';
|
|
||||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
|
||||||
} else {
|
|
||||||
detail.className = 'res-err';
|
|
||||||
detail.textContent = r.error || 'Unknown error';
|
|
||||||
}
|
}
|
||||||
row.append(mark, net, detail);
|
outputPanel.classList.remove('is-hidden');
|
||||||
resultsEl.appendChild(row);
|
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) {
|
if (ok > 0) {
|
||||||
outputDir = outEl.value;
|
outputDir = outEl.value;
|
||||||
|
|||||||
@@ -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