19 Commits

Author SHA1 Message Date
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
afbf504957 0.1.6 updates and add changelog 2026-05-28 01:49:33 +08:00
16847a78ca standalone 2026-05-28 01:25:53 +08:00
ca9cc62b8c home view 2026-05-28 01:01:38 +08:00
8e8d3d6da0 simplify extension ui 2026-05-28 00:59:37 +08:00
3f86aeaf0c filenames 2026-05-27 22:42:19 +08:00
92de00b52e 0.1.6
- cleanup per network folder for playworks converter
- loosen mraid checker file and folder scanning
2026-05-27 19:08:15 +08:00
78578cec16 0.1.6
- playworks converter (beta)
- mraid checker (beta)
- removed daily updates generator
2026-05-27 18:41:26 +08:00
52 changed files with 5441 additions and 1218 deletions

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# HPL Toolbox
Bundled VS Code extension and standalone tools for playable ad workflows.
# Tools
- **PLEC Upload** - Upload selected HTML files to the internal PLEC server.
- **AppLovin Playable Preview** - Upload HTML files to p.applov.in and generate QR preview links.
- **Base64 Scanner** - Scan HTML files for external or non-base64 asset references.
- **Send To Mobile** - Share selected HTML files to a phone over LAN with a QR code.
- **MRAID Checker** - Check HTML files against MRAID requirements and best practices.
- **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta.
# Changelog
**v0.1.2**
```
Added
- Added local hosting for HTML files.
- Added the standalone app.
```
**v0.1.3**
```
Added
- Added QR regeneration for AppLovin previews that fail to render.
- Added Save QR and Save All QRs actions.
Changed
- Updated AppLovin Demo Upload.
- Improved the file selection workflow.
```
**v0.1.5**
```
Added
- Added multi-file selection for PLEC Upload.
- Added automatic iteration name detection.
Changed
- Improved PLEC Upload file selection.
- Moved standalone temporary files into the user temp folder.
```
**v0.1.6**
```
Added
- Added beta tools for further testing:
- Playworks Converter for converting Playworks UnityAds HTML into other network variants.
- MRAID Checker for scanning HTML files against MRAID requirements and best practices.
Changed
- Simplified the UI.
```
**v0.1.7**
```
Added
- Added standalone drag-and-drop file/folder selection for HTML inputs across supported tools.
- Added status throbber/progress updates for batch scans, uploads, sharing, and conversions.
- Added streamed standalone progress updates so long-running actions update the status bar before completion.
Changed
- MRAID Checker is no longer marked as beta in extension or standalone.
- Playworks Converter source selection now matches the other file-selection UIs more closely.
- Playworks Converter default output folder now resolves to the input file location's Output folder.
- Progress text now follows the format: [throbber] Verb count file.
Fixed
- Fixed standalone drag-and-drop handling for multiple files and multiple folders.
- Fixed cleanup behavior for temporary dropped files when clearing or removing rows.
- Fixed standalone PLEC Upload Open button using an incorrect/mangled URL by switching to the Windows URL protocol handler.
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

19
aiAssets/LAYOUT.md Normal file
View File

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

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

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

53
package-lock.json generated
View File

@@ -1,15 +1,17 @@
{ {
"name": "hpl-toolbox", "name": "hpl-toolbox",
"version": "0.1.0", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hpl-toolbox", "name": "hpl-toolbox",
"version": "0.1.0", "version": "0.1.6",
"license": "UNLICENSED",
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/vscode": "^1.85.0", "@types/vscode": "^1.85.0",
"playwright": "^1.60.0",
"typescript": "^5.4.0" "typescript": "^5.4.0"
}, },
"engines": { "engines": {
@@ -33,6 +35,53 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -1,8 +1,8 @@
{ {
"name": "hpl-toolbox", "name": "hpl-toolbox",
"displayName": "HPL Toolbox", "displayName": "HPL Toolbox",
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker. Local HTML Host.", "description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
"version": "0.1.5", "version": "0.1.7",
"publisher": "hesukastro", "publisher": "hesukastro",
"license": "UNLICENSED", "license": "UNLICENSED",
"repository": { "repository": {
@@ -12,7 +12,9 @@
"engines": { "engines": {
"vscode": "^1.85.0" "vscode": "^1.85.0"
}, },
"categories": ["Other"], "categories": [
"Other"
],
"icon": "media/hpl.png", "icon": "media/hpl.png",
"main": "./out/extension.js", "main": "./out/extension.js",
"activationEvents": [], "activationEvents": [],
@@ -24,7 +26,7 @@
}, },
{ {
"command": "hplToolbox.openApplovinUpload", "command": "hplToolbox.openApplovinUpload",
"title": "HPL Toolbox: Open AppLovin Demo Upload" "title": "HPL Toolbox: Open AppLovin Playable Preview"
}, },
{ {
"command": "hplToolbox.openBase64Scanner", "command": "hplToolbox.openBase64Scanner",
@@ -57,7 +59,7 @@
{ {
"type": "webview", "type": "webview",
"id": "hplToolbox.launcher", "id": "hplToolbox.launcher",
"name": "Tools" "name": ""
} }
] ]
}, },
@@ -128,6 +130,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/vscode": "^1.85.0", "@types/vscode": "^1.85.0",
"playwright": "^1.60.0",
"typescript": "^5.4.0" "typescript": "^5.4.0"
} }
} }

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

222
src/changelogView.ts Normal file
View File

@@ -0,0 +1,222 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
export function openChangelog(context: vscode.ExtensionContext) {
const panel = vscode.window.createWebviewPanel(
'hplToolbox.changelog',
'HPL Toolbox Changelog',
vscode.ViewColumn.One,
{ enableScripts: false }
);
const readmePath = path.join(context.extensionPath, 'README.md');
let markdown = '# Changelog\n\nREADME.md was not found.';
try {
markdown = fs.readFileSync(readmePath, 'utf8');
} catch {
// Keep the fallback text readable in packaged or development installs.
}
panel.webview.html = getHtml(renderMarkdown(markdown));
}
function getHtml(content: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 18px;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
background: var(--vscode-editor-background);
color: var(--vscode-foreground);
}
main {
max-width: 840px;
margin: 0 auto;
}
h1 {
margin: 0 0 14px;
font-size: 20px;
line-height: 1.25;
}
h2 {
margin: 18px 0 8px;
padding-top: 12px;
border-top: 1px solid var(--vscode-panel-border);
font-size: 15px;
line-height: 1.3;
}
h3 {
margin: 14px 0 6px;
font-size: 13px;
line-height: 1.3;
}
strong {
font-weight: 600;
}
ul {
margin: 6px 0 12px;
padding-left: 20px;
}
li {
margin: 4px 0;
line-height: 1.45;
}
p {
margin: 8px 0;
line-height: 1.5;
}
code {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.95em;
background: var(--vscode-textCodeBlock-background);
padding: 1px 4px;
border-radius: 3px;
}
.changelog-block {
margin: 8px 0 18px;
padding: 10px 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-textCodeBlock-background);
}
.change-section {
margin: 8px 0 4px;
font-weight: 600;
}
.changelog-block ul {
margin: 4px 0 10px;
}
.changelog-block li.nested {
margin-left: 18px;
}
</style>
</head>
<body>
<main>${content}</main>
</body>
</html>`;
}
function renderMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const html: string[] = [];
let inList = false;
let inCodeBlock = false;
let codeLines: string[] = [];
const closeList = () => {
if (inList) {
html.push('</ul>');
inList = false;
}
};
for (const rawLine of lines) {
if (rawLine.trim() === '```') {
closeList();
if (inCodeBlock) {
html.push(renderChangelogBlock(codeLines));
codeLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeLines.push(rawLine);
continue;
}
const line = rawLine.trim();
if (!line) {
closeList();
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
closeList();
const level = Math.min(heading[1].length, 2);
html.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const boldHeading = line.match(/^\*\*(.+)\*\*$/);
if (boldHeading) {
closeList();
html.push(`<h2>${escapeHtml(boldHeading[1])}</h2>`);
continue;
}
const listItem = line.match(/^-\s+(.+)$/);
if (listItem) {
if (!inList) {
html.push('<ul>');
inList = true;
}
html.push(`<li>${renderInlineMarkdown(listItem[1])}</li>`);
continue;
}
closeList();
html.push(`<p>${renderInlineMarkdown(line)}</p>`);
}
if (inCodeBlock) {
html.push(renderChangelogBlock(codeLines));
}
closeList();
return html.join('\n');
}
function renderChangelogBlock(lines: string[]): string {
const html: string[] = ['<div class="changelog-block">'];
let inList = false;
const closeList = () => {
if (inList) {
html.push('</ul>');
inList = false;
}
};
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
closeList();
continue;
}
const listItem = line.match(/^-\s+(.+)$/);
if (listItem) {
if (!inList) {
html.push('<ul>');
inList = true;
}
const nested = rawLine.match(/^\s{8,}-\s+/) ? ' class="nested"' : '';
html.push(`<li${nested}>${renderInlineMarkdown(listItem[1])}</li>`);
continue;
}
closeList();
html.push(`<div class="change-section">${renderInlineMarkdown(line)}</div>`);
}
closeList();
html.push('</div>');
return html.join('\n');
}
function renderInlineMarkdown(value: string): string {
return escapeHtml(value)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, ch => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ch] ?? ch));
}

View File

@@ -6,6 +6,7 @@ import { openBase64Scanner } from './tools/base64Scanner';
import { openMraidChecker } from './tools/mraidChecker'; import { openMraidChecker } from './tools/mraidChecker';
import { openSendToMobile } from './tools/sendToMobile'; import { openSendToMobile } from './tools/sendToMobile';
import { openPlayworksConverter } from './tools/playworksConverter'; import { openPlayworksConverter } from './tools/playworksConverter';
import { openChangelog } from './changelogView';
export function activate(context: vscode.ExtensionContext) { export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push( context.subscriptions.push(
@@ -15,6 +16,7 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)), vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)), vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)), vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context)) vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
); );
} }

View File

@@ -1,15 +1,18 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled'; const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
interface ToolDefinition { interface ToolDefinition {
id?: string;
command: string; command: string;
title: string; title: string;
description: string; description: string;
beta?: boolean; beta?: boolean;
} }
const TOOLS: ToolDefinition[] = [ const FALLBACK_TOOLS: ToolDefinition[] = [
{ {
command: 'hplToolbox.openPlecUpload', command: 'hplToolbox.openPlecUpload',
title: 'PLEC Upload', title: 'PLEC Upload',
@@ -17,7 +20,7 @@ const TOOLS: ToolDefinition[] = [
}, },
{ {
command: 'hplToolbox.openApplovinUpload', command: 'hplToolbox.openApplovinUpload',
title: 'AppLovin Demo Upload', title: 'AppLovin Playable Preview',
description: 'Upload to p.applov.in (QR preview)', description: 'Upload to p.applov.in (QR preview)',
}, },
{ {
@@ -25,17 +28,16 @@ const TOOLS: ToolDefinition[] = [
title: 'Base64 Scanner', title: 'Base64 Scanner',
description: 'Find non-base64 assets in HTML', 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', command: 'hplToolbox.openSendToMobile',
title: 'Send To Mobile', title: 'Send To Mobile',
description: 'Share HTML to a phone via LAN + QR', 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', command: 'hplToolbox.openPlayworksConverter',
title: 'Playworks Converter', title: 'Playworks Converter',
@@ -50,52 +52,111 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
resolveWebviewView(view: vscode.WebviewView) { resolveWebviewView(view: vscode.WebviewView) {
view.webview.options = { enableScripts: true }; view.webview.options = { enableScripts: true };
const version = this.context.extension.packageJSON.version as string; const version = this.context.extension.packageJSON.version as string;
const tools = loadToolDefinitions(this.context.extensionPath);
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false); const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
view.webview.html = getHtml(version, betaToolsEnabled); view.webview.html = getHtml(version, betaToolsEnabled, tools);
view.webview.onDidReceiveMessage(async (msg) => { view.webview.onDidReceiveMessage(async (msg) => {
if (msg?.type === 'open' && typeof msg.command === 'string') { if (msg?.type === 'open' && typeof msg.command === 'string') {
vscode.commands.executeCommand(msg.command); vscode.commands.executeCommand(msg.command);
} else if (msg?.type === 'openChangelog') {
vscode.commands.executeCommand('hplToolbox.openChangelog');
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') { } else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled); await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
view.webview.html = getHtml(version, msg.enabled); view.webview.html = getHtml(version, msg.enabled, tools);
} }
}); });
} }
} }
function getHtml(version: string, betaToolsEnabled: boolean): string { function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta)); const toolsPath = path.join(extensionPath, 'tools.json');
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length; 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[]): string {
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta));
const toolButtons = visibleTools.map(renderToolButton).join('\n'); const toolButtons = visibleTools.map(renderToolButton).join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style> <style>
* { box-sizing: border-box; }
body { body {
min-height: calc(100vh - 24px); min-height: 100vh;
margin: 0;
padding: 10px 8px 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: var(--vscode-font-family); font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
color: var(--vscode-foreground); color: var(--vscode-foreground);
padding: 12px 8px;
} }
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; } .tools {
.tools { flex: 1; } flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-btn { .tool-btn {
display: block; width: 100%; text-align: left; display: block;
padding: 8px 10px; margin-bottom: 6px; width: 100%;
background: var(--vscode-button-secondaryBackground); min-height: 52px;
color: var(--vscode-button-secondaryForeground); padding: 8px 9px;
border: 1px solid var(--vscode-panel-border); text-align: left;
border-radius: 3px; cursor: pointer; font-size: 13px; background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-button-secondaryBackground));
color: var(--vscode-foreground);
border: 1px solid var(--vscode-panel-border, transparent);
border-radius: 5px;
cursor: pointer;
font: inherit;
} }
.tool-btn:hover { .tool-btn:hover {
background: var(--vscode-button-secondaryHoverBackground); background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
}
.tool-btn:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.tool-btn:active {
background: var(--vscode-list-activeSelectionBackground, var(--vscode-button-background));
color: var(--vscode-list-activeSelectionForeground, var(--vscode-button-foreground));
}
.tool-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
font-weight: 600;
line-height: 1.25;
}
.tool-desc {
display: block;
margin-top: 4px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
line-height: 1.35;
} }
.tool-title { display: flex; align-items: center; gap: 6px; }
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
.beta-badge { .beta-badge {
flex: 0 0 auto;
padding: 1px 5px; padding: 1px 5px;
border: 1px solid var(--vscode-panel-border); border: 1px solid var(--vscode-panel-border);
border-radius: 3px; border-radius: 3px;
@@ -103,37 +164,54 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
} }
.beta-toggle { .footer {
margin-top: 12px; margin-top: auto;
padding-top: 10px; padding-top: 8px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: 8px;
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);
} }
.beta-toggle button { .footer-action {
width: 100%; padding: 0;
padding: 7px 10px; background: transparent;
background: var(--vscode-button-secondaryBackground); color: var(--vscode-descriptionForeground);
color: var(--vscode-button-secondaryForeground); border: 0;
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
font: inherit;
font-size: 10px;
opacity: 0.72;
} }
.beta-toggle button:hover { #betaToggle {
background: var(--vscode-button-secondaryHoverBackground); justify-self: start;
}
.footer-action:hover {
opacity: 1;
text-decoration: underline;
}
.footer-version {
opacity: 0.75;
text-align: center;
white-space: nowrap;
}
.changelog-link {
justify-self: end;
text-align: right;
} }
.beta-note { display: block; margin-top: 3px; color: var(--vscode-descriptionForeground); font-size: 11px; }
</style> </style>
</head> </head>
<body> <body>
<h3>HPL Toolbox ${version}</h3>
<div class="tools"> <div class="tools">
${toolButtons} ${toolButtons}
</div> </div>
<div class="beta-toggle"> <div class="footer">
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}"> <button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'} <span class="footer-version">${version} - JJGC 00784</span>
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span> <button id="changelogLink" class="footer-action changelog-link">Changelog</button>
</button>
</div> </div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
@@ -146,6 +224,9 @@ ${toolButtons}
const enabled = event.currentTarget.dataset.enabled === 'true'; const enabled = event.currentTarget.dataset.enabled === 'true';
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled }); vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
}); });
document.getElementById('changelogLink').addEventListener('click', () => {
vscode.postMessage({ type: 'openChangelog' });
});
</script> </script>
</body> </body>
</html>`; </html>`;

View File

