30 Commits

Author SHA1 Message Date
8e5d56ab1d v0.2.4 2026-06-22 11:45:36 +08:00
68d15aa208 Update manifest 2026-06-22 11:23:54 +08:00
b254daf06a Test manifest json 2026-06-22 11:17:47 +08:00
16de3cc116 Test Manifest for Project Initialization 2026-06-22 11:03:57 +08:00
5fa1ce949c v0.2.3 2026-06-16 10:21:41 +08:00
e50b4c2133 Added localization in Device Simulator 2026-06-16 10:20:49 +08:00
cebb65fb35 v0.2.2 2026-06-15 13:55:05 +08:00
7094e6c3b0 0.2.1 2026-06-08 22:35:56 +08:00
6c09beea43 0.2.1 2026-06-08 22:31:02 +08:00
ad6403f3e4 device simulator 2026-06-08 19:43:17 +08:00
700bb135ad update icon. 2026-06-05 09:26:49 +08:00
986993a027 0.2.0 2026-06-02 00:07:34 +08:00
9e24342142 0.2.0 2026-06-02 00:06:11 +08:00
b71e730482 UI changes for standalone 2026-06-01 23:58:44 +08:00
e3f454c5f2 0.1.8 2026-06-01 23:48:21 +08:00
88981d209b standalone mintegral checker 2026-06-01 23:36:46 +08:00
2524bacb5d mintegral checker 2026-06-01 23:18:06 +08:00
9e919756fd Add Mintegral playable checker 2026-06-01 21:59:52 +08:00
1c445e2771 minor changes to playworks converter, not a fix, just QOL 2026-05-29 14:16:50 +08:00
a2d80d2660 embed readme 2026-05-29 14:09:05 +08:00
1ee22b7155 readme 2026-05-29 13:51:47 +08:00
2ca858cfa4 changelog and final build 2026-05-29 13:50:09 +08:00
80f155dfe8 Add status progress for batch tool operations 2026-05-29 13:40:17 +08:00
5625d94106 drop zone, and remove beta tag from mraid checker 2026-05-29 13:20:33 +08:00
a1d3fde774 0.1.6 2026-05-28 18:00:10 +08:00
7201bf9845 playworks converter 2026-05-28 14:30:38 +08:00
2921d4d384 playworks converter 2026-05-28 14:23:48 +08:00
cce30c0729 update playworks checker 2026-05-28 14:17:26 +08:00
4f17523f26 playworks 2026-05-28 11:47:26 +08:00
274bafe1cb mraid checker 2026-05-28 11:38:41 +08:00
75 changed files with 15191 additions and 820 deletions

View File

@@ -1,26 +0,0 @@
# CHANGELOG
## v0.1.2:
- Added local hosting for html files
- Created standalone version
## v0.1.3
- Updated Applovin Demo Upload
- Changed file selection workflow
- Added "Regenerate QRs" if in case they don't render
- Added "Save QR" and "Save All QRs"
## v0.1.5
- Better file selection for PLEC Uoload
- Multi file selection
- Auto detects iteration name (it tries its best)
Standalone .exe
- Move temp files into user temp folder
## v0.1.6
- Simplify UI
- Added beta tools (needs further testing)
- Playworks Converter
- Upload UnityAds HTML from Playworks to add injections for the other networks
- MRAID Checker
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices

147
README.md
View File

@@ -2,29 +2,132 @@
Bundled VS Code extension and standalone tools for playable ad workflows.
# CHANGELOG
## v0.1.2:
- Added local hosting for html files
- Created standalone version
# Tools
## v0.1.3
- Updated Applovin Demo Upload
- Changed file selection workflow
- Added "Regenerate QRs" if in case they don't render
- Added "Save QR" and "Save All QRs"
- **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. Beta.
- **Initialize Project** - Download team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared Google Drive manifest.
## v0.1.5
- Better file selection for PLEC Uoload
- Multi file selection
- Auto detects iteration name (it tries its best)
# Changelog
Standalone .exe
- Move temp files into user temp folder
**v0.1.2**
```
Added
- Added local hosting for HTML files.
- Added the standalone app.
```
## v0.1.6
- Simplify UI
- Added beta tools (needs further testing)
- Playworks Converter
- Upload UnityAds HTML from Playworks to add injections for the other networks
- MRAID Checker
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices
**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.
```

155
TODO.md Normal file
View 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.

View File

@@ -73,18 +73,19 @@ const offY = (viewH - 1920 * s) / 2
## CTA — SDK Priority (triggerCTA fallback chain)
```
1. ExitApi.exit() → GoogleAds (gg)
2. FbPlayableAd.onCTAClick() → Facebook (fb)
2. FbPlayableAd.onCTAClick() → Facebook (fb) · Moloco (mo)
3. Luna.Unity.Playable → Unity (un)
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
5. window.install() → Mintegral (mtg)
6. window.openAppStore() → (runtime fallback)
7. window.clickTag → Moloco (mo)
7. window.clickTag → Moloco (mo) fallback
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
10. mraid.open(url) → Applovin (al) / Ironsource (is)
10. mraid.open(url) → Applovin (al) / Ironsource (is) / Unity (un fallback after Luna)
11. window.open(storeUrl) → Fallback (all others)
```
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
MRAID CTA calls must be guarded: require `typeof mraid.open === 'function'`; treat missing `mraid.getState()` as `ready`; if state is still `loading`, fall through to `window.open(storeUrl)`.
## Analytics — SDK Priority
```
@@ -107,15 +108,63 @@ gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCT
### Pause / Resume / Mute (bindLifecycle in networks.ts)
| 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` |
| 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 |
### MRAID 3.0 Requirements & Best Practices
MRAID networks are **Applovin (`al`)**, **Ironsource (`is`)**, and **Unity (`un`)**. All three must request `<script src="mraid.js"></script>` early in the generated HTML. Unity still uses Luna first for CTA/lifecycle where available, but MRAID must exist as the standards fallback.
Creative requirements from the MRAID 3.0 spec and Best Practices Guide:
- Request `mraid.js` as early as possible, exactly once, either with `<script src="mraid.js"></script>` or DOM insertion. Do not rely on the container to inject it automatically.
- Wait for both DOM readiness and MRAID readiness before starting rich media behavior. Use `document.readyState` / `DOMContentLoaded` or `load` for the DOM side.
- Use `mraid.getState()` together with `mraid.addEventListener('ready', ...)` so the creative still starts when the container fires `ready` before the listener attaches.
- Guard access to `mraid` and MRAID methods with `typeof` checks. Treat missing optional methods as unsupported and fall back gracefully.
- Use `mraid.open(url)` for MRAID clickthroughs. Avoid `<a href>`, `location.href` / `assign` / `replace`, and unguarded `window.open`.
- Include `<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">` or an equivalent mobile viewport tag.
- Do not assume nested iframes can access native MRAID. If nested iframe access is required, the outer frame must provide its own bridge.
- Use `exposureChange` as the MRAID 3.0 viewability signal, then degrade to `viewableChange` / `isViewable()` for MRAID 2.0 compatibility.
- Listen for `audioVolumeChange`; `null` is valid and must be ignored. Only apply volume math when the value is numeric.
- Listen for `error`; if the ad cannot run or assets fail, gracefully degrade or call `mraid.unload()` instead of showing a broken/blank ad.
- Call `setResizeProperties()` before `resize()`. Do not use `resize()` for full-screen takeovers; use `expand()` for that case.
- Avoid resizing or expanding interstitials. Check placement with `getPlacementType()` when one creative can serve inline and interstitial placements.
- Avoid `useCustomClose()` and custom close indicators in MRAID 3.0. The host provides the close control.
- Avoid two-part expandable ads (`expand(url)`); use self-contained one-part expandables.
- Call `supports()` before optional/native features such as `storePicture`, `createCalendarEvent`, `getLocation`, VPAID, SMS/tel, or inline video behavior.
- Use `playVideo()` for native video playback cases instead of `mraid.open(videoUrl)`. Use HTML5 `<video>` for inline playback when supported.
Implementation requirements for `networks.ts` / `networks.js`:
- Export `initMraid(timeoutMs = 2000, detectTimeoutMs = 500)` and call it at module load.
- If `window.mraid` is missing at module load, poll briefly (`detectTimeoutMs`, default 500 ms) for late container injection before resolving as no-MRAID.
- If MRAID exists and `mraid.getState() === 'loading'`, wait for `mraid.addEventListener('ready', ...)`; self-resolve after `timeoutMs` (default 2000 ms) so startup never hangs.
- `main` / boot must `await initMraid()` before constructing `new Phaser.Game(...)`.
- On setup, seed cached visibility from `mraid.isViewable()` when available.
- Register MRAID callbacks once, before any Phaser scene is ready:
- `mraid.addEventListener('error', (message, action) => ...)` — log or gracefully handle failed/unsupported MRAID calls.
- `mraid.addEventListener('stateChange', state => ...)` — required when using state-changing MRAID behavior such as `close`, `expand`, or `resize`.
- `mraid.addEventListener('exposureChange', exposedPercentage => ...)` — MRAID 3.0 primary viewability. Pause when `exposedPercentage <= 0`; resume when positive.
- `mraid.addEventListener('viewableChange', viewable => ...)` — MRAID 2.0 compatibility fallback.
- `mraid.addEventListener('audioVolumeChange', pct => ...)` — only apply `pct / 100` when `typeof pct === 'number'`.
- In `bindLifecycle(scene)`, apply cached hidden/non-exposed MRAID state immediately once a scene exists.
- If the creative renders its own close control, wire that control to a guarded `mraid.close()` helper and call `notifyGameClose()` before closing.
## Build
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
MRAID networks (`al`, `is`, `un`) inject exactly one `<script src="mraid.js"></script>` request.
If built output contains `console.error` from Phaser or bundled node modules, prefer dropping it at bundle time instead of editing library source. Add this option to the shared Vite config, usually `vite.shared.js`:
```js
export default {
esbuild: {
pure: ['console.error'],
},
};
```
Keep project-authored error handling meaningful: use visible fallback UI, `console.warn`, or guarded debug logging where needed.
| Network | Tag | Injected | Zipped | Included |
|---|---|---|---|---|
@@ -124,7 +173,7 @@ Build script strips `type="module"` and `crossorigin` — ad networks reject ES
| Ironsource | is | `mraid.js` | — | ✓ |
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
| Facebook | fb | — | — | ✓ |
| Unity | un | | — | ✓ |
| Unity | un | `mraid.js` | — | ✓ |
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
| Moloco | mo | — | — | ✓ |
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
@@ -146,7 +195,6 @@ dist/
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
Google/
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
index.zip
Ironsource/
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
…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 |
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
| Bundle > 5 MB | WebP q75, shorter audio |
| Built output contains library `console.error` | If `console.error` comes from Phaser or bundled node modules, add `esbuild: { pure: ['console.error'] }` in `vite.shared.js` so it is dropped at bundle time. Do not edit library source. |
| Audio blocked | Gate on `pointerdown` |
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
@@ -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. |
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top``window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
| MRAID network missing `mraid.js` | MRAID 3.0 creatives must request `mraid.js` early. Inject `<script src="mraid.js"></script>` exactly once for Applovin (`al`), Ironsource (`is`), and Unity (`un`), even if the container may also expose runtime MRAID objects. |
| MRAID listener wired too late | Previewers can probe MRAID before any Phaser scene is ready — if listeners are wired only inside `bindLifecycle` / scene `create()`, the creative can miss `ready`, `exposureChange`, or `viewableChange`. Fix: call `initMraid()` at `networks.ts` module-load time, guarded by `getState() === 'loading'``addEventListener('ready', setupMraid)` vs immediate call. Cache `_mraidViewable` / `_mraidExposed`; in `bindLifecycle` apply cached hidden/non-exposed state immediately. |
| MRAID 3.0 `exposureChange` not handled | MRAID 3.0 containers dispatch `exposureChange` with `exposedPercentage` directly instead of — or in addition to — `viewableChange`. If only listening to `viewableChange`, the ad may never receive pause signals on newer AL/IS/Unity builds. Register both: `mraid.addEventListener('exposureChange', exposedPercentage => onExposure(exposedPercentage > 0))` and `mraid.addEventListener('viewableChange', onViewable)` inside `initMraid()`. Seed initial state from `mraid.isViewable()` on ready. |
| MRAID 3.0 `audioVolumeChange` not handled | MRAID 3.0 containers fire `audioVolumeChange` with a volume level (0100) or `null` when unavailable. Without a listener, the ad ignores host-level mute/unmute signals; if `null` is divided by 100, the ad can mute accidentally. Register `mraid.addEventListener('audioVolumeChange', v => { if (typeof v === 'number') scene.sound.setVolume(v / 100) })` in `initMraid()`. |
| MRAID `error` listener missing | Validators expect failed or unsupported MRAID calls to be observable. Register `mraid.addEventListener('error', (message, action) => console.warn('[MRAID error]', { message, action }))` during early MRAID setup. |
| MRAID `stateChange` listener missing | If the creative uses state-changing container behavior such as `mraid.close()`, `expand`, or `resize`, validators expect a `stateChange` listener. Register `mraid.addEventListener('stateChange', state => console.log('[MRAID stateChange]', state))` with the other early MRAID callbacks. |
| Custom close control not wired to MRAID | If the creative renders its own close button, the button must call a guarded close helper: `notifyGameClose()` then `mraid.close()` when `typeof mraid.close === 'function'` and `mraid.getState() !== 'loading'`. Do not use CTA/install buttons as close controls. |
| MRAID init not awaited before Phaser boot | If `new Phaser.Game()` is created before `initMraid()` resolves, the scene may fire `create()` before MRAID state is known — `bindLifecycle` then cannot apply a cached hidden/paused state. Make `boot()` async and `await initMraid()` before constructing the Phaser game. `initMraid()` must self-resolve after a timeout (~2 s) so a missing or non-firing MRAID container never blocks startup. |
| Late MRAID injection not handled | Some containers inject `window.mraid` after the script executes — a synchronous `typeof window.mraid !== 'undefined'` check at module load returns false even though MRAID will be present. After the immediate check fails, poll briefly (~500 ms) before concluding no-MRAID, then proceed. |
| Unsafe optional MRAID API calls | `useCustomClose()` was removed in MRAID 3.0, and APIs such as `expand`, `resize`, `storePicture`, and `createCalendarEvent` are optional/capability-dependent. Do not call them unless the project intentionally supports the feature and guards every call with `typeof mraid.method === 'function'` / `mraid.supports(...)` where relevant. |
## Ironsource — Runtime Analysis
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.
## Moloco — Upload Checklist & CTA Implementation
Creative specs: HTML5 (`.html` / `.htm`), < 5 MB, portrait + landscape.
Before uploading to Moloco Ads, confirm all three:
- [ ] No `XMLHttpRequest` anywhere in the ad (Moloco rejects it outright)
- [ ] CTA calls `FbPlayableAd.onCTAClick()` with no parameters — Moloco uses this for store redirect (not `window.clickTag`)
- [ ] No JavaScript redirects in ad file assets
### CTA implementation options
**Option 1 — event listener**
```html
<button id="ctaButton">Install</button>
<script>
document.getElementById('ctaButton').addEventListener('click', function() {
FbPlayableAd.onCTAClick();
});
</script>
```
**Option 2 — inline onClick**
```html
<button onclick="FbPlayableAd.onCTAClick()">Install</button>
```
Moloco's validator requires `FbPlayableAd.onCTAClick()` — this is why it shares priority #2 in the CTA chain with Facebook. `window.clickTag` is kept as a fallback at priority #7.
***EOF***
[05/28/2026]