@@ -2,12 +2,12 @@ import * as vscode from 'vscode';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { File } from 'node:buffer'; import { File } from 'node:buffer';
import { handleClipboardAndOpen, singletonPanel } from './shared'; import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null }; const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openApplovinUpload(_context: vscode.ExtensionContext) { export function openApplovinUpload(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Demo Upload'); const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Playable Preview');
if (!isNew) return; if (!isNew) return;
panel.webview.html = getHtml(); panel.webview.html = getHtml();
@@ -15,6 +15,24 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
try { try {
if (handleClipboardAndOpen(msg)) return; if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({
type: 'filesSelected',
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
});
}
return;
}
if (msg.type === 'pickFiles') { if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({ const picked = await vscode.window.showOpenDialog({
canSelectMany: true, canSelectMany: true,
@@ -116,11 +134,11 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; } if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
if (i > 0 && delayMs > 0) { if (i > 0 && delayMs > 0) {
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` }); panel.webview.postMessage({ type: 'status', message: `Waiting ${idx}/${paths.length} ${fileName}` });
await sleep(delayMs); await sleep(delayMs);
} }
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` }); panel.webview.postMessage({ type: 'status', message: `Validating ${idx}/${paths.length} ${fileName}` });
let res: Response; let res: Response;
try { try {
@@ -136,7 +154,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue; reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue;
} }
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Uploading ${fileName}...` }); panel.webview.postMessage({ type: 'status', message: `Uploading ${idx}/${paths.length} ${fileName}` });
try { try {
res = await fetch('https://p.applov.in/getCachedAdURL', { res = await fetch('https://p.applov.in/getCachedAdURL', {
@@ -175,6 +193,30 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
return vscode.Uri.file(root); return vscode.Uri.file(root);
} }
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function qrBaseName(name: string): string { function qrBaseName(name: string): string {
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr'; return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
} }
@@ -243,54 +285,87 @@ function getHtml(): string {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style> <style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; } ${getToolWebviewStyles()}
h2 { margin-top: 0; } .selected-files { margin-top: 10px; }
button { .qr-cell { text-align: center; }
background: var(--vscode-button-background); color: var(--vscode-button-foreground); .qr-cell img {
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer; width: 104px;
height: 104px;
max-width: 100%;
padding: 6px;
border-radius: var(--tool-radius);
background: #fff;
} }
button:hover { background: var(--vscode-button-hoverBackground); } .result-actions { display: flex; gap: 6px; flex-wrap: wrap; }
button.secondary { .qr-cell img { cursor: zoom-in; }
background: var(--vscode-button-secondaryBackground); .qr-modal {
color: var(--vscode-button-secondaryForeground); position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.72);
z-index: 10;
} }
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); } .qr-modal.open { display: flex; }
.file-cell { display: flex; align-items: center; gap: 8px; } .qr-modal img {
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; } width: min(70vw, 520px);
.actions { margin-top: 12px; display: flex; gap: 8px; } height: min(70vw, 520px);
#status { margin-top: 12px; min-height: 20px; } padding: 18px;
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; } border-radius: var(--tool-radius);
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); } background: #fff;
.result {
padding: 10px;
background: var(--vscode-textBlockQuote-background);
border-left: 3px solid var(--vscode-textBlockQuote-border);
word-break: break-all;
} }
.result-actions { margin-top: 8px; display: flex; gap: 8px; } .qr-results:not(.has-errors) .error-col { display: none; }
#results { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill,minmax(280px,1fr)); gap: 12px; } .remove-selected {
min-width: 28px;
width: 28px;
padding: 3px 0;
color: var(--vscode-errorForeground);
font-size: 16px;
line-height: 1;
}
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style> </style>
</head> </head>
<body> <body>
<h2>AppLovin Playable Preview (QR)</h2> <main class="tool-page">
<p style="opacity:0.8;font-size:12px;margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app (iOS/Android).</p> <header class="tool-header">
<h2 class="tool-title">AppLovin Playable Preview</h2>
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
</header>
<div class="file-cell" style="margin-bottom:8px;"> <section class="tool-panel input-panel">
<button id="pick" class="secondary">Add files...</button> <div class="panel-header">
<button id="clear" class="secondary" style="display:none;">Clear</button> <h3 class="panel-title">Inputs</h3>
<span class="file-name" id="fileName">(no files)</span> </div>
<div class="panel-body">
<div class="control-group">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-files"></div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="saveAll" class="secondary" disabled>Save All</button>
<button id="regenAll" class="secondary" disabled>Regenerate</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div> </div>
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div> <div id="qrModal" class="qr-modal" title="Click to close"><img alt="Expanded QR" /></div>
<div class="actions"> </main>
<button id="upload">Upload to AppLovin</button>
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
</div>
<div id="status"></div>
<div id="results"></div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
const pickFolderBtn = document.getElementById('pickFolder');
const pickBtn = document.getElementById('pick'); const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear'); const clearBtn = document.getElementById('clear');
const uploadBtn = document.getElementById('upload'); const uploadBtn = document.getElementById('upload');
@@ -298,9 +373,13 @@ function getHtml(): string {
const fileListEl = document.getElementById('fileList'); const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const qrModal = document.getElementById('qrModal');
const saveAllBtn = document.getElementById('saveAll'); const saveAllBtn = document.getElementById('saveAll');
const regenAllBtn = document.getElementById('regenAll'); const regenAllBtn = document.getElementById('regenAll');
const PAGE_SIZE = 5;
let selectedPaths = []; let selectedPaths = [];
let selectedPage = 0;
let resultItems = []; let resultItems = [];
function basename(p) { function basename(p) {
@@ -308,15 +387,51 @@ function getHtml(): string {
return i >= 0 ? p.slice(i + 1) : p; return i >= 0 ? p.slice(i + 1) : p;
} }
function renderSelection() { function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedPaths.length) { if (!selectedPaths.length) {
fileNameEl.textContent = '(no files)'; fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = ''; fileListEl.innerHTML = '';
clearBtn.style.display = 'none';
return; return;
} }
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's'); fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join(''); const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
clearBtn.style.display = ''; selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visiblePaths.map((p, offset) => {
const i = start + offset;
return (
'<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
);
}).join('') +
'</tbody>' +
'</table>' +
'</div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
selectedPaths.splice(Number(btn.dataset.index), 1);
renderSelection();
});
});
if (selectedPaths.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
} }
saveAllBtn.addEventListener('click', () => { saveAllBtn.addEventListener('click', () => {
@@ -331,24 +446,33 @@ function getHtml(): string {
img.src = base + '&_=' + Date.now(); img.src = base + '&_=' + Date.now();
}); });
}); });
qrModal.addEventListener('click', () => {
qrModal.classList.remove('open');
});
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' })); pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', () => {
selectedPaths = []; selectedPaths = [];
selectedPage = 0;
renderSelection(); renderSelection();
}); });
uploadBtn.addEventListener('click', () => { uploadBtn.addEventListener('click', () => {
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (!selectedPaths.length) { if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>'; statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return; return;
} }
uploadBtn.disabled = true; uploadBtn.disabled = true;
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
resultItems = []; resultItems = [];
saveAllBtn.style.display = 'none'; saveAllBtn.disabled = true;
regenAllBtn.style.display = 'none'; regenAllBtn.disabled = true;
vscode.postMessage({ type: 'upload', paths: selectedPaths }); vscode.postMessage({ type: 'upload', paths: selectedPaths });
}); });
@@ -356,21 +480,36 @@ function getHtml(): string {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
} }
function ensureResultsTable() {
let tbody = resultsEl.querySelector('tbody');
if (tbody) return tbody;
resultsEl.innerHTML =
'<table class="data-table qr-results">' +
'<thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
outputPanel.classList.remove('is-hidden');
return resultsEl.querySelector('tbody');
}
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
const m = e.data; const m = e.data;
if (m.type === 'filesSelected') { if (m.type === 'filesSelected') {
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p)); const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added); selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} else if (m.type === 'start') { } else if (m.type === 'start') {
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
resultItems = []; resultItems = [];
saveAllBtn.style.display = 'none'; saveAllBtn.disabled = true;
regenAllBtn.style.display = 'none'; regenAllBtn.disabled = true;
} else if (m.type === 'status') { } else if (m.type === 'status') {
statusEl.textContent = m.message; statusEl.textContent = m.message;
statusEl.className = ''; statusEl.className = 'status-panel is-busy';
} else if (m.type === 'error') { } else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'err'; div.className = 'err';
div.textContent = m.message; div.textContent = m.message;
@@ -378,20 +517,32 @@ function getHtml(): string {
statusEl.appendChild(div); statusEl.appendChild(div);
uploadBtn.disabled = false; uploadBtn.disabled = false;
} else if (m.type === 'fileError') { } else if (m.type === 'fileError') {
const wrap = document.createElement('div'); const tbody = ensureResultsTable();
wrap.className = 'result'; resultsEl.querySelector('table').classList.add('has-errors');
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' + const row = document.createElement('tr');
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>'; row.innerHTML =
resultsEl.appendChild(wrap); '<td class="mono wrap">' + escapeHtml(m.name) + '</td>' +
'<td class="muted">-</td>' +
'<td class="muted">-</td>' +
'<td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
tbody.appendChild(row);
} else if (m.type === 'fileResult') { } else if (m.type === 'fileResult') {
const wrap = document.createElement('div'); const tbody = ensureResultsTable();
wrap.className = 'result'; const row = document.createElement('tr');
wrap.innerHTML = const file = document.createElement('td');
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' + file.className = 'mono wrap';
'<div style="text-align:center;margin-bottom:8px;">' + file.textContent = m.name;
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' + const qr = document.createElement('td');
'</div>' + qr.className = 'qr-cell';
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>'; qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
qr.querySelector('img').addEventListener('click', (event) => {
qrModal.querySelector('img').src = event.currentTarget.src;
qrModal.classList.add('open');
});
const actionCell = document.createElement('td');
const error = document.createElement('td');
error.className = 'error-col';
error.innerHTML = '<span class="muted">-</span>';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'result-actions'; actions.className = 'result-actions';
const openPreview = document.createElement('button'); const openPreview = document.createElement('button');
@@ -404,14 +555,14 @@ function getHtml(): string {
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name }); saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
actions.appendChild(openPreview); actions.appendChild(openPreview);
actions.appendChild(saveQr); actions.appendChild(saveQr);
wrap.appendChild(actions); actionCell.appendChild(actions);
resultsEl.appendChild(wrap); row.append(file, qr, actionCell, error);
tbody.appendChild(row);
resultItems.push({ name: m.name, url: m.qrImg }); resultItems.push({ name: m.name, url: m.qrImg });
if (resultItems.length >= 2) { saveAllBtn.disabled = resultItems.length === 0;
saveAllBtn.style.display = ''; regenAllBtn.disabled = resultItems.length === 0;
regenAllBtn.style.display = '';
}
} else if (m.type === 'done') { } else if (m.type === 'done') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Done.</span>'; statusEl.innerHTML = '<span class="ok">Done.</span>';
uploadBtn.disabled = false; uploadBtn.disabled = false;
} }

View File

@@ -1,12 +1,12 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { singletonPanel } from './shared'; import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null }; const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openBase64Scanner(_context: vscode.ExtensionContext) { export function openBase64Scanner(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Asset Scanner'); const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Scanner');
if (!isNew) return; if (!isNew) return;
panel.webview.html = getHtml(); panel.webview.html = getHtml();
@@ -16,7 +16,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
if (ws) { if (ws) {
const distPath = path.join(ws.uri.fsPath, 'dist'); const distPath = path.join(ws.uri.fsPath, 'dist');
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) { if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
panel.webview.postMessage({ type: 'folderPicked', path: distPath }); const files = await collectHtmlFiles(distPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
} }
} }
} else if (msg.type === 'pickFolder') { } else if (msg.type === 'pickFolder') {
@@ -27,7 +28,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
openLabel: 'Select Folder', openLabel: 'Select Folder',
}); });
if (picked && picked[0]) { if (picked && picked[0]) {
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath }); const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
} }
} else if (msg.type === 'pickFiles') { } else if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({ const picked = await vscode.window.showOpenDialog({
@@ -56,7 +58,9 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
return; return;
} }
const results: { file: string; ok: boolean; assets: string[] }[] = []; const results: { file: string; ok: boolean; assets: string[] }[] = [];
for (const file of targets) { for (let i = 0; i < targets.length; i++) {
const file = targets[i];
panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` });
try { try {
const content = fs.readFileSync(file, 'utf8'); const content = fs.readFileSync(file, 'utf8');
const offenders = findNonBase64Assets(content); const offenders = findNonBase64Assets(content);
@@ -144,46 +148,125 @@ function getHtml(): string {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style> <style>
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); } ${getToolWebviewStyles()}
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } .asset-list { margin: 0; padding-left: 16px; }
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); } .asset-list li + li { margin-top: 3px; }
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; } .selected-list { margin-top: 8px; }
button:hover { background: var(--vscode-button-hoverBackground); } .remove-selected {
.row-result { display: flex; gap: 8px; padding: 2px 0; font-family: var(--vscode-editor-font-family); font-size: 12px; } min-width: 28px;
.mark { width: 14px; flex-shrink: 0; font-weight: bold; } width: 28px;
.ok { color: #3fb950; } padding: 3px 0;
.bad { color: #f85149; } color: var(--vscode-errorForeground);
.file-name { word-break: break-all; } font-size: 16px;
.assets { color: var(--vscode-descriptionForeground); margin-left: 6px; word-break: break-all; } line-height: 1;
.summary { margin-top: 12px; opacity: 0.8; } }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style> </style>
</head> </head>
<body> <body>
<h2>Base64 Asset Scanner</h2> <main class="tool-page">
<div class="row"> <header class="tool-header">
<input id="folder" type="text" placeholder="Folder to scan recursively..." /> <h2 class="tool-title">Base64 Scanner</h2>
<button id="pick">Browse Folder...</button> <p class="tool-description">Scan folders or selected HTML files for asset references that are not embedded as base64 data URIs.</p>
<button id="pickFiles">Pick Files...</button> </header>
<button id="scan">Scan</button>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="field-row">
<input id="folder" type="hidden" />
<button id="pick" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row">
<button id="scan">Scan</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div> </div>
<div id="selection" class="summary"></div> </main>
<div id="status"></div>
<div id="results"></div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' }); vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder'); const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const selectionEl = document.getElementById('selection'); const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFolder = '';
let pickedFiles = []; let pickedFiles = [];
let selectedPage = 0;
function clearFiles() { function basename(p) {
pickedFiles = []; const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
selectionEl.textContent = ''; return i >= 0 ? p.slice(i + 1) : p;
} }
folderEl.addEventListener('input', clearFiles); function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const items = pickedFiles.length
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
if (!items.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
selectionEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleItems.map(item =>
'<tr><td class="mono wrap">' + escapeHtml(item.label) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
).join('') +
'</tbody></table>' +
'</div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.type === 'folder') {
pickedFolder = '';
folderEl.value = '';
} else {
pickedFiles.splice(Number(btn.dataset.index), 1);
}
renderSelection();
});
});
if (items.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
document.getElementById('pick').addEventListener('click', () => { document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' }); vscode.postMessage({ type: 'pickFolder' });
@@ -191,9 +274,22 @@ function getHtml(): string {
document.getElementById('pickFiles').addEventListener('click', () => { document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFiles' }); vscode.postMessage({ type: 'pickFiles' });
}); });
document.getElementById('clear').addEventListener('click', () => {
folderEl.value = '';
pickedFiles = [];
pickedFolder = '';
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
});
document.getElementById('scan').addEventListener('click', () => { document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (pickedFiles.length) { if (pickedFiles.length) {
vscode.postMessage({ type: 'scan', files: pickedFiles }); vscode.postMessage({ type: 'scan', files: pickedFiles });
} else { } else {
@@ -207,35 +303,68 @@ function getHtml(): string {
const msg = event.data; const msg = event.data;
if (msg.type === 'folderPicked') { if (msg.type === 'folderPicked') {
folderEl.value = msg.path; folderEl.value = msg.path;
clearFiles(); pickedFolder = msg.path;
pickedFiles = [];
selectedPage = 0;
renderSelection();
} else if (msg.type === 'filesPicked') { } else if (msg.type === 'filesPicked') {
pickedFiles = msg.paths; const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
folderEl.value = ''; folderEl.value = '';
selectionEl.textContent = pickedFiles.length + ' file(s) selected'; pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'results') { } else if (msg.type === 'results') {
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; } if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const total = msg.results.length; const total = msg.results.length;
statusEl.classList.remove('is-busy');
const flagged = msg.results.filter(r => !r.ok).length; const flagged = msg.results.filter(r => !r.ok).length;
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>'; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
resultsEl.innerHTML = ''; if (!total) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of msg.results) { for (const r of msg.results) {
const row = document.createElement('div'); const row = document.createElement('tr');
row.className = 'row-result'; const status = document.createElement('td');
const mark = document.createElement('span'); status.innerHTML = r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>';
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad'); const name = document.createElement('td');
mark.textContent = r.ok ? '✓' : '✗'; name.className = 'mono wrap';
const name = document.createElement('span');
name.className = 'file-name';
name.textContent = r.file; name.textContent = r.file;
row.appendChild(mark); const count = document.createElement('td');
row.appendChild(name); count.textContent = String(r.assets.length);
const assets = document.createElement('td');
assets.className = 'wrap';
if (!r.ok) { if (!r.ok) {
const a = document.createElement('span'); const list = document.createElement('ul');
a.className = 'assets'; list.className = 'asset-list mono';
a.textContent = '— ' + r.assets.join(', '); r.assets.forEach(asset => {
row.appendChild(a); const item = document.createElement('li');
item.textContent = asset;
list.appendChild(item);
});
assets.appendChild(list);
} else {
assets.innerHTML = '<span class="muted">None</span>';
} }
resultsEl.appendChild(row); row.append(status, name, count, assets);
tbody.appendChild(row);
} }
} }
}); });

View File

@@ -1,7 +1,7 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { singletonPanel } from './shared'; import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null }; const store: { panel: vscode.WebviewPanel | null } = { panel: null };
@@ -32,6 +32,7 @@ interface MraidRule {
} }
interface ScanContext { interface ScanContext {
filePath: string;
html: string; html: string;
scriptText: string; scriptText: string;
lowerHtml: string; lowerHtml: string;
@@ -50,7 +51,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
if (ws) { if (ws) {
const distPath = path.join(ws.uri.fsPath, 'dist'); const distPath = path.join(ws.uri.fsPath, 'dist');
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) { if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
panel.webview.postMessage({ type: 'folderPicked', path: distPath }); const files = (await collectHtmlFiles(distPath)).filter(isInSupportedMraidFolderPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
} }
} }
} else if (msg.type === 'pickFolder') { } else if (msg.type === 'pickFolder') {
@@ -61,7 +63,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
openLabel: 'Select Folder', openLabel: 'Select Folder',
}); });
if (picked && picked[0]) { if (picked && picked[0]) {
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath }); const files = (await collectHtmlFiles(picked[0].fsPath)).filter(isInSupportedMraidFolderPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
} }
} else if (msg.type === 'pickFiles') { } else if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({ const picked = await vscode.window.showOpenDialog({
@@ -80,15 +83,21 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
let targets: string[] = []; let targets: string[] = [];
let baseDir = ''; let baseDir = '';
let skipped = 0; let skipped = 0;
let skippedReason = '';
let noTargetsError = 'No HTML files were found.';
if (files && files.length) { if (files && files.length) {
const existing = files.filter(f => fs.existsSync(f)); const existing = files.filter(f => fs.existsSync(f));
targets = existing.filter(isSupportedMraidBuild); targets = existing;
skipped = existing.length - targets.length; skipped = files.length - existing.length;
skippedReason = 'missing file(s).';
noTargetsError = 'No selected HTML files were found.';
baseDir = targets.length ? commonBaseDir(targets) : ''; baseDir = targets.length ? commonBaseDir(targets) : '';
} else if (folder && fs.existsSync(folder)) { } else if (folder && fs.existsSync(folder)) {
const allHtmlFiles = await collectHtmlFiles(folder); const allHtmlFiles = await collectHtmlFiles(folder);
targets = allHtmlFiles.filter(isSupportedMraidBuild); targets = allHtmlFiles.filter(isInSupportedMraidFolderPath);
skipped = allHtmlFiles.length - targets.length; skipped = allHtmlFiles.length - targets.length;
skippedReason = 'HTML file(s) outside AppLovin/ironSource/Unity folders.';
noTargetsError = 'No AppLovin, ironSource, or Unity folder HTML files were found.';
baseDir = folder; baseDir = folder;
} else { } else {
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' }); panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
@@ -100,23 +109,26 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
type: 'results', type: 'results',
results: [], results: [],
skipped, skipped,
error: 'No AppLovin, ironSource, or Unity HTML builds were found.', skippedReason,
error: noTargetsError,
}); });
return; return;
} }
const results: MraidResult[] = []; 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 { try {
const content = fs.readFileSync(file, 'utf8'); 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; const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
results.push({ file: display, ok: issues.length === 0, issues }); results.push({ file: display, ok: issues.length === 0, issues });
} catch { } catch {
// Skip unreadable files, matching the scanner pattern used elsewhere. // Skip unreadable files, matching the scanner pattern used elsewhere.
} }
} }
panel.webview.postMessage({ type: 'results', results, skipped }); panel.webview.postMessage({ type: 'results', results, skipped, skippedReason });
} }
}); });
} }
@@ -155,9 +167,10 @@ async function collectHtmlFiles(root: string): Promise<string[]> {
return out; return out;
} }
function checkMraid(html: string): MraidIssue[] { function checkMraid(html: string, filePath = ''): MraidIssue[] {
const scriptText = extractScriptText(html); const scriptText = extractScriptText(html);
const ctx: ScanContext = { const ctx: ScanContext = {
filePath,
html, html,
scriptText, scriptText,
lowerHtml: html.toLowerCase(), lowerHtml: html.toLowerCase(),
@@ -193,15 +206,38 @@ function collectMraidCallLines(html: string): Map<string, number> {
return calls; return calls;
} }
function isSupportedMraidBuild(filePath: string): boolean { function isInSupportedMraidFolderPath(filePath: string): boolean {
const normalized = filePath.toLowerCase().replace(/\\/g, '/'); const folderParts = path.dirname(filePath)
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized); .split(/[\\/]+/)
.map(part => part.toLowerCase());
return folderParts.some(part => [
'al',
'applovin',
'app-lovin',
'is',
'ironsource',
'iron-source',
'iron_source',
'un',
'unity',
'unityads',
'unity-ads',
'unity_ads',
].includes(part));
} }
function hasMraidReference(ctx: ScanContext): boolean { function hasMraidReference(ctx: ScanContext): boolean {
return /\bmraid\b/i.test(ctx.html); 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 { function hasMraidCall(ctx: ScanContext, method: string): boolean {
return ctx.mraidCallLines.has(method); return ctx.mraidCallLines.has(method);
} }
@@ -229,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 { function hasEventListener(ctx: ScanContext, eventName: string): boolean {
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i'); const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
return pattern.test(ctx.scriptText); return pattern.test(ctx.scriptText);
@@ -240,15 +347,32 @@ function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
} }
function usesDirectBrowserNavigation(ctx: ScanContext): boolean { function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
return ( return /\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText);
/\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) ||
/\blocation\s*\.\s*(href|assign|replace)\b/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[] = [ 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', id: 'mraid-reference',
severity: 'requirement', severity: 'requirement',
@@ -257,6 +381,30 @@ const MRAID_RULES: MraidRule[] = [
reference: MRAID_REFERENCE, reference: MRAID_REFERENCE,
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['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', id: 'ready-gate',
severity: 'requirement', severity: 'requirement',
@@ -265,6 +413,30 @@ const MRAID_RULES: MraidRule[] = [
reference: MRAID_REFERENCE, reference: MRAID_REFERENCE,
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']), 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', id: 'error-listener',
severity: 'best-practice', severity: 'best-practice',
@@ -276,21 +448,45 @@ const MRAID_RULES: MraidRule[] = [
{ {
id: 'viewability', id: 'viewability',
severity: 'best-practice', severity: 'best-practice',
title: 'Viewability handling not found', title: 'MRAID viewability fallback chain not found',
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.', 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, reference: MRAID_REFERENCE,
test: (ctx) => { 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']); 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', id: 'open-api',
severity: 'requirement', severity: 'requirement',
title: 'Browser navigation used instead of mraid.open', 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, 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', id: 'close-api',
@@ -303,29 +499,93 @@ const MRAID_RULES: MraidRule[] = [
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null; 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', id: 'expand-support',
severity: 'requirement', severity: 'requirement',
title: 'mraid.expand used without supports check', title: 'mraid.expand used without function guard',
detail: 'Check mraid.supports("expand") before using expand behavior.', detail: 'Guard mraid.expand with a function check before using expand behavior.',
reference: MRAID_REFERENCE, 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', id: 'resize-support',
severity: 'requirement', severity: 'requirement',
title: 'mraid.resize used without supports check', title: 'mraid.resize used without function guard',
detail: 'Check mraid.supports("resize") before using resize behavior.', detail: 'Guard mraid.resize with a function check before using resize behavior.',
reference: MRAID_REFERENCE, 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', id: 'orientation-support',
severity: 'best-practice', severity: 'best-practice',
title: 'Orientation properties set without supports check', title: 'Orientation properties set without function guard',
detail: 'Check mraid.supports("orientation") before requiring orientation behavior.', detail: 'Guard mraid.setOrientationProperties with a function check before requiring orientation behavior.',
reference: MRAID_REFERENCE, 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', id: 'size-change',
@@ -349,6 +609,35 @@ const MRAID_RULES: MraidRule[] = [
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null; 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) => { const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
@@ -362,53 +651,76 @@ function getHtml(): string {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style> <style>
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); } ${getToolWebviewStyles()}
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } .issues { margin: 0; padding: 0; }
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); } .issue { list-style: none; margin: 0 0 8px; }
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; } .issue:last-child { margin-bottom: 0; }
button:hover { background: var(--vscode-button-hoverBackground); } .severity-badge {
.summary { margin-top: 12px; opacity: 0.85; } display: inline-flex;
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; } min-width: 82px;
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); } justify-content: center;
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; } margin-right: 6px;
.mark { width: 18px; flex-shrink: 0; font-weight: bold; } padding: 1px 6px;
.ok { color: #3fb950; } border-radius: 999px;
.bad { color: #f85149; } border: 1px solid var(--tool-border);
.file-name { word-break: break-all; } font-size: 11px;
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; } }
.issues { margin: 6px 0 0 26px; padding: 0; } .requirement { color: var(--vscode-errorForeground); background: var(--vscode-inputValidation-errorBackground, rgba(248,81,73,0.14)); }
.issue { margin: 6px 0; list-style: none; } .best-practice { color: var(--vscode-editorWarning-foreground, #d29922); background: var(--vscode-inputValidation-warningBackground, rgba(210,153,34,0.14)); }
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
.issue-title { font-weight: 600; } .issue-title { font-weight: 600; }
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; } .issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
.selected-list { margin-top: 8px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style> </style>
</head> </head>
<body> <body>
<h2>MRAID Checker</h2> <main class="tool-page">
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div> <header class="tool-header">
<div class="row"> <h2 class="tool-title">MRAID Checker</h2>
<input id="folder" type="text" placeholder="Folder to scan recursively..." /> <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>
<button id="pick">Browse Folder...</button> </header>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button> <section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="field-row">
<input id="folder" type="hidden" />
<button id="pick" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row">
<button id="scan">Scan</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div> </div>
<div id="selection" class="summary"></div> </main>
<div id="status"></div>
<div id="results"></div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' }); vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder'); const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const selectionEl = document.getElementById('selection'); const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFolder = '';
let pickedFiles = []; let pickedFiles = [];
let selectedPage = 0;
function clearFiles() { function basename(p) {
pickedFiles = []; const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
selectionEl.textContent = ''; return i >= 0 ? p.slice(i + 1) : p;
} }
function escapeText(value) { function escapeText(value) {
@@ -421,7 +733,57 @@ function getHtml(): string {
}[ch])); }[ch]));
} }
folderEl.addEventListener('input', clearFiles); function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const items = pickedFiles.length
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
if (!items.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
selectionEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleItems.map(item =>
'<tr><td class="mono wrap">' + escapeText(item.label) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
).join('') +
'</tbody>' +
'</table>' +
'</div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.type === 'folder') {
pickedFolder = '';
folderEl.value = '';
} else {
pickedFiles.splice(Number(btn.dataset.index), 1);
}
renderSelection();
});
});
if (items.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
document.getElementById('pick').addEventListener('click', () => { document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' }); vscode.postMessage({ type: 'pickFolder' });
@@ -429,9 +791,22 @@ function getHtml(): string {
document.getElementById('pickFiles').addEventListener('click', () => { document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFiles' }); vscode.postMessage({ type: 'pickFiles' });
}); });
document.getElementById('clear').addEventListener('click', () => {
folderEl.value = '';
pickedFolder = '';
pickedFiles = [];
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
});
document.getElementById('scan').addEventListener('click', () => { document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (pickedFiles.length) { if (pickedFiles.length) {
vscode.postMessage({ type: 'scan', files: pickedFiles }); vscode.postMessage({ type: 'scan', files: pickedFiles });
} else { } else {
@@ -445,32 +820,53 @@ function getHtml(): string {
const msg = event.data; const msg = event.data;
if (msg.type === 'folderPicked') { if (msg.type === 'folderPicked') {
folderEl.value = msg.path; folderEl.value = msg.path;
clearFiles(); pickedFolder = msg.path;
pickedFiles = [];
selectedPage = 0;
renderSelection();
} else if (msg.type === 'filesPicked') { } else if (msg.type === 'filesPicked') {
pickedFiles = msg.paths; const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
folderEl.value = ''; folderEl.value = '';
selectionEl.textContent = pickedFiles.length + ' file(s) selected'; pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'results') { } else if (msg.type === 'results') {
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; } if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const total = msg.results.length; const total = msg.results.length;
statusEl.classList.remove('is-busy');
const withIssues = msg.results.filter(r => !r.ok).length; const withIssues = msg.results.filter(r => !r.ok).length;
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
const skipped = Number(msg.skipped || 0); const skipped = Number(msg.skipped || 0);
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : ''; const skippedReason = msg.skippedReason || 'file(s).';
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>'; const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
resultsEl.innerHTML = ''; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
if (!total) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th>File</th><th>Results</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of msg.results) { for (const r of msg.results) {
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length; const row = document.createElement('tr');
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length; const file = document.createElement('td');
const row = document.createElement('div'); file.className = 'mono wrap';
row.className = 'row-result'; file.textContent = r.file;
row.innerHTML = const issues = document.createElement('td');
'<div class="file-head">' + issues.className = 'wrap';
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
'<span class="file-name">' + escapeText(r.file) + '</span>' +
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
'</div>';
if (!r.ok) { if (!r.ok) {
const list = document.createElement('ul'); const list = document.createElement('ul');
list.className = 'issues'; list.className = 'issues';
@@ -479,14 +875,17 @@ function getHtml(): string {
item.className = 'issue'; item.className = 'issue';
const line = i.line ? ' line ' + i.line + ':' : ''; const line = i.line ? ' line ' + i.line + ':' : '';
item.innerHTML = item.innerHTML =
'<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' + '<span class="severity-badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' + '<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
'<div class="issue-detail">' + escapeText(i.detail) + '</div>'; '<div class="issue-detail">' + escapeText(i.detail) + '</div>';
list.appendChild(item); list.appendChild(item);
} }
row.appendChild(list); issues.appendChild(list);
} else {
issues.innerHTML = '<span class="badge ok">OK</span>';
} }
resultsEl.appendChild(row); row.append(file, issues);
tbody.appendChild(row);
} }
} }
}); });