View File

@@ -1,19 +0,0 @@
# Unified Layout
[Select Folder] [Select Files] [Clear]
(PLEC UPLOAD)
| FILENAME |ITERATION NAME| |
|-----------------|--------------|---|
|pato-to-file.html| 01_full | X |
|pato-to-file.html| 01_full | X |
|pato-to-file.html| 01_full | X |
(AppLovin Playable Preview)(Base64 Scanner)(Send To Mobile)(MRAID Checker)
| FILENAME | |
|-----------------|-------|
|pato-to-file.html| X |
|pato-to-file.html| X |
|pato-to-file.html| X |
[Scan]/[Upload]/[Start]

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -112,8 +112,14 @@ if ($Standalone) {
$exeName = "hpl-toolbox-$version.exe"
$exeOut = Join-Path $distDir $exeName
go build -ldflags="-s -w -H windowsgui -X main.AppVersion=$version" -trimpath -o $exeOut .
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
$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 }
} finally {
Remove-Item -Path $embeddedReadme -Force -ErrorAction SilentlyContinue
}
$summary += [pscustomobject]@{
Artifact = $exeName

Binary file not shown.

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

Binary file not shown.

17
manifest.json Normal file
View 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"
}
]

File diff suppressed because one or more lines are too long

53
package-lock.json generated
View File

@@ -1,15 +1,17 @@
{
"name": "hpl-toolbox",
"version": "0.1.0",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hpl-toolbox",
"version": "0.1.0",
"version": "0.1.6",
"license": "UNLICENSED",
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.85.0",
"playwright": "^1.60.0",
"typescript": "^5.4.0"
},
"engines": {
@@ -33,6 +35,53 @@
"dev": true,
"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": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -2,7 +2,7 @@
"name": "hpl-toolbox",
"displayName": "HPL Toolbox",
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
"version": "0.1.6",
"version": "0.2.4",
"publisher": "hesukastro",
"license": "UNLICENSED",
"repository": {
@@ -43,6 +43,22 @@
{
"command": "hplToolbox.openPlayworksConverter",
"title": "HPL Toolbox: Open Playworks Converter"
},
{
"command": "hplToolbox.openMintegralChecker",
"title": "HPL Toolbox: Open Mintegral Checker"
},
{
"command": "hplToolbox.openDeviceSimulator",
"title": "HPL Toolbox: Open Device Simulator"
},
{
"command": "hplToolbox.checkForUpdates",
"title": "HPL Toolbox: Check for Updates"
},
{
"command": "hplToolbox.openProjectInit",
"title": "HPL Toolbox: Initialize Project"
}
],
"viewsContainers": {
@@ -119,18 +135,28 @@
"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."
}
}
}
]
},
"scripts": {
"sync-readme": "node scripts/sync-readme.js",
"compile": "npm run sync-readme && tsc -p ./",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"vscode:prepublish": "npm run compile"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.85.0",
"playwright": "^1.60.0",
"typescript": "^5.4.0"
}
}

11
run-standalone.ps1 Normal file
View File

@@ -0,0 +1,11 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$standalone = Join-Path $root 'standalone'
Push-Location $standalone
try {
go run .
} finally {
Pop-Location
}

View File

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

View File

@@ -1,20 +0,0 @@
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const changelogPath = path.join(root, 'CHANGELOG.md');
const readmePath = path.join(root, 'README.md');
let changelog = '# CHANGELOG\n\nNo changelog entries yet.';
if (fs.existsSync(changelogPath)) {
changelog = fs.readFileSync(changelogPath, 'utf8').trim();
}
const readme = `# HPL Toolbox
Bundled VS Code extension and standalone tools for playable ad workflows.
${changelog}
`;
fs.writeFileSync(readmePath, readme.replace(/\r?\n/g, '\n'), 'utf8');

View File