View File

@@ -2,13 +2,14 @@ import * as vscode from 'vscode';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as cp from 'child_process'; import * as cp from 'child_process';
import { singletonPanel } from './shared'; import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null }; const store: { panel: vscode.WebviewPanel | null } = { panel: null };
interface ConvertOptions { interface ConvertOptions {
sourcePath: string; sourcePath: string;
outputDir: string; outputDir: string;
baseName?: string;
networks: string[]; networks: string[];
} }
@@ -38,7 +39,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
if (!picked?.[0]) return; if (!picked?.[0]) return;
const srcPath = picked[0].fsPath; const srcPath = picked[0].fsPath;
const dir = path.dirname(srcPath); const dir = path.dirname(srcPath);
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir }); panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir, baseName: sourceBaseName(srcPath) });
break; break;
} }
case 'pickOutput': { case 'pickOutput': {
@@ -67,6 +68,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
let html: string; let html: string;
try { try {
panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' });
html = fs.readFileSync(opts.sourcePath, 'utf8'); html = fs.readFileSync(opts.sourcePath, 'utf8');
} catch (e: any) { } catch (e: any) {
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` }); panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
@@ -74,7 +76,15 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
} }
const results: NetworkResult[] = []; const results: NetworkResult[] = [];
for (const network of opts.networks) { try {
panel.webview.postMessage({ type: 'status', message: 'Converting Unity...' });
results.push({ network: 'un', file: copyUnityInput(opts), ok: true });
} catch (e: any) {
results.push({ network: 'un', file: '', ok: false, error: e.message });
}
for (let i = 0; i < opts.networks.length; i++) {
const network = opts.networks[i];
panel.webview.postMessage({ type: 'status', message: `Converting ${i + 1}/${opts.networks.length} ${network}` });
try { try {
const filePath = await convertNetwork(html, opts, network); const filePath = await convertNetwork(html, opts, network);
results.push({ network, file: filePath, ok: true }); results.push({ network, file: filePath, ok: true });
@@ -93,22 +103,48 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
}); });
} }
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']); const ZIPPED_NETWORKS = new Set(['gg', 'ap', 'vu', 'mtg']);
const SUPPORTED_NETWORKS = new Set(['al', 'is', 'fb', 'gg', 'ap', 'mo', 'vu', 'mtg', 'tt']);
const MRAID_NETWORKS = new Set(['al', 'is']);
const EXITAPI_NETWORKS = new Set(['gg', 'ap']);
const NETWORK_OUTPUT_FOLDERS: Record<string, string> = {
al: 'Applovin',
is: 'Ironsource',
fb: 'Facebook',
gg: 'GoogleAds',
ap: 'Appreciate',
mo: 'Moloco',
vu: 'Vungle',
mtg: 'Mintegral',
tt: 'TikTok',
};
function sourceBaseName(sourcePath: string): string { function sourceBaseName(sourcePath: string): string {
return path.basename(sourcePath, path.extname(sourcePath)) return path.basename(sourcePath, path.extname(sourcePath))
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, ''); .replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/, '');
}
function outputBaseName(opts: ConvertOptions): string {
const rawBaseName = opts.baseName?.trim() || sourceBaseName(opts.sourcePath);
const withoutExtension = rawBaseName.replace(/\.html?$/i, '');
const safeBaseName = path.basename(withoutExtension).replace(/[<>:"/\\|?*]/g, '_').trim();
return safeBaseName || sourceBaseName(opts.sourcePath);
} }
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> { async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
if (!SUPPORTED_NETWORKS.has(network)) {
throw new Error(`Unsupported Playworks target network: ${network}`);
}
const transformed = transformHtml(html, network); const transformed = transformHtml(html, network);
const needsZip = ZIPPED_NETWORKS.has(network); const needsZip = ZIPPED_NETWORKS.has(network);
const baseName = sourceBaseName(opts.sourcePath); const baseName = outputBaseName(opts);
const htmlFileName = `${baseName}_${network}.html`; const htmlFileName = `${baseName}_${network}.html`;
const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network);
fs.mkdirSync(outputDir, { recursive: true });
if (needsZip) { if (needsZip) {
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`); const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`);
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`); const zipPath = path.join(outputDir, `${baseName}_${network}.zip`);
fs.writeFileSync(tmpPath, transformed, 'utf8'); fs.writeFileSync(tmpPath, transformed, 'utf8');
try { try {
await createZip(tmpPath, 'index.html', zipPath); await createZip(tmpPath, 'index.html', zipPath);
@@ -118,45 +154,135 @@ async function convertNetwork(html: string, opts: ConvertOptions, network: strin
return zipPath; return zipPath;
} }
const outPath = path.join(opts.outputDir, htmlFileName); const outPath = path.join(outputDir, htmlFileName);
fs.writeFileSync(outPath, transformed, 'utf8'); fs.writeFileSync(outPath, transformed, 'utf8');
return outPath; return outPath;
} }
function copyUnityInput(opts: ConvertOptions): string {
const baseName = outputBaseName(opts);
const outputDir = path.join(opts.outputDir, 'Unity');
const outPath = path.join(outputDir, `${baseName}_un.html`);
fs.mkdirSync(outputDir, { recursive: true });
if (path.resolve(opts.sourcePath) === path.resolve(outPath)) {
return outPath;
}
fs.copyFileSync(opts.sourcePath, outPath);
return outPath;
}
function transformHtml(html: string, network: string): string { function transformHtml(html: string, network: string): string {
let result = html; let result = sanitizePlayworksHtml(html);
// Inject into </head>: lifecycle stubs only (must register luna:build listener // Inject into </head>: lifecycle stubs only (must register luna:build listener
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script. // BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console. // Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
result = removeNetworkSdkScripts(result);
let headInject = buildLifecycleScript(); let headInject = buildLifecycleScript();
if (network === 'al' || network === 'is') { if (MRAID_NETWORKS.has(network)) {
headInject += '<script src="mraid.js"></script>'; headInject += '<script src="mraid.js"></script>' + buildMraidComplianceScript();
} else if (network === 'gg') { } else if (EXITAPI_NETWORKS.has(network)) {
headInject += '<script src="exitapi.js"></script>'; headInject += '<script src="exitapi.js"></script>';
} }
result = result.replace('</head>', headInject + '\n</head>'); result = injectBeforeHeadClose(result, headInject);
// Body-level flags // Body-level flags
if (network === 'vu') { if (network === 'vu') {
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>'); result = injectAfterBodyOpen(result, '<script>window.__VUNGLE__=true;</script>');
} else if (network === 'mtg') { } else if (network === 'mtg') {
result = result.replace('<body>', '<body onload="gameReady()">'); result = addBodyOnload(result, 'gameReady()');
} else if (network === 'tt') { } else if (network === 'tt') {
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>'); result = injectAfterBodyOpen(result, '<script>window.__TIKTOK__=true;</script>');
} }
// Replace the CTA script // Replace the CTA script
result = replaceCTAScript(result, network); result = replaceCTAScript(result, network);
// Unity: rewrite window.top → window.self (Luna static scan requirement) return finalizeNetworkHtml(result, network);
if (network === 'un') { }
result = result.split('window.top').join('window.self');
}
function sanitizePlayworksHtml(html: string): string {
let result = cleanupPlayworksHtml(html);
return removeNetworkSdkScripts(result);
}
function cleanupPlayworksHtml(html: string): string {
let result = html;
result = trimAfterFirstHtmlClose(result);
result = result.replace(/<script\b[^>]*>[\s\S]*?insertYourRemoteDebuggingTokenHere[\s\S]*?<\/script>\s*/gi, '');
result = result.replace(/https:\/\/mrdoob\.github\.io\/stats\.js\/build\/stats\.min\.js/gi, 'data:text/javascript,');
result = result.replace(/\s+crossorigin(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
result = result.replace(/\s+type\s*=\s*["']module["']/gi, '');
result = result.replace(/\bconsole\.error\s*\(/g, 'console.warn(');
return result; return result;
} }
function trimAfterFirstHtmlClose(html: string): string {
const idx = html.search(/<\/html\s*>/i);
if (idx === -1) return html;
const close = html.match(/<\/html\s*>/i);
return close ? html.slice(0, idx + close[0].length) : html;
}
function finalizeNetworkHtml(html: string, network: string): string {
let result = cleanupPlayworksHtml(html);
result = dedupeScriptSrc(result, 'mraid.js', MRAID_NETWORKS.has(network));
result = dedupeScriptSrc(result, 'exitapi.js', EXITAPI_NETWORKS.has(network));
return result;
}
function removeNetworkSdkScripts(html: string): string {
let result = html;
for (const src of ['mraid.js', 'exitapi.js']) {
const escaped = src.replace('.', '\\.');
result = result.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), '');
}
return result;
}
function dedupeScriptSrc(html: string, src: string, shouldExist: boolean): string {
const escaped = src.replace('.', '\\.');
let seen = false;
let result = html.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), (match) => {
if (shouldExist && !seen) {
seen = true;
return `<script src="${src}"></script>`;
}
return '';
});
if (shouldExist && !seen) {
result = injectBeforeHeadClose(result, `<script src="${src}"></script>`);
}
return result;
}
function injectBeforeHeadClose(html: string, injection: string): string {
if (/<\/head>/i.test(html)) {
return html.replace(/<\/head>/i, `${injection}\n</head>`);
}
return `${injection}\n${html}`;
}
function injectAfterBodyOpen(html: string, injection: string): string {
if (/<body\b[^>]*>/i.test(html)) {
return html.replace(/<body\b[^>]*>/i, match => `${match}\n${injection}`);
}
return `${injection}\n${html}`;
}
function addBodyOnload(html: string, handler: string): string {
if (!/<body\b[^>]*>/i.test(html)) {
return `<body onload="${handler}">\n${html}`;
}
return html.replace(/<body\b([^>]*)>/i, (match, attrs: string) => {
const onloadMatch = attrs.match(/\bonload\s*=\s*(["'])(.*?)\1/i);
if (!onloadMatch) return `<body${attrs} onload="${handler}">`;
if (onloadMatch[2].includes(handler)) return match;
const updated = `${onloadMatch[2]};${handler}`;
return match.replace(onloadMatch[0], `onload=${onloadMatch[1]}${updated}${onloadMatch[1]}`);
});
}
function replaceCTAScript(html: string, network: string): string { function replaceCTAScript(html: string, network: string): string {
const marker = 'Luna.Unity.Playable.InstallFullGame=function'; const marker = 'Luna.Unity.Playable.InstallFullGame=function';
const markerIdx = html.lastIndexOf(marker); const markerIdx = html.lastIndexOf(marker);
@@ -214,6 +340,28 @@ function buildLifecycleScript(): string {
].join(''); ].join('');
} }
function buildMraidComplianceScript(): string {
return [
'<script>(function(){',
'var m=null,scene=null,viewable=true,exposed=true,volume=null;',
'function get(){return window.mraid||m;}',
'function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}',
'function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}',
'function onViewable(v){viewable=!!v;apply();}',
'function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}',
'function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}',
'function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}',
'function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}',
'window.__hplMraidUnload=unload;',
'function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}',
'function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}',
'window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};',
'if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}',
'setTimeout(setup,2000);',
'})();</script>',
].join('');
}
function buildCTAScript(network: string): string { function buildCTAScript(network: string): string {
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`; const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
// gameClose must fire before every redirect // gameClose must fire before every redirect
@@ -224,21 +372,18 @@ function buildCTAScript(network: string): string {
case 'al': case 'al':
case 'is': case 'is':
// MRAID — mraid.js injected in <head> // MRAID — mraid.js injected in <head>
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`; ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`;
break;
case 'un':
// Unity Ads — MRAID-based; window.top already rewritten separately
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
break; break;
case 'fb': case 'fb':
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`; ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
break; break;
case 'gg': case 'gg':
case 'ap':
// Google — exitapi.js injected in <head> // Google — exitapi.js injected in <head>
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`; ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
break; break;
case 'mo': case 'mo':
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`; ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
break; break;
case 'vu': case 'vu':
// Vungle — window.__VUNGLE__ flag set in body // Vungle — window.__VUNGLE__ flag set in body
@@ -293,131 +438,122 @@ function getHtml(): string {
const networks = [ const networks = [
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' }, { tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' }, { tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
{ tag: 'fb', label: 'Facebook', note: 'HTML' }, { tag: 'fb', label: 'Facebook', note: 'HTML' },
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' }, { tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
{ tag: 'mo', label: 'Moloco', note: 'HTML' }, { tag: 'ap', label: 'Appreciate', note: 'ZIP + exitapi.js in head' },
{ tag: 'mo', label: 'Moloco', note: 'HTML + FbPlayableAd CTA' },
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' }, { tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' }, { tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' }, { tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
]; ];
const checkboxRows = networks.map(n => ` const checkboxRows = networks.map(n => `
<label class="net-row"> <tr>
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked /> <td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span> <td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
<span class="net-note">${n.note}</span> <td><span class="net-note">${n.note}</span></td>
</label>`).join(''); </tr>`).join('');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style> <style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; } ${getToolWebviewStyles()}
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
.section { margin-bottom: 14px; }
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
.row { display: flex; gap: 6px; align-items: center; }
input[type=text] {
flex: 1; padding: 4px 7px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, #555);
border-radius: 2px; font-size: 12px;
}
button {
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: 1px solid var(--vscode-panel-border, #444);
}
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
button.primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none; padding: 6px 18px; font-size: 13px;
}
button.primary:hover { background: var(--vscode-button-hoverBackground); }
button:disabled { opacity: 0.45; cursor: default; }
.net-grid { display: flex; flex-direction: column; gap: 4px; }
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
.net-label { min-width: 100px; font-weight: 500; } .net-label { min-width: 100px; font-weight: 500; }
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; } .net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
.net-note { font-size: 11px; opacity: 0.6; } .net-note { font-size: 11px; opacity: 0.6; }
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; } .toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
.toggle-row button { font-size: 11px; padding: 2px 8px; }
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
.ok { color: #3fb950; }
.bad { color: #f85149; }
.res-net { min-width: 70px; font-weight: 500; }
.res-file { opacity: 0.65; word-break: break-all; }
.res-err { color: #f85149; opacity: 0.9; }
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
</style> </style>
</head> </head>
<body> <body>
<h2>Playworks Converter</h2> <main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Playworks Converter</h2>
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
</header>
<div class="section"> <section class="tool-panel input-panel">
<div class="section-label">Source HTML (Playworks export)</div> <div class="panel-header">
<div class="row"> <h3 class="panel-title">Inputs</h3>
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
<button id="pickSrc">Browse...</button>
</div> </div>
</div> <div class="panel-body">
<div class="control-group">
<div class="section"> <div class="control-label">Source HTML (Playworks export)</div>
<div class="section-label">Output Folder</div> <div class="field-row">
<div class="row"> <input id="src" type="hidden" />
<input id="outDir" type="text" placeholder="Output folder..." /> <button id="pickSrc" class="secondary">Select File</button>
<button id="pickOut">Browse...</button> <span id="srcName" class="file-name">(no file)</span>
</div>
</div>
<div class="control-group">
<div class="control-label">Base Filename</div>
<div class="field-row">
<input id="baseName" type="text" placeholder="Base filename..." />
</div>
</div>
<div class="control-group">
<div class="control-label">Output Folder</div>
<div class="field-row">
<input id="outDir" type="text" placeholder="Output folder..." />
<button id="pickOut" class="secondary">Browse...</button>
</div>
</div>
<div class="control-group">
<div class="control-label">Networks to Generate</div>
<div class="toggle-row">
<button id="selectAll" class="secondary">Select All</button>
<button id="selectNone" class="secondary">Select None</button>
</div>
<div class="results-panel" style="margin-top:0;">
<table class="data-table">
<thead><tr><th style="width:70px;">Use</th><th style="width:160px;">Network</th><th>Output behavior</th></tr></thead>
<tbody>${checkboxRows}</tbody>
</table>
</div>
</div>
<div class="action-row">
<button id="convert" disabled>Convert</button>
</div>
<div id="status" class="status-panel"></div>
</div> </div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
<div class="action-row">
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
</div>
</div> </div>
</main>
<hr />
<div class="section">
<div class="section-label">Networks to generate</div>
<div class="toggle-row">
<button id="selectAll">All</button>
<button id="selectNone">None</button>
</div>
<div class="net-grid">${checkboxRows}</div>
</div>
<div class="actions">
<button class="primary" id="convert" disabled>Convert</button>
<button id="openOut" style="display:none">Open Output Folder</button>
</div>
<div id="status"></div>
<div class="results" id="results"></div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
let outputDir = ''; let outputDir = '';
const srcEl = document.getElementById('src'); const srcEl = document.getElementById('src');
const srcNameEl = document.getElementById('srcName');
const outEl = document.getElementById('outDir'); const outEl = document.getElementById('outDir');
const baseNameEl = document.getElementById('baseName');
const convertBtn = document.getElementById('convert'); const convertBtn = document.getElementById('convert');
const openOutBtn = document.getElementById('openOut'); const openOutBtn = document.getElementById('openOut');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
function updateConvertBtn() { function updateConvertBtn() {
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked); const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet; convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
}
function defaultOutputDir(dir) {
return dir ? dir.replace(/[\\\\/]+$/, '') + '\\\\Output' : '';
} }
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' })); document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' })); document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
document.getElementById('selectAll').addEventListener('click', () => { document.getElementById('selectAll').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = true); document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
updateConvertBtn(); updateConvertBtn();
}); });
document.getElementById('selectNone').addEventListener('click', () => { document.getElementById('selectNone').addEventListener('click', () => {
@@ -426,23 +562,32 @@ function getHtml(): string {
}); });
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn)); document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
outEl.addEventListener('input', updateConvertBtn); outEl.addEventListener('input', updateConvertBtn);
baseNameEl.addEventListener('input', updateConvertBtn);
openOutBtn.addEventListener('click', () => { openOutBtn.addEventListener('click', () => {
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir }); if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
}); });
function outputDisplayName(file) {
const parts = file.split(/[\\\\/]/).filter(Boolean);
return parts.slice(-2).join('/');
}
document.getElementById('convert').addEventListener('click', () => { document.getElementById('convert').addEventListener('click', () => {
const networks = [...document.querySelectorAll('.net-cb')] const networks = [...document.querySelectorAll('.net-cb')]
.filter(c => c.checked).map(c => c.dataset.tag); .filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...'; statusEl.textContent = 'Converting...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
openOutBtn.style.display = 'none'; openOutBtn.style.display = 'none';
outputPanel.classList.add('is-hidden');
convertBtn.disabled = true; convertBtn.disabled = true;
vscode.postMessage({ vscode.postMessage({
type: 'convert', type: 'convert',
opts: { opts: {
sourcePath: srcEl.value, sourcePath: srcEl.value,
outputDir: outEl.value, outputDir: outEl.value,
baseName: baseNameEl.value,
networks, networks,
}, },
}); });
@@ -452,45 +597,67 @@ function getHtml(): string {
const msg = event.data; const msg = event.data;
if (msg.type === 'sourcePicked') { if (msg.type === 'sourcePicked') {
srcEl.value = msg.path; srcEl.value = msg.path;
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
baseNameEl.value = msg.baseName || '';
if (!outEl.value && msg.dir) { if (!outEl.value && msg.dir) {
outEl.value = msg.dir; outEl.value = defaultOutputDir(msg.dir);
outputDir = msg.dir; outputDir = outEl.value;
} }
updateConvertBtn(); updateConvertBtn();
} else if (msg.type === 'outputPicked') { } else if (msg.type === 'outputPicked') {
outEl.value = msg.path; outEl.value = msg.path;
outputDir = msg.path; outputDir = msg.path;
updateConvertBtn(); updateConvertBtn();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'convertDone') { } else if (msg.type === 'convertDone') {
convertBtn.disabled = false; convertBtn.disabled = false;
updateConvertBtn(); updateConvertBtn();
if (msg.error) { if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error; statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return; return;
} }
const results = msg.results || []; const results = msg.results || [];
statusEl.classList.remove('is-busy');
const ok = results.filter(r => r.ok).length; const ok = results.filter(r => r.ok).length;
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.'; statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
resultsEl.innerHTML = ''; if (!results.length) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th style="width:90px;">Output</th><th>Path</th><th>Error</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of results) { for (const r of results) {
const row = document.createElement('div'); const row = document.createElement('tr');
row.className = 'res-row'; const net = document.createElement('td');
const mark = document.createElement('span');
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
mark.textContent = r.ok ? '✓' : '✗';
const net = document.createElement('span');
net.className = 'res-net';
net.textContent = r.network; net.textContent = r.network;
const detail = document.createElement('span'); const status = document.createElement('td');
status.innerHTML = r.ok ? '<span class="badge ok">Done</span>' : '<span class="badge err">Failed</span>';
const outputType = document.createElement('td');
const detail = document.createElement('td');
detail.className = 'mono wrap';
const error = document.createElement('td');
error.className = 'err wrap';
if (r.ok) { if (r.ok) {
detail.className = 'res-file'; outputType.textContent = /\\.zip$/i.test(r.file) ? 'ZIP' : 'HTML';
detail.textContent = r.file.split(/[\\\\/]/).pop(); detail.textContent = outputDisplayName(r.file);
} else { } else {
detail.className = 'res-err'; outputType.textContent = '-';
detail.textContent = r.error || 'Unknown error'; detail.innerHTML = '<span class="muted">-</span>';
error.textContent = r.error || 'Unknown error';
} }
row.append(mark, net, detail); if (r.ok) error.innerHTML = '<span class="muted">-</span>';
resultsEl.appendChild(row); row.append(net, status, outputType, detail, error);
tbody.appendChild(row);
} }
if (ok > 0) { if (ok > 0) {
outputDir = outEl.value; outputDir = outEl.value;

View File

@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { File } from 'node:buffer'; import { File } from 'node:buffer';
import { handleClipboardAndOpen, singletonPanel } from './shared'; import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null }; const store: { panel: vscode.WebviewPanel | null } = { panel: null };
@@ -15,6 +15,24 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
try { try {
if (handleClipboardAndOpen(msg)) return; if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({
type: 'filesSelected',
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
});
}
return;
}
if (msg.type === 'pickFile') { if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({ const picked = await vscode.window.showOpenDialog({
canSelectMany: true, canSelectMany: true,
@@ -83,11 +101,13 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
for (let i = 0; i < valid.length; i++) { for (let i = 0; i < valid.length; i++) {
const r = valid[i]; const r = valid[i];
const idx = i + 1; const idx = i + 1;
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${path.basename(r.path)}` });
const buf = fs.readFileSync(r.path); const buf = fs.readFileSync(r.path);
const file = new File([buf], stampedNames[i], { type: 'text/html' }); const file = new File([buf], stampedNames[i], { type: 'text/html' });
form.append(`fileUpload_${idx}`, file as any); form.append(`fileUpload_${idx}`, file as any);
form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, ''))); form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, '')));
} }
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
let res: Response; let res: Response;
try { try {
@@ -159,78 +179,100 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
return vscode.Uri.file(root); return vscode.Uri.file(root);
} }
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function getHtml(): string { function getHtml(): string {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style> <style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; } ${getToolWebviewStyles()}
h2 { margin-top: 0; } #rows input[type=text] { width: 100%; }
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; } .file-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
th, td { padding: 6px 8px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--vscode-panel-border); }
th { font-weight: 600; font-size: 12px; text-transform: uppercase; opacity: 0.7; }
input[type=text] {
width: 100%; padding: 4px 6px;
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 2px;
}
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
}
button:hover { background: var(--vscode-button-hoverBackground); }
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
.file-cell { display: flex; align-items: center; gap: 8px; }
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
.row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; } .row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; }
.actions { margin-top: 12px; display: flex; gap: 8px; } .pick { min-width: 82px; }
#status { margin-top: 12px; min-height: 20px; } .remove-btn {
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; } min-width: 28px;
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); } width: 28px;
.result { padding: 3px 0;
margin-top: 12px; padding: 10px; color: var(--vscode-errorForeground);
background: var(--vscode-textBlockQuote-background); font-size: 16px;
border-left: 3px solid var(--vscode-textBlockQuote-border); line-height: 1;
word-break: break-all;
} }
.result a { color: var(--vscode-textLink-foreground); } .result a { color: var(--vscode-textLink-foreground); }
.result-actions { margin-top: 8px; display: flex; gap: 8px; } .result-actions { margin-top: 8px; display: flex; gap: 8px; }
.remove-btn { background: transparent; color: var(--vscode-foreground); padding: 4px 8px; } .pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style> </style>
</head> </head>
<body> <body>
<h2>PLEC Upload</h2> <main class="tool-page">
<p style="opacity:0.8;font-size:12px;margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p> <header class="tool-header">
<h2 class="tool-title">PLEC Upload</h2>
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
</header>
<table id="rows"> <section class="tool-panel input-panel">
<thead> <div class="panel-header">
<tr> <h3 class="panel-title">Inputs</h3>
<th style="width:45%">HTML File</th> </div>
<th style="width:45%">Iteration Name</th> <div class="panel-body">
<th></th> <div class="control-group">
</tr> <div class="file-row">
</thead> <button id="pickFolder" class="secondary">Select Folder</button>
<tbody></tbody> <button id="pickFiles" class="secondary">Select File(s)</button>
</table> <button id="clear" class="secondary">Clear</button>
</div>
<div class="actions"> <div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
<button id="addRow" class="secondary">+ Add Row</button> <div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
<button id="upload">Upload</button> <table id="rows" class="data-table">
</div> <thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
<tbody></tbody>
<div id="status"></div> </table>
<div id="results"></div> </div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="openResult" class="secondary" disabled>Open</button>
<button id="copyResult" class="secondary" disabled>Copy Link</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
</main>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
const PAGE_SIZE = 5;
let nextId = 1; let nextId = 1;
let rows = [];
let page = 0;
let previewUrl = '';
const tbody = document.querySelector('#rows tbody'); const tbody = document.querySelector('#rows tbody');
const rowsPanel = document.getElementById('rowsPanel');
const emptySelection = document.getElementById('emptySelection');
function parseIterationName(filename) { function parseIterationName(filename) {
const base = filename.replace(/\\.html?$/i, ''); const base = filename.replace(/\\.html?$/i, '');
@@ -241,135 +283,167 @@ function getHtml(): string {
return base; return base;
} }
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const openResultBtn = document.getElementById('openResult');
const copyResultBtn = document.getElementById('copyResult');
function addRow() { function escapeHtml(s) {
const id = 'r' + (nextId++); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const tr = document.createElement('tr');
tr.dataset.id = id;
tr.innerHTML = \`
<td>
<div class="file-cell">
<button class="pick secondary">Choose...</button>
<span class="file-name" data-name>(no file)</span>
</div>
<input type="hidden" data-path />
<div class="row-error" data-error></div>
</td>
<td><input type="text" data-iter placeholder="iteration name" /></td>
<td><button class="remove-btn" title="Remove">×</button></td>
\`;
tr.querySelector('.pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFile', rowId: id });
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
if (!tbody.children.length) addRow();
});
tbody.appendChild(tr);
} }
document.getElementById('addRow').addEventListener('click', addRow); function addFiles(files) {
const existing = new Set(rows.map(r => r.path));
files.forEach(f => {
if (existing.has(f.path)) return;
rows.push({
id: 'r' + (nextId++),
path: f.path,
name: f.name,
iteration: parseIterationName(f.name),
error: ''
});
existing.add(f.path);
});
normalizeDuplicateIterations(files.length);
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
renderRows();
}
function normalizeDuplicateIterations(addedCount) {
if (addedCount <= 1) return;
const added = rows.slice(Math.max(0, rows.length - addedCount));
if (!added.length) return;
const first = added[0].iteration;
if (first && added.every(r => r.iteration === first)) {
added.forEach((r, i) => {
r.iteration = String(i + 1).padStart(2, '0') + '_' + first;
});
}
}
function renderRows() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
page = Math.min(page, maxPage);
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
if (!rows.length) {
tbody.innerHTML = '';
return;
}
const start = page * PAGE_SIZE;
const pageRows = rows.slice(start, start + PAGE_SIZE);
tbody.innerHTML = pageRows.map((row, offset) => {
const i = start + offset;
return \`
<tr data-id="\${row.id}">
<td>
<span class="mono wrap">\${escapeHtml(row.name)}</span>
<div class="row-error" data-error>\${escapeHtml(row.error || '')}</div>
</td>
<td><input type="text" data-iter data-index="\${i}" value="\${escapeHtml(row.iteration)}" /></td>
<td><button class="remove-btn danger" data-remove="\${i}" title="Remove">×</button></td>
</tr>
\`;
}).join('');
tbody.querySelectorAll('[data-iter]').forEach(input => {
input.addEventListener('input', () => {
rows[Number(input.dataset.index)].iteration = input.value;
});
});
tbody.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => {
rows.splice(Number(btn.dataset.remove), 1);
renderRows();
});
});
if (rows.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
document.querySelector('#rows').parentElement.after(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
}
}
function setPreview(url) {
previewUrl = url || '';
openResultBtn.disabled = !previewUrl;
copyResultBtn.disabled = !previewUrl;
}
document.getElementById('pickFolder').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
});
document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFile' });
});
document.getElementById('clear').addEventListener('click', () => {
rows = [];
page = 0;
setPreview('');
renderRows();
statusEl.textContent = '';
});
document.getElementById('upload').addEventListener('click', () => { document.getElementById('upload').addEventListener('click', () => {
clearErrors(); clearErrors();
statusEl.textContent = ''; statusEl.textContent = '';
resultsEl.innerHTML = ''; statusEl.classList.remove('is-busy');
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({ setPreview('');
id: tr.dataset.id,
path: tr.querySelector('[data-path]').value,
iteration: tr.querySelector('[data-iter]').value,
})).filter(r => r.path || r.iteration);
if (rows.length === 0) { if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>'; statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return; return;
} }
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'upload', rows }); vscode.postMessage({ type: 'upload', rows });
}); });
openResultBtn.addEventListener('click', () => {
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
});
copyResultBtn.addEventListener('click', () => {
if (previewUrl) vscode.postMessage({ type: 'copy', text: previewUrl });
});
function clearErrors() { function clearErrors() {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = ''); rows.forEach(row => row.error = '');
renderRows();
} }
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
const m = e.data; const m = e.data;
if (m.type === 'filesSelected') { if (m.type === 'filesSelected') {
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]'); addFiles(m.files || []);
const affected = [];
m.files.forEach(function(f, i) {
let tr;
if (i === 0) {
tr = firstTr;
} else {
addRow();
tr = tbody.lastElementChild;
}
if (!tr) return;
tr.querySelector('[data-path]').value = f.path;
tr.querySelector('[data-name]').textContent = f.name;
const iter = tr.querySelector('[data-iter]');
if (!iter.value) iter.value = parseIterationName(f.name);
affected.push(tr);
});
if (affected.length > 1) {
const iters = affected.map(r => r.querySelector('[data-iter]').value);
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
affected.forEach((r, i) => {
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
});
}
}
} else if (m.type === 'status') { } else if (m.type === 'status') {
statusEl.textContent = m.message; statusEl.textContent = m.message;
statusEl.className = ''; statusEl.className = 'status-panel is-busy';
} else if (m.type === 'error') { } else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = ''; statusEl.innerHTML = '';
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'err'; div.className = 'err';
div.textContent = m.message; div.textContent = m.message;
statusEl.appendChild(div); statusEl.appendChild(div);
} else if (m.type === 'rowError') { } else if (m.type === 'rowError') {
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]'); statusEl.classList.remove('is-busy');
if (tr) tr.querySelector('[data-error]').textContent = m.message; const row = rows.find(r => r.id === m.rowId);
if (row) row.error = m.message;
renderRows();
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>'; statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
} else if (m.type === 'result') { } else if (m.type === 'result') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Upload complete.</span>'; statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
const wrap = document.createElement('div'); setPreview(m.preview);
wrap.className = 'result';
if (m.preview) {
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + m.preview + '">' + m.preview + '</a></div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copy = document.createElement('button');
copy.textContent = 'Copy';
copy.onclick = () => vscode.postMessage({ type: 'copy', text: m.preview });
const open = document.createElement('button');
open.textContent = 'Open';
open.className = 'secondary';
open.onclick = () => vscode.postMessage({ type: 'open', text: m.preview });
const showRaw = document.createElement('button');
showRaw.textContent = 'Show server response';
showRaw.className = 'secondary';
showRaw.onclick = () => {
const pre = document.createElement('pre');
pre.style.marginTop = '8px';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = m.rawText || JSON.stringify(m.raw, null, 2);
wrap.appendChild(pre);
showRaw.disabled = true;
};
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
wrap.appendChild(actions);
} else {
wrap.innerHTML = '<div>No preview field in response. Raw JSON:</div><pre>' +
(m.rawText || JSON.stringify(m.raw, null, 2)).replace(/</g, '&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
} }
}); });
addRow(); renderRows();
</script> </script>
</body> </body>
</html>`; </html>`;

View File

@@ -4,7 +4,7 @@ import * as path from 'path';
import * as http from 'http'; import * as http from 'http';
import * as os from 'os'; import * as os from 'os';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { handleClipboardAndOpen } from './shared'; import { getToolWebviewStyles, handleClipboardAndOpen } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null }; const store: { panel: vscode.WebviewPanel | null } = { panel: null };
@@ -12,7 +12,11 @@ interface ActiveShare {
server: http.Server; server: http.Server;
port: number; port: number;
token: string; token: string;
fileBuf: Buffer; files: SharedFile[];
}
interface SharedFile {
buf: Buffer;
filename: string; filename: string;
} }
@@ -57,19 +61,35 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
try { try {
if (handleClipboardAndOpen(msg)) return; if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({
type: 'fileSelected',
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
});
}
return;
}
if (msg.type === 'pickFile') { if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({ const picked = await vscode.window.showOpenDialog({
canSelectMany: false, canSelectMany: true,
filters: { HTML: ['html', 'htm'] }, filters: { HTML: ['html', 'htm'] },
openLabel: 'Select', openLabel: 'Select',
defaultUri: getPickerDefaultUri(), defaultUri: getPickerDefaultUri(),
}); });
if (picked && picked[0]) { if (picked && picked.length) {
const fp = picked[0].fsPath;
panel.webview.postMessage({ panel.webview.postMessage({
type: 'fileSelected', type: 'fileSelected',
path: fp, files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
name: path.basename(fp),
}); });
} }
return; return;
@@ -77,13 +97,22 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
if (msg.type === 'startShare') { if (msg.type === 'startShare') {
stopActive(); stopActive();
const filePath: string = msg.path; const filePaths: string[] = Array.isArray(msg.paths) ? msg.paths : (msg.path ? [msg.path] : []);
if (!filePath || !fs.existsSync(filePath)) { if (!filePaths.length) {
panel.webview.postMessage({ type: 'error', message: 'File missing.' }); panel.webview.postMessage({ type: 'error', message: 'File missing.' });
return; return;
} }
const fileBuf = fs.readFileSync(filePath); const files: SharedFile[] = [];
const filename = path.basename(filePath); 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 token = crypto.randomBytes(6).toString('base64url');
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile'); const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
@@ -93,8 +122,7 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
server: http.createServer(), server: http.createServer(),
port: 0, port: 0,
token, token,
fileBuf, files,
filename,
}; };
share.server.on('request', (req, res) => handleRequest(req, res, share)); share.server.on('request', (req, res) => handleRequest(req, res, share));
share.server.on('error', (err) => { share.server.on('error', (err) => {
@@ -125,7 +153,11 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
iface: ip.iface, iface: ip.iface,
url: `http://${ip.address}:${share.port}/s/${token}/`, url: `http://${ip.address}:${share.port}/s/${token}/`,
})); }));
panel.webview.postMessage({ type: 'sharing', urls, filename }); panel.webview.postMessage({
type: 'sharing',
urls,
filename: files.length === 1 ? files[0].filename : `${files.length} files`,
});
}); });
return; return;
} }
@@ -141,6 +173,30 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
}); });
} }
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) { function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) {
if (req.method !== 'GET' || !req.url) { if (req.method !== 'GET' || !req.url) {
res.statusCode = 404; res.statusCode = 404;
@@ -156,7 +212,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
const rest = req.url.slice(prefix.length).replace(/\?.*$/, ''); const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
if (rest === '' || rest === '/') { if (rest === '' || rest === '/') {
const body = chooserPage(share.filename); const body = chooserPage(share.files);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8', 'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
@@ -165,24 +221,40 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
return; return;
} }
if (rest === '/view') { const viewMatch = rest.match(/^\/view\/(\d+)$/);
if (rest === '/view' || viewMatch) {
const index = viewMatch ? Number(viewMatch[1]) : 0;
const file = share.files[index];
if (!file) {
res.statusCode = 404;
res.end();
return;
}
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8', 'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
}); });
res.end(share.fileBuf); res.end(file.buf);
return; return;
} }
if (rest === '/file') { const fileMatch = rest.match(/^\/file\/(\d+)$/);
const safeName = share.filename.replace(/[^A-Za-z0-9._-]/g, '_'); if (rest === '/file' || fileMatch) {
const index = fileMatch ? Number(fileMatch[1]) : 0;
const file = share.files[index];
if (!file) {
res.statusCode = 404;
res.end();
return;
}
const safeName = file.filename.replace(/[^A-Za-z0-9._-]/g, '_');
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${safeName}"`, 'Content-Disposition': `attachment; filename="${safeName}"`,
'Content-Length': String(share.fileBuf.length), 'Content-Length': String(file.buf.length),
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
}); });
res.end(share.fileBuf); res.end(file.buf);
return; return;
} }
@@ -190,10 +262,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
res.end(); res.end();
} }
function chooserPage(filename: string): string { function chooserPage(files: SharedFile[]): string {
const escName = filename.replace(/[&<>"]/g, (c) => const esc = (value: string) => value.replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string) ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string)
); );
const items = files.map((file, index) => {
const escName = esc(file.filename);
return `<div class="item">
<div class="file">${escName}</div>
<a class="btn view" href="view/${index}">View in browser</a>
<a class="btn dl" href="file/${index}" download="${escName}">Download .html</a>
</div>`;
}).join('');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html><head> <html><head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -203,7 +283,8 @@ function chooserPage(filename: string): string {
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 24px; background: #111; color: #eee; } margin: 0; padding: 24px; background: #111; color: #eee; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; } h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; } .item { margin-bottom: 26px; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px; a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; } font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
a.view { background: #2d7dff; color: white; } a.view { background: #2d7dff; color: white; }
@@ -211,9 +292,7 @@ function chooserPage(filename: string): string {
</style></head> </style></head>
<body> <body>
<h1>HPL Toolbox</h1> <h1>HPL Toolbox</h1>
<div class="file">${escName}</div> ${items}
<a class="btn view" href="view">View in browser</a>
<a class="btn dl" href="file" download="${escName}">Download .html</a>
</body></html>`; </body></html>`;
} }
@@ -250,26 +329,8 @@ function getHtml(qrScriptUri: string): string {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<style> <style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; } ${getToolWebviewStyles()}
h2 { margin-top: 0; } #qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: var(--tool-radius); }
.row { margin-bottom: 14px; }
.file-cell { display: flex; align-items: center; gap: 8px; }
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
}
button:hover { background: var(--vscode-button-hoverBackground); }
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.iface-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
.iface-list label { font-size: 12px; cursor: pointer; }
.iface-list .meta { opacity: 0.6; margin-left: 6px; }
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: 4px; }
#qr svg, #qr img { display: block; } #qr svg, #qr img { display: block; }
.url { .url {
margin-top: 10px; padding: 8px; margin-top: 10px; padding: 8px;
@@ -277,99 +338,170 @@ function getHtml(qrScriptUri: string): string {
border-left: 3px solid var(--vscode-textBlockQuote-border); border-left: 3px solid var(--vscode-textBlockQuote-border);
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all; font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
} }
.url-actions { margin-top: 6px; display: flex; gap: 6px; } .url-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
#status { margin-top: 12px; min-height: 18px; } .share-panel { padding: 12px; }
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; } .iface-radio { text-align: center; }
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); } #qr { margin-top: 0; }
.hint { font-size: 12px; opacity: 0.7; margin-top: 8px; } .selected-list { margin-top: 10px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style> </style>
<script src="${qrScriptUri}"></script> <script src="${qrScriptUri}"></script>
</head> </head>
<body> <body>
<h2>Send To Mobile</h2> <main class="tool-page">
<p style="opacity:0.8;font-size:12px;margin-top:0;"> <header class="tool-header">
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. <h2 class="tool-title">Send To Mobile</h2>
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>. <p class="tool-description">Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. The phone gets View and Download options.</p>
</p> </header>
<div class="row"> <section class="tool-panel input-panel">
<div class="file-cell"> <div class="panel-header">
<button id="pick" class="secondary">Choose...</button> <h3 class="panel-title">Inputs</h3>
<span class="file-name" id="fileName">(no file)</span>
</div> </div>
<input type="hidden" id="filePath" /> <div class="panel-body">
</div> <div class="control-group">
<div class="file-row">
<div class="row"> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="start">Start sharing</button> <button id="pick" class="secondary">Select File(s)</button>
<button id="stop" class="secondary" disabled>Stop</button> <button id="clear" class="secondary">Clear</button>
</div> </div>
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
<div id="ifaceWrap" class="row" style="display:none;"> <div id="fileList" class="selected-list"></div>
<div style="font-size:12px;opacity:0.8;">Multiple network interfaces found. Pick the one your phone can reach:</div> </div>
<div class="iface-list" id="ifaceList"></div> <div class="action-row">
</div> <button id="start">Start</button>
<button id="stop" class="secondary" disabled>Stop</button>
<div id="shareInfo" style="display:none;"> </div>
<div id="qr"></div> <div id="status" class="status-panel"></div>
<div class="url" id="urlText"></div>
<div class="url-actions">
<button id="copyUrl" class="secondary">Copy URL</button>
<button id="openUrl" class="secondary">Open in browser</button>
</div> </div>
<div class="hint"> </section>
First run on Windows may show a firewall prompt — allow access for "Private networks".
Sharing stops automatically when this panel is closed.
</div>
</div>
<div id="status"></div> <div id="outputPanel" class="is-hidden">
<div id="ifaceWrap" class="control-group" style="display:none;">
<div class="control-label">Network Interfaces</div>
<p class="section-description">Pick the interface your phone can reach.</p>
<div class="iface-list" id="ifaceList"></div>
</div>
<div id="shareInfo" class="result-card share-panel" style="display:none;">
<div id="qr"></div>
<div class="muted" style="margin-top:8px;font-size:12px;">
First run on Windows may show a firewall prompt - allow access for "Private networks".
Sharing stops automatically when this panel is closed.
</div>
</div>
</div>
</main>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
const pickFolderBtn = document.getElementById('pickFolder');
const pickBtn = document.getElementById('pick'); const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const startBtn = document.getElementById('start'); const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop'); const stopBtn = document.getElementById('stop');
const fileNameEl = document.getElementById('fileName'); const fileNameEl = document.getElementById('fileName');
const filePathEl = document.getElementById('filePath'); const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const outputPanel = document.getElementById('outputPanel');
const shareInfo = document.getElementById('shareInfo'); const shareInfo = document.getElementById('shareInfo');
const ifaceWrap = document.getElementById('ifaceWrap'); const ifaceWrap = document.getElementById('ifaceWrap');
const ifaceList = document.getElementById('ifaceList'); const ifaceList = document.getElementById('ifaceList');
const qrEl = document.getElementById('qr'); const qrEl = document.getElementById('qr');
const urlTextEl = document.getElementById('urlText');
const PAGE_SIZE = 5;
let currentUrls = []; let currentUrls = [];
let selectedIdx = 0; let selectedIdx = 0;
let selectedPaths = [];
let selectedFiles = [];
let selectedPage = 0;
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' })); pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' }));
clearBtn.addEventListener('click', () => {
selectedPaths = [];
selectedFiles = [];
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
});
startBtn.addEventListener('click', () => { startBtn.addEventListener('click', () => {
statusEl.innerHTML = ''; statusEl.innerHTML = '';
const path = filePathEl.value; outputPanel.classList.add('is-hidden');
if (!path) { if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>'; statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return; return;
} }
startBtn.disabled = true; startBtn.disabled = true;
statusEl.textContent = 'Starting server...'; statusEl.textContent = 'Starting server...';
vscode.postMessage({ type: 'startShare', path }); statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
}); });
stopBtn.addEventListener('click', () => { stopBtn.addEventListener('click', () => {
vscode.postMessage({ type: 'stopShare' }); vscode.postMessage({ type: 'stopShare' });
}); });
document.getElementById('copyUrl').addEventListener('click', () => { function escapeHtml(s) {
vscode.postMessage({ type: 'copy', text: currentUrls[selectedIdx]?.url || '' }); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}); }
document.getElementById('openUrl').addEventListener('click', () => {
vscode.postMessage({ type: 'open', text: currentUrls[selectedIdx]?.url || '' }); function renderSelection() {
}); const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedFiles.length) {
fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = '';
return;
}
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visibleFiles = selectedFiles.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleFiles.map((file, offset) => {
const i = start + offset;
return (
'<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
);
}).join('') +
'</tbody>' +
'</table>' +
'</div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
const index = Number(btn.dataset.index);
selectedFiles.splice(index, 1);
selectedPaths.splice(index, 1);
renderSelection();
});
});
if (selectedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
function renderQr(url) { function renderQr(url) {
qrEl.innerHTML = ''; qrEl.innerHTML = '';
urlTextEl.textContent = url;
try { try {
const qr = qrcode(0, 'M'); const qr = qrcode(0, 'M');
qr.addData(url); qr.addData(url);
@@ -389,41 +521,62 @@ function getHtml(qrScriptUri: string): string {
return; return;
} }
ifaceWrap.style.display = ''; ifaceWrap.style.display = '';
ifaceList.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead>' +
'<tbody></tbody>' +
'</table>' +
'</div>';
const tbody = ifaceList.querySelector('tbody');
urls.forEach((u, i) => { urls.forEach((u, i) => {
const id = 'iface_' + i; const id = 'iface_' + i;
const label = document.createElement('label'); const row = document.createElement('tr');
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' + row.innerHTML =
(i === selectedIdx ? ' checked' : '') + ' /> ' + '<td class="iface-radio"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
u.ip + '<span class="meta">(' + u.iface + ')</span>'; '<td class="mono wrap">' + u.ip + '</td>' +
label.querySelector('input').addEventListener('change', () => { '<td class="wrap">' + u.iface + '</td>';
row.querySelector('input').addEventListener('change', () => {
selectedIdx = i; selectedIdx = i;
renderQr(urls[i].url); renderQr(urls[i].url);
}); });
ifaceList.appendChild(label); tbody.appendChild(row);
}); });
} }
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
const m = e.data; const m = e.data;
if (m.type === 'fileSelected') { if (m.type === 'fileSelected') {
filePathEl.value = m.path; const existing = new Set(selectedFiles.map(f => f.path));
fileNameEl.textContent = m.name; selectedFiles = selectedFiles.concat((m.files || []).filter(f => !existing.has(f.path)));
selectedPaths = selectedFiles.map(f => f.path);
selectedPage = 0;
renderSelection();
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = 'status-panel is-busy';
} else if (m.type === 'sharing') { } else if (m.type === 'sharing') {
statusEl.classList.remove('is-busy');
currentUrls = m.urls; currentUrls = m.urls;
selectedIdx = 0; selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>'; statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
outputPanel.classList.remove('is-hidden');
shareInfo.style.display = ''; shareInfo.style.display = '';
renderIfaces(m.urls); renderIfaces(m.urls);
renderQr(m.urls[0].url); renderQr(m.urls[0].url);
startBtn.disabled = true; startBtn.disabled = true;
stopBtn.disabled = false; stopBtn.disabled = false;
} else if (m.type === 'stopped') { } else if (m.type === 'stopped') {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Stopped.'; statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none'; shareInfo.style.display = 'none';
ifaceWrap.style.display = 'none';
outputPanel.classList.add('is-hidden');
currentUrls = []; currentUrls = [];
startBtn.disabled = false; startBtn.disabled = false;
stopBtn.disabled = true; stopBtn.disabled = true;
} else if (m.type === 'error') { } else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '&lt;') + '</span>'; statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '&lt;') + '</span>';
startBtn.disabled = false; startBtn.disabled = false;
} }

View File

@@ -13,6 +13,306 @@ export function handleClipboardAndOpen(msg: any): boolean {
return false; return false;
} }
export function getToolWebviewStyles(): string {
return `
:root {
color-scheme: dark;
--tool-gap-xs: 4px;
--tool-gap-sm: 8px;
--tool-gap-md: 12px;
--tool-gap-lg: 16px;
--tool-radius: 4px;
--tool-border: var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
--tool-panel: var(--vscode-editorWidget-background, var(--vscode-sideBar-background));
--tool-panel-soft: var(--vscode-textBlockQuote-background, rgba(128, 128, 128, 0.08));
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 0;
background: var(--vscode-editor-background);
color: var(--vscode-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.tool-page {
max-width: 1100px;
padding: 18px;
}
.tool-header {
margin-bottom: var(--tool-gap-lg);
}
.tool-title {
margin: 0;
font-size: 18px;
line-height: 1.25;
font-weight: 600;
}
.tool-description,
.section-description,
.muted {
color: var(--vscode-descriptionForeground);
}
.tool-description {
margin: 5px 0 0;
max-width: 780px;
font-size: 12px;
line-height: 1.45;
}
.tool-section {
margin-bottom: var(--tool-gap-lg);
}
.tool-panel {
margin-bottom: var(--tool-gap-lg);
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: var(--tool-panel-soft);
overflow: hidden;
}
.is-hidden,
.tool-panel.is-hidden {
display: none;
}
.panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--tool-gap-md);
padding: 10px 12px;
border-bottom: 1px solid var(--tool-border);
background: var(--tool-panel);
}
.panel-title {
margin: 0;
color: var(--vscode-foreground);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.45px;
text-transform: uppercase;
}
.panel-body {
padding: 12px;
}
.control-group + .control-group {
margin-top: var(--tool-gap-md);
}
.control-label {
margin: 0 0 6px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.section-title {
margin: 0 0 6px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.section-description {
margin: -2px 0 8px;
font-size: 12px;
line-height: 1.45;
}
.field-row,
.file-row,
.action-row {
display: flex;
align-items: center;
gap: var(--tool-gap-sm);
flex-wrap: wrap;
}
.field-row + .field-row {
margin-top: var(--tool-gap-sm);
}
input[type=text],
input[type=date],
select,
textarea {
min-width: 0;
padding: 6px 8px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, var(--tool-border));
border-radius: var(--tool-radius);
font: inherit;
}
input[type=text]:focus,
input[type=date]:focus,
select:focus,
textarea:focus {
outline: 1px solid var(--vscode-focusBorder);
border-color: var(--vscode-focusBorder);
}
.field-row input[type=text] {
flex: 1 1 260px;
}
button {
min-height: 28px;
padding: 5px 12px;
border: 1px solid transparent;
border-radius: var(--tool-radius);
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font: inherit;
cursor: pointer;
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border-color: var(--tool-border);
}
button.secondary:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
button.danger {
background: transparent;
color: var(--vscode-errorForeground);
border-color: var(--tool-border);
}
button.danger:hover {
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.16));
}
.remove-selected {
min-width: 28px;
width: 28px;
padding: 3px 0;
color: var(--vscode-errorForeground);
font-size: 16px;
line-height: 1;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-row {
margin-top: var(--tool-gap-md);
}
.status-panel,
.results-panel,
.result-card {
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: var(--tool-panel-soft);
}
.status-panel {
min-height: 30px;
margin-top: var(--tool-gap-md);
padding: 8px 10px;
white-space: pre-wrap;
}
.status-panel.is-busy {
display: flex;
align-items: center;
gap: 8px;
}
.status-panel.is-busy::before {
content: "";
width: 12px;
height: 12px;
flex: 0 0 auto;
border: 2px solid color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent);
border-top-color: var(--vscode-foreground);
border-radius: 50%;
animation: status-spin 800ms linear infinite;
}
@keyframes status-spin {
to { transform: rotate(360deg); }
}
.status-panel:empty {
display: none;
}
.results-panel {
margin-top: var(--tool-gap-md);
overflow: auto;
}
.results-panel:empty {
display: none;
}
.output-panel .results-panel {
margin-top: 0;
}
.result-card {
padding: 10px;
word-break: break-word;
}
.data-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.data-table th,
.data-table td {
padding: 8px 10px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--tool-border);
}
.data-table th {
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
background: var(--tool-panel);
}
.data-table tr:last-child td {
border-bottom: none;
}
.mono {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 12px;
}
.wrap {
overflow-wrap: anywhere;
word-break: break-word;
}
.file-name {
color: var(--vscode-descriptionForeground);
font-size: 12px;
overflow-wrap: anywhere;
}
.ok {
color: var(--vscode-testing-iconPassed, #3fb950);
}
.err {
color: var(--vscode-errorForeground, #f85149);
}
.warn {
color: var(--vscode-editorWarning-foreground, #d29922);
}
.badge {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--tool-border);
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.badge.ok {
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #3fb950) 45%, transparent);
background: rgba(63, 185, 80, 0.12);
}
.badge.err {
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f85149) 45%, transparent);
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.12));
}
.badge.warn {
border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground, #d29922) 45%, transparent);
background: var(--vscode-inputValidation-warningBackground, rgba(210, 153, 34, 0.12));
}
`;
}
export function singletonPanel( export function singletonPanel(
store: { panel: vscode.WebviewPanel | null }, store: { panel: vscode.WebviewPanel | null },
viewType: string, viewType: string,

View File

@@ -35,31 +35,53 @@ func pickApplovinUserAgent(randomize bool) string {
func ApplovinPage(w http.ResponseWriter, r *http.Request) { func ApplovinPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>AppLovin Playable Preview (QR)</h2> <header class="tool-header">
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p> <h2 class="tool-title">AppLovin Playable Preview</h2>
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
</header>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;"> <section class="tool-panel">
<button id="pick" class="secondary">Add files...</button> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<button id="clear" class="secondary" style="display:none;">Clear</button> <div class="panel-body">
<span id="fileName" class="hint">(no files)</span> <div class="control-group">
</div> <div id="dropZone" class="drop-zone">
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div> <div class="file-row">
<div style="display:flex;gap:8px;"> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="upload">Upload to AppLovin</button> <button id="pick" class="secondary">Select File(s)</button>
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button> <button id="clear" class="secondary">Clear</button>
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button> </div>
</div> <div class="drop-zone-text">or drop HTML files or folders here</div>
</div>
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-files"></div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="saveAll" class="secondary" disabled>Save All</button>
<button id="regenAll" class="secondary" disabled>Regenerate</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="status" style="margin-top:12px;min-height:20px;"></div> <div id="results" class="results-panel"></div>
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
<style> <style>
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; } .qr-cell { text-align:center; }
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; } .qr-cell img { width:104px; height:104px; max-width:100%; padding:6px; border-radius:4px; background:#fff; cursor:zoom-in; }
.result-actions { display:flex; gap:6px; flex-wrap:wrap; }
.qr-results:not(.has-errors) .error-col { display:none; }
.qr-modal { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:24px; background:rgba(0,0,0,0.72); z-index:10; }
.qr-modal.open { display:flex; }
.qr-modal img { width:min(70vw,520px); height:min(70vw,520px); padding:18px; border-radius:4px; background:#fff; }
</style> </style>
<div id="qrModal" class="qr-modal"><img alt="Expanded QR" /></div>
<script> <script>
const PAGE_SIZE = 5;
let selectedPage = 0;
const pickBtn = document.getElementById('pick'); const pickBtn = document.getElementById('pick');
const pickFolderBtn = document.getElementById('pickFolder');
const clearBtn = document.getElementById('clear'); const clearBtn = document.getElementById('clear');
const uploadBtn = document.getElementById('upload'); const uploadBtn = document.getElementById('upload');
const fileNameEl = document.getElementById('fileName'); const fileNameEl = document.getElementById('fileName');
@@ -68,6 +90,7 @@ const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const saveAllBtn = document.getElementById('saveAll'); const saveAllBtn = document.getElementById('saveAll');
const regenAllBtn = document.getElementById('regenAll'); const regenAllBtn = document.getElementById('regenAll');
const qrModal = document.getElementById('qrModal');
let selectedPaths = []; let selectedPaths = [];
let resultItems = []; let resultItems = [];
@@ -76,15 +99,38 @@ function basename(p) {
return i >= 0 ? p.slice(i + 1) : p; return i >= 0 ? p.slice(i + 1) : p;
} }
function renderSelection() { function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedPaths.length) { if (!selectedPaths.length) {
fileNameEl.textContent = '(no files)'; fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = ''; fileListEl.innerHTML = '';
clearBtn.style.display = 'none';
return; return;
} }
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's'); fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join(''); const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
clearBtn.style.display = ''; selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visiblePaths.map((p, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => { selectedPaths.splice(Number(btn.dataset.index), 1); renderSelection(); });
});
if (selectedPaths.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
} }
saveAllBtn.addEventListener('click', async () => { saveAllBtn.addEventListener('click', async () => {
@@ -111,33 +157,57 @@ regenAllBtn.addEventListener('click', () => {
img.src = base + '&_=' + Date.now(); img.src = base + '&_=' + Date.now();
}); });
}); });
setupDropZone('dropZone', statusEl, (j) => {
if (j.files && j.files.length) {
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection();
}
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', () => {
selectedPaths = []; selectedPaths = [];
selectedPage = 0;
renderSelection(); renderSelection();
}); });
pickFolderBtn.addEventListener('click', async () => {
const r = await fetch('/api/applovin/pickFolder', { method: 'POST' });
const j = await r.json();
if (j.files && j.files.length) {
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection();
}
});
pickBtn.addEventListener('click', async () => { pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/applovin/pick', { method: 'POST' }); const r = await fetch('/api/applovin/pick', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.files && j.files.length) { if (j.files && j.files.length) {
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p)); const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added); selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} }
}); });
uploadBtn.addEventListener('click', async () => { uploadBtn.addEventListener('click', async () => {
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
if (!selectedPaths.length) { if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>'; statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return; return;
} }
uploadBtn.disabled = true; uploadBtn.disabled = true;
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
resultItems = []; resultItems = [];
saveAllBtn.style.display = 'none'; saveAllBtn.disabled = true;
regenAllBtn.style.display = 'none'; regenAllBtn.disabled = true;
try { try {
const res = await fetch('/api/applovin/upload', { const res = await fetch('/api/applovin/upload', {
method: 'POST', method: 'POST',
@@ -145,6 +215,7 @@ uploadBtn.addEventListener('click', async () => {
body: JSON.stringify({ paths: selectedPaths }), body: JSON.stringify({ paths: selectedPaths }),
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">Upload failed.</span>'; statusEl.innerHTML = '<span class="err">Upload failed.</span>';
uploadBtn.disabled = false; uploadBtn.disabled = false;
return; return;
@@ -164,6 +235,7 @@ uploadBtn.addEventListener('click', async () => {
} }
} }
} catch (e) { } catch (e) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>'; statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
} finally { } finally {
uploadBtn.disabled = false; uploadBtn.disabled = false;
@@ -174,24 +246,41 @@ function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
} }
function ensureResultsTable() {
let tbody = resultsEl.querySelector('tbody');
if (tbody) return tbody;
resultsEl.innerHTML = '<table class="data-table qr-results"><thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead><tbody></tbody></table>';
return resultsEl.querySelector('tbody');
}
qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
function handle(m) { function handle(m) {
if (m.type === 'status') { if (m.type === 'status') {
statusEl.textContent = m.message; statusEl.textContent = m.message;
statusEl.classList.add('is-busy');
} else if (m.type === 'fileError') { } else if (m.type === 'fileError') {
const wrap = document.createElement('div'); const tbody = ensureResultsTable();
wrap.className = 'result'; resultsEl.querySelector('table').classList.add('has-errors');
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' + const row = document.createElement('tr');
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>'; row.innerHTML = '<td class="mono wrap">' + escapeHtml(m.name) + '</td><td class="muted">-</td><td class="muted">-</td><td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
resultsEl.appendChild(wrap); tbody.appendChild(row);
} else if (m.type === 'fileResult') { } else if (m.type === 'fileResult') {
const wrap = document.createElement('div'); const tbody = ensureResultsTable();
wrap.className = 'result'; const row = document.createElement('tr');
wrap.innerHTML = const file = document.createElement('td');
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' + file.className = 'mono wrap';
'<div style="text-align:center;margin-bottom:8px;">' + file.textContent = m.name;
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' + const qr = document.createElement('td');
'</div>' + qr.className = 'qr-cell';
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>'; qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
qr.querySelector('img').addEventListener('click', (event) => {
qrModal.querySelector('img').src = event.currentTarget.src;
qrModal.classList.add('open');
});
const actionCell = document.createElement('td');
const error = document.createElement('td');
error.className = 'error-col';
error.innerHTML = '<span class="muted">-</span>';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'result-actions'; actions.className = 'result-actions';
const openBtn = document.createElement('button'); const openBtn = document.createElement('button');
@@ -218,21 +307,21 @@ function handle(m) {
} }
}; };
actions.appendChild(openBtn); actions.appendChild(saveQr); actions.appendChild(openBtn); actions.appendChild(saveQr);
wrap.appendChild(actions); actionCell.appendChild(actions);
resultsEl.appendChild(wrap); row.append(file, qr, actionCell, error);
tbody.appendChild(row);
resultItems.push({ name: m.name, url: m.qrImg }); resultItems.push({ name: m.name, url: m.qrImg });
if (resultItems.length >= 2) { saveAllBtn.disabled = resultItems.length === 0;
saveAllBtn.style.display = ''; regenAllBtn.disabled = resultItems.length === 0;
regenAllBtn.style.display = '';
}
} else if (m.type === 'done') { } else if (m.type === 'done') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Done.</span>'; statusEl.innerHTML = '<span class="ok">Done.</span>';
} }
} }
</script> </script>
` `
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body))) _, _ = w.Write([]byte(Page("/applovin", "AppLovin Playable Preview", body)))
} }
func ApplovinPick(w http.ResponseWriter, r *http.Request) { func ApplovinPick(w http.ResponseWriter, r *http.Request) {
@@ -253,6 +342,23 @@ func ApplovinPick(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"files": files}) writeJSON(w, map[string]any{"files": files})
} }
func ApplovinPickFolder(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
dir := PickFolder("Select folder", cfg.LastPickDir)
if dir == "" {
writeJSON(w, map[string]any{"files": []any{}})
return
}
cfg.LastPickDir = dir
_ = SaveConfig(cfg)
paths := collectHTMLFiles(dir)
files := make([]map[string]string, 0, len(paths))
for _, p := range paths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
func ApplovinUpload(w http.ResponseWriter, r *http.Request) { func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Paths []string `json:"paths"` Paths []string `json:"paths"`
@@ -345,11 +451,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
} }
if i > 0 && delayMs > 0 { if i > 0 && delayMs > 0 {
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Waiting %dms before %s...", idx, len(req.Paths), delayMs, fileName)}) send(map[string]any{"type": "status", "message": fmt.Sprintf("Waiting %d/%d %s", idx, len(req.Paths), fileName)})
time.Sleep(time.Duration(delayMs) * time.Millisecond) time.Sleep(time.Duration(delayMs) * time.Millisecond)
} }
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)}) send(map[string]any{"type": "status", "message": fmt.Sprintf("Validating %d/%d %s", idx, len(req.Paths), fileName)})
body, ct, err := buildForm(filePath) body, ct, err := buildForm(filePath)
if err != nil { if err != nil {
@@ -374,7 +480,7 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
} }
} }
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)}) send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d/%d %s", idx, len(req.Paths), fileName)})
body, ct, err = buildForm(filePath) body, ct, err = buildForm(filePath)
if err != nil { if err != nil {

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
@@ -12,83 +13,164 @@ import (
func Base64Page(w http.ResponseWriter, r *http.Request) { func Base64Page(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>Base64 Asset Scanner</h2> <header class="tool-header">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;"> <h2 class="tool-title">Base64 Scanner</h2>
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" /> <p class="tool-description">Scan selected HTML files for asset references that are not embedded as base64 data URIs.</p>
<button id="pickFolder">Browse Folder...</button> </header>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button> <section class="tool-panel">
</div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div id="selection" class="hint"></div> <div class="panel-body">
<div id="status" style="margin-top:8px;"></div> <div class="control-group">
<div id="results" style="margin-top:8px;"></div> <div id="dropZone" class="drop-zone">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row"><button id="scan">Scan</button></div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="results" class="results-panel"></div>
<style> <style>
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; } .asset-list { margin:0; padding-left:16px; }
.mark { width:14px; flex-shrink:0; font-weight:bold; } .asset-list li + li { margin-top:3px; }
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
.file-name { word-break:break-all; }
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
.summary { opacity:0.8; margin-bottom:6px; }
</style> </style>
<script> <script>
const folderEl = document.getElementById('folder'); const PAGE_SIZE = 5;
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection'); const selectionEl = document.getElementById('selection');
let pickedFiles = []; let pickedFiles = [];
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; } let selectedPage = 0;
folderEl.addEventListener('input', clearFiles); function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!pickedFiles.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visible.map((p, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
});
if (pickedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
function addPaths(paths) {
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
addPaths(j.paths || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
document.getElementById('pickFolder').addEventListener('click', async () => { document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFolder', { method: 'POST' }); const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { folderEl.value = j.path; clearFiles(); } addPaths(j.paths || []);
}); });
document.getElementById('pickFiles').addEventListener('click', async () => { document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFiles', { method: 'POST' }); const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.paths && j.paths.length) { addPaths(j.paths || []);
pickedFiles = j.paths; });
folderEl.value = ''; document.getElementById('clear').addEventListener('click', () => {
selectionEl.textContent = pickedFiles.length + ' file(s) selected'; pickedFiles = [];
} selectedPage = 0;
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = '';
renderSelection();
}); });
document.getElementById('scan').addEventListener('click', async () => { document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
let payload; if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
if (pickedFiles.length) payload = { files: pickedFiles }; const payload = { files: pickedFiles };
else { let j = null;
const folder = folderEl.value.trim(); try {
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; } const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
payload = { folder }; 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;
} }
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
const j = await r.json(); if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length; const total = j.results.length;
const flagged = j.results.filter(r => !r.ok).length; const flagged = j.results.filter(r => !r.ok).length;
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>'; statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
if (!total) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead><tbody></tbody></table>';
const tbody = resultsEl.querySelector('tbody');
for (const rr of j.results) { for (const rr of j.results) {
const row = document.createElement('div'); const row = document.createElement('tr');
row.className = 'row-result'; const assets = rr.ok ? '<span class="muted">None</span>' : '<ul class="asset-list mono">' + rr.assets.map(a => '<li>' + escapeHtml(a) + '</li>').join('') + '</ul>';
const mark = document.createElement('span'); row.innerHTML = '<td>' + (rr.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>') + '</td>' +
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad'); '<td class="mono wrap">' + escapeHtml(rr.file) + '</td><td>' + rr.assets.length + '</td><td class="wrap">' + assets + '</td>';
mark.textContent = rr.ok ? '✓' : '✗'; tbody.appendChild(row);
const name = document.createElement('span');
name.className = 'file-name';
name.textContent = rr.file;
row.appendChild(mark); row.appendChild(name);
if (!rr.ok) {
const a = document.createElement('span');
a.className = 'assets';
a.textContent = '— ' + rr.assets.join(', ');
row.appendChild(a);
}
resultsEl.appendChild(row);
} }
}); });
</script> </script>
@@ -99,7 +181,11 @@ document.getElementById('scan').addEventListener('click', async () => {
func Base64PickFolder(w http.ResponseWriter, r *http.Request) { func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
p := PickFolder("Select folder to scan", "") p := PickFolder("Select folder to scan", "")
writeJSON(w, map[string]any{"path": p}) if p == "" {
writeJSON(w, map[string]any{"paths": []string{}})
return
}
writeJSON(w, map[string]any{"paths": collectHTMLFiles(p)})
} }
func Base64PickFiles(w http.ResponseWriter, r *http.Request) { func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
@@ -118,12 +204,13 @@ type scanResult struct {
} }
func Base64Scan(w http.ResponseWriter, r *http.Request) { func Base64Scan(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct { var req struct {
Folder string `json:"folder"` Folder string `json:"folder"`
Files []string `json:"files"` Files []string `json:"files"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
@@ -143,12 +230,13 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
targets = collectHTMLFiles(req.Folder) targets = collectHTMLFiles(req.Folder)
baseDir = req.Folder baseDir = req.Folder
} else { } else {
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"}) send(map[string]any{"type": "error", "message": "Pick a folder or files first"})
return return
} }
results := []scanResult{} results := []scanResult{}
for _, file := range targets { for i, file := range targets {
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
data, err := os.ReadFile(file) data, err := os.ReadFile(file)
if err != nil { if err != nil {
continue continue
@@ -169,7 +257,7 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
Assets: offenders, Assets: offenders,
}) })
} }
writeJSON(w, map[string]any{"results": results}) send(map[string]any{"type": "done", "results": results})
} }
func commonBaseDir(files []string) string { func commonBaseDir(files []string) string {

229
standalone/changelog.go Normal file
View File

@@ -0,0 +1,229 @@
package main
import (
"html"
"net/http"
"os"
"path/filepath"
"strings"
)
func ChangelogPage(w http.ResponseWriter, r *http.Request) {
markdown := readChangelog()
body := `
<header class="tool-header">
<h2 class="tool-title">Changelog</h2>
<p class="tool-description">Loaded from README.md.</p>
</header>
<section class="tool-panel changelog-panel">
<div class="panel-body changelog-content">
` + renderChangelogMarkdown(markdown) + `
</div>
</section>
<style>
.changelog-panel { max-width: 840px; }
.changelog-content h1 { margin: 0 0 14px; font-size: 20px; line-height: 1.25; }
.changelog-content h2 { margin: 18px 0 8px; padding-top: 12px; border-top: 1px solid #333; font-size: 15px; line-height: 1.3; }
.changelog-content h3 { margin: 14px 0 6px; font-size: 13px; line-height: 1.3; }
.changelog-content ul { margin: 6px 0 12px; padding-left: 20px; }
.changelog-content li { margin: 4px 0; line-height: 1.45; }
.changelog-content li.nested { margin-left: 18px; }
.changelog-content p { margin: 8px 0; line-height: 1.5; }
.changelog-content code { font-family: Consolas, monospace; font-size: 0.95em; background: rgba(128,128,128,0.12); padding: 1px 4px; border-radius: 3px; }
.changelog-block { margin: 8px 0 18px; padding: 10px 12px; border: 1px solid #333; border-radius: 4px; background: rgba(128,128,128,0.08); }
.change-section { margin: 8px 0 4px; font-weight: 600; }
.changelog-block ul { margin: 4px 0 10px; }
</style>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/changelog", "Changelog", body)))
}
func readChangelog() string {
if embeddedReadme != "" {
return embeddedReadme
}
for _, start := range changelogSearchRoots() {
for _, candidate := range changelogCandidates(start) {
data, err := os.ReadFile(candidate)
if err == nil {
return string(data)
}
}
}
return "# Changelog\n\nREADME.md was not found."
}
func changelogSearchRoots() []string {
roots := []string{}
if wd, err := os.Getwd(); err == nil {
roots = append(roots, wd)
}
roots = append(roots, appDir())
return roots
}
func changelogCandidates(start string) []string {
candidates := []string{}
dir := filepath.Clean(start)
for i := 0; i < 5; i++ {
candidates = append(candidates, filepath.Join(dir, "README.md"))
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return candidates
}
func renderChangelogMarkdown(markdown string) string {
lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n")
var out strings.Builder
inList := false
inCodeBlock := false
codeLines := []string{}
closeList := func() {
if inList {
out.WriteString("</ul>")
inList = false
}
}
for _, rawLine := range lines {
if strings.TrimSpace(rawLine) == "```" {
closeList()
if inCodeBlock {
out.WriteString(renderChangelogBlock(codeLines))
codeLines = []string{}
inCodeBlock = false
} else {
inCodeBlock = true
}
continue
}
if inCodeBlock {
codeLines = append(codeLines, rawLine)
continue
}
line := strings.TrimSpace(rawLine)
if line == "" {
closeList()
continue
}
if strings.HasPrefix(line, "### ") {
closeList()
out.WriteString("<h2>" + renderInlineMarkdown(strings.TrimSpace(line[4:])) + "</h2>")
continue
}
if strings.HasPrefix(line, "## ") {
closeList()
out.WriteString("<h2>" + renderInlineMarkdown(strings.TrimSpace(line[3:])) + "</h2>")
continue
}
if strings.HasPrefix(line, "# ") {
closeList()
out.WriteString("<h1>" + renderInlineMarkdown(strings.TrimSpace(line[2:])) + "</h1>")
continue
}
if strings.HasPrefix(line, "**") && strings.HasSuffix(line, "**") && len(line) > 4 {
closeList()
out.WriteString("<h2>" + html.EscapeString(strings.TrimSuffix(strings.TrimPrefix(line, "**"), "**")) + "</h2>")
continue
}
if strings.HasPrefix(line, "- ") {
if !inList {
out.WriteString("<ul>")
inList = true
}
out.WriteString("<li>" + renderInlineMarkdown(strings.TrimSpace(line[2:])) + "</li>")
continue
}
closeList()
out.WriteString("<p>" + renderInlineMarkdown(line) + "</p>")
}
if inCodeBlock {
out.WriteString(renderChangelogBlock(codeLines))
}
closeList()
return out.String()
}
func renderChangelogBlock(lines []string) string {
var out strings.Builder
inList := false
closeList := func() {
if inList {
out.WriteString("</ul>")
inList = false
}
}
out.WriteString(`<div class="changelog-block">`)
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" {
closeList()
continue
}
if strings.HasPrefix(line, "- ") {
if !inList {
out.WriteString("<ul>")
inList = true
}
className := ""
if leadingSpaceCount(rawLine) >= 8 {
className = ` class="nested"`
}
out.WriteString("<li" + className + ">" + renderInlineMarkdown(strings.TrimSpace(line[2:])) + "</li>")
continue
}
closeList()
out.WriteString(`<div class="change-section">` + renderInlineMarkdown(line) + `</div>`)
}
closeList()
out.WriteString("</div>")
return out.String()
}
func renderInlineMarkdown(value string) string {
escaped := html.EscapeString(value)
escaped = replaceDelimited(escaped, "**", "strong")
escaped = replaceDelimited(escaped, "`", "code")
return escaped
}
func replaceDelimited(value, marker, tag string) string {
var out strings.Builder
for {
start := strings.Index(value, marker)
if start < 0 {
out.WriteString(value)
break
}
end := strings.Index(value[start+len(marker):], marker)
if end < 0 {
out.WriteString(value)
break
}
end += start + len(marker)
out.WriteString(value[:start])
out.WriteString("<" + tag + ">")
out.WriteString(value[start+len(marker) : end])
out.WriteString("</" + tag + ">")
value = value[end+len(marker):]
}
return out.String()
}
func leadingSpaceCount(value string) int {
count := 0
for _, ch := range value {
if ch != ' ' {
break
}
count++
}
return count
}

View File

@@ -12,19 +12,40 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
if n.Path == "/" { if n.Path == "/" {
continue continue
} }
label := n.Label badge := ""
if n.Beta { if n.Beta {
label += ` <span style="font-size:10px;opacity:0.65;text-transform:uppercase;">Beta</span>` badge = ` <span class="beta-pill">Beta</span>`
} }
items.WriteString(`<li><a href="` + n.Path + `" style="color:#9cdcfe;">` + label + `</a> — ` + n.Description + `</li>`) 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>`)
} }
body := ` body := `
<h2>HPL Toolbox</h2> <header class="tool-header">
<p class="hint">Standalone build. Pick a tool from the top bar.</p> <h2 class="tool-title">HPL Toolbox</h2>
<ul style="line-height:1.9;"> <p class="tool-description">Standalone build. Pick a tool to open.</p>
</header>
<div class="home-tools">
` + items.String() + ` ` + items.String() + `
</ul> </div>
<style>
.home-tools { display:flex; flex-direction:column; gap:8px; max-width:620px; }
.home-tool {
display:block;
width:100%;
min-height:52px;
padding:9px 11px;
color:#ddd;
text-align:left;
background:#2a2d2e;
border:1px solid #333;
border-radius:5px;
cursor:pointer;
font:inherit;
}
.home-tool:hover { background:#33373a; border-color:#007fd4; }
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
</style>
` `
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/", "Home", body))) _, _ = w.Write([]byte(Page("/", "Home", body)))

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
) )
@@ -9,3 +10,27 @@ func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v) _ = 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,14 +2,31 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "net/url"
"os"
"path/filepath"
"strings" "strings"
"time"
) )
const SharedCSS = ` const SharedCSS = `
:root { color-scheme: dark; } :root {
color-scheme: dark;
--tool-gap-sm: 8px;
--tool-gap-md: 12px;
--tool-gap-lg: 16px;
--tool-radius: 5px;
--tool-border: #333;
--tool-panel: #252526;
}
* { box-sizing: border-box; }
html { min-height: 100%; }
body { body {
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e; color: #ddd; background: #1e1e1e; color: #ddd;
margin: 0; padding: 0; margin: 0; padding: 0;
@@ -19,17 +36,61 @@ const SharedCSS = `
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
font-size: 13px; font-size: 13px;
} }
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; } .topbar .nav-link {
.topbar a:hover { background: #2a2d2e; } background: transparent;
.topbar a.active { background: #094771; color: #fff; } color: #ddd;
border: 0;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.topbar .nav-link:hover { background: #2a2d2e; }
.topbar .nav-link.active { background: #094771; color: #fff; }
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; } .beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; } .topbar-spacer { margin-left: auto; }
.content { padding: 16px; max-width: 980px; margin: 0 auto; } .content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
.beta-tools-footer { max-width: 980px; margin: 24px auto 16px auto; padding: 12px 16px 0; border-top: 1px solid #333; } .app-footer {
.beta-tools-footer button { width: 100%; text-align: left; background: #3a3d41; color: #ddd; } width: 100%;
.beta-tools-footer button:hover { background: #45494e; } max-width: 1100px;
.beta-note { display: block; margin-top: 3px; opacity: 0.65; font-size: 11px; } margin: auto auto 0;
h2 { margin-top: 0; font-weight: 500; } padding: 8px 18px 14px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
color: #a7a7a7;
font-size: 10px;
border-top: 1px solid #333;
}
.beta-tools-toggle {
justify-self: start;
padding: 0;
background: transparent;
color: #a7a7a7;
border: 0;
cursor: pointer;
font: inherit;
opacity: 0.72;
text-align: left;
}
.beta-tools-toggle:hover { background: transparent; opacity: 1; text-decoration: underline; }
.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; }
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; }
.tool-description, .section-description, .muted { color: #a7a7a7; }
.tool-description { margin: 5px 0 0; max-width: 780px; font-size: 12px; line-height: 1.45; }
.tool-panel { margin-bottom: var(--tool-gap-lg); border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); overflow: hidden; }
.panel-header { padding: 10px 12px; border-bottom: 1px solid var(--tool-border); background: var(--tool-panel); }
.panel-title { margin: 0; color: #ddd; font-size: 12px; font-weight: 700; letter-spacing: 0.45px; text-transform: uppercase; }
.panel-body { padding: 12px; }
.control-group + .control-group { margin-top: var(--tool-gap-md); }
.control-label { margin: 0 0 6px; color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; }
.file-row, .field-row, .action-row { display: flex; align-items: center; gap: var(--tool-gap-sm); flex-wrap: wrap; }
.action-row { margin-top: var(--tool-gap-md); }
input[type=text], input[type=date], select, textarea { input[type=text], input[type=date], select, textarea {
box-sizing: border-box; padding: 6px 8px; box-sizing: border-box; padding: 6px 8px;
background: #3c3c3c; color: #ddd; background: #3c3c3c; color: #ddd;
@@ -46,12 +107,214 @@ const SharedCSS = `
button:hover { background: #1177bb; } button:hover { background: #1177bb; }
button.secondary { background: #3a3d41; color: #ddd; } button.secondary { background: #3a3d41; color: #ddd; }
button.secondary:hover { background: #45494e; } button.secondary:hover { background: #45494e; }
button.danger { background: transparent; color: #f48771; border: 1px solid #444; }
button.danger:hover { background: rgba(244,135,113,0.14); }
button:disabled { opacity: 0.5; cursor: not-allowed; } 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; }
.result-card { padding: 10px; word-break: break-word; }
.data-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.data-table th, .data-table td { padding: 8px 10px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--tool-border); }
.data-table th { color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; background: var(--tool-panel); }
.data-table tr:last-child td { border-bottom: none; }
.mono { font-family: 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; }
.badge { display: inline-flex; align-items: center; min-height: 18px; padding: 1px 6px; border-radius: 999px; border: 1px solid #444; font-size: 11px; line-height: 1.2; white-space: nowrap; }
.badge.ok { color: #89d185; background: rgba(137,209,133,0.12); }
.badge.err { color: #f48771; background: rgba(244,135,113,0.14); }
.badge.warn { color: #ffd580; background: rgba(210,153,34,0.16); }
.is-hidden { display: none !important; }
.err { color: #f48771; white-space: pre-wrap; } .err { color: #f48771; white-space: pre-wrap; }
.ok { color: #89d185; } .ok { color: #89d185; }
.hint { font-size: 12px; opacity: 0.7; } .hint { font-size: 12px; opacity: 0.7; }
` `
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 { type navItem struct {
Path string Path string
Label string Label string
@@ -62,10 +325,10 @@ type navItem struct {
var navItems = []navItem{ var navItems = []navItem{
{Path: "/", Label: "Home"}, {Path: "/", Label: "Home"},
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"}, {Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
{Path: "/applovin", Label: "AppLovin Upload", Description: "Upload to p.applov.in (QR preview)"}, {Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, {Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
{Path: "/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: "/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: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true}, {Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
} }
@@ -109,20 +372,18 @@ func Page(activePath, title, body string) string {
for _, n := range visibleNavItems(betaToolsEnabled) { for _, n := range visibleNavItems(betaToolsEnabled) {
cls := "" cls := ""
if n.Path == activePath { if n.Path == activePath {
cls = " class=\"active\"" cls = " active"
} }
label := n.Label label := n.Label
if n.Beta { if n.Beta {
label += `<span class="beta-pill">Beta</span>` label += `<span class="beta-pill">Beta</span>`
} }
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + label + `</a>`) tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `">` + label + `</button>`)
} }
betaButtonLabel := "Enable Beta Tools" betaButtonLabel := "Show beta"
betaButtonNote := "Hidden beta tools: " + strconv.Itoa(betaToolCount())
if betaToolsEnabled { if betaToolsEnabled {
betaButtonLabel = "Disable Beta Tools" betaButtonLabel = "Hide beta"
betaButtonNote = "Beta tools are visible in this standalone launcher."
} }
return `<!DOCTYPE html> return `<!DOCTYPE html>
@@ -130,13 +391,22 @@ func Page(activePath, title, body string) string {
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>` + title + ` — HPL Toolbox</title> <title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style> <style>` + SharedCSS + `</style>
<script>` + SharedDropZoneScript + `</script>
</head><body> </head><body>
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div> <div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
<div class="content">` + body + `</div> <div class="content">` + body + `</div>
<div class="beta-tools-footer"> <div class="app-footer">
<button id="betaToolsToggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `<span class="beta-note">` + betaButtonNote + `</span></button> <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>
</div> </div>
<script> <script>
document.querySelectorAll('[data-path]').forEach((element) => {
element.addEventListener('click', () => {
const path = element.dataset.path;
if (path) window.location.href = path;
});
});
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => { document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
const enabled = event.currentTarget.dataset.enabled === 'true'; const enabled = event.currentTarget.dataset.enabled === 'true';
await fetch('/api/betaTools', { await fetch('/api/betaTools', {
@@ -157,6 +427,153 @@ func boolAttr(v bool) string {
return "false" 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) { func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`

View File

@@ -113,6 +113,7 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("GET /mraid", MraidPage) mux.HandleFunc("GET /mraid", MraidPage)
mux.HandleFunc("GET /mobile", MobilePage) mux.HandleFunc("GET /mobile", MobilePage)
mux.HandleFunc("GET /playworks", PlayworksPage) mux.HandleFunc("GET /playworks", PlayworksPage)
mux.HandleFunc("GET /changelog", ChangelogPage)
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript) mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
// Shared API // Shared API
@@ -120,13 +121,16 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /api/open", OpenEndpoint) mux.HandleFunc("POST /api/open", OpenEndpoint)
mux.HandleFunc("POST /api/focus", FocusEndpoint) mux.HandleFunc("POST /api/focus", FocusEndpoint)
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint) mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
// PLEC // PLEC
mux.HandleFunc("POST /api/plec/pick", PlecPick) mux.HandleFunc("POST /api/plec/pick", PlecPick)
mux.HandleFunc("POST /api/plec/pickFolder", PlecPickFolder)
mux.HandleFunc("POST /api/plec/upload", PlecUpload) mux.HandleFunc("POST /api/plec/upload", PlecUpload)
// AppLovin // AppLovin
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick) mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
mux.HandleFunc("POST /api/applovin/pickFolder", ApplovinPickFolder)
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload) mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR) mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs) mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
@@ -143,6 +147,7 @@ func buildMux() *http.ServeMux {
// Send To Mobile // Send To Mobile
mux.HandleFunc("POST /api/mobile/pick", MobilePick) mux.HandleFunc("POST /api/mobile/pick", MobilePick)
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)
mux.HandleFunc("POST /api/mobile/start", MobileStart) mux.HandleFunc("POST /api/mobile/start", MobileStart)
mux.HandleFunc("POST /api/mobile/stop", MobileStop) mux.HandleFunc("POST /api/mobile/stop", MobileStop)

View File

@@ -2,10 +2,10 @@ package main
import ( import (
"context" "context"
"crypto/rand"
_ "embed" _ "embed"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"crypto/rand"
"fmt" "fmt"
"html" "html"
"net" "net"
@@ -27,7 +27,11 @@ type activeShare struct {
listener net.Listener listener net.Listener
port int port int
token string token string
fileBuf []byte files []sharedFile
}
type sharedFile struct {
buf []byte
filename string filename string
} }
@@ -54,89 +58,198 @@ func MobileQrScript(w http.ResponseWriter, r *http.Request) {
func MobilePage(w http.ResponseWriter, r *http.Request) { func MobilePage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>Send To Mobile</h2> <header class="tool-header">
<p class="hint" style="margin-top:0;"> <h2 class="tool-title">Send To Mobile</h2>
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. <p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>. </header>
</p>
<div style="margin-bottom:14px;"> <section class="tool-panel">
<div style="display:flex;align-items:center;gap:8px;"> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<button id="pick" class="secondary">Choose...</button> <div class="panel-body">
<span id="fileName" class="hint">(no file)</span> <div class="control-group">
<div id="dropZone" class="drop-zone">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div 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>
</div>
<div class="action-row">
<button id="start">Start</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<div id="status" class="status-panel"></div>
</div> </div>
<input type="hidden" id="filePath" /> </section>
<div id="ifaceWrap" class="control-group" style="display:none;margin-bottom:14px;">
<div class="control-label">Network Interfaces</div>
<div id="ifaceList"></div>
</div> </div>
<div style="margin-bottom:14px;"> <div id="shareInfo" class="result-card" style="display:none;">
<button id="start">Start sharing</button> <div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
<button id="stop" class="secondary" disabled>Stop</button> <div class="muted" style="margin-top:8px;font-size:12px;">
</div> First run on Windows may show a firewall prompt - allow access for Private networks.
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
</div>
<div id="shareInfo" style="display:none;">
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button id="copyUrl" class="secondary">Copy URL</button>
<button id="openUrl" class="secondary">Open in browser</button>
</div>
<div class="hint" style="margin-top:8px;">
First run on Windows may show a firewall prompt — allow access for "Private networks".
</div> </div>
</div> </div>
<div id="status" style="margin-top:12px;min-height:18px;"></div>
<script src="/assets/qrcode.min.js"></script> <script src="/assets/qrcode.min.js"></script>
<script> <script>
const PAGE_SIZE = 5;
const pickFolderBtn = document.getElementById('pickFolder');
const pickBtn = document.getElementById('pick'); const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const startBtn = document.getElementById('start'); const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop'); const stopBtn = document.getElementById('stop');
const fileNameEl = document.getElementById('fileName'); const fileNameEl = document.getElementById('fileName');
const filePathEl = document.getElementById('filePath'); const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const shareInfo = document.getElementById('shareInfo'); const shareInfo = document.getElementById('shareInfo');
const ifaceWrap = document.getElementById('ifaceWrap'); const ifaceWrap = document.getElementById('ifaceWrap');
const ifaceList = document.getElementById('ifaceList'); const ifaceList = document.getElementById('ifaceList');
const qrEl = document.getElementById('qr'); const qrEl = document.getElementById('qr');
const urlTextEl = document.getElementById('urlText');
let currentUrls = []; let currentUrls = [];
let selectedIdx = 0; let selectedIdx = 0;
let selectedFiles = [];
let selectedPaths = [];
let selectedPage = 0;
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedFiles.length) {
fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = '';
return;
}
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visible.map((file, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
const index = Number(btn.dataset.index);
selectedFiles.splice(index, 1);
selectedPaths.splice(index, 1);
renderSelection();
});
});
if (selectedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
function addFiles(files) {
const existing = new Set(selectedFiles.map(f => f.path));
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
selectedPaths = selectedFiles.map(f => f.path);
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
addFiles(j.files || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
pickFolderBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
const j = await r.json();
addFiles(j.files || []);
});
pickBtn.addEventListener('click', async () => { pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pick', { method:'POST' }); const r = await fetch('/api/mobile/pick', { method:'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { addFiles(j.files || []);
filePathEl.value = j.path; });
fileNameEl.textContent = j.name; clearBtn.addEventListener('click', () => {
} selectedFiles = [];
selectedPaths = [];
selectedPage = 0;
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
renderSelection();
}); });
startBtn.addEventListener('click', async () => { startBtn.addEventListener('click', async () => {
statusEl.innerHTML = ''; statusEl.innerHTML = '';
if (!filePathEl.value) { if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>'; statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return; return;
} }
startBtn.disabled = true; startBtn.disabled = true;
statusEl.textContent = 'Starting server...'; statusEl.textContent = 'Starting server...';
const r = await fetch('/api/mobile/start', { statusEl.classList.add('is-busy');
method:'POST', headers:{'Content-Type':'application/json'}, let j = null;
body: JSON.stringify({ path: filePathEl.value }), try {
}); const r = await fetch('/api/mobile/start', {
const j = await r.json(); 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) { if (j.error) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + j.error + '</span>'; statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
startBtn.disabled = false; startBtn.disabled = false;
return; return;
} }
currentUrls = j.urls; currentUrls = j.urls;
statusEl.classList.remove('is-busy');
selectedIdx = 0; selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>'; statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
shareInfo.style.display = ''; shareInfo.style.display = '';
@@ -147,6 +260,7 @@ startBtn.addEventListener('click', async () => {
stopBtn.addEventListener('click', async () => { stopBtn.addEventListener('click', async () => {
await fetch('/api/mobile/stop', { method:'POST' }); await fetch('/api/mobile/stop', { method:'POST' });
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Stopped.'; statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none'; shareInfo.style.display = 'none';
currentUrls = []; currentUrls = [];
@@ -154,18 +268,8 @@ stopBtn.addEventListener('click', async () => {
stopBtn.disabled = true; stopBtn.disabled = true;
}); });
document.getElementById('copyUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
});
document.getElementById('openUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
});
function renderQr(url) { function renderQr(url) {
qrEl.innerHTML = ''; qrEl.innerHTML = '';
urlTextEl.textContent = url;
try { try {
const qr = qrcode(0, 'M'); const qr = qrcode(0, 'M');
qr.addData(url); qr.addData(url);
@@ -182,19 +286,18 @@ function renderIfaces(urls) {
ifaceList.innerHTML = ''; ifaceList.innerHTML = '';
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; } if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
ifaceWrap.style.display = ''; ifaceWrap.style.display = '';
ifaceList.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table"><thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead><tbody></tbody></table></div>';
const tbody = ifaceList.querySelector('tbody');
urls.forEach((u, i) => { urls.forEach((u, i) => {
const id = 'iface_' + i; const id = 'iface_' + i;
const label = document.createElement('label'); const row = document.createElement('tr');
label.style.fontSize = '12px'; row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
label.style.cursor = 'pointer'; '<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' + row.querySelector('input').addEventListener('change', () => {
(i === selectedIdx ? ' checked' : '') + ' /> ' +
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
label.querySelector('input').addEventListener('change', () => {
selectedIdx = i; selectedIdx = i;
renderQr(urls[i].url); renderQr(urls[i].url);
}); });
ifaceList.appendChild(label); tbody.appendChild(row);
}); });
} }
</script> </script>
@@ -207,48 +310,81 @@ func MobilePick(w http.ResponseWriter, r *http.Request) {
picked := PickFiles( picked := PickFiles(
"Select HTML", "Select HTML",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
false, "", true, "",
) )
if len(picked) == 0 { if len(picked) == 0 {
writeJSON(w, map[string]any{"path": nil}) writeJSON(w, map[string]any{"files": []any{}})
return return
} }
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])}) files := make([]map[string]string, 0, len(picked))
for _, p := range picked {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
func MobilePickFolder(w http.ResponseWriter, r *http.Request) {
dir := PickFolder("Select folder", "")
if dir == "" {
writeJSON(w, map[string]any{"files": []any{}})
return
}
paths := collectHTMLFiles(dir)
files := make([]map[string]string, 0, len(paths))
for _, p := range paths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
} }
func MobileStart(w http.ResponseWriter, r *http.Request) { func MobileStart(w http.ResponseWriter, r *http.Request) {
stopActiveShare() stopActiveShare()
send := newJSONStream(w)
var req struct { var req struct {
Path string `json:"path"` Path string `json:"path"`
Paths []string `json:"paths"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
if req.Path == "" || !fileExists(req.Path) { paths := req.Paths
writeJSON(w, map[string]any{"error": "File missing."}) if len(paths) == 0 && req.Path != "" {
paths = []string{req.Path}
}
if len(paths) == 0 {
send(map[string]any{"type": "error", "message": "File missing."})
return return
} }
fileBuf, err := os.ReadFile(req.Path) files := make([]sharedFile, 0, len(paths))
if err != nil { for i, p := range paths {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) if p == "" || !fileExists(p) {
return 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 {
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
return
}
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
} }
filename := filepath.Base(req.Path)
// 6 random bytes -> base64url, no padding // 6 random bytes -> base64url, no padding
tokenBytes := make([]byte, 6) tokenBytes := make([]byte, 6)
if _, err := rand.Read(tokenBytes); err != nil { if _, err := rand.Read(tokenBytes); err != nil {
writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()}) send(map[string]any{"type": "error", "message": "Token gen failed: " + err.Error()})
return return
} }
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=") token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
cfgPort := LoadConfig().SendToMobile.Port cfgPort := LoadConfig().SendToMobile.Port
send(map[string]any{"type": "status", "message": "Starting server..."})
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort)) listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()}) send(map[string]any{"type": "error", "message": "Failed to bind: " + err.Error()})
return return
} }
port := listener.Addr().(*net.TCPAddr).Port port := listener.Addr().(*net.TCPAddr).Port
@@ -257,8 +393,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
listener: listener, listener: listener,
port: port, port: port,
token: token, token: token,
fileBuf: fileBuf, files: files,
filename: filename,
} }
share.server = &http.Server{Handler: shareHandler(share)} share.server = &http.Server{Handler: shareHandler(share)}
@@ -267,7 +402,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
ips := getLanIPs() ips := getLanIPs()
if len(ips) == 0 { if len(ips) == 0 {
_ = share.server.Close() _ = share.server.Close()
writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"}) send(map[string]any{"type": "error", "message": "No non-loopback IPv4 interface found. Are you on a network?"})
return return
} }
@@ -288,7 +423,11 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token), URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
}) })
} }
writeJSON(w, map[string]any{"urls": urls, "filename": filename}) filename := fmt.Sprintf("%d files", len(files))
if len(files) == 1 {
filename = files[0].filename
}
send(map[string]any{"type": "done", "urls": urls, "filename": filename})
} }
func MobileStop(w http.ResponseWriter, r *http.Request) { func MobileStop(w http.ResponseWriter, r *http.Request) {
@@ -308,19 +447,53 @@ func shareHandler(s *activeShare) http.Handler {
case "", "/": case "", "/":
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(chooserPage(s.filename))) _, _ = w.Write([]byte(chooserPage(s.files)))
case "/view": case "/view":
fallthrough
case "/view/0":
if len(s.files) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf) _, _ = w.Write(s.files[0].buf)
case "/file": case "/file":
safeName := safeFilename(s.filename) fallthrough
case "/file/0":
if len(s.files) == 0 {
http.NotFound(w, r)
return
}
safeName := safeFilename(s.files[0].filename)
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`) w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf))) w.Header().Set("Content-Length", strconv.Itoa(len(s.files[0].buf)))
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf) _, _ = w.Write(s.files[0].buf)
default: default:
if strings.HasPrefix(rest, "/view/") || strings.HasPrefix(rest, "/file/") {
parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) == 2 {
idx, err := strconv.Atoi(parts[1])
if err == nil && idx >= 0 && idx < len(s.files) {
file := s.files[idx]
if parts[0] == "view" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf)
return
}
safeName := safeFilename(file.filename)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
w.Header().Set("Content-Length", strconv.Itoa(len(file.buf)))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf)
return
}
}
}
http.NotFound(w, r) http.NotFound(w, r)
} }
}) })
@@ -330,8 +503,14 @@ var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") } func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
func chooserPage(filename string) string { func chooserPage(files []sharedFile) string {
escName := html.EscapeString(filename) var items strings.Builder
for i, file := range files {
escName := html.EscapeString(file.filename)
items.WriteString(`<div class="item"><div class="file">` + escName + `</div>
<a class="btn view" href="view/` + strconv.Itoa(i) + `">View in browser</a>
<a class="btn dl" href="file/` + strconv.Itoa(i) + `" download="` + escName + `">Download .html</a></div>`)
}
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html><head> <html><head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -341,7 +520,8 @@ func chooserPage(filename string) string {
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 24px; background: #111; color: #eee; } margin: 0; padding: 24px; background: #111; color: #eee; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; } h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; } .item { margin-bottom: 26px; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px; a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; } font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
a.view { background: #2d7dff; color: white; } a.view { background: #2d7dff; color: white; }
@@ -349,9 +529,7 @@ func chooserPage(filename string) string {
</style></head> </style></head>
<body> <body>
<h1>HPL Toolbox</h1> <h1>HPL Toolbox</h1>
<div class="file">` + escName + `</div> ` + items.String() + `
<a class="btn view" href="view">View in browser</a>
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
</body></html>` </body></html>`
} }

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -34,6 +35,7 @@ type mraidRule struct {
} }
type mraidContext struct { type mraidContext struct {
FilePath string
HTML string HTML string
ScriptText string ScriptText string
MraidCallLines map[string]int MraidCallLines map[string]int
@@ -41,17 +43,29 @@ type mraidContext struct {
func MraidPage(w http.ResponseWriter, r *http.Request) { func MraidPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>MRAID Checker</h2> <header class="tool-header">
<p class="hint">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p> <h2 class="tool-title">MRAID Checker</h2>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;"> <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>
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" /> </header>
<button id="pickFolder">Browse Folder...</button> <section class="tool-panel">
<button id="pickFiles">Pick Files...</button> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<button id="scan">Scan</button> <div class="panel-body">
</div> <div class="control-group">
<div id="selection" class="hint"></div> <div id="dropZone" class="drop-zone">
<div id="status" style="margin-top:8px;"></div> <div class="file-row">
<div id="results" style="margin-top:8px;"></div> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row"><button id="scan">Scan</button></div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="results" class="results-panel"></div>
<style> <style>
.row-result { padding:8px 0; border-bottom:1px solid #333; } .row-result { padding:8px 0; border-bottom:1px solid #333; }
@@ -75,52 +89,136 @@ const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection'); const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFiles = []; let pickedFiles = [];
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; } let selectedPage = 0;
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); } function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); }
folderEl.addEventListener('input', clearFiles); function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!pickedFiles.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visible.map((p, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeText(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
});
if (pickedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
function addPaths(paths) {
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
addPaths(j.paths || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
document.getElementById('pickFolder').addEventListener('click', async () => { document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' }); const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { folderEl.value = j.path; clearFiles(); } addPaths(j.paths || []);
}); });
document.getElementById('pickFiles').addEventListener('click', async () => { document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' }); const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.paths && j.paths.length) { addPaths(j.paths || []);
pickedFiles = j.paths; });
folderEl.value = ''; document.getElementById('clear').addEventListener('click', () => {
selectionEl.textContent = pickedFiles.length + ' file(s) selected'; pickedFiles = [];
} selectedPage = 0;
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = '';
renderSelection();
}); });
document.getElementById('scan').addEventListener('click', async () => { document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
let payload; if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
if (pickedFiles.length) payload = { files: pickedFiles }; const payload = { files: pickedFiles };
else { let j = null;
const folder = folderEl.value.trim(); try {
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; } const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
payload = { folder }; 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;
} }
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
const j = await r.json(); if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length; const total = j.results.length;
const withIssues = j.results.filter(r => !r.ok).length; const withIssues = j.results.filter(r => !r.ok).length;
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0); const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0); const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
const skipped = Number(j.skipped || 0); const skipped = Number(j.skipped || 0);
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : ''; const skippedReason = j.skippedReason || 'file(s).';
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>'; const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
resultsEl.innerHTML = ''; statusEl.classList.remove('is-busy');
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
if (!total) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
const tbody = resultsEl.querySelector('tbody');
for (const rr of j.results) { for (const rr of j.results) {
const reqCount = rr.issues.filter(i => i.severity === 'requirement').length; const row = document.createElement('tr');
const bpCount = rr.issues.filter(i => i.severity === 'best-practice').length; const file = document.createElement('td');
const row = document.createElement('div'); file.className = 'mono wrap';
row.className = 'row-result'; file.textContent = rr.file;
row.innerHTML = '<div class="file-head"><span class="mark ' + (rr.ok ? 'ok' : 'bad') + '">' + (rr.ok ? 'OK' : 'X') + '</span><span class="file-name">' + escapeText(rr.file) + '</span><span class="counts">' + reqCount + ' req / ' + bpCount + ' bp</span></div>'; const result = document.createElement('td');
result.className = 'wrap';
if (!rr.ok) { if (!rr.ok) {
const list = document.createElement('ul'); const list = document.createElement('ul');
list.className = 'issues'; list.className = 'issues';
@@ -131,9 +229,12 @@ document.getElementById('scan').addEventListener('click', async () => {
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>'; item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
list.appendChild(item); list.appendChild(item);
} }
row.appendChild(list); result.appendChild(list);
} else {
result.innerHTML = '<span class="badge ok">OK</span>';
} }
resultsEl.appendChild(row); row.append(file, result);
tbody.appendChild(row);
} }
}); });
</script> </script>
@@ -143,7 +244,18 @@ document.getElementById('scan').addEventListener('click', async () => {
} }
func MraidPickFolder(w http.ResponseWriter, r *http.Request) { func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"path": PickFolder("Select folder to scan", "")}) p := PickFolder("Select folder to scan", "")
if p == "" {
writeJSON(w, map[string]any{"paths": []string{}})
return
}
files := []string{}
for _, f := range collectHTMLFiles(p) {
if isInSupportedMraidFolderPath(f) {
files = append(files, f)
}
}
writeJSON(w, map[string]any{"paths": files})
} }
func MraidPickFiles(w http.ResponseWriter, r *http.Request) { func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
@@ -152,18 +264,21 @@ func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
} }
func MraidScan(w http.ResponseWriter, r *http.Request) { func MraidScan(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct { var req struct {
Folder string `json:"folder"` Folder string `json:"folder"`
Files []string `json:"files"` Files []string `json:"files"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error(), "results": []mraidResult{}})
return return
} }
var targets []string var targets []string
var baseDir string var baseDir string
skipped := 0 skipped := 0
skippedReason := ""
noTargetsError := "No HTML files were found."
if len(req.Files) > 0 { if len(req.Files) > 0 {
var existing []string var existing []string
for _, f := range req.Files { for _, f := range req.Files {
@@ -171,40 +286,41 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
existing = append(existing, f) existing = append(existing, f)
} }
} }
for _, f := range existing { targets = existing
if isSupportedMraidBuild(f) { skipped = len(req.Files) - len(existing)
targets = append(targets, f) skippedReason = "missing file(s)."
} noTargetsError = "No selected HTML files were found."
}
skipped = len(existing) - len(targets)
if len(targets) > 0 { if len(targets) > 0 {
baseDir = commonBaseDir(targets) baseDir = commonBaseDir(targets)
} }
} else if req.Folder != "" && fileExists(req.Folder) { } else if req.Folder != "" && fileExists(req.Folder) {
all := collectHTMLFiles(req.Folder) all := collectHTMLFiles(req.Folder)
for _, f := range all { for _, f := range all {
if isSupportedMraidBuild(f) { if isInSupportedMraidFolderPath(f) {
targets = append(targets, f) targets = append(targets, f)
} }
} }
skipped = len(all) - len(targets) skipped = len(all) - len(targets)
skippedReason = "HTML file(s) outside AppLovin/ironSource/Unity folders."
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
baseDir = req.Folder baseDir = req.Folder
} else { } 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 return
} }
if len(targets) == 0 { if len(targets) == 0 {
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "error": "No AppLovin, ironSource, or Unity HTML builds were found."}) send(map[string]any{"type": "error", "message": noTargetsError, "results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason})
return return
} }
results := []mraidResult{} 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) data, err := os.ReadFile(file)
if err != nil { if err != nil {
continue continue
} }
issues := checkMraid(string(data)) issues := checkMraid(string(data), file)
display := file display := file
if baseDir != "" { if baseDir != "" {
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" { if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
@@ -215,30 +331,71 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
} }
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues}) results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
} }
writeJSON(w, map[string]any{"results": results, "skipped": skipped}) send(map[string]any{"type": "done", "results": results, "skipped": skipped, "skippedReason": skippedReason})
} }
var ( var (
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`) mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`) mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
mraidBuildNameRx = regexp.MustCompile(`(?i)(^|[/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([/._ -]|$)`) mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`) mraidJsRx = regexp.MustCompile(`(?i)\bmraid\.js\b`)
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`) mraidViewportRx = regexp.MustCompile(`(?i)<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>`)
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`) mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`) mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`) mraidHyperlinkRx = regexp.MustCompile(`(?i)<a\b[^>]*\bhref\s*=`)
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\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 isSupportedMraidBuild(filePath string) bool { func isInSupportedMraidFolderPath(filePath string) bool {
normalized := strings.ReplaceAll(strings.ToLower(filePath), `\`, `/`) supported := map[string]bool{
return mraidBuildNameRx.MatchString(normalized) "al": true,
"applovin": true,
"app-lovin": true,
"is": true,
"ironsource": true,
"iron-source": true,
"iron_source": true,
"un": true,
"unity": true,
"unityads": true,
"unity-ads": true,
"unity_ads": true,
}
parts := strings.FieldsFunc(strings.ToLower(filepath.Dir(filePath)), func(r rune) bool {
return r == '/' || r == '\\'
})
for _, part := range parts {
if supported[part] {
return true
}
}
return false
} }
func checkMraid(htmlSrc string) []mraidIssue { func checkMraid(htmlSrc string, filePath ...string) []mraidIssue {
sourcePath := ""
if len(filePath) > 0 {
sourcePath = filePath[0]
}
ctx := mraidContext{ ctx := mraidContext{
FilePath: sourcePath,
HTML: htmlSrc, HTML: htmlSrc,
ScriptText: extractMraidScriptText(htmlSrc), ScriptText: extractMraidScriptText(htmlSrc),
MraidCallLines: collectMraidCallLines(htmlSrc), MraidCallLines: collectMraidCallLines(htmlSrc),
@@ -276,12 +433,31 @@ func collectMraidCallLines(htmlSrc string) map[string]int {
} }
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) } 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 { func mraidHasCall(ctx mraidContext, method string) bool {
_, ok := ctx.MraidCallLines[method] _, ok := ctx.MraidCallLines[method]
return ok return ok
} }
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] } 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 { func mraidHasEventListener(ctx mraidContext, eventName string) bool {
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`) rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
return rx.MatchString(ctx.ScriptText) return rx.MatchString(ctx.ScriptText)
@@ -296,6 +472,38 @@ func mraidHasReadyGate(ctx mraidContext) bool {
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText) 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 { func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference} found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
if line > 0 { if line > 0 {
@@ -305,56 +513,171 @@ func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
} }
var mraidRules = []mraidRule{ 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 { {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) { if mraidHasReference(ctx) {
return nil return nil
} }
return mraidIssueFrom(rule, 0) 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 { {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) { if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
return nil return nil
} }
return mraidIssueFrom(rule, 0) 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 { {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") { if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
return nil return nil
} }
return mraidIssueFrom(rule, 0) 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 { {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 {
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") { hasVisibility := mraidHasEventListener(ctx, "exposureChange") && (mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange"))
if !mraidHasReference(ctx) || hasVisibility {
return nil return nil
} }
return mraidIssueFrom(rule, 0) 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 { {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 (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") { 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 mraidIssueFrom(rule, 0)
} }
return nil 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 { {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") { if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
return mraidIssueFrom(rule, 0) return mraidIssueFrom(rule, 0)
} }
return nil 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 { {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 mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") { if mraidHasReference(ctx) && !mraidHasCall(ctx, "unload") {
return mraidIssueFrom(rule, mraidLine(ctx, "expand")) return mraidIssueFrom(rule, 0)
} }
return nil 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 { {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") && !mraidHasSupportCheck(ctx, "resize") { if mraidHasCall(ctx, "resize") && !mraidHasCall(ctx, "setResizeProperties") {
return mraidIssueFrom(rule, mraidLine(ctx, "resize")) return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
} }
return nil 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 { {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, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") { 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 mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
} }
return nil return nil
@@ -373,4 +696,30 @@ var mraidRules = []mraidRule{
} }
return nil 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

@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"syscall"
) )
type PlecConfig struct { type PlecConfig struct {
@@ -113,13 +114,12 @@ func SaveConfig(cfg AppConfig) error {
} }
func OpenInBrowser(url string) { func OpenInBrowser(url string) {
// `start` is a cmd builtin. Quoted empty arg is the window title. cmd := hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url)
cmd := exec.Command("cmd", "/c", "start", "", url)
_ = cmd.Start() _ = cmd.Start()
} }
func CopyToClipboard(text string) error { func CopyToClipboard(text string) error {
cmd := exec.Command("clip") cmd := hiddenCommand("clip")
cmd.Stdin = strings.NewReader(text) cmd.Stdin = strings.NewReader(text)
return cmd.Run() return cmd.Run()
} }
@@ -131,6 +131,15 @@ type FileFilter struct {
func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") } func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
func hiddenCommand(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
return cmd
}
func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string { func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string {
filterStr := "All files|*.*" filterStr := "All files|*.*"
if len(filters) > 0 { if len(filters) > 0 {
@@ -159,7 +168,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$dlg.FileNames | ForEach-Object { Write-Output $_ } $dlg.FileNames | ForEach-Object { Write-Output $_ }
} }
`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir)) `, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output() out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
if err != nil { if err != nil {
return nil return nil
} }
@@ -198,7 +207,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dlg.FileName Write-Output $dlg.FileName
} }
`, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir)) `, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir))
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output() out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
if err != nil { if err != nil {
return "" return ""
} }
@@ -215,7 +224,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dlg.SelectedPath Write-Output $dlg.SelectedPath
} }
`, psEscape(title), psEscape(initialDir), psEscape(initialDir)) `, psEscape(title), psEscape(initialDir), psEscape(initialDir))
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output() out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
if err != nil { if err != nil {
return "" return ""
} }