@@ -9,10 +9,10 @@ export function openChangelog(context: vscode.ExtensionContext) {
vscode.ViewColumn.One,
{ enableScripts: false }
);
const changelogPath = path.join(context.extensionPath, 'CHANGELOG.md');
let markdown = '# Changelog\n\nCHANGELOG.md was not found.';
const readmePath = path.join(context.extensionPath, 'README.md');
let markdown = '# Changelog\n\nREADME.md was not found.';
try {
markdown = fs.readFileSync(changelogPath, 'utf8');
markdown = fs.readFileSync(readmePath, 'utf8');
} catch {
// Keep the fallback text readable in packaged or development installs.
}
@@ -50,6 +50,14 @@ function getHtml(content: string): string {
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;
@@ -62,6 +70,30 @@ function getHtml(content: string): string {
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>
@@ -74,6 +106,73 @@ 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) {
@@ -88,29 +187,30 @@ function renderMarkdown(markdown: string): string {
closeList();
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
closeList();
const level = Math.min(heading[1].length, 2);
html.push(`<h${level}>${escapeHtml(heading[2])}</h${level}>`);
continue;
}
const listItem = line.match(/^-\s+(.+)$/);
if (listItem) {
if (!inList) {
html.push('<ul>');
inList = true;
}
html.push(`<li>${escapeHtml(listItem[1])}</li>`);
const nested = rawLine.match(/^\s{8,}-\s+/) ? ' class="nested"' : '';
html.push(`<li${nested}>${renderInlineMarkdown(listItem[1])}</li>`);
continue;
}
closeList();
html.push(`<p>${escapeHtml(line)}</p>`);
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 => ({
'&': '&amp;',

View File

@@ -6,7 +6,11 @@ import { openBase64Scanner } from './tools/base64Scanner';
import { openMraidChecker } from './tools/mraidChecker';
import { openSendToMobile } from './tools/sendToMobile';
import { openPlayworksConverter } from './tools/playworksConverter';
import { openMintegralChecker } from './tools/mintegralChecker';
import { openChangelog } from './changelogView';
import { openDeviceSimulator } from './tools/deviceSimulator';
import { openProjectInit } from './tools/projectInit';
import { checkForUpdates } from './updateChecker';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
@@ -16,9 +20,15 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
vscode.commands.registerCommand('hplToolbox.openMintegralChecker', () => openMintegralChecker(context)),
vscode.commands.registerCommand('hplToolbox.openDeviceSimulator', () => openDeviceSimulator(context)),
vscode.commands.registerCommand('hplToolbox.openProjectInit', () => openProjectInit(context)),
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
vscode.commands.registerCommand('hplToolbox.checkForUpdates', () => checkForUpdates(context)),
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
);
void checkForUpdates(context);
}
export function deactivate() {}

View File

@@ -1,15 +1,25 @@
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 TOOLS: ToolDefinition[] = [
const FALLBACK_TOOLS: ToolDefinition[] = [
{
id: 'project-init',
command: 'hplToolbox.openProjectInit',
title: 'Initialize Project',
description: 'Download AGENTS.md and .gitignore templates from Google Drive',
},
{
command: 'hplToolbox.openPlecUpload',
title: 'PLEC Upload',
@@ -25,17 +35,16 @@ const TOOLS: ToolDefinition[] = [
title: 'Base64 Scanner',
description: 'Find non-base64 assets in HTML',
},
{
command: 'hplToolbox.openMraidChecker',
title: 'MRAID Checker',
description: 'Check MRAID requirements and best practices',
beta: true,
},
{
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',
@@ -50,23 +59,49 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
resolveWebviewView(view: vscode.WebviewView) {
view.webview.options = { enableScripts: true };
const version = this.context.extension.packageJSON.version as string;
const tools = loadToolDefinitions(this.context.extensionPath);
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
view.webview.html = getHtml(version, betaToolsEnabled);
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') {
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);
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, betaToolsEnabled: boolean): string {
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta));
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>
<html>
@@ -104,6 +139,9 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
cursor: pointer;
font: inherit;
}
.tool-btn.dragging {
opacity: 0.55;
}
.tool-btn:hover {
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
@@ -174,6 +212,7 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
opacity: 0.75;
text-align: center;
white-space: nowrap;
justify-self: center;
}
.changelog-link {
justify-self: end;
@@ -187,16 +226,51 @@ ${toolButtons}
</div>
<div class="footer">
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
<span class="footer-version">${version} - JJGC 00784</span>
<button id="updateCheck" class="footer-action footer-version" title="Check for updates">${version} - JJGC 00784</button>
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
</div>
<script>
const vscode = acquireVsCodeApi();
document.querySelectorAll('.tool-btn').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.dragging === 'true') {
btn.dataset.dragging = 'false';
return;
}
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 });
@@ -204,22 +278,37 @@ ${toolButtons}
document.getElementById('changelogLink').addEventListener('click', () => {
vscode.postMessage({ type: 'openChangelog' });
});
document.getElementById('updateCheck').addEventListener('click', () => {
vscode.postMessage({ type: 'checkForUpdates' });
});
</script>
</body>
</html>`;
}
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta)));
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-cmd="${escapeHtml(tool.command)}">
return ` <button class="tool-btn" data-id="${escapeHtml(toolId(tool))}" data-cmd="${escapeHtml(tool.command)}">
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
<span class="tool-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 => ({
'&': '&amp;',

View File

@@ -134,11 +134,11 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
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);
}
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;
try {
@@ -154,7 +154,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
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 {
res = await fetch('https://p.applov.in/getCachedAdURL', {
@@ -460,6 +460,7 @@ ${getToolWebviewStyles()}
uploadBtn.addEventListener('click', () => {
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (!selectedPaths.length) {
@@ -467,6 +468,8 @@ ${getToolWebviewStyles()}
return;
}
uploadBtn.disabled = true;
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
resultItems = [];
saveAllBtn.disabled = true;
regenAllBtn.disabled = true;
@@ -504,8 +507,9 @@ ${getToolWebviewStyles()}
regenAllBtn.disabled = true;
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = 'status-panel';
statusEl.className = 'status-panel is-busy';
} else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
const div = document.createElement('div');
div.className = 'err';
div.textContent = m.message;
@@ -558,6 +562,7 @@ ${getToolWebviewStyles()}
saveAllBtn.disabled = resultItems.length === 0;
regenAllBtn.disabled = resultItems.length === 0;
} else if (m.type === 'done') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Done.</span>';
uploadBtn.disabled = false;
}

View File

@@ -58,7 +58,9 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
return;
}
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 {
const content = fs.readFileSync(file, 'utf8');
const offenders = findNonBase64Assets(content);
@@ -279,11 +281,13 @@ ${getToolWebviewStyles()}
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) {
@@ -310,13 +314,18 @@ ${getToolWebviewStyles()}
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 flagged = msg.results.filter(r => !r.ok).length;
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
if (!total) {

1150
src/tools/deviceSimulator.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ interface MraidRule {
}
interface ScanContext {
filePath: string;
html: string;
scriptText: string;
lowerHtml: string;
@@ -115,10 +116,12 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
}
const results: MraidResult[] = [];
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 {
const content = fs.readFileSync(file, 'utf8');
const issues = checkMraid(content);
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 {
@@ -164,9 +167,10 @@ async function collectHtmlFiles(root: string): Promise<string[]> {
return out;
}
function checkMraid(html: string): MraidIssue[] {
function checkMraid(html: string, filePath = ''): MraidIssue[] {
const scriptText = extractScriptText(html);
const ctx: ScanContext = {
filePath,
html,
scriptText,
lowerHtml: html.toLowerCase(),
@@ -226,6 +230,14 @@ 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);
}
@@ -253,6 +265,77 @@ function hasReadyGate(ctx: ScanContext): boolean {
);
}
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);
@@ -264,15 +347,32 @@ function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
}
function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
return (
/\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) ||
/\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText)
);
return /\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText);
}
const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/';
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',
@@ -281,6 +381,30 @@ const MRAID_RULES: MraidRule[] = [
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',
@@ -289,6 +413,30 @@ const MRAID_RULES: MraidRule[] = [
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',
@@ -300,21 +448,45 @@ const MRAID_RULES: MraidRule[] = [
{
id: 'viewability',
severity: 'best-practice',
title: 'Viewability handling not found',
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.',
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 = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange');
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.',
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) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null,
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',
@@ -327,29 +499,93 @@ const MRAID_RULES: MraidRule[] = [
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 supports check',
detail: 'Check mraid.supports("expand") before using expand behavior.',
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') && !hasSupportCheck(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null,
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 supports check',
detail: 'Check mraid.supports("resize") before using resize behavior.',
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') && !hasSupportCheck(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
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 supports check',
detail: 'Check mraid.supports("orientation") before requiring orientation behavior.',
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') && !hasSupportCheck(ctx, 'orientation') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasMraidFunctionGuard(ctx, 'setOrientationProperties') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
},
{
id: 'size-change',
@@ -373,6 +609,35 @@ const MRAID_RULES: MraidRule[] = [
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) => {
@@ -412,7 +677,7 @@ ${getToolWebviewStyles()}
<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 folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
<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">
@@ -533,11 +798,13 @@ ${getToolWebviewStyles()}
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) {
@@ -564,13 +831,18 @@ ${getToolWebviewStyles()}
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).';

View File

@@ -68,6 +68,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
let html: string;
try {
panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' });
html = fs.readFileSync(opts.sourcePath, 'utf8');
} catch (e: any) {
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
@@ -75,7 +76,15 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
}
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 {
const filePath = await convertNetwork(html, opts, network);
results.push({ network, file: filePath, ok: true });
@@ -94,13 +103,16 @@ 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',
un: 'Unity',
fb: 'Facebook',
gg: 'GoogleAds',
ap: 'Appreciate',
mo: 'Moloco',
vu: 'Vungle',
mtg: 'Mintegral',
@@ -109,7 +121,7 @@ const NETWORK_OUTPUT_FOLDERS: Record<string, string> = {
function sourceBaseName(sourcePath: string): string {
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 {
@@ -120,6 +132,9 @@ function outputBaseName(opts: ConvertOptions): 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 needsZip = ZIPPED_NETWORKS.has(network);
const baseName = outputBaseName(opts);
@@ -144,40 +159,130 @@ async function convertNetwork(html: string, opts: ConvertOptions, network: strin
return outPath;
}
function copyUnityInput(opts: ConvertOptions): string {
const baseName = outputBaseName(opts);
const outputDir = path.join(opts.outputDir, 'Unity');
const outPath = path.join(outputDir, `${baseName}_un.html`);
fs.mkdirSync(outputDir, { recursive: true });
if (path.resolve(opts.sourcePath) === path.resolve(outPath)) {
return outPath;
}
fs.copyFileSync(opts.sourcePath, outPath);
return outPath;
}
function transformHtml(html: string, network: string): string {
let result = html;
let result = sanitizePlayworksHtml(html);
// Inject into </head>: lifecycle stubs only (must register luna:build listener
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
result = removeNetworkSdkScripts(result);
let headInject = buildLifecycleScript();
if (network === 'al' || network === 'is') {
headInject += '<script src="mraid.js"></script>';
} else if (network === 'gg') {
if (MRAID_NETWORKS.has(network)) {
headInject += '<script src="mraid.js"></script>' + buildMraidComplianceScript();
} else if (EXITAPI_NETWORKS.has(network)) {
headInject += '<script src="exitapi.js"></script>';
}
result = result.replace('</head>', headInject + '\n</head>');
result = injectBeforeHeadClose(result, headInject);
// Body-level flags
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') {
result = result.replace('<body>', '<body onload="gameReady()">');
result = addBodyOnload(result, 'gameReady()');
} 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
result = replaceCTAScript(result, network);
// Unity: rewrite window.top → window.self (Luna static scan requirement)
if (network === 'un') {
result = result.split('window.top').join('window.self');
}
return finalizeNetworkHtml(result, network);
}
function sanitizePlayworksHtml(html: string): string {
let result = cleanupPlayworksHtml(html);
return removeNetworkSdkScripts(result);
}
function cleanupPlayworksHtml(html: string): string {
let result = html;
result = trimAfterFirstHtmlClose(result);
result = result.replace(/<script\b[^>]*>[\s\S]*?insertYourRemoteDebuggingTokenHere[\s\S]*?<\/script>\s*/gi, '');
result = result.replace(/https:\/\/mrdoob\.github\.io\/stats\.js\/build\/stats\.min\.js/gi, 'data:text/javascript,');
result = result.replace(/\s+crossorigin(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
result = result.replace(/\s+type\s*=\s*["']module["']/gi, '');
result = result.replace(/\bconsole\.error\s*\(/g, 'console.warn(');
return result;
}
function trimAfterFirstHtmlClose(html: string): string {
const idx = html.search(/<\/html\s*>/i);
if (idx === -1) return html;
const close = html.match(/<\/html\s*>/i);
return close ? html.slice(0, idx + close[0].length) : html;
}
function finalizeNetworkHtml(html: string, network: string): string {
let result = cleanupPlayworksHtml(html);
result = dedupeScriptSrc(result, 'mraid.js', MRAID_NETWORKS.has(network));
result = dedupeScriptSrc(result, 'exitapi.js', EXITAPI_NETWORKS.has(network));
return result;
}
function removeNetworkSdkScripts(html: string): string {
let result = html;
for (const src of ['mraid.js', 'exitapi.js']) {
const escaped = src.replace('.', '\\.');
result = result.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), '');
}
return result;
}
function dedupeScriptSrc(html: string, src: string, shouldExist: boolean): string {
const escaped = src.replace('.', '\\.');
let seen = false;
let result = html.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), (match) => {
if (shouldExist && !seen) {
seen = true;
return `<script src="${src}"></script>`;
}
return '';
});
if (shouldExist && !seen) {
result = injectBeforeHeadClose(result, `<script src="${src}"></script>`);
}
return result;
}
function injectBeforeHeadClose(html: string, injection: string): string {
if (/<\/head>/i.test(html)) {
return html.replace(/<\/head>/i, `${injection}\n</head>`);
}
return `${injection}\n${html}`;
}
function injectAfterBodyOpen(html: string, injection: string): string {
if (/<body\b[^>]*>/i.test(html)) {
return html.replace(/<body\b[^>]*>/i, match => `${match}\n${injection}`);
}
return `${injection}\n${html}`;
}
function addBodyOnload(html: string, handler: string): string {
if (!/<body\b[^>]*>/i.test(html)) {
return `<body onload="${handler}">\n${html}`;
}
return html.replace(/<body\b([^>]*)>/i, (match, attrs: string) => {
const onloadMatch = attrs.match(/\bonload\s*=\s*(["'])(.*?)\1/i);
if (!onloadMatch) return `<body${attrs} onload="${handler}">`;
if (onloadMatch[2].includes(handler)) return match;
const updated = `${onloadMatch[2]};${handler}`;
return match.replace(onloadMatch[0], `onload=${onloadMatch[1]}${updated}${onloadMatch[1]}`);
});
}
function replaceCTAScript(html: string, network: string): string {
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
const markerIdx = html.lastIndexOf(marker);
@@ -235,6 +340,28 @@ function buildLifecycleScript(): string {
].join('');
}
function buildMraidComplianceScript(): string {
return [
'<script>(function(){',
'var m=null,scene=null,viewable=true,exposed=true,volume=null;',
'function get(){return window.mraid||m;}',
'function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}',
'function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}',
'function onViewable(v){viewable=!!v;apply();}',
'function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}',
'function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}',
'function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}',
'function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}',
'window.__hplMraidUnload=unload;',
'function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}',
'function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}',
'window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};',
'if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}',
'setTimeout(setup,2000);',
'})();</script>',
].join('');
}
function buildCTAScript(network: string): string {
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
// gameClose must fire before every redirect
@@ -245,21 +372,18 @@ function buildCTAScript(network: string): string {
case 'al':
case 'is':
// 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");}`;
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");}`;
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`;
break;
case 'fb':
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
break;
case 'gg':
case 'ap':
// Google — exitapi.js injected in <head>
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
break;
case 'mo':
ctaLogic = `${closeCall}if(typeof 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;
case 'vu':
// Vungle — window.__VUNGLE__ flag set in body
@@ -314,10 +438,10 @@ function getHtml(): string {
const networks = [
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
{ 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: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
@@ -422,6 +546,10 @@ ${getToolWebviewStyles()}
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
}
function defaultOutputDir(dir) {
return dir ? dir.replace(/[\\\\/]+$/, '') + '\\\\Output' : '';
}
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
document.getElementById('selectAll').addEventListener('click', () => {
@@ -449,6 +577,7 @@ ${getToolWebviewStyles()}
const networks = [...document.querySelectorAll('.net-cb')]
.filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = '';
openOutBtn.style.display = 'none';
outputPanel.classList.add('is-hidden');
@@ -471,23 +600,28 @@ ${getToolWebviewStyles()}
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
baseNameEl.value = msg.baseName || '';
if (!outEl.value && msg.dir) {
outEl.value = msg.dir;
outputDir = msg.dir;
outEl.value = defaultOutputDir(msg.dir);
outputDir = outEl.value;
}
updateConvertBtn();
} else if (msg.type === 'outputPicked') {
outEl.value = msg.path;
outputDir = msg.path;
updateConvertBtn();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'convertDone') {
convertBtn.disabled = false;
updateConvertBtn();
if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const results = msg.results || [];
statusEl.classList.remove('is-busy');
const ok = results.filter(r => r.ok).length;
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
if (!results.length) {

View File

@@ -101,11 +101,13 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
for (let i = 0; i < valid.length; i++) {
const r = valid[i];
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 file = new File([buf], stampedNames[i], { type: 'text/html' });
form.append(`fileUpload_${idx}`, file as any);
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;
try {
@@ -392,11 +394,14 @@ ${getToolWebviewStyles()}
document.getElementById('upload').addEventListener('click', () => {
clearErrors();
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
setPreview('');
if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return;
}
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'upload', rows });
});
openResultBtn.addEventListener('click', () => {
@@ -417,19 +422,22 @@ ${getToolWebviewStyles()}
addFiles(m.files || []);
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = 'status-panel';
statusEl.className = 'status-panel is-busy';
} else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '';
const div = document.createElement('div');
div.className = 'err';
div.textContent = m.message;
statusEl.appendChild(div);
} else if (m.type === 'rowError') {
statusEl.classList.remove('is-busy');
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>';
} else if (m.type === 'result') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
setPreview(m.preview);
}

266
src/tools/projectInit.ts Normal file
View 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()">&#8635;</button>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="openManifestSettings()">&#9881;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);
}

View File

@@ -103,13 +103,16 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
return;
}
const files: SharedFile[] = [];
for (const filePath of filePaths) {
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
if (!filePath || !fs.existsSync(filePath)) {
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
return;
}
panel.webview.postMessage({ type: 'status', message: `Loading ${i + 1}/${filePaths.length} ${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 cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
@@ -231,7 +234,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(file.buf);
res.end(mobilePreviewPage(file));
return;
}
@@ -293,6 +296,35 @@ function chooserPage(files: SharedFile[]): string {
</body></html>`;
}
function mobilePreviewPage(file: SharedFile): string {
const playableJson = JSON.stringify(file.buf.toString('utf8')).replace(/<\/script/gi, '<\\/script');
const title = file.filename.replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string)
);
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>${title}</title>
<style>
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
</style>
</head><body>
<div id="boot" class="boot">Loading preview...</div>
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
<script>
const playableHtml = ${playableJson};
setTimeout(() => {
const frame = document.getElementById('playable');
document.getElementById('boot').style.display = 'none';
frame.srcdoc = playableHtml;
}, 750);
</script>
</body></html>`;
}
interface LanIp { address: string; iface: string; }
function getLanIps(): LanIp[] {
@@ -421,6 +453,7 @@ ${getToolWebviewStyles()}
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
});
startBtn.addEventListener('click', () => {
@@ -432,6 +465,7 @@ ${getToolWebviewStyles()}
}
startBtn.disabled = true;
statusEl.textContent = 'Starting server...';
statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
});
@@ -547,7 +581,11 @@ ${getToolWebviewStyles()}
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') {
statusEl.classList.remove('is-busy');
currentUrls = m.urls;
selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
@@ -558,6 +596,7 @@ ${getToolWebviewStyles()}
startBtn.disabled = true;
stopBtn.disabled = false;
} else if (m.type === 'stopped') {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none';
ifaceWrap.style.display = 'none';
@@ -566,6 +605,7 @@ ${getToolWebviewStyles()}
startBtn.disabled = false;
stopBtn.disabled = true;
} else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '&lt;') + '</span>';
startBtn.disabled = false;
}

View File

@@ -207,6 +207,24 @@ export function getToolWebviewStyles(): string {
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;
}

80
src/updateChecker.ts Normal file
View 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));
}

View File

@@ -44,10 +44,13 @@ func ApplovinPage(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></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 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 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>
@@ -154,6 +157,15 @@ regenAllBtn.addEventListener('click', () => {
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', () => {
selectedPaths = [];
@@ -184,12 +196,15 @@ pickBtn.addEventListener('click', async () => {
uploadBtn.addEventListener('click', async () => {
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = '';
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return;
}
uploadBtn.disabled = true;
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
resultItems = [];
saveAllBtn.disabled = true;
regenAllBtn.disabled = true;
@@ -200,6 +215,7 @@ uploadBtn.addEventListener('click', async () => {
body: JSON.stringify({ paths: selectedPaths }),
});
if (!res.ok || !res.body) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
uploadBtn.disabled = false;
return;
@@ -219,6 +235,7 @@ uploadBtn.addEventListener('click', async () => {
}
}
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
} finally {
uploadBtn.disabled = false;
@@ -240,6 +257,7 @@ qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
function handle(m) {
if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.classList.add('is-busy');
} else if (m.type === 'fileError') {
const tbody = ensureResultsTable();
resultsEl.querySelector('table').classList.add('has-errors');
@@ -296,6 +314,7 @@ function handle(m) {
saveAllBtn.disabled = resultItems.length === 0;
regenAllBtn.disabled = resultItems.length === 0;
} else if (m.type === 'done') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Done.</span>';
}
}
@@ -432,11 +451,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
}
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)
}
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)
if err != nil {
@@ -461,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)
if err != nil {

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
@@ -21,10 +22,13 @@ func Base64Page(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></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 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>
@@ -86,7 +90,29 @@ function addPaths(paths) {
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/base64/pickFolder', { method: 'POST' });
@@ -102,19 +128,39 @@ 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.textContent = 'Pick files first.'; return; }
if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
const payload = { files: pickedFiles };
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json();
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
let j = null;
try {
const r = await fetch('/api/base64/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 };
}
});
} 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 flagged = j.results.filter(r => !r.ok).length;
statusEl.classList.remove('is-busy');
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>';
@@ -158,12 +204,13 @@ type scanResult struct {
}
func Base64Scan(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 {
writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
@@ -183,12 +230,13 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
targets = collectHTMLFiles(req.Folder)
baseDir = req.Folder
} 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
}
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)
if err != nil {
continue
@@ -209,7 +257,7 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
Assets: offenders,
})
}
writeJSON(w, map[string]any{"results": results})
send(map[string]any{"type": "done", "results": results})
}
func commonBaseDir(files []string) string {

View File

@@ -13,7 +13,7 @@ func ChangelogPage(w http.ResponseWriter, r *http.Request) {
body := `
<header class="tool-header">
<h2 class="tool-title">Changelog</h2>
<p class="tool-description">Loaded from CHANGELOG.md.</p>
<p class="tool-description">Loaded from README.md.</p>
</header>
<section class="tool-panel changelog-panel">
<div class="panel-body changelog-content">
@@ -24,9 +24,15 @@ func ChangelogPage(w http.ResponseWriter, r *http.Request) {
.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")
@@ -34,6 +40,9 @@ func ChangelogPage(w http.ResponseWriter, r *http.Request) {
}
func readChangelog() string {
if embeddedReadme != "" {
return embeddedReadme
}
for _, start := range changelogSearchRoots() {
for _, candidate := range changelogCandidates(start) {
data, err := os.ReadFile(candidate)
@@ -42,7 +51,7 @@ func readChangelog() string {
}
}
}
return "# Changelog\n\nCHANGELOG.md was not found."
return "# Changelog\n\nREADME.md was not found."
}
func changelogSearchRoots() []string {
@@ -58,7 +67,7 @@ func changelogCandidates(start string) []string {
candidates := []string{}
dir := filepath.Clean(start)
for i := 0; i < 5; i++ {
candidates = append(candidates, filepath.Join(dir, "CHANGELOG.md"))
candidates = append(candidates, filepath.Join(dir, "README.md"))
parent := filepath.Dir(dir)
if parent == dir {
break
@@ -72,6 +81,8 @@ 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>")
@@ -80,6 +91,22 @@ func renderChangelogMarkdown(markdown string) string {
}
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()
@@ -87,17 +114,22 @@ func renderChangelogMarkdown(markdown string) string {
}
if strings.HasPrefix(line, "### ") {
closeList()
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[4:])) + "</h2>")
out.WriteString("<h2>" + renderInlineMarkdown(strings.TrimSpace(line[4:])) + "</h2>")
continue
}
if strings.HasPrefix(line, "## ") {
closeList()
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[3:])) + "</h2>")
out.WriteString("<h2>" + renderInlineMarkdown(strings.TrimSpace(line[3:])) + "</h2>")
continue
}
if strings.HasPrefix(line, "# ") {
closeList()
out.WriteString("<h1>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</h1>")
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, "- ") {
@@ -105,12 +137,93 @@ func renderChangelogMarkdown(markdown string) string {
out.WriteString("<ul>")
inList = true
}
out.WriteString("<li>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</li>")
out.WriteString("<li>" + renderInlineMarkdown(strings.TrimSpace(line[2:])) + "</li>")
continue
}
closeList()
out.WriteString("<p>" + html.EscapeString(line) + "</p>")
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
if n.Beta {
badge = ` <span class="beta-pill">Beta</span>`
}
items.WriteString(`<button type="button" class="home-tool" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
items.WriteString(`<button type="button" class="home-tool" draggable="true" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
}
body := `
@@ -43,6 +43,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
)
@@ -9,3 +10,27 @@ 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"
}

View File

@@ -2,8 +2,13 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const SharedCSS = `
@@ -20,30 +25,111 @@ const SharedCSS = `
html { min-height: 100%; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: var(--sidebar-width, 236px) minmax(0, 1fr);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e; color: #ddd;
margin: 0; padding: 0;
}
.topbar {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
body.sidebar-collapsed { --sidebar-width: 56px; }
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 8px;
background: #252526;
border-right: 1px solid #333;
font-size: 13px;
overflow: hidden;
}
.topbar .nav-link {
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 2px 6px;
border-bottom: 1px solid #333;
}
.sidebar-title {
min-width: 0;
flex: 1;
color: #ddd;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.45px;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-toggle {
width: 32px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
background: transparent;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
font-size: 16px;
line-height: 1;
}
.sidebar-toggle:hover { background: #2a2d2e; }
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: auto;
}
.sidebar .nav-link {
width: 100%;
min-height: 34px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 8px;
background: transparent;
color: #ddd;
border: 0;
padding: 5px 10px;
padding: 4px 8px 4px 4px;
border-radius: 4px;
cursor: pointer;
font: inherit;
text-align: left;
}
.sidebar .nav-link:hover { background: #2a2d2e; }
.sidebar .nav-link.active { background: #094771; color: #fff; }
.sidebar .nav-link.dragging { opacity: 0.55; }
.nav-icon {
width: 28px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
background: rgba(128,128,128,0.12);
color: #ddd;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.nav-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar .nav-link:hover { background: #2a2d2e; }
.topbar .nav-link.active { background: #094771; color: #fff; }
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
.topbar-spacer { margin-left: auto; }
.app-shell {
min-width: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
.app-footer {
width: 100%;
@@ -73,6 +159,77 @@ const SharedCSS = `
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
.app-footer-version-button { justify-self: center; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-version-button:hover { background: transparent; opacity: 1; text-decoration: underline; }
.toast-box {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 50;
max-width: min(420px, calc(100vw - 36px));
padding: 10px 12px;
display: none;
align-items: center;
gap: 10px;
color: #ddd;
background: #252526;
border: 1px solid #444;
border-left: 3px solid #007fd4;
border-radius: 5px;
box-shadow: 0 10px 28px rgba(0,0,0,0.35);
font-size: 12px;
line-height: 1.35;
}
.toast-box.is-visible { display: flex; }
.toast-box.is-update { border-left-color: #ffd580; }
.toast-box.is-current { border-left-color: #89d185; }
.toast-box.is-error { border-left-color: #f48771; }
.toast-message { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.toast-action {
flex: 0 0 auto;
padding: 4px 8px;
background: #0e639c;
color: #fff;
border: 0;
border-radius: 2px;
font-size: 12px;
}
.toast-close {
flex: 0 0 auto;
width: 22px;
height: 22px;
padding: 0;
display: grid;
place-items: center;
background: transparent;
color: #a7a7a7;
border: 0;
font-size: 16px;
line-height: 1;
}
.toast-close:hover { background: rgba(255,255,255,0.08); color: #fff; }
body.sidebar-collapsed .sidebar { align-items: stretch; }
body.sidebar-collapsed .sidebar-title,
body.sidebar-collapsed .nav-text,
body.sidebar-collapsed .beta-pill { display: none; }
body.sidebar-collapsed .sidebar .nav-link {
grid-template-columns: 28px;
justify-content: center;
padding: 4px;
}
body.sidebar-collapsed .sidebar-header { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .sidebar-toggle { width: 38px; }
@media (max-width: 760px) {
body { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
max-height: 48vh;
border-right: 0;
border-bottom: 1px solid #333;
}
body.sidebar-collapsed .sidebar { max-height: 50px; }
}
h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
.tool-header { margin-bottom: var(--tool-gap-lg); }
.tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
@@ -107,6 +264,9 @@ const SharedCSS = `
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status-panel, .results-panel, .result-card { border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); }
.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 rgba(167,167,167,0.35); border-top-color: #ddd; 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; }
@@ -118,6 +278,28 @@ const SharedCSS = `
.mono { font-family: Consolas, "Courier New", monospace; font-size: 12px; }
.wrap { overflow-wrap: anywhere; word-break: break-word; }
.file-name { color: #a7a7a7; font-size: 12px; overflow-wrap: anywhere; }
.drop-zone {
margin-top: 8px;
min-height: 44px;
display: grid;
justify-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px dashed #444;
border-radius: var(--tool-radius);
color: #a7a7a7;
background: rgba(128,128,128,0.08);
font-size: 12px;
text-align: center;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
.drop-zone .file-row { justify-content: center; }
.drop-zone-text { color: #a7a7a7; font-size: 12px; }
.drop-zone.is-dragover {
border-color: #007fd4;
color: #ddd;
background: rgba(0,127,212,0.12);
}
.selected-list, .selected-files { margin-top: 8px; }
.remove-selected, .remove-btn { min-width: 28px; width: 28px; padding: 3px 0; color: #f48771; font-size: 16px; line-height: 1; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
@@ -131,6 +313,160 @@ const SharedCSS = `
.hint { font-size: 12px; opacity: 0.7; }
`
const SharedDropZoneScript = `
function extractDroppedPaths(dataTransfer) {
const paths = [];
const add = (value) => {
if (!value) return;
const trimmed = String(value).trim();
if (trimmed && !paths.includes(trimmed)) paths.push(trimmed);
};
const addPathText = (text) => {
const value = String(text || '');
if (!value) return;
const uriMatches = value.match(/file:\/\/(?:\/[A-Za-z]:)?[^\\r\\n\\0]+/gi) || [];
uriMatches.forEach(add);
const quotedMatches = value.match(/"([^"]+)"|'([^']+)'/g) || [];
quotedMatches.forEach(match => add(match.replace(/^["']|["']$/g, '')));
value.split(/[\r\n\0]+/).forEach(line => {
let rest = line.trim();
if (!rest || rest.startsWith('#')) return;
while (rest.length) {
const driveMatches = [...rest.matchAll(/[A-Za-z]:\\/g)].map(m => m.index).filter(i => typeof i === 'number');
if (driveMatches.length <= 1) {
add(rest);
break;
}
const next = driveMatches[1];
add(rest.slice(0, next).trim());
rest = rest.slice(next).trim();
}
});
};
const addLines = (text) => {
addPathText(text);
};
try { addLines(dataTransfer.getData('text/uri-list')); } catch {}
try { addLines(dataTransfer.getData('text/plain')); } catch {}
for (const file of Array.from(dataTransfer.files || [])) {
add(file.path);
}
return paths;
}
async function extractDroppedHtmlFiles(dataTransfer) {
const files = [];
const seen = new Set();
const droppedFiles = Array.from(dataTransfer.files || []);
const entries = [];
const handlePromises = [];
for (const item of Array.from(dataTransfer.items || [])) {
if (typeof item.getAsFileSystemHandle === 'function') {
handlePromises.push(item.getAsFileSystemHandle().catch(() => null));
}
let entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (!entry && typeof item.getAsEntry === 'function') entry = item.getAsEntry();
if (entry) entries.push(entry);
}
const addFile = async (file, name) => {
const fileName = name || file.name || 'dropped.html';
if (!/\.html?$/i.test(fileName)) return;
const key = fileName + ':' + file.size + ':' + file.lastModified;
if (seen.has(key)) return;
seen.add(key);
files.push({ name: fileName, content: await file.text() });
};
const readEntry = async (entry, prefix) => {
if (!entry) return;
if (entry.isFile) {
await new Promise((resolve) => {
entry.file(async (file) => {
await addFile(file, (prefix || '') + file.name);
resolve();
}, () => resolve());
});
return;
}
if (entry.isDirectory) {
const reader = entry.createReader();
while (true) {
const entries = await new Promise(resolve => reader.readEntries(resolve, () => resolve([])));
if (!entries.length) break;
for (const child of entries) await readEntry(child, (prefix || '') + entry.name + '/');
}
}
};
const readHandle = async (handle, prefix) => {
if (!handle) return;
if (handle.kind === 'file') {
try {
const file = await handle.getFile();
await addFile(file, (prefix || '') + file.name);
} catch {}
return;
}
if (handle.kind === 'directory') {
try {
for await (const child of handle.values()) {
await readHandle(child, (prefix || '') + handle.name + '/');
}
} catch {}
}
};
for (const file of droppedFiles) await addFile(file, file.name);
for (const entry of entries) await readEntry(entry, '');
for (const handle of await Promise.all(handlePromises)) await readHandle(handle, '');
return files;
}
function setupDropZone(id, statusElement, onResolved) {
const zone = document.getElementById(id);
if (!zone) return;
const fallback = 'This view could not read dropped file paths. Use Select File(s) or Select Folder instead.';
const setMessage = (text) => {
if (statusElement) statusElement.textContent = text || '';
};
['dragenter', 'dragover'].forEach(type => {
zone.addEventListener(type, (event) => {
if (event.target && event.target.closest && event.target.closest('button, input')) return;
event.preventDefault();
event.stopPropagation();
zone.classList.add('is-dragover');
});
});
['dragleave', 'dragend'].forEach(type => {
zone.addEventListener(type, (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragover');
});
});
zone.addEventListener('drop', async (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragover');
const paths = extractDroppedPaths(event.dataTransfer);
const files = await extractDroppedHtmlFiles(event.dataTransfer);
if (!paths.length && !files.length) {
setMessage(fallback);
return;
}
setMessage('');
try {
const r = await fetch('/api/drop/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths, files }),
});
const j = await r.json();
if (typeof onResolved === 'function') onResolved(j);
} catch (e) {
setMessage('Could not read dropped items: ' + (e.message || e));
}
});
}
`
type navItem struct {
Path string
Label string
@@ -140,12 +476,15 @@ type navItem struct {
var navItems = []navItem{
{Path: "/", Label: "Home"},
{Path: "/project-init", Label: "Initialize Project", Description: "Download AGENTS.md and .gitignore templates from Google Drive"},
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
{Path: "/device-simulator", Label: "Device Simulator", Description: "Preview HTML playables inside mobile device frames"},
}
func visibleNavItems(betaToolsEnabled bool) []navItem {
@@ -181,6 +520,38 @@ func betaToolCount() int {
return count
}
func navInitials(label string) string {
switch label {
case "Initialize Project":
return "IP"
case "Base64 Scanner":
return "B64"
case "AppLovin Playable Preview":
return "APP"
case "Mintegral Checker":
return "MiC"
case "Device Simulator":
return "DS"
}
words := strings.Fields(label)
if len(words) == 0 {
return "?"
}
if len(words) == 1 {
r := []rune(words[0])
if len(r) == 0 {
return "?"
}
return strings.ToUpper(string(r[0]))
}
first := []rune(words[0])
second := []rune(words[1])
if len(first) == 0 || len(second) == 0 {
return strings.ToUpper(string([]rune(label)[0]))
}
return strings.ToUpper(string(first[0]) + string(second[0]))
}
func Page(activePath, title, body string) string {
cfg := LoadConfig()
betaToolsEnabled := cfg.BetaToolsEnabled
@@ -194,7 +565,7 @@ func Page(activePath, title, body string) string {
if n.Beta {
label += `<span class="beta-pill">Beta</span>`
}
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `">` + label + `</button>`)
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" draggable="` + boolAttr(n.Path != "/") + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
}
betaButtonLabel := "Show beta"
@@ -207,21 +578,104 @@ func Page(activePath, title, body string) string {
<meta charset="UTF-8" />
<title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style>
<script>` + SharedDropZoneScript + `</script>
</head><body>
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
<div class="content">` + body + `</div>
<div class="app-footer">
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
<aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">HPL Toolbox</span>
<button id="sidebarToggle" class="sidebar-toggle" title="Toggle sidebar" aria-label="Toggle sidebar">☰</button>
</div>
<nav class="sidebar-nav">` + tabs.String() + `</nav>
</aside>
<div class="app-shell">
<div class="content">` + body + `</div>
<div class="app-footer">
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
<button id="updateCheck" type="button" class="app-footer-version-button" title="Check for updates">` + AppVersion + ` - JJGC 00784</button>
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
</div>
</div>
<div id="toastBox" class="toast-box" role="status" aria-live="polite">
<span id="toastMessage" class="toast-message"></span>
<button id="toastAction" type="button" class="toast-action is-hidden">Open Repository</button>
<button id="toastClose" type="button" class="toast-close" aria-label="Close">x</button>
</div>
<script>
if (localStorage.getItem('hplSidebarCollapsed') === 'true') {
document.body.classList.add('sidebar-collapsed');
}
document.getElementById('sidebarToggle').addEventListener('click', () => {
const collapsed = document.body.classList.toggle('sidebar-collapsed');
localStorage.setItem('hplSidebarCollapsed', collapsed ? 'true' : 'false');
});
document.querySelectorAll('[data-path]').forEach((element) => {
element.addEventListener('click', () => {
if (element.dataset.dragging === 'true') {
element.dataset.dragging = 'false';
return;
}
const path = element.dataset.path;
if (path) window.location.href = path;
});
});
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
function getToolOrder() {
try {
const parsed = JSON.parse(localStorage.getItem(TOOL_ORDER_KEY) || '[]');
return Array.isArray(parsed) ? parsed.filter(path => typeof path === 'string') : [];
} catch {
return [];
}
}
function setToolOrder(paths) {
localStorage.setItem(TOOL_ORDER_KEY, JSON.stringify(paths));
}
function applyStoredToolOrder(container, selector) {
if (!container) return;
const order = getToolOrder();
if (!order.length) return;
const elements = Array.from(container.querySelectorAll(selector));
const byPath = new Map(elements.map(el => [el.dataset.path, el]));
const home = byPath.get('/');
const ordered = order.map(path => byPath.get(path)).filter(Boolean);
const orderedSet = new Set(ordered);
const remaining = elements.filter(el => el !== home && !orderedSet.has(el));
if (home) container.appendChild(home);
ordered.concat(remaining).forEach(el => container.appendChild(el));
}
function setupToolReorder(container, selector) {
if (!container) return;
applyStoredToolOrder(container, selector);
let dragged = null;
container.querySelectorAll(selector).forEach(element => {
if (element.dataset.path === '/') return;
element.addEventListener('dragstart', event => {
dragged = element;
element.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', element.dataset.path || '');
});
element.addEventListener('dragend', () => {
element.classList.remove('dragging');
element.dataset.dragging = 'true';
dragged = null;
const paths = Array.from(container.querySelectorAll(selector))
.map(el => el.dataset.path)
.filter(path => path && path !== '/');
setToolOrder(paths);
setTimeout(() => { element.dataset.dragging = 'false'; }, 0);
});
element.addEventListener('dragover', event => {
event.preventDefault();
if (!dragged || dragged === element || element.dataset.path === '/') return;
const rect = element.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
container.insertBefore(dragged, after ? element.nextSibling : element);
});
});
}
setupToolReorder(document.querySelector('.sidebar-nav'), '.nav-link');
setupToolReorder(document.querySelector('.home-tools'), '.home-tool');
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
const enabled = event.currentTarget.dataset.enabled === 'true';
await fetch('/api/betaTools', {
@@ -231,6 +685,51 @@ document.getElementById('betaToolsToggle').addEventListener('click', async (even
});
window.location.reload();
});
const toastBox = document.getElementById('toastBox');
const toastMessage = document.getElementById('toastMessage');
const toastAction = document.getElementById('toastAction');
const toastClose = document.getElementById('toastClose');
let toastTimer = null;
let updateRepositoryUrl = '';
function showToast(message, status, repositoryUrl) {
window.clearTimeout(toastTimer);
updateRepositoryUrl = repositoryUrl || '';
toastMessage.textContent = message || '';
toastAction.classList.toggle('is-hidden', !updateRepositoryUrl);
toastBox.className = 'toast-box is-visible is-' + (status || 'current');
toastTimer = window.setTimeout(() => {
toastBox.classList.remove('is-visible');
}, updateRepositoryUrl ? 9000 : 5000);
}
async function checkForUpdates(showCurrent) {
try {
const response = await fetch('/api/update/check');
const result = await response.json();
if (result.status === 'current' && !showCurrent) return;
showToast(result.message || 'HPL Toolbox update check finished.', result.status, result.repositoryUrl);
} catch (error) {
showToast('HPL Toolbox update check failed: ' + (error.message || error), 'error');
}
}
document.getElementById('updateCheck').addEventListener('click', () => {
checkForUpdates(true);
});
toastClose.addEventListener('click', () => {
window.clearTimeout(toastTimer);
toastBox.classList.remove('is-visible');
});
toastAction.addEventListener('click', async () => {
if (!updateRepositoryUrl) return;
await fetch('/api/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: updateRepositoryUrl }),
});
});
if (sessionStorage.getItem('hplToolboxUpdateChecked') !== 'true') {
sessionStorage.setItem('hplToolboxUpdateChecked', 'true');
checkForUpdates(true);
}
</script>
</body></html>`
}
@@ -242,6 +741,153 @@ func boolAttr(v bool) string {
return "false"
}
func DropResolveEndpoint(w http.ResponseWriter, r *http.Request) {
type droppedFile struct {
Name string `json:"name"`
Content string `json:"content"`
}
var req struct {
Paths []string `json:"paths"`
Files []droppedFile `json:"files"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"files": []any{}, "paths": []string{}, "skipped": 0, "error": err.Error()})
return
}
seen := map[string]bool{}
var outPaths []string
skipped := 0
addFile := func(p string) {
if !isHTMLPath(p) {
skipped++
return
}
key := strings.ToLower(filepath.Clean(p))
if seen[key] {
return
}
seen[key] = true
outPaths = append(outPaths, p)
}
for _, raw := range req.Paths {
p := normalizeDroppedPath(raw)
if p == "" {
skipped++
continue
}
info, err := os.Stat(p)
if err != nil {
skipped++
continue
}
if info.IsDir() {
for _, filePath := range collectHTMLFiles(p) {
addFile(filePath)
}
continue
}
if info.Mode().IsRegular() {
addFile(p)
continue
}
skipped++
}
if len(req.Files) > 0 {
cleanupStaleStandaloneDropDirs()
dir, err := os.MkdirTemp("", "hpltoolbox-drop-")
if err != nil {
skipped += len(req.Files)
} else {
for _, file := range req.Files {
if !isHTMLPath(file.Name) {
skipped++
continue
}
name := safeStandaloneDropName(file.Name)
target := filepath.Join(dir, name)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; fileExists(target); i++ {
target = filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, i, ext))
}
if err := os.WriteFile(target, []byte(file.Content), 0644); err != nil {
skipped++
continue
}
addFile(target)
}
}
}
files := make([]map[string]string, 0, len(outPaths))
for _, p := range outPaths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files, "paths": outPaths, "skipped": skipped})
}
func safeStandaloneDropName(name string) string {
base := filepath.Base(name)
base = strings.Map(func(r rune) rune {
switch r {
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
return '_'
default:
if r < 32 {
return '_'
}
return r
}
}, base)
base = strings.TrimSpace(base)
if base == "" {
return "dropped.html"
}
return base
}
func cleanupStaleStandaloneDropDirs() {
root := os.TempDir()
entries, err := os.ReadDir(root)
if err != nil {
return
}
cutoff := time.Now().Add(-24 * time.Hour)
for _, entry := range entries {
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "hpltoolbox-drop-") {
continue
}
dir := filepath.Join(root, entry.Name())
info, err := entry.Info()
if err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(dir)
}
}
}
func normalizeDroppedPath(value string) string {
result := strings.Trim(strings.TrimSpace(value), `"'`)
if strings.HasPrefix(strings.ToLower(result), "file://") {
result = strings.TrimPrefix(result, "file://")
if decoded, err := url.PathUnescape(result); err == nil {
result = decoded
}
if len(result) >= 3 && result[0] == '/' && result[2] == ':' {
result = result[1:]
}
result = filepath.FromSlash(result)
}
return result
}
func isHTMLPath(p string) bool {
ext := strings.ToLower(filepath.Ext(p))
return ext == ".html" || ext == ".htm"
}
func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled bool `json:"enabled"`

View File

@@ -107,20 +107,31 @@ func buildMux() *http.ServeMux {
}
HomePage(w, r)
})
mux.HandleFunc("GET /project-init", ProjectInitPage)
mux.HandleFunc("GET /plec", PlecPage)
mux.HandleFunc("GET /applovin", ApplovinPage)
mux.HandleFunc("GET /base64", Base64Page)
mux.HandleFunc("GET /mraid", MraidPage)
mux.HandleFunc("GET /mintegral", MintegralPage)
mux.HandleFunc("GET /mobile", MobilePage)
mux.HandleFunc("GET /playworks", PlayworksPage)
mux.HandleFunc("GET /device-simulator", DeviceSimulatorPage)
mux.HandleFunc("GET /changelog", ChangelogPage)
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
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
mux.HandleFunc("POST /api/open", OpenEndpoint)
mux.HandleFunc("POST /api/focus", FocusEndpoint)
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
mux.HandleFunc("GET /api/update/check", UpdateCheckEndpoint)
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
// PLEC
mux.HandleFunc("POST /api/plec/pick", PlecPick)
@@ -144,6 +155,14 @@ func buildMux() *http.ServeMux {
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
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)
@@ -155,6 +174,11 @@ func buildMux() *http.ServeMux {
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)
return mux
}

545
standalone/mintegral.go Normal file
View 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">&#128263;</button><button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">&#8635;</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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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?'&#128264;':'&#128263;';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

View File

@@ -67,10 +67,13 @@ func MobilePage(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></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 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 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-list"></div>
@@ -164,7 +167,29 @@ function addFiles(files) {
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' });
@@ -181,6 +206,7 @@ clearBtn.addEventListener('click', () => {
selectedPaths = [];
selectedPage = 0;
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
renderSelection();
});
@@ -192,17 +218,38 @@ startBtn.addEventListener('click', async () => {
}
startBtn.disabled = true;
statusEl.textContent = 'Starting server...';
const r = await fetch('/api/mobile/start', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ paths: selectedPaths }),
});
const j = await r.json();
statusEl.classList.add('is-busy');
let j = null;
try {
const r = await fetch('/api/mobile/start', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ paths: selectedPaths }),
});
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) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
startBtn.disabled = false;
return;
}
currentUrls = j.urls;
statusEl.classList.remove('is-busy');
selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
shareInfo.style.display = '';
@@ -213,6 +260,7 @@ startBtn.addEventListener('click', async () => {
stopBtn.addEventListener('click', async () => {
await fetch('/api/mobile/stop', { method:'POST' });
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none';
currentUrls = [];
@@ -291,13 +339,14 @@ func MobilePickFolder(w http.ResponseWriter, r *http.Request) {
func MobileStart(w http.ResponseWriter, r *http.Request) {
stopActiveShare()
send := newJSONStream(w)
var req struct {
Path string `json:"path"`
Paths []string `json:"paths"`
}
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
}
paths := req.Paths
@@ -305,18 +354,19 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
paths = []string{req.Path}
}
if len(paths) == 0 {
writeJSON(w, map[string]any{"error": "File missing."})
send(map[string]any{"type": "error", "message": "File missing."})
return
}
files := make([]sharedFile, 0, len(paths))
for _, p := range paths {
for i, p := range paths {
if p == "" || !fileExists(p) {
writeJSON(w, map[string]any{"error": "File missing."})
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 {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
return
}
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
@@ -325,15 +375,16 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
// 6 random bytes -> base64url, no padding
tokenBytes := make([]byte, 6)
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
}
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
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))
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
}
port := listener.Addr().(*net.TCPAddr).Port
@@ -351,7 +402,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
ips := getLanIPs()
if len(ips) == 0 {
_ = 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
}
@@ -376,7 +427,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
if len(files) == 1 {
filename = files[0].filename
}
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
send(map[string]any{"type": "done", "urls": urls, "filename": filename})
}
func MobileStop(w http.ResponseWriter, r *http.Request) {
@@ -406,7 +457,7 @@ func shareHandler(s *activeShare) http.Handler {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.files[0].buf)
_, _ = w.Write([]byte(mobilePreviewPage(s.files[0])))
case "/file":
fallthrough
case "/file/0":
@@ -430,7 +481,7 @@ func shareHandler(s *activeShare) http.Handler {
if parts[0] == "view" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf)
_, _ = w.Write([]byte(mobilePreviewPage(file)))
return
}
safeName := safeFilename(file.filename)
@@ -448,6 +499,35 @@ func shareHandler(s *activeShare) http.Handler {
})
}
func mobilePreviewPage(file sharedFile) string {
playableJSON, _ := json.Marshal(string(file.buf))
playableScriptValue := strings.ReplaceAll(string(playableJSON), "</script", "<\\/script")
playableScriptValue = strings.ReplaceAll(playableScriptValue, "</SCRIPT", "<\\/SCRIPT")
title := html.EscapeString(file.filename)
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>` + title + `</title>
<style>
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
</style>
</head><body>
<div id="boot" class="boot">Loading preview...</div>
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
<script>
const playableHtml = ` + playableScriptValue + `;
setTimeout(() => {
const frame = document.getElementById('playable');
document.getElementById('boot').style.display = 'none';
frame.srcdoc = playableHtml;
}, 750);
</script>
</body></html>`
}
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
@@ -34,6 +35,7 @@ type mraidRule struct {
}
type mraidContext struct {
FilePath string
HTML string
ScriptText string
MraidCallLines map[string]int
@@ -43,16 +45,19 @@ 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 folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
<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 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 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>
@@ -127,7 +132,29 @@ function addPaths(paths) {
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' });
@@ -143,17 +170,36 @@ 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.textContent = 'Pick files first.'; return; }
if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
const payload = { files: pickedFiles };
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json();
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
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);
@@ -161,6 +207,7 @@ document.getElementById('scan').addEventListener('click', async () => {
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>';
@@ -217,12 +264,13 @@ func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
}
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 {
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()})
send(map[string]any{"type": "error", "message": err.Error(), "results": []mraidResult{}})
return
}
@@ -257,21 +305,22 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
baseDir = req.Folder
} else {
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
send(map[string]any{"type": "error", "message": "Pick a folder or files first", "results": []mraidResult{}})
return
}
if len(targets) == 0 {
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason, "error": noTargetsError})
send(map[string]any{"type": "error", "message": noTargetsError, "results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason})
return
}
results := []mraidResult{}
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)
if err != nil {
continue
}
issues := checkMraid(string(data))
issues := checkMraid(string(data), file)
display := file
if baseDir != "" {
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
@@ -282,21 +331,37 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
}
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
}
writeJSON(w, map[string]any{"results": results, "skipped": skipped, "skippedReason": skippedReason})
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`)
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
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*\)`)
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 = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
const mraidReference = "GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide."
func isInSupportedMraidFolderPath(filePath string) bool {
supported := map[string]bool{
@@ -324,8 +389,13 @@ func isInSupportedMraidFolderPath(filePath string) bool {
return false
}
func checkMraid(htmlSrc string) []mraidIssue {
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),
@@ -363,12 +433,31 @@ func collectMraidCallLines(htmlSrc string) map[string]int {
}
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)
@@ -383,6 +472,38 @@ 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 {
@@ -392,56 +513,171 @@ func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
}
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: "Viewability handling not found", Detail: "Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") {
{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: "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.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") {
{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: "expand-support", Severity: "requirement", Title: "mraid.expand used without supports check", Detail: "Check mraid.supports(\"expand\") before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") {
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
{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-support", Severity: "requirement", Title: "mraid.resize used without supports check", Detail: "Check mraid.supports(\"resize\") before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidHasCall(ctx, "resize") && !mraidHasSupportCheck(ctx, "resize") {
{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: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without supports check", Detail: "Check mraid.supports(\"orientation\") before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") {
{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
@@ -460,4 +696,30 @@ var mraidRules = []mraidRule{
}
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
}},
}

View 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
}

View File

@@ -29,11 +29,12 @@ type SendToMobileConfig struct {
}
type AppConfig struct {
Plec PlecConfig `json:"plec"`
Applovin ApplovinConfig `json:"applovin"`
SendToMobile SendToMobileConfig `json:"sendToMobile"`
BetaToolsEnabled bool `json:"betaToolsEnabled"`
LastPickDir string `json:"lastPickDir,omitempty"`
Plec PlecConfig `json:"plec"`
Applovin ApplovinConfig `json:"applovin"`
SendToMobile SendToMobileConfig `json:"sendToMobile"`
BetaToolsEnabled bool `json:"betaToolsEnabled"`
LastPickDir string `json:"lastPickDir,omitempty"`
ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"`
}
var configMu sync.Mutex
@@ -114,8 +115,7 @@ func SaveConfig(cfg AppConfig) error {
}
func OpenInBrowser(url string) {
// `start` is a cmd builtin. Quoted empty arg is the window title.
cmd := hiddenCommand("cmd", "/c", "start", "", url)
cmd := hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url)
_ = cmd.Start()
}

View File

@@ -47,10 +47,15 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
<div class="panel-body">
<div class="control-group">
<div class="control-label">Source HTML (Playworks export)</div>
<div class="field-row">
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" />
<button id="pickSrc" class="secondary">Select File</button>
<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">
@@ -77,10 +82,10 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
<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="un" checked /><span class="net-label">Unity <span class="net-tag">un</span></span><span class="net-note">HTML (window.top to self)</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="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</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>
@@ -106,22 +111,82 @@ 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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">&times;</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) {
srcEl.value = j.path;
baseNameEl.value = j.baseName || '';
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
updateConvertBtn();
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();
@@ -146,10 +211,25 @@ 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;
@@ -158,12 +238,28 @@ document.getElementById('convert').addEventListener('click', async () => {
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, baseName: baseNameEl.value, networks })
});
const msg = await res.json();
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.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
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>';
@@ -198,27 +294,37 @@ func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
}
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var opts playworksConvertOptions
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()})
send(map[string]any{"type": "error", "message": err.Error(), "results": []playworksNetworkResult{}})
return
}
if !fileExists(opts.SourcePath) {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."})
send(map[string]any{"type": "error", "message": "Source file not found.", "results": []playworksNetworkResult{}})
return
}
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."})
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 {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()})
send(map[string]any{"type": "error", "message": "Could not read source: " + err.Error(), "results": []playworksNetworkResult{}})
return
}
results := []playworksNetworkResult{}
for _, network := range opts.Networks {
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()})
@@ -226,10 +332,21 @@ func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
}
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
}
writeJSON(w, map[string]any{"results": results})
send(map[string]any{"type": "done", "results": results})
}
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`)
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))
@@ -259,6 +376,9 @@ func playworksOutputBaseName(opts playworksConvertOptions) string {
}
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)
@@ -282,18 +402,31 @@ func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, netwo
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 "un":
return "Unity"
case "fb":
return "Facebook"
case "gg":
return "GoogleAds"
case "ap":
return "Appreciate"
case "mo":
return "Moloco"
case "vu":
@@ -308,36 +441,177 @@ func playworksNetworkOutputFolder(network string) string {
}
func playworksNeedsZip(network string) bool {
return network == "gg" || network == "vu" || network == "mtg"
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 := htmlSrc
result := sanitizePlayworksHTML(htmlSrc)
headInject := buildPlayworksLifecycleScript()
if network == "al" || network == "is" {
headInject += `<script src="mraid.js"></script>`
} else if network == "gg" {
if playworksIsMraidNetwork(network) {
headInject += `<script src="mraid.js"></script>` + buildPlayworksMraidComplianceScript()
} else if playworksIsExitapiNetwork(network) {
headInject += `<script src="exitapi.js"></script>`
}
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1)
result = injectPlayworksBeforeHeadClose(result, headInject)
if network == "vu" {
result = strings.Replace(result, "<body>", `<body>
<script>window.__VUNGLE__=true;</script>`, 1)
result = injectPlayworksAfterBodyOpen(result, `<script>window.__VUNGLE__=true;</script>`)
} else if network == "mtg" {
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1)
result = addPlayworksBodyOnload(result, "gameReady()")
} else if network == "tt" {
result = strings.Replace(result, "<body>", `<body>
<script>window.__TIKTOK__=true;</script>`, 1)
result = injectPlayworksAfterBodyOpen(result, `<script>window.__TIKTOK__=true;</script>`)
}
result = replacePlayworksCTAScript(result, network)
if network == "un" {
result = strings.ReplaceAll(result, "window.top", "window.self")
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)
@@ -387,21 +661,41 @@ func buildPlayworksLifecycleScript() string {
}, "")
}
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"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`
case "un":
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){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");`
case "fb":
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
case "gg":
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 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");`
case "vu":
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
case "mtg":

View 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)
}
}
}

View 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)
}
}

View File

@@ -26,10 +26,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></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 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;">
@@ -97,6 +100,24 @@ function addFiles(files) {
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();
@@ -138,6 +159,10 @@ function setPreview(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' });
@@ -154,6 +179,7 @@ document.getElementById('clear').addEventListener('click', () => {
page = 0;
setPreview('');
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
renderRows();
});
openResultBtn.addEventListener('click', () => {
@@ -167,19 +193,41 @@ document.getElementById('upload').addEventListener('click', async () => {
rows.forEach(row => row.error = '');
renderRows();
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
setPreview('');
if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return;
}
statusEl.textContent = 'Uploading...';
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
const j = await res.json();
statusEl.classList.add('is-busy');
let j = null;
try {
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
await readJSONStream(res, (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 };
} 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;
@@ -189,9 +237,11 @@ document.getElementById('upload').addEventListener('click', async () => {
return;
}
if (j.error) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
return;
}
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
setPreview(j.preview);
});
@@ -247,11 +297,12 @@ type plecRow struct {
}
func PlecUpload(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct {
Rows []plecRow `json:"rows"`
}
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
}
cfg := LoadConfig().Plec
@@ -276,11 +327,11 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
}
}
if len(rowErrors) > 0 {
writeJSON(w, map[string]any{"rowErrors": rowErrors})
send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors})
return
}
if len(valid) == 0 {
writeJSON(w, map[string]any{"error": "Nothing to upload."})
send(map[string]any{"type": "error", "message": "Nothing to upload."})
return
}
@@ -290,37 +341,39 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
mw := multipart.NewWriter(&buf)
for i, row := range valid {
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)
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
}
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
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
}
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
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
}
}
if err := mw.Close(); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
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, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
@@ -332,18 +385,18 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
resp, err := http.DefaultClient.Do(httpReq)
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
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
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
}
var parsed map[string]any
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
}
var preview any
@@ -352,7 +405,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
} else if v, ok := parsed["previewURL"]; ok {
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 ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {

354
standalone/project_init.go Normal file
View File

@@ -0,0 +1,354 @@
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) {
manifestURL := getManifestURL()
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()">&#8635;</button>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="toggleSettings()">&#9881;</button>
</div>
</div>
<div class="panel-body">
<div id="settingsGroup" class="control-group" style="display:none;">
<p class="control-label">Manifest URL</p>
<div class="field-row">
<input type="text" id="manifestUrlInput" value="` + htmlEscape(manifestURL) + `" style="flex:1;" placeholder="https://…" />
<button onclick="saveManifestUrl()">Save &amp; Refresh</button>
</div>
</div>
<div class="control-group">
<div id="fileList"></div>
</div>
<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&#8230;</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;
let settingsVisible = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function toggleSettings() {
settingsVisible = !settingsVisible;
document.getElementById('settingsGroup').style.display = settingsVisible ? '' : 'none';
}
async function saveManifestUrl() {
const url = document.getElementById('manifestUrlInput').value.trim();
await fetch('/api/project-init/setManifestUrl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
settingsVisible = false;
document.getElementById('settingsGroup').style.display = 'none';
refresh();
}
async function refresh() {
setStatus('', '');
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest&#8230;</p>';
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, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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),
})
}
}

View File

@@ -0,0 +1,5 @@
//go:build !release
package main
const embeddedReadme = ""

View File

@@ -0,0 +1,8 @@
//go:build release
package main
import _ "embed"
//go:embed README.md
var embeddedReadme string

Binary file not shown.

View 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
View 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": true
},
{
"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
},
]