View File

@@ -14,6 +14,7 @@ import (
type playworksConvertOptions struct { type playworksConvertOptions struct {
SourcePath string `json:"sourcePath"` SourcePath string `json:"sourcePath"`
OutputDir string `json:"outputDir"` OutputDir string `json:"outputDir"`
BaseName string `json:"baseName"`
Networks []string `json:"networks"` Networks []string `json:"networks"`
} }
@@ -26,107 +27,173 @@ type playworksNetworkResult struct {
func PlayworksPage(w http.ResponseWriter, r *http.Request) { func PlayworksPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>Playworks Converter</h2> <header class="tool-header">
<h2 class="tool-title">Playworks Converter</h2>
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
</header>
<style> <style>
.section { margin-bottom:14px; } .net-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:4px 12px; }
.section-label { font-size:11px; text-transform:uppercase; letter-spacing:0.5px; opacity:0.65; margin-bottom:5px; } .net-row { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; user-select:none; border:1px solid #333; border-radius:4px; background:rgba(128,128,128,0.06); }
.row { display:flex; gap:6px; align-items:center; }
.net-grid { display:flex; flex-direction:column; gap:4px; }
.net-row { display:flex; align-items:center; gap:8px; padding:3px 0; cursor:pointer; user-select:none; }
.net-label { min-width:100px; font-weight:500; } .net-label { min-width:100px; font-weight:500; }
.net-tag { font-size:10px; opacity:0.6; font-weight:400; } .net-tag { font-size:10px; opacity:0.6; font-weight:400; }
.net-note { font-size:11px; opacity:0.6; } .net-note { font-size:11px; opacity:0.6; }
.toggle-row { display:flex; gap:8px; margin-bottom:6px; } .toggle-row { display:flex; gap:8px; margin-bottom:6px; }
.toggle-row button { font-size:11px; padding:2px 8px; } .toggle-row button { font-size:11px; padding:2px 8px; }
.actions { display:flex; gap:10px; align-items:center; margin-top:16px; }
#status { margin-top:12px; font-size:12px; opacity:0.75; min-height:16px; }
.results { margin-top:10px; display:flex; flex-direction:column; gap:3px; }
.res-row { display:flex; align-items:center; gap:8px; font-size:12px; font-family:Consolas, monospace; }
.mark { font-weight:bold; width:14px; flex-shrink:0; }
.ok { color:#3fb950; }
.bad { color:#f85149; }
.res-net { min-width:70px; font-weight:500; }
.res-file { opacity:0.65; word-break:break-all; }
.res-err { color:#f85149; opacity:0.9; }
hr { border:none; border-top:1px solid #333; margin:14px 0; }
</style> </style>
<div class="section"> <section class="tool-panel">
<div class="section-label">Source HTML (Playworks export)</div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="row"> <div class="panel-body">
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" /> <div class="control-group">
<button id="pickSrc">Browse...</button> <div class="control-label">Source HTML (Playworks export)</div>
<div id="dropZone" class="drop-zone">
<div class="file-row">
<button id="pickSrc" class="secondary">Select File</button>
<button id="clearSrc" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop a Playworks HTML file here</div>
</div>
<input id="src" type="hidden" />
<div id="sourceSelection" class="selected-list"></div>
</div> </div>
</div>
<div class="section"> <div class="control-group">
<div class="section-label">Output Folder</div> <div class="control-label">Base Filename</div>
<div class="row"> <div class="field-row">
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
</div>
</div>
<div class="control-group">
<div class="control-label">Output Folder</div>
<div class="field-row">
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" /> <input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
<button id="pickOut">Browse...</button> <button id="pickOut" class="secondary">Select Folder</button>
</div>
</div> </div>
</div>
<hr /> <div class="control-group">
<div class="control-label">Networks to generate</div>
<div class="section">
<div class="section-label">Networks to generate</div>
<div class="toggle-row"> <div class="toggle-row">
<button id="selectAll">All</button> <button id="selectAll" class="secondary">All</button>
<button id="selectNone">None</button> <button id="selectNone" class="secondary">None</button>
</div> </div>
<div class="net-grid"> <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="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="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="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="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="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="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" checked /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label> <label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
</div>
</div> </div>
</div>
<div class="actions"> <div class="action-row">
<button id="convert" disabled>Convert</button> <button id="convert" disabled>Convert</button>
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button> <button id="openOut" class="secondary" disabled>Open Output Folder</button>
</div> </div>
<div id="status"></div> <div id="status" class="status-panel"></div>
<div class="results" id="results"></div> </div>
</section>
<div id="results" class="results-panel"></div>
<script> <script>
let outputDir = ''; let outputDir = '';
const srcEl = document.getElementById('src'); const srcEl = document.getElementById('src');
const outEl = document.getElementById('outDir'); const outEl = document.getElementById('outDir');
const baseNameEl = document.getElementById('baseName');
const convertBtn = document.getElementById('convert'); const convertBtn = document.getElementById('convert');
const openOutBtn = document.getElementById('openOut'); const openOutBtn = document.getElementById('openOut');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const 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() { function updateConvertBtn() {
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked); const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet; convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
}
function 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 () => { document.getElementById('pickSrc').addEventListener('click', async () => {
const r = await fetch('/api/playworks/pickSource', { method:'POST' }); const r = await fetch('/api/playworks/pickSource', { method:'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { if (j.path) {
srcEl.value = j.path; setSource(j.path, j.baseName || '', j.dir || '');
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
updateConvertBtn();
} }
}); });
document.getElementById('clearSrc').addEventListener('click', clearSource);
document.getElementById('pickOut').addEventListener('click', async () => { document.getElementById('pickOut').addEventListener('click', async () => {
const r = await fetch('/api/playworks/pickOutput', { method:'POST' }); const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); } if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
}); });
document.getElementById('selectAll').addEventListener('click', () => { document.getElementById('selectAll').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = true); document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
updateConvertBtn(); updateConvertBtn();
}); });
document.getElementById('selectNone').addEventListener('click', () => { document.getElementById('selectNone').addEventListener('click', () => {
@@ -135,50 +202,77 @@ document.getElementById('selectNone').addEventListener('click', () => {
}); });
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn)); document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
outEl.addEventListener('input', updateConvertBtn); outEl.addEventListener('input', updateConvertBtn);
baseNameEl.addEventListener('input', updateConvertBtn);
openOutBtn.addEventListener('click', () => { openOutBtn.addEventListener('click', () => {
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) }); if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
}); });
function outputDisplayName(file) {
const parts = file.split(/[\\\\/]/).filter(Boolean);
return parts.slice(-2).join('/');
}
setupDropZone('dropZone', statusEl, (j) => {
const paths = j.paths || [];
if (!paths.length) {
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
return;
}
const sourcePath = paths[0];
const droppedBase = basename(sourcePath).replace(/\\.html?$/i, '').replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/i, '');
const i = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\\\'));
setSource(sourcePath, droppedBase, i >= 0 ? sourcePath.slice(0, i) : '');
const skipped = Number(j.skipped || 0) + Math.max(0, paths.length - 1);
if (skipped) statusEl.textContent = 'Using first dropped HTML file. Skipped ' + skipped + ' other item(s).';
});
renderSourceSelection();
document.getElementById('convert').addEventListener('click', async () => { document.getElementById('convert').addEventListener('click', async () => {
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag); const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...'; statusEl.textContent = 'Converting...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
openOutBtn.style.display = 'none'; openOutBtn.disabled = true;
convertBtn.disabled = true; convertBtn.disabled = true;
const res = await fetch('/api/playworks/convert', { const res = await fetch('/api/playworks/convert', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, networks }) 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; convertBtn.disabled = false;
updateConvertBtn(); 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 results = msg.results || [];
const ok = results.filter(r => r.ok).length; const ok = results.filter(r => r.ok).length;
statusEl.classList.remove('is-busy');
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.'; statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
resultsEl.innerHTML = ''; if (!results.length) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th>Output Path</th><th>Error</th></tr></thead><tbody></tbody></table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of results) { for (const r of results) {
const row = document.createElement('div'); const row = document.createElement('tr');
row.className = 'res-row'; row.innerHTML = '<td class="mono">' + r.network + '</td>' +
const mark = document.createElement('span'); '<td>' + (r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Error</span>') + '</td>' +
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad'); '<td class="mono wrap">' + (r.ok ? outputDisplayName(r.file) : '<span class="muted">-</span>') + '</td>' +
mark.textContent = r.ok ? '' : '✗'; '<td class="err wrap">' + (r.ok ? '' : (r.error || 'Unknown error')) + '</td>';
const net = document.createElement('span'); tbody.appendChild(row);
net.className = 'res-net';
net.textContent = r.network;
const detail = document.createElement('span');
if (r.ok) {
detail.className = 'res-file';
detail.textContent = r.file.split(/[\\\\/]/).pop();
} else {
detail.className = 'res-err';
detail.textContent = r.error || 'Unknown error';
}
row.append(mark, net, detail);
resultsEl.appendChild(row);
} }
if (ok > 0) { outputDir = outEl.value; openOutBtn.style.display = ''; } if (ok > 0) { outputDir = outEl.value; openOutBtn.disabled = false; }
}); });
</script> </script>
` `
@@ -192,7 +286,7 @@ func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{}) writeJSON(w, map[string]any{})
return return
} }
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0])}) writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0]), "baseName": playworksSourceBaseName(picked[0])})
} }
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) { func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
@@ -200,27 +294,37 @@ func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
} }
func PlayworksConvert(w http.ResponseWriter, r *http.Request) { func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var opts playworksConvertOptions var opts playworksConvertOptions
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { 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 return
} }
if !fileExists(opts.SourcePath) { 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 return
} }
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { 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 return
} }
send(map[string]any{"type": "status", "message": "Reading source HTML..."})
data, err := os.ReadFile(opts.SourcePath) data, err := os.ReadFile(opts.SourcePath)
if err != nil { 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 return
} }
results := []playworksNetworkResult{} 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) filePath, err := convertPlayworksNetwork(string(data), opts, network)
if err != nil { if err != nil {
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()}) results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
@@ -228,67 +332,286 @@ func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
} }
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true}) 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 { func playworksSourceBaseName(sourcePath string) string {
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath)) base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
return playworksSuffixRx.ReplaceAllString(base, "") return playworksSuffixRx.ReplaceAllString(base, "")
} }
func playworksOutputBaseName(opts playworksConvertOptions) string {
baseName := strings.TrimSpace(opts.BaseName)
if baseName == "" {
baseName = playworksSourceBaseName(opts.SourcePath)
}
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
baseName = filepath.Base(baseName)
baseName = strings.Map(func(r rune) rune {
switch r {
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
return '_'
default:
return r
}
}, baseName)
baseName = strings.TrimSpace(baseName)
if baseName == "" {
return playworksSourceBaseName(opts.SourcePath)
}
return baseName
}
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) { 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) transformed := transformPlayworksHTML(htmlSrc, network)
baseName := playworksSourceBaseName(opts.SourcePath) baseName := playworksOutputBaseName(opts)
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network) htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
outputDir := filepath.Join(opts.OutputDir, playworksNetworkOutputFolder(network))
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", err
}
if playworksNeedsZip(network) { if playworksNeedsZip(network) {
zipPath := filepath.Join(opts.OutputDir, fmt.Sprintf("%s_%s.zip", baseName, network)) zipPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil { if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil {
return "", err return "", err
} }
return zipPath, nil return zipPath, nil
} }
outPath := filepath.Join(opts.OutputDir, htmlFileName) outPath := filepath.Join(outputDir, htmlFileName)
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil { if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
return "", err return "", err
} }
return outPath, nil return outPath, nil
} }
func copyPlayworksUnityInput(data []byte, opts playworksConvertOptions) (string, error) {
baseName := playworksOutputBaseName(opts)
outputDir := filepath.Join(opts.OutputDir, "Unity")
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", err
}
outPath := filepath.Join(outputDir, fmt.Sprintf("%s_un.html", baseName))
if err := os.WriteFile(outPath, data, 0644); err != nil {
return "", err
}
return outPath, nil
}
func playworksNetworkOutputFolder(network string) string {
switch network {
case "al":
return "Applovin"
case "is":
return "Ironsource"
case "fb":
return "Facebook"
case "gg":
return "GoogleAds"
case "ap":
return "Appreciate"
case "mo":
return "Moloco"
case "vu":
return "Vungle"
case "mtg":
return "Mintegral"
case "tt":
return "TikTok"
default:
return network
}
}
func playworksNeedsZip(network string) bool { 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 { func transformPlayworksHTML(htmlSrc, network string) string {
result := htmlSrc result := sanitizePlayworksHTML(htmlSrc)
headInject := buildPlayworksLifecycleScript() headInject := buildPlayworksLifecycleScript()
if network == "al" || network == "is" { if playworksIsMraidNetwork(network) {
headInject += `<script src="mraid.js"></script>` headInject += `<script src="mraid.js"></script>` + buildPlayworksMraidComplianceScript()
} else if network == "gg" { } else if playworksIsExitapiNetwork(network) {
headInject += `<script src="exitapi.js"></script>` headInject += `<script src="exitapi.js"></script>`
} }
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1) result = injectPlayworksBeforeHeadClose(result, headInject)
if network == "vu" { if network == "vu" {
result = strings.Replace(result, "<body>", `<body> result = injectPlayworksAfterBodyOpen(result, `<script>window.__VUNGLE__=true;</script>`)
<script>window.__VUNGLE__=true;</script>`, 1)
} else if network == "mtg" { } else if network == "mtg" {
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1) result = addPlayworksBodyOnload(result, "gameReady()")
} else if network == "tt" { } else if network == "tt" {
result = strings.Replace(result, "<body>", `<body> result = injectPlayworksAfterBodyOpen(result, `<script>window.__TIKTOK__=true;</script>`)
<script>window.__TIKTOK__=true;</script>`, 1)
} }
result = replacePlayworksCTAScript(result, network) result = replacePlayworksCTAScript(result, network)
if network == "un" { return finalizePlayworksNetworkHTML(result, network)
result = strings.ReplaceAll(result, "window.top", "window.self") }
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 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 { func replacePlayworksCTAScript(htmlSrc, network string) string {
marker := "Luna.Unity.Playable.InstallFullGame=function" marker := "Luna.Unity.Playable.InstallFullGame=function"
markerIdx := strings.LastIndex(htmlSrc, marker) markerIdx := strings.LastIndex(htmlSrc, marker)
@@ -338,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 { 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;` 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){}` closeCall := `try{window.gameClose();}catch(e){}`
ctaLogic := closeCall + `window.open(o,"_blank");` ctaLogic := closeCall + `window.open(o,"_blank");`
switch network { switch network {
case "al", "is": 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");}` 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 "un":
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`
case "fb": case "fb":
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");` ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
case "gg": case "gg", "ap":
ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");` ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`
case "mo": case "mo":
ctaLogic = closeCall + `if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");` ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`
case "vu": case "vu":
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}` ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
case "mtg": 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

@@ -17,42 +17,57 @@ import (
func PlecPage(w http.ResponseWriter, r *http.Request) { func PlecPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>PLEC Upload</h2> <header class="tool-header">
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p> <h2 class="tool-title">PLEC Upload</h2>
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
</header>
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;"> <section class="tool-panel">
<thead><tr> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th> <div class="panel-body">
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th> <div class="control-group">
<th></th> <div id="dropZone" class="drop-zone">
</tr></thead> <div class="file-row">
<tbody></tbody> <button id="pickFolder" class="secondary">Select Folder</button>
</table> <button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
<div style="display:flex;gap:8px;"> </div>
<button id="addRow" class="secondary">+ Add Row</button> <div class="drop-zone-text">or drop HTML files or folders here</div>
<button id="upload">Upload</button> </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;">
<div id="status" style="margin-top:12px;min-height:20px;"></div> <table id="rows" class="data-table">
<div id="results"></div> <thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="openResult" class="secondary" disabled>Open</button>
<button id="copyResult" class="secondary" disabled>Copy Link</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<style> <style>
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
.file-cell { display:flex; align-items:center; gap:8px; }
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
.row-error { color:#f48771; font-size:12px; margin-top:4px; } .row-error { color:#f48771; font-size:12px; margin-top:4px; }
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; } #rows input[type=text] { width: 100%; }
.result a { color:#9cdcfe; }
.result-actions { margin-top:8px; display:flex; gap:8px; }
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
</style> </style>
<script> <script>
const PAGE_SIZE = 5;
let nextId = 1; let nextId = 1;
let rows = [];
let page = 0;
let previewUrl = '';
const tbody = document.querySelector('#rows tbody'); const tbody = document.querySelector('#rows tbody');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const rowsPanel = document.getElementById('rowsPanel');
const emptySelection = document.getElementById('emptySelection');
const openResultBtn = document.getElementById('openResult');
const copyResultBtn = document.getElementById('copyResult');
function parseIterationName(filename) { function parseIterationName(filename) {
const base = filename.replace(/\.html?$/i, ''); const base = filename.replace(/\.html?$/i, '');
@@ -63,121 +78,175 @@ function parseIterationName(filename) {
return base; return base;
} }
function addRow() { function escapeHtml(s){
const id = 'r' + (nextId++); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const tr = document.createElement('tr');
tr.dataset.id = id;
tr.innerHTML = ` + "`" + `
<td>
<div class="file-cell">
<button class="pick secondary">Choose...</button>
<span class="file-name" data-name>(no file)</span>
</div>
<input type="hidden" data-path />
<div class="row-error" data-error></div>
</td>
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
<td><button class="remove-btn" title="Remove">×</button></td>
` + "`" + `;
tr.querySelector('.pick').addEventListener('click', async () => {
const res = await fetch('/api/plec/pick', { method: 'POST' });
const j = await res.json();
if (j.files && j.files.length) {
const affected = [];
j.files.forEach(function(f, i) {
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
target.querySelector('[data-path]').value = f.path;
target.querySelector('[data-name]').textContent = f.name;
const iter = target.querySelector('[data-iter]');
if (!iter.value) iter.value = parseIterationName(f.name);
affected.push(target);
});
if (affected.length > 1) {
const iters = affected.map(r => r.querySelector('[data-iter]').value);
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
affected.forEach((r, i) => {
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
});
}
}
}
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
if (!tbody.children.length) addRow();
});
tbody.appendChild(tr);
} }
function addFiles(files) {
const existing = new Set(rows.map(r => r.path));
const before = rows.length;
(files || []).forEach(f => {
if (existing.has(f.path)) return;
rows.push({ id:'r' + (nextId++), path:f.path, name:f.name, iteration:parseIterationName(f.name), error:'' });
existing.add(f.path);
});
const added = rows.length - before;
if (added > 1) {
const batch = rows.slice(rows.length - added);
const first = batch[0].iteration;
if (first && batch.every(r => r.iteration === first)) {
batch.forEach((r, i) => r.iteration = String(i + 1).padStart(2, '0') + '_' + first);
}
}
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
renderRows();
}
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
function renderRows() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
if (!rows.length) { tbody.innerHTML = ''; return; }
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
page = Math.min(page, maxPage);
const start = page * PAGE_SIZE;
const pageRows = rows.slice(start, start + PAGE_SIZE);
tbody.innerHTML = pageRows.map((row, offset) => {
const i = start + offset;
return '<tr data-id="' + row.id + '">' +
'<td><span class="mono wrap">' + escapeHtml(row.name) + '</span><div class="row-error">' + escapeHtml(row.error || '') + '</div></td>' +
'<td><input type="text" data-iter data-index="' + i + '" value="' + escapeHtml(row.iteration) + '" /></td>' +
'<td><button class="remove-btn danger" data-remove="' + i + '" title="Remove">×</button></td>' +
'</tr>';
}).join('');
tbody.querySelectorAll('[data-iter]').forEach(input => {
input.addEventListener('input', () => rows[Number(input.dataset.index)].iteration = input.value);
});
tbody.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => { rows.splice(Number(btn.dataset.remove), 1); renderRows(); });
});
if (rows.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
rowsPanel.after(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
}
}
function setPreview(url) {
previewUrl = url || '';
openResultBtn.disabled = !previewUrl;
copyResultBtn.disabled = !previewUrl;
}
setupDropZone('dropZone', statusEl, (j) => {
addFiles(j.files || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
document.getElementById('addRow').addEventListener('click', addRow); document.getElementById('pickFolder').addEventListener('click', async () => {
const res = await fetch('/api/plec/pickFolder', { method: 'POST' });
const j = await res.json();
addFiles(j.files || []);
});
document.getElementById('pickFiles').addEventListener('click', async () => {
const res = await fetch('/api/plec/pick', { method: 'POST' });
const j = await res.json();
addFiles(j.files || []);
});
document.getElementById('clear').addEventListener('click', () => {
rows = [];
page = 0;
setPreview('');
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
renderRows();
});
openResultBtn.addEventListener('click', () => {
if (previewUrl) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: previewUrl }) });
});
copyResultBtn.addEventListener('click', () => {
if (previewUrl) fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: previewUrl }) });
});
document.getElementById('upload').addEventListener('click', async () => { document.getElementById('upload').addEventListener('click', async () => {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = ''); rows.forEach(row => row.error = '');
renderRows();
statusEl.textContent = ''; statusEl.textContent = '';
resultsEl.innerHTML = ''; statusEl.classList.remove('is-busy');
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({ setPreview('');
id: tr.dataset.id,
path: tr.querySelector('[data-path]').value,
iteration: tr.querySelector('[data-iter]').value,
})).filter(r => r.path || r.iteration);
if (rows.length === 0) { if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>'; statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return; return;
} }
statusEl.textContent = 'Uploading...'; statusEl.textContent = 'Uploading...';
const res = await fetch('/api/plec/upload', { statusEl.classList.add('is-busy');
method: 'POST', let j = null;
headers: { 'Content-Type': 'application/json' }, try {
body: JSON.stringify({ rows }), const res = await fetch('/api/plec/upload', {
}); method: 'POST',
const j = await res.json(); 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) { if (j.rowErrors) {
statusEl.classList.remove('is-busy');
for (const re of j.rowErrors) { for (const re of j.rowErrors) {
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]'); const row = rows.find(r => r.id === re.id);
if (tr) tr.querySelector('[data-error]').textContent = re.message; if (row) row.error = re.message;
} }
renderRows();
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>'; statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
return; return;
} }
if (j.error) { if (j.error) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + j.error + '</span>'; statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
return; return;
} }
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Upload complete.</span>'; statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
const wrap = document.createElement('div'); setPreview(j.preview);
wrap.className = 'result';
if (j.preview) {
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copy = document.createElement('button');
copy.textContent = 'Copy';
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
const open = document.createElement('button');
open.textContent = 'Open';
open.className = 'secondary';
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
const showRaw = document.createElement('button');
showRaw.textContent = 'Show server response';
showRaw.className = 'secondary';
showRaw.onclick = () => {
const pre = document.createElement('pre');
pre.style.marginTop = '8px';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = j.rawText;
wrap.appendChild(pre);
showRaw.disabled = true;
};
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
wrap.appendChild(actions);
} else {
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
}); });
addRow(); renderRows();
</script> </script>
` `
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -204,6 +273,23 @@ func PlecPick(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"files": files}) writeJSON(w, map[string]any{"files": files})
} }
func PlecPickFolder(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
dir := PickFolder("Select folder", cfg.LastPickDir)
if dir == "" {
writeJSON(w, map[string]any{"files": []any{}})
return
}
cfg.LastPickDir = dir
_ = SaveConfig(cfg)
paths := collectHTMLFiles(dir)
files := make([]map[string]string, 0, len(paths))
for _, p := range paths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
type plecRow struct { type plecRow struct {
ID string `json:"id"` ID string `json:"id"`
Path string `json:"path"` Path string `json:"path"`
@@ -211,11 +297,12 @@ type plecRow struct {
} }
func PlecUpload(w http.ResponseWriter, r *http.Request) { func PlecUpload(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct { var req struct {
Rows []plecRow `json:"rows"` Rows []plecRow `json:"rows"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
cfg := LoadConfig().Plec cfg := LoadConfig().Plec
@@ -240,11 +327,11 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
} }
} }
if len(rowErrors) > 0 { if len(rowErrors) > 0 {
writeJSON(w, map[string]any{"rowErrors": rowErrors}) send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors})
return return
} }
if len(valid) == 0 { if len(valid) == 0 {
writeJSON(w, map[string]any{"error": "Nothing to upload."}) send(map[string]any{"type": "error", "message": "Nothing to upload."})
return return
} }
@@ -254,37 +341,39 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
mw := multipart.NewWriter(&buf) mw := multipart.NewWriter(&buf)
for i, row := range valid { for i, row := range valid {
idx := i + 1 idx := i + 1
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filepath.Base(row.Path))})
data, err := os.ReadFile(row.Path) data, err := os.ReadFile(row.Path)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
return return
} }
stampedName := appendTimestamp(filepath.Base(row.Path), stamp) stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html") fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
if _, err := fw.Write(data); err != nil { if _, err := fw.Write(data); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", "")) iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars. // Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", "")) iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil { if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
} }
if err := mw.Close(); err != nil { if err := mw.Close(); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf) httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
httpReq.Header.Set("Content-Type", mw.FormDataContentType()) httpReq.Header.Set("Content-Type", mw.FormDataContentType())
@@ -296,18 +385,18 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
resp, err := http.DefaultClient.Do(httpReq) resp, err := http.DefaultClient.Do(httpReq)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()}) send(map[string]any{"type": "error", "message": "Network error: " + err.Error()})
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))}) send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
return return
} }
var parsed map[string]any var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil { if err := json.Unmarshal(respBody, &parsed); err != nil {
writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)}) send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
return return
} }
var preview any var preview any
@@ -316,7 +405,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
} else if v, ok := parsed["previewURL"]; ok { } else if v, ok := parsed["previewURL"]; ok {
preview = v preview = v
} }
writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)}) send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
} }
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) { func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {

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

50
tools.json Normal file
View File

@@ -0,0 +1,50 @@
[
{
"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
}
]