Compare commits
15 Commits
playworks-
...
80f155dfe8
| Author | SHA1 | Date | |
|---|---|---|---|
| 80f155dfe8 | |||
| 5625d94106 | |||
| a1d3fde774 | |||
| 7201bf9845 | |||
| 2921d4d384 | |||
| cce30c0729 | |||
| 4f17523f26 | |||
| 274bafe1cb | |||
| afbf504957 | |||
| 16847a78ca | |||
| ca9cc62b8c | |||
| 8e8d3d6da0 | |||
| 3f86aeaf0c | |||
| 92de00b52e | |||
| 78578cec16 |
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# CHANGELOG
|
||||
## v0.1.2:
|
||||
- Added local hosting for html files
|
||||
- Created standalone version
|
||||
|
||||
## v0.1.3
|
||||
- Updated Applovin Demo Upload
|
||||
- Changed file selection workflow
|
||||
- Added "Regenerate QRs" if in case they don't render
|
||||
- Added "Save QR" and "Save All QRs"
|
||||
|
||||
## v0.1.5
|
||||
- Better file selection for PLEC Uoload
|
||||
- Multi file selection
|
||||
- Auto detects iteration name (it tries its best)
|
||||
|
||||
Standalone .exe
|
||||
- Move temp files into user temp folder
|
||||
|
||||
## v0.1.6
|
||||
- Simplify UI
|
||||
- Added beta tools (needs further testing)
|
||||
- Playworks Converter
|
||||
- Upload UnityAds HTML from Playworks to add injections for the other networks
|
||||
- MRAID Checker
|
||||
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices
|
||||
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# HPL Toolbox
|
||||
|
||||
Bundled VS Code extension and standalone tools for playable ad workflows.
|
||||
|
||||
# CHANGELOG
|
||||
## v0.1.2:
|
||||
- Added local hosting for html files
|
||||
- Created standalone version
|
||||
|
||||
## v0.1.3
|
||||
- Updated Applovin Demo Upload
|
||||
- Changed file selection workflow
|
||||
- Added "Regenerate QRs" if in case they don't render
|
||||
- Added "Save QR" and "Save All QRs"
|
||||
|
||||
## v0.1.5
|
||||
- Better file selection for PLEC Uoload
|
||||
- Multi file selection
|
||||
- Auto detects iteration name (it tries its best)
|
||||
|
||||
Standalone .exe
|
||||
- Move temp files into user temp folder
|
||||
|
||||
## v0.1.6
|
||||
- Simplify UI
|
||||
- Added beta tools (needs further testing)
|
||||
- Playworks Converter
|
||||
- Upload UnityAds HTML from Playworks to add injections for the other networks
|
||||
- MRAID Checker
|
||||
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
aiAssets/DogCat3/GoogleAds/DogCat3_Full_gg.zip
Normal file
BIN
aiAssets/DogCat3/GoogleAds/DogCat3_Full_gg.zip
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
aiAssets/DogCat3/Mintegral/DogCat3_Full_mtg.zip
Normal file
BIN
aiAssets/DogCat3/Mintegral/DogCat3_Full_mtg.zip
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
aiAssets/DogCat3/Vungle/DogCat3_Full_vu.zip
Normal file
BIN
aiAssets/DogCat3/Vungle/DogCat3_Full_vu.zip
Normal file
Binary file not shown.
@@ -73,18 +73,19 @@ const offY = (viewH - 1920 * s) / 2
|
||||
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||
```
|
||||
1. ExitApi.exit() → GoogleAds (gg)
|
||||
2. FbPlayableAd.onCTAClick() → Facebook (fb)
|
||||
2. FbPlayableAd.onCTAClick() → Facebook (fb) · Moloco (mo)
|
||||
3. Luna.Unity.Playable → Unity (un)
|
||||
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
||||
5. window.install() → Mintegral (mtg)
|
||||
6. window.openAppStore() → (runtime fallback)
|
||||
7. window.clickTag → Moloco (mo)
|
||||
7. window.clickTag → Moloco (mo) fallback
|
||||
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
||||
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
||||
10. mraid.open(url) → Applovin (al) / Ironsource (is)
|
||||
10. mraid.open(url) → Applovin (al) / Ironsource (is) / Unity (un fallback after Luna)
|
||||
11. window.open(storeUrl) → Fallback (all others)
|
||||
```
|
||||
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
||||
MRAID CTA calls must be guarded: require `typeof mraid.open === 'function'`; treat missing `mraid.getState()` as `ready`; if state is still `loading`, fall through to `window.open(storeUrl)`.
|
||||
|
||||
## Analytics — SDK Priority
|
||||
```
|
||||
@@ -107,15 +108,63 @@ gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCT
|
||||
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||
| Network | Mechanism |
|
||||
|---|---|
|
||||
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
|
||||
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events; also shared MRAID exposure/viewability when `mraid.js` is present |
|
||||
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
|
||||
| Applovin / Ironsource / Unity | MRAID 3.0 `exposureChange`, MRAID 2.0 fallback `viewableChange` / `isViewable`, and `audioVolumeChange` (gated in `initMraid`) |
|
||||
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
|
||||
|
||||
### MRAID 3.0 Requirements & Best Practices
|
||||
MRAID networks are **Applovin (`al`)**, **Ironsource (`is`)**, and **Unity (`un`)**. All three must request `<script src="mraid.js"></script>` early in the generated HTML. Unity still uses Luna first for CTA/lifecycle where available, but MRAID must exist as the standards fallback.
|
||||
|
||||
Creative requirements from the MRAID 3.0 spec and Best Practices Guide:
|
||||
- Request `mraid.js` as early as possible, exactly once, either with `<script src="mraid.js"></script>` or DOM insertion. Do not rely on the container to inject it automatically.
|
||||
- Wait for both DOM readiness and MRAID readiness before starting rich media behavior. Use `document.readyState` / `DOMContentLoaded` or `load` for the DOM side.
|
||||
- Use `mraid.getState()` together with `mraid.addEventListener('ready', ...)` so the creative still starts when the container fires `ready` before the listener attaches.
|
||||
- Guard access to `mraid` and MRAID methods with `typeof` checks. Treat missing optional methods as unsupported and fall back gracefully.
|
||||
- Use `mraid.open(url)` for MRAID clickthroughs. Avoid `<a href>`, `location.href` / `assign` / `replace`, and unguarded `window.open`.
|
||||
- Include `<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">` or an equivalent mobile viewport tag.
|
||||
- Do not assume nested iframes can access native MRAID. If nested iframe access is required, the outer frame must provide its own bridge.
|
||||
- Use `exposureChange` as the MRAID 3.0 viewability signal, then degrade to `viewableChange` / `isViewable()` for MRAID 2.0 compatibility.
|
||||
- Listen for `audioVolumeChange`; `null` is valid and must be ignored. Only apply volume math when the value is numeric.
|
||||
- Listen for `error`; if the ad cannot run or assets fail, gracefully degrade or call `mraid.unload()` instead of showing a broken/blank ad.
|
||||
- Call `setResizeProperties()` before `resize()`. Do not use `resize()` for full-screen takeovers; use `expand()` for that case.
|
||||
- Avoid resizing or expanding interstitials. Check placement with `getPlacementType()` when one creative can serve inline and interstitial placements.
|
||||
- Avoid `useCustomClose()` and custom close indicators in MRAID 3.0. The host provides the close control.
|
||||
- Avoid two-part expandable ads (`expand(url)`); use self-contained one-part expandables.
|
||||
- Call `supports()` before optional/native features such as `storePicture`, `createCalendarEvent`, `getLocation`, VPAID, SMS/tel, or inline video behavior.
|
||||
- Use `playVideo()` for native video playback cases instead of `mraid.open(videoUrl)`. Use HTML5 `<video>` for inline playback when supported.
|
||||
|
||||
Implementation requirements for `networks.ts` / `networks.js`:
|
||||
- Export `initMraid(timeoutMs = 2000, detectTimeoutMs = 500)` and call it at module load.
|
||||
- If `window.mraid` is missing at module load, poll briefly (`detectTimeoutMs`, default 500 ms) for late container injection before resolving as no-MRAID.
|
||||
- If MRAID exists and `mraid.getState() === 'loading'`, wait for `mraid.addEventListener('ready', ...)`; self-resolve after `timeoutMs` (default 2000 ms) so startup never hangs.
|
||||
- `main` / boot must `await initMraid()` before constructing `new Phaser.Game(...)`.
|
||||
- On setup, seed cached visibility from `mraid.isViewable()` when available.
|
||||
- Register MRAID callbacks once, before any Phaser scene is ready:
|
||||
- `mraid.addEventListener('error', (message, action) => ...)` — log or gracefully handle failed/unsupported MRAID calls.
|
||||
- `mraid.addEventListener('stateChange', state => ...)` — required when using state-changing MRAID behavior such as `close`, `expand`, or `resize`.
|
||||
- `mraid.addEventListener('exposureChange', exposedPercentage => ...)` — MRAID 3.0 primary viewability. Pause when `exposedPercentage <= 0`; resume when positive.
|
||||
- `mraid.addEventListener('viewableChange', viewable => ...)` — MRAID 2.0 compatibility fallback.
|
||||
- `mraid.addEventListener('audioVolumeChange', pct => ...)` — only apply `pct / 100` when `typeof pct === 'number'`.
|
||||
- In `bindLifecycle(scene)`, apply cached hidden/non-exposed MRAID state immediately once a scene exists.
|
||||
- If the creative renders its own close control, wire that control to a guarded `mraid.close()` helper and call `notifyGameClose()` before closing.
|
||||
|
||||
## Build
|
||||
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
||||
MRAID networks (`al`, `is`, `un`) inject exactly one `<script src="mraid.js"></script>` request.
|
||||
If built output contains `console.error` from Phaser or bundled node modules, prefer dropping it at bundle time instead of editing library source. Add this option to the shared Vite config, usually `vite.shared.js`:
|
||||
|
||||
```js
|
||||
export default {
|
||||
esbuild: {
|
||||
pure: ['console.error'],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Keep project-authored error handling meaningful: use visible fallback UI, `console.warn`, or guarded debug logging where needed.
|
||||
|
||||
| Network | Tag | Injected | Zipped | Included |
|
||||
|---|---|---|---|---|
|
||||
@@ -124,7 +173,7 @@ Build script strips `type="module"` and `crossorigin` — ad networks reject ES
|
||||
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||
| Facebook | fb | — | — | ✓ |
|
||||
| Unity | un | — | — | ✓ |
|
||||
| Unity | un | `mraid.js` | — | ✓ |
|
||||
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||
| Moloco | mo | — | — | ✓ |
|
||||
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||
@@ -146,7 +195,6 @@ dist/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||
Google/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||
index.zip
|
||||
Ironsource/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||
…one HTML or ZIP per network…
|
||||
@@ -213,6 +261,7 @@ Define depths as named constants in [constants.ts](src/constants.ts) — never u
|
||||
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||
| Bundle > 5 MB | WebP q75, shorter audio |
|
||||
| Built output contains library `console.error` | If `console.error` comes from Phaser or bundled node modules, add `esbuild: { pure: ['console.error'] }` in `vite.shared.js` so it is dropped at bundle time. Do not edit library source. |
|
||||
| Audio blocked | Gate on `pointerdown` |
|
||||
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||
@@ -227,6 +276,46 @@ Define depths as named constants in [constants.ts](src/constants.ts) — never u
|
||||
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed** — `document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
|
||||
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
|
||||
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top` → `window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
|
||||
| MRAID network missing `mraid.js` | MRAID 3.0 creatives must request `mraid.js` early. Inject `<script src="mraid.js"></script>` exactly once for Applovin (`al`), Ironsource (`is`), and Unity (`un`), even if the container may also expose runtime MRAID objects. |
|
||||
| MRAID listener wired too late | Previewers can probe MRAID before any Phaser scene is ready — if listeners are wired only inside `bindLifecycle` / scene `create()`, the creative can miss `ready`, `exposureChange`, or `viewableChange`. Fix: call `initMraid()` at `networks.ts` module-load time, guarded by `getState() === 'loading'` → `addEventListener('ready', setupMraid)` vs immediate call. Cache `_mraidViewable` / `_mraidExposed`; in `bindLifecycle` apply cached hidden/non-exposed state immediately. |
|
||||
| MRAID 3.0 `exposureChange` not handled | MRAID 3.0 containers dispatch `exposureChange` with `exposedPercentage` directly instead of — or in addition to — `viewableChange`. If only listening to `viewableChange`, the ad may never receive pause signals on newer AL/IS/Unity builds. Register both: `mraid.addEventListener('exposureChange', exposedPercentage => onExposure(exposedPercentage > 0))` and `mraid.addEventListener('viewableChange', onViewable)` inside `initMraid()`. Seed initial state from `mraid.isViewable()` on ready. |
|
||||
| MRAID 3.0 `audioVolumeChange` not handled | MRAID 3.0 containers fire `audioVolumeChange` with a volume level (0–100) or `null` when unavailable. Without a listener, the ad ignores host-level mute/unmute signals; if `null` is divided by 100, the ad can mute accidentally. Register `mraid.addEventListener('audioVolumeChange', v => { if (typeof v === 'number') scene.sound.setVolume(v / 100) })` in `initMraid()`. |
|
||||
| MRAID `error` listener missing | Validators expect failed or unsupported MRAID calls to be observable. Register `mraid.addEventListener('error', (message, action) => console.warn('[MRAID error]', { message, action }))` during early MRAID setup. |
|
||||
| MRAID `stateChange` listener missing | If the creative uses state-changing container behavior such as `mraid.close()`, `expand`, or `resize`, validators expect a `stateChange` listener. Register `mraid.addEventListener('stateChange', state => console.log('[MRAID stateChange]', state))` with the other early MRAID callbacks. |
|
||||
| Custom close control not wired to MRAID | If the creative renders its own close button, the button must call a guarded close helper: `notifyGameClose()` then `mraid.close()` when `typeof mraid.close === 'function'` and `mraid.getState() !== 'loading'`. Do not use CTA/install buttons as close controls. |
|
||||
| MRAID init not awaited before Phaser boot | If `new Phaser.Game()` is created before `initMraid()` resolves, the scene may fire `create()` before MRAID state is known — `bindLifecycle` then cannot apply a cached hidden/paused state. Make `boot()` async and `await initMraid()` before constructing the Phaser game. `initMraid()` must self-resolve after a timeout (~2 s) so a missing or non-firing MRAID container never blocks startup. |
|
||||
| Late MRAID injection not handled | Some containers inject `window.mraid` after the script executes — a synchronous `typeof window.mraid !== 'undefined'` check at module load returns false even though MRAID will be present. After the immediate check fails, poll briefly (~500 ms) before concluding no-MRAID, then proceed. |
|
||||
| Unsafe optional MRAID API calls | `useCustomClose()` was removed in MRAID 3.0, and APIs such as `expand`, `resize`, `storePicture`, and `createCalendarEvent` are optional/capability-dependent. Do not call them unless the project intentionally supports the feature and guards every call with `typeof mraid.method === 'function'` / `mraid.supports(...)` where relevant. |
|
||||
|
||||
## Ironsource — Runtime Analysis
|
||||
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.
|
||||
|
||||
## Moloco — Upload Checklist & CTA Implementation
|
||||
Creative specs: HTML5 (`.html` / `.htm`), < 5 MB, portrait + landscape.
|
||||
|
||||
Before uploading to Moloco Ads, confirm all three:
|
||||
- [ ] No `XMLHttpRequest` anywhere in the ad (Moloco rejects it outright)
|
||||
- [ ] CTA calls `FbPlayableAd.onCTAClick()` with no parameters — Moloco uses this for store redirect (not `window.clickTag`)
|
||||
- [ ] No JavaScript redirects in ad file assets
|
||||
|
||||
### CTA implementation options
|
||||
|
||||
**Option 1 — event listener**
|
||||
```html
|
||||
<button id="ctaButton">Install</button>
|
||||
<script>
|
||||
document.getElementById('ctaButton').addEventListener('click', function() {
|
||||
FbPlayableAd.onCTAClick();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Option 2 — inline onClick**
|
||||
```html
|
||||
<button onclick="FbPlayableAd.onCTAClick()">Install</button>
|
||||
```
|
||||
|
||||
Moloco's validator requires `FbPlayableAd.onCTAClick()` — this is why it shares priority #2 in the CTA chain with Facebook. `window.clickTag` is kept as a fallback at priority #7.
|
||||
|
||||
***EOF***
|
||||
[05/28/2026]
|
||||
|
||||
19
aiAssets/LAYOUT.md
Normal file
19
aiAssets/LAYOUT.md
Normal 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]
|
||||
BIN
aiAssets/MRAID-3.0-Best-Practices-Guide.pdf
Normal file
BIN
aiAssets/MRAID-3.0-Best-Practices-Guide.pdf
Normal file
Binary file not shown.
BIN
aiAssets/MRAID_3.0_FINAL_June_2018.pdf
Normal file
BIN
aiAssets/MRAID_3.0_FINAL_June_2018.pdf
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "hpl-toolbox",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hpl-toolbox",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.6",
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"playwright": "^1.60.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -33,6 +35,53 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
18
package.json
18
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "hpl-toolbox",
|
||||
"displayName": "HPL Toolbox",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker. Local HTML Host.",
|
||||
"version": "0.1.5",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||
"version": "0.1.7",
|
||||
"publisher": "hesukastro",
|
||||
"license": "UNLICENSED",
|
||||
"repository": {
|
||||
@@ -12,7 +12,9 @@
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": ["Other"],
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"icon": "media/hpl.png",
|
||||
"main": "./out/extension.js",
|
||||
"activationEvents": [],
|
||||
@@ -24,7 +26,7 @@
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openApplovinUpload",
|
||||
"title": "HPL Toolbox: Open AppLovin Demo Upload"
|
||||
"title": "HPL Toolbox: Open AppLovin Playable Preview"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openBase64Scanner",
|
||||
@@ -57,7 +59,7 @@
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "hplToolbox.launcher",
|
||||
"name": "Tools"
|
||||
"name": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -121,13 +123,15 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "tsc -p ./",
|
||||
"sync-readme": "node scripts/sync-readme.js",
|
||||
"compile": "npm run sync-readme && tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"vscode:prepublish": "npm run compile"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"playwright": "^1.60.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
run-standalone.ps1
Normal file
11
run-standalone.ps1
Normal 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
|
||||
}
|
||||
177
scripts/check-dogcat3-playwright.mjs
Normal file
177
scripts/check-dogcat3-playwright.mjs
Normal 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;
|
||||
});
|
||||
20
scripts/sync-readme.js
Normal file
20
scripts/sync-readme.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const changelogPath = path.join(root, 'CHANGELOG.md');
|
||||
const readmePath = path.join(root, 'README.md');
|
||||
|
||||
let changelog = '# CHANGELOG\n\nNo changelog entries yet.';
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
changelog = fs.readFileSync(changelogPath, 'utf8').trim();
|
||||
}
|
||||
|
||||
const readme = `# HPL Toolbox
|
||||
|
||||
Bundled VS Code extension and standalone tools for playable ad workflows.
|
||||
|
||||
${changelog}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(readmePath, readme.replace(/\r?\n/g, '\n'), 'utf8');
|
||||
122
src/changelogView.ts
Normal file
122
src/changelogView.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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 changelogPath = path.join(context.extensionPath, 'CHANGELOG.md');
|
||||
let markdown = '# Changelog\n\nCHANGELOG.md was not found.';
|
||||
try {
|
||||
markdown = fs.readFileSync(changelogPath, '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;
|
||||
}
|
||||
ul {
|
||||
margin: 6px 0 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
li {
|
||||
margin: 4px 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</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;
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
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}>${escapeHtml(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const listItem = line.match(/^-\s+(.+)$/);
|
||||
if (listItem) {
|
||||
if (!inList) {
|
||||
html.push('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
html.push(`<li>${escapeHtml(listItem[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
html.push(`<p>${escapeHtml(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
return html.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch] ?? ch));
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { openBase64Scanner } from './tools/base64Scanner';
|
||||
import { openMraidChecker } from './tools/mraidChecker';
|
||||
import { openSendToMobile } from './tools/sendToMobile';
|
||||
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||
import { openChangelog } from './changelogView';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
@@ -15,6 +16,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
||||
|
||||
interface ToolDefinition {
|
||||
id?: string;
|
||||
command: string;
|
||||
title: string;
|
||||
description: string;
|
||||
beta?: boolean;
|
||||
}
|
||||
|
||||
const TOOLS: ToolDefinition[] = [
|
||||
const FALLBACK_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
command: 'hplToolbox.openPlecUpload',
|
||||
title: 'PLEC Upload',
|
||||
@@ -17,7 +20,7 @@ const TOOLS: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openApplovinUpload',
|
||||
title: 'AppLovin Demo Upload',
|
||||
title: 'AppLovin Playable Preview',
|
||||
description: 'Upload to p.applov.in (QR preview)',
|
||||
},
|
||||
{
|
||||
@@ -25,17 +28,16 @@ const TOOLS: ToolDefinition[] = [
|
||||
title: 'Base64 Scanner',
|
||||
description: 'Find non-base64 assets in HTML',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openMraidChecker',
|
||||
title: 'MRAID Checker',
|
||||
description: 'Check MRAID requirements and best practices',
|
||||
beta: true,
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openSendToMobile',
|
||||
title: 'Send To Mobile',
|
||||
description: 'Share HTML to a phone via LAN + QR',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openMraidChecker',
|
||||
title: 'MRAID Checker',
|
||||
description: 'Check MRAID requirements and best practices',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openPlayworksConverter',
|
||||
title: 'Playworks Converter',
|
||||
@@ -50,52 +52,111 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||
resolveWebviewView(view: vscode.WebviewView) {
|
||||
view.webview.options = { enableScripts: true };
|
||||
const version = this.context.extension.packageJSON.version as string;
|
||||
const tools = loadToolDefinitions(this.context.extensionPath);
|
||||
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
||||
view.webview.html = getHtml(version, betaToolsEnabled);
|
||||
view.webview.html = getHtml(version, betaToolsEnabled, tools);
|
||||
view.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||
vscode.commands.executeCommand(msg.command);
|
||||
} else if (msg?.type === 'openChangelog') {
|
||||
vscode.commands.executeCommand('hplToolbox.openChangelog');
|
||||
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
||||
view.webview.html = getHtml(version, msg.enabled);
|
||||
view.webview.html = getHtml(version, msg.enabled, tools);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta));
|
||||
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
|
||||
function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
|
||||
const toolsPath = path.join(extensionPath, 'tools.json');
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(toolsPath, 'utf8'));
|
||||
if (!Array.isArray(raw)) return FALLBACK_TOOLS;
|
||||
const tools = raw
|
||||
.filter((tool: any) => tool && typeof tool.command === 'string' && typeof tool.title === 'string')
|
||||
.map((tool: any) => ({
|
||||
id: typeof tool.id === 'string' ? tool.id : undefined,
|
||||
command: tool.command,
|
||||
title: tool.title,
|
||||
description: typeof tool.description === 'string' ? tool.description : '',
|
||||
beta: Boolean(tool.beta),
|
||||
}));
|
||||
return tools.length ? tools : FALLBACK_TOOLS;
|
||||
} catch {
|
||||
return FALLBACK_TOOLS;
|
||||
}
|
||||
}
|
||||
|
||||
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[]): string {
|
||||
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta));
|
||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
min-height: calc(100vh - 24px);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 10px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
|
||||
color: var(--vscode-foreground);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
|
||||
.tools { flex: 1; }
|
||||
.tools {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.tool-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px; cursor: pointer; font-size: 13px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
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 {
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
@@ -103,37 +164,54 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.beta-toggle {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
.beta-toggle button {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
.footer-action {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.beta-toggle button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
#betaToggle {
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<h3>HPL Toolbox ${version}</h3>
|
||||
<div class="tools">
|
||||
${toolButtons}
|
||||
</div>
|
||||
<div class="beta-toggle">
|
||||
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">
|
||||
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'}
|
||||
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
|
||||
</button>
|
||||
<div class="footer">
|
||||
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
|
||||
<span class="footer-version">${version} - JJGC 00784</span>
|
||||
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
@@ -146,6 +224,9 @@ ${toolButtons}
|
||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||
});
|
||||
document.getElementById('changelogLink').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'openChangelog' });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -2,12 +2,12 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { File } from 'node:buffer';
|
||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
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;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
@@ -15,6 +15,24 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
try {
|
||||
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') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
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 (i > 0 && delayMs > 0) {
|
||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` });
|
||||
panel.webview.postMessage({ type: 'status', message: `Waiting ${idx}/${paths.length} ${fileName}` });
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` });
|
||||
panel.webview.postMessage({ type: 'status', message: `Validating ${idx}/${paths.length} ${fileName}` });
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -136,7 +154,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue;
|
||||
}
|
||||
|
||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Uploading ${fileName}...` });
|
||||
panel.webview.postMessage({ type: 'status', message: `Uploading ${idx}/${paths.length} ${fileName}` });
|
||||
|
||||
try {
|
||||
res = await fetch('https://p.applov.in/getCachedAdURL', {
|
||||
@@ -175,6 +193,30 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||
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 {
|
||||
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
|
||||
}
|
||||
@@ -243,54 +285,87 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
||||
${getToolWebviewStyles()}
|
||||
.selected-files { margin-top: 10px; }
|
||||
.qr-cell { text-align: center; }
|
||||
.qr-cell img {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
max-width: 100%;
|
||||
padding: 6px;
|
||||
border-radius: var(--tool-radius);
|
||||
background: #fff;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
.result-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.qr-cell img { cursor: zoom-in; }
|
||||
.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;
|
||||
}
|
||||
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; }
|
||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
#status { margin-top: 12px; min-height: 20px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.result {
|
||||
padding: 10px;
|
||||
background: var(--vscode-textBlockQuote-background);
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
word-break: break-all;
|
||||
.qr-modal.open { display: flex; }
|
||||
.qr-modal img {
|
||||
width: min(70vw, 520px);
|
||||
height: min(70vw, 520px);
|
||||
padding: 18px;
|
||||
border-radius: var(--tool-radius);
|
||||
background: #fff;
|
||||
}
|
||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||
#results { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill,minmax(280px,1fr)); gap: 12px; }
|
||||
.qr-results:not(.has-errors) .error-col { display: none; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<h2>AppLovin Playable Preview (QR)</h2>
|
||||
<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>
|
||||
<main class="tool-page">
|
||||
<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;">
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span class="file-name" id="fileName">(no files)</span>
|
||||
<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="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 id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div class="actions">
|
||||
<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>
|
||||
<div id="qrModal" class="qr-modal" title="Click to close"><img alt="Expanded QR" /></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
@@ -298,9 +373,13 @@ function getHtml(): string {
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const qrModal = document.getElementById('qrModal');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
const PAGE_SIZE = 5;
|
||||
let selectedPaths = [];
|
||||
let selectedPage = 0;
|
||||
let resultItems = [];
|
||||
|
||||
function basename(p) {
|
||||
@@ -308,15 +387,51 @@ function getHtml(): string {
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
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', () => {
|
||||
@@ -331,24 +446,33 @@ function getHtml(): string {
|
||||
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' }));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
statusEl.textContent = 'Uploading...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
||||
});
|
||||
|
||||
@@ -356,21 +480,36 @@ function getHtml(): string {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function ensureResultsTable() {
|
||||
let tbody = resultsEl.querySelector('tbody');
|
||||
if (tbody) return tbody;
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table qr-results">' +
|
||||
'<thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
return resultsEl.querySelector('tbody');
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
const added = m.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();
|
||||
} else if (m.type === 'start') {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
statusEl.className = 'status-panel is-busy';
|
||||
} else if (m.type === 'error') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'err';
|
||||
div.textContent = m.message;
|
||||
@@ -378,20 +517,32 @@ function getHtml(): string {
|
||||
statusEl.appendChild(div);
|
||||
uploadBtn.disabled = false;
|
||||
} else if (m.type === 'fileError') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
||||
resultsEl.appendChild(wrap);
|
||||
const tbody = ensureResultsTable();
|
||||
resultsEl.querySelector('table').classList.add('has-errors');
|
||||
const row = document.createElement('tr');
|
||||
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>';
|
||||
tbody.appendChild(row);
|
||||
} else if (m.type === 'fileResult') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const tbody = ensureResultsTable();
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = m.name;
|
||||
const qr = document.createElement('td');
|
||||
qr.className = 'qr-cell';
|
||||
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');
|
||||
actions.className = 'result-actions';
|
||||
const openPreview = document.createElement('button');
|
||||
@@ -404,14 +555,14 @@ function getHtml(): string {
|
||||
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
||||
actions.appendChild(openPreview);
|
||||
actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
actionCell.appendChild(actions);
|
||||
row.append(file, qr, actionCell, error);
|
||||
tbody.appendChild(row);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
saveAllBtn.disabled = resultItems.length === 0;
|
||||
regenAllBtn.disabled = resultItems.length === 0;
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
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;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
@@ -16,7 +16,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
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') {
|
||||
@@ -27,7 +28,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
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') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
@@ -56,7 +58,9 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
return;
|
||||
}
|
||||
const results: { file: string; ok: boolean; assets: string[] }[] = [];
|
||||
for (const file of targets) {
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const file = targets[i];
|
||||
panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` });
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const offenders = findNonBase64Assets(content);
|
||||
@@ -144,46 +148,125 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
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); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.row-result { display: flex; gap: 8px; padding: 2px 0; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 14px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.assets { color: var(--vscode-descriptionForeground); margin-left: 6px; word-break: break-all; }
|
||||
.summary { margin-top: 12px; opacity: 0.8; }
|
||||
${getToolWebviewStyles()}
|
||||
.asset-list { margin: 0; padding-left: 16px; }
|
||||
.asset-list li + li { margin-top: 3px; }
|
||||
.selected-list { margin-top: 8px; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Base64 Scanner</h2>
|
||||
<p class="tool-description">Scan folders or selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="field-row">
|
||||
<input id="folder" type="hidden" />
|
||||
<button id="pick" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
</main>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFolder = '';
|
||||
let pickedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
const items = pickedFiles.length
|
||||
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
|
||||
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
|
||||
if (!items.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
|
||||
selectionEl.innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||
'<tbody>' +
|
||||
visibleItems.map(item =>
|
||||
'<tr><td class="mono wrap">' + escapeHtml(item.label) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
|
||||
).join('') +
|
||||
'</tbody></table>' +
|
||||
'</div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.type === 'folder') {
|
||||
pickedFolder = '';
|
||||
folderEl.value = '';
|
||||
} else {
|
||||
pickedFiles.splice(Number(btn.dataset.index), 1);
|
||||
}
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (items.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
@@ -191,9 +274,22 @@ function getHtml(): string {
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
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', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
@@ -207,35 +303,68 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
pickedFolder = msg.path;
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} 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 = '';
|
||||
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') {
|
||||
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;
|
||||
statusEl.classList.remove('is-busy');
|
||||
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>';
|
||||
resultsEl.innerHTML = '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||
if (!total) {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of msg.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
const row = document.createElement('tr');
|
||||
const status = document.createElement('td');
|
||||
status.innerHTML = r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>';
|
||||
const name = document.createElement('td');
|
||||
name.className = 'mono wrap';
|
||||
name.textContent = r.file;
|
||||
row.appendChild(mark);
|
||||
row.appendChild(name);
|
||||
const count = document.createElement('td');
|
||||
count.textContent = String(r.assets.length);
|
||||
const assets = document.createElement('td');
|
||||
assets.className = 'wrap';
|
||||
if (!r.ok) {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'assets';
|
||||
a.textContent = '— ' + r.assets.join(', ');
|
||||
row.appendChild(a);
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'asset-list mono';
|
||||
r.assets.forEach(asset => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = asset;
|
||||
list.appendChild(item);
|
||||
});
|
||||
assets.appendChild(list);
|
||||
} else {
|
||||
assets.innerHTML = '<span class="muted">None</span>';
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
row.append(status, name, count, assets);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -32,6 +32,7 @@ interface MraidRule {
|
||||
}
|
||||
|
||||
interface ScanContext {
|
||||
filePath: string;
|
||||
html: string;
|
||||
scriptText: string;
|
||||
lowerHtml: string;
|
||||
@@ -50,7 +51,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
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') {
|
||||
@@ -61,7 +63,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
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') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
@@ -80,15 +83,21 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
let targets: string[] = [];
|
||||
let baseDir = '';
|
||||
let skipped = 0;
|
||||
let skippedReason = '';
|
||||
let noTargetsError = 'No HTML files were found.';
|
||||
if (files && files.length) {
|
||||
const existing = files.filter(f => fs.existsSync(f));
|
||||
targets = existing.filter(isSupportedMraidBuild);
|
||||
skipped = existing.length - targets.length;
|
||||
targets = existing;
|
||||
skipped = files.length - existing.length;
|
||||
skippedReason = 'missing file(s).';
|
||||
noTargetsError = 'No selected HTML files were found.';
|
||||
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||
} else if (folder && fs.existsSync(folder)) {
|
||||
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||
targets = allHtmlFiles.filter(isSupportedMraidBuild);
|
||||
targets = allHtmlFiles.filter(isInSupportedMraidFolderPath);
|
||||
skipped = allHtmlFiles.length - targets.length;
|
||||
skippedReason = 'HTML file(s) outside AppLovin/ironSource/Unity folders.';
|
||||
noTargetsError = 'No AppLovin, ironSource, or Unity folder HTML files were found.';
|
||||
baseDir = folder;
|
||||
} else {
|
||||
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
|
||||
@@ -100,23 +109,26 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
type: 'results',
|
||||
results: [],
|
||||
skipped,
|
||||
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
|
||||
skippedReason,
|
||||
error: noTargetsError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const results: MraidResult[] = [];
|
||||
for (const file of targets) {
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const file = targets[i];
|
||||
panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` });
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const issues = checkMraid(content);
|
||||
const issues = checkMraid(content, file);
|
||||
const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
|
||||
results.push({ file: display, ok: issues.length === 0, issues });
|
||||
} catch {
|
||||
// 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;
|
||||
}
|
||||
|
||||
function checkMraid(html: string): MraidIssue[] {
|
||||
function checkMraid(html: string, filePath = ''): MraidIssue[] {
|
||||
const scriptText = extractScriptText(html);
|
||||
const ctx: ScanContext = {
|
||||
filePath,
|
||||
html,
|
||||
scriptText,
|
||||
lowerHtml: html.toLowerCase(),
|
||||
@@ -193,15 +206,38 @@ function collectMraidCallLines(html: string): Map<string, number> {
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isSupportedMraidBuild(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized);
|
||||
function isInSupportedMraidFolderPath(filePath: string): boolean {
|
||||
const folderParts = path.dirname(filePath)
|
||||
.split(/[\\/]+/)
|
||||
.map(part => part.toLowerCase());
|
||||
return folderParts.some(part => [
|
||||
'al',
|
||||
'applovin',
|
||||
'app-lovin',
|
||||
'is',
|
||||
'ironsource',
|
||||
'iron-source',
|
||||
'iron_source',
|
||||
'un',
|
||||
'unity',
|
||||
'unityads',
|
||||
'unity-ads',
|
||||
'unity_ads',
|
||||
].includes(part));
|
||||
}
|
||||
|
||||
function hasMraidReference(ctx: ScanContext): boolean {
|
||||
return /\bmraid\b/i.test(ctx.html);
|
||||
}
|
||||
|
||||
function countMraidJsReferences(ctx: ScanContext): number {
|
||||
return (ctx.html.match(/\bmraid\.js\b/gi) || []).length;
|
||||
}
|
||||
|
||||
function hasViewportMeta(ctx: ScanContext): boolean {
|
||||
return /<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>/i.test(ctx.html);
|
||||
}
|
||||
|
||||
function hasMraidCall(ctx: ScanContext, method: string): boolean {
|
||||
return ctx.mraidCallLines.has(method);
|
||||
}
|
||||
@@ -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 {
|
||||
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
|
||||
return pattern.test(ctx.scriptText);
|
||||
@@ -240,15 +347,32 @@ function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
|
||||
}
|
||||
|
||||
function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) ||
|
||||
/\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText)
|
||||
);
|
||||
return /\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/';
|
||||
function usesLocationNavigation(ctx: ScanContext): boolean {
|
||||
return /\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
const MRAID_REFERENCE = 'GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide.';
|
||||
|
||||
const MRAID_RULES: MraidRule[] = [
|
||||
{
|
||||
id: 'mraid-js-missing',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.js reference not found',
|
||||
detail: 'MRAID creatives for AppLovin, ironSource, and Unity must request mraid.js early in the generated HTML.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => isInMraidNetworkPath(ctx) && countMraidJsReferences(ctx) === 0 ? issue(MRAID_RULES_BY_ID['mraid-js-missing']) : null,
|
||||
},
|
||||
{
|
||||
id: 'mraid-js-duplicate',
|
||||
severity: 'requirement',
|
||||
title: 'Multiple mraid.js references found',
|
||||
detail: 'Request mraid.js exactly once; duplicate references can inject multiple MRAID libraries and slow or destabilize the ad.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => countMraidJsReferences(ctx) > 1 ? issue(MRAID_RULES_BY_ID['mraid-js-duplicate']) : null,
|
||||
},
|
||||
{
|
||||
id: 'mraid-reference',
|
||||
severity: 'requirement',
|
||||
@@ -257,6 +381,30 @@ const MRAID_RULES: MraidRule[] = [
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']),
|
||||
},
|
||||
{
|
||||
id: 'viewport-meta',
|
||||
severity: 'best-practice',
|
||||
title: 'Viewport meta tag not found',
|
||||
detail: 'MRAID creatives should include a mobile viewport meta tag so the ad scales predictably on mobile WebViews.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasViewportMeta(ctx) ? null : issue(MRAID_RULES_BY_ID['viewport-meta']),
|
||||
},
|
||||
{
|
||||
id: 'mraid-typeof-guard',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID object is not guarded',
|
||||
detail: 'Guard MRAID access with typeof mraid !== "undefined" or an equivalent window.mraid guard before making MRAID calls.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) && !hasMraidTypeofGuard(ctx) ? issue(MRAID_RULES_BY_ID['mraid-typeof-guard']) : null,
|
||||
},
|
||||
{
|
||||
id: 'dom-ready-gate',
|
||||
severity: 'requirement',
|
||||
title: 'DOM readiness is not guarded',
|
||||
detail: 'MRAID startup should wait for DOM readiness as well as MRAID readiness before building or binding rich media content.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasDomReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['dom-ready-gate']),
|
||||
},
|
||||
{
|
||||
id: 'ready-gate',
|
||||
severity: 'requirement',
|
||||
@@ -265,6 +413,30 @@ const MRAID_RULES: MraidRule[] = [
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']),
|
||||
},
|
||||
{
|
||||
id: 'ready-state-fallback',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID ready listener/state fallback pair not found',
|
||||
detail: 'Use both mraid.addEventListener("ready", ...) and mraid.getState() so startup works when ready fires before the listener attaches.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasReadyListenerAndStateFallback(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-state-fallback']),
|
||||
},
|
||||
{
|
||||
id: 'ready-timeout-fallback',
|
||||
severity: 'best-practice',
|
||||
title: 'MRAID ready timeout fallback not found',
|
||||
detail: 'A timeout fallback prevents startup hangs when a container never fires ready.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasMraidReadyTimeout(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-timeout-fallback']),
|
||||
},
|
||||
{
|
||||
id: 'late-mraid-detection',
|
||||
severity: 'best-practice',
|
||||
title: 'Late MRAID injection detection not found',
|
||||
detail: 'Poll or retry briefly for late window.mraid injection before deciding the creative is running without MRAID.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasLateMraidDetection(ctx) ? null : issue(MRAID_RULES_BY_ID['late-mraid-detection']),
|
||||
},
|
||||
{
|
||||
id: 'error-listener',
|
||||
severity: 'best-practice',
|
||||
@@ -276,21 +448,45 @@ const MRAID_RULES: MraidRule[] = [
|
||||
{
|
||||
id: 'viewability',
|
||||
severity: 'best-practice',
|
||||
title: 'Viewability handling not found',
|
||||
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.',
|
||||
title: 'MRAID viewability fallback chain not found',
|
||||
detail: 'Use exposureChange for MRAID 3.0 and keep viewableChange or isViewable() as MRAID 2.0 fallback before starting animation, audio, video, or timers.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange');
|
||||
const hasVisibilitySignal = hasEventListener(ctx, 'exposureChange') && (hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange'));
|
||||
return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'audio-volume-change',
|
||||
severity: 'best-practice',
|
||||
title: 'audioVolumeChange handling not found',
|
||||
detail: 'Listen for audioVolumeChange and ignore null values; only apply volume math when the value is numeric.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasAudioVolumeNullSafeHandling(ctx) ? null : issue(MRAID_RULES_BY_ID['audio-volume-change']),
|
||||
},
|
||||
{
|
||||
id: 'open-api',
|
||||
severity: 'requirement',
|
||||
title: 'Browser navigation used instead of mraid.open',
|
||||
detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container.',
|
||||
detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container; avoid hyperlinks, location redirects, and unguarded browser navigation.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||
test: (ctx) => hasUnsupportedHyperlink(ctx) || usesLocationNavigation(ctx) || (usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open')) ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||
},
|
||||
{
|
||||
id: 'open-guarded-fallback',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.open guarded fallback not found',
|
||||
detail: 'Guard mraid.open with function/state checks and provide a window.open fallback when MRAID is unavailable or still loading.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'open') && (!hasMraidOpenGuard(ctx) || !hasMraidOpenLoadingGuard(ctx) || !usesDirectBrowserNavigation(ctx)) ? issue(MRAID_RULES_BY_ID['open-guarded-fallback'], mraidLine(ctx, 'open')) : null,
|
||||
},
|
||||
{
|
||||
id: 'open-video-url',
|
||||
severity: 'best-practice',
|
||||
title: 'mraid.open appears to target video media',
|
||||
detail: 'Use mraid.playVideo(url) for native video playback cases instead of mraid.open(videoUrl).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => opensVideoUrl(ctx) && !hasMraidCall(ctx, 'playVideo') ? issue(MRAID_RULES_BY_ID['open-video-url'], mraidLine(ctx, 'open')) : null,
|
||||
},
|
||||
{
|
||||
id: 'close-api',
|
||||
@@ -303,29 +499,93 @@ const MRAID_RULES: MraidRule[] = [
|
||||
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'unload-fallback',
|
||||
severity: 'best-practice',
|
||||
title: 'No mraid.unload graceful failure path found',
|
||||
detail: 'Use mraid.unload() when the ad cannot run or refuses to show, instead of leaving a broken or blank creative.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) && !hasMraidCall(ctx, 'unload') ? issue(MRAID_RULES_BY_ID['unload-fallback']) : null,
|
||||
},
|
||||
{
|
||||
id: 'resize-properties',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.resize used without setResizeProperties',
|
||||
detail: 'Call mraid.setResizeProperties(...) before mraid.resize(); otherwise the container should emit an error.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasMraidCall(ctx, 'setResizeProperties') ? issue(MRAID_RULES_BY_ID['resize-properties'], mraidLine(ctx, 'resize')) : null,
|
||||
},
|
||||
{
|
||||
id: 'use-custom-close',
|
||||
severity: 'best-practice',
|
||||
title: 'Deprecated useCustomClose found',
|
||||
detail: 'MRAID 3.0 ignores useCustomClose; the host provides the mandatory close control.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'useCustomClose') || /useCustomClose/i.test(ctx.scriptText) ? issue(MRAID_RULES_BY_ID['use-custom-close'], mraidLine(ctx, 'useCustomClose')) : null,
|
||||
},
|
||||
{
|
||||
id: 'two-part-expand',
|
||||
severity: 'best-practice',
|
||||
title: 'Two-part expand appears to be used',
|
||||
detail: 'Two-part expandable ads are deprecated in MRAID 3.0; use self-contained one-part expandables.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasTwoPartExpand(ctx) ? issue(MRAID_RULES_BY_ID['two-part-expand'], mraidLine(ctx, 'expand')) : null,
|
||||
},
|
||||
{
|
||||
id: 'expand-support',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.expand used without supports check',
|
||||
detail: 'Check mraid.supports("expand") before using expand behavior.',
|
||||
title: 'mraid.expand used without function guard',
|
||||
detail: 'Guard mraid.expand with a function check before using expand behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'expand') && !hasSupportCheck(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null,
|
||||
test: (ctx) => hasMraidCall(ctx, 'expand') && !hasMraidFunctionGuard(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null,
|
||||
},
|
||||
{
|
||||
id: 'resize-support',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.resize used without supports check',
|
||||
detail: 'Check mraid.supports("resize") before using resize behavior.',
|
||||
title: 'mraid.resize used without function guard',
|
||||
detail: 'Guard mraid.resize with a function check before using resize behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasSupportCheck(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
|
||||
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasMraidFunctionGuard(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
|
||||
},
|
||||
{
|
||||
id: 'store-picture-support',
|
||||
severity: 'requirement',
|
||||
title: 'storePicture used without supports check',
|
||||
detail: 'Call mraid.supports("storePicture") before mraid.storePicture(...).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'storePicture') && !hasSupportCheck(ctx, 'storePicture') ? issue(MRAID_RULES_BY_ID['store-picture-support'], mraidLine(ctx, 'storePicture')) : null,
|
||||
},
|
||||
{
|
||||
id: 'calendar-support',
|
||||
severity: 'requirement',
|
||||
title: 'createCalendarEvent used without supports check',
|
||||
detail: 'Call mraid.supports("calendar") before mraid.createCalendarEvent(...).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'createCalendarEvent') && !hasSupportCheck(ctx, 'calendar') ? issue(MRAID_RULES_BY_ID['calendar-support'], mraidLine(ctx, 'createCalendarEvent')) : null,
|
||||
},
|
||||
{
|
||||
id: 'location-support',
|
||||
severity: 'requirement',
|
||||
title: 'getLocation used without supports check',
|
||||
detail: 'Call mraid.supports("location") before mraid.getLocation(). Do not use HTML5 geolocation as a substitute in MRAID.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'getLocation') && !hasSupportCheck(ctx, 'location') ? issue(MRAID_RULES_BY_ID['location-support'], mraidLine(ctx, 'getLocation')) : null,
|
||||
},
|
||||
{
|
||||
id: 'vpaid-support',
|
||||
severity: 'requirement',
|
||||
title: 'VPAID integration used without supports check',
|
||||
detail: 'Call mraid.supports("vpaid") before mraid.initVpaid(...).',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'initVpaid') && !hasSupportCheck(ctx, 'vpaid') ? issue(MRAID_RULES_BY_ID['vpaid-support'], mraidLine(ctx, 'initVpaid')) : null,
|
||||
},
|
||||
{
|
||||
id: 'orientation-support',
|
||||
severity: 'best-practice',
|
||||
title: 'Orientation properties set without supports check',
|
||||
detail: 'Check mraid.supports("orientation") before requiring orientation behavior.',
|
||||
title: 'Orientation properties set without function guard',
|
||||
detail: 'Guard mraid.setOrientationProperties with a function check before requiring orientation behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasSupportCheck(ctx, 'orientation') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
|
||||
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasMraidFunctionGuard(ctx, 'setOrientationProperties') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
|
||||
},
|
||||
{
|
||||
id: 'size-change',
|
||||
@@ -349,6 +609,35 @@ const MRAID_RULES: MraidRule[] = [
|
||||
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output-script-hygiene',
|
||||
severity: 'requirement',
|
||||
title: 'Rejected script attributes found',
|
||||
detail: 'Playable output should not contain type="module" or crossorigin on script tags; ad networks can reject ES modules/CORS attributes.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => /<script\b[^>]*(\btype\s*=\s*['"]module['"]|\bcrossorigin\b)/i.test(ctx.html) ? issue(MRAID_RULES_BY_ID['output-script-hygiene']) : null,
|
||||
},
|
||||
{
|
||||
id: 'console-error',
|
||||
severity: 'best-practice',
|
||||
title: 'console.error found',
|
||||
detail: 'The ship checklist expects no console.error calls in output; use guarded logging or remove debug error logs before submission.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => /\bconsole\s*\.\s*error\s*\(/i.test(ctx.scriptText) ? issue(MRAID_RULES_BY_ID['console-error']) : null,
|
||||
},
|
||||
{
|
||||
id: 'lifecycle-stubs',
|
||||
severity: 'requirement',
|
||||
title: 'Playable lifecycle signals not found',
|
||||
detail: 'Expose or call gameReady, gameStart, gameEnd, and gameClose so network preview tools can detect the playable lifecycle.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const missing = ['gameReady', 'gameStart', 'gameEnd', 'gameClose'].filter(name => !hasLifecycleSignal(ctx, name));
|
||||
if (!missing.length) return null;
|
||||
const rule = MRAID_RULES_BY_ID['lifecycle-stubs'];
|
||||
return { ...issue(rule), detail: `${rule.detail} Missing: ${missing.join(', ')}.` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
|
||||
@@ -362,53 +651,76 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
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); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.summary { margin-top: 12px; opacity: 0.85; }
|
||||
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
|
||||
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
|
||||
.issues { margin: 6px 0 0 26px; padding: 0; }
|
||||
.issue { margin: 6px 0; list-style: none; }
|
||||
.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); }
|
||||
${getToolWebviewStyles()}
|
||||
.issues { margin: 0; padding: 0; }
|
||||
.issue { list-style: none; margin: 0 0 8px; }
|
||||
.issue:last-child { margin-bottom: 0; }
|
||||
.severity-badge {
|
||||
display: inline-flex;
|
||||
min-width: 82px;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tool-border);
|
||||
font-size: 11px;
|
||||
}
|
||||
.requirement { color: var(--vscode-errorForeground); background: var(--vscode-inputValidation-errorBackground, rgba(248,81,73,0.14)); }
|
||||
.best-practice { color: var(--vscode-editorWarning-foreground, #d29922); background: var(--vscode-inputValidation-warningBackground, rgba(210,153,34,0.14)); }
|
||||
.issue-title { font-weight: 600; }
|
||||
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
.selected-list { margin-top: 8px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MRAID Checker</h2>
|
||||
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">MRAID Checker</h2>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity HTML builds based on GUIDE.md plus the MRAID 3.0 specification and Best Practices Guide.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="field-row">
|
||||
<input id="folder" type="hidden" />
|
||||
<button id="pick" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
</main>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFolder = '';
|
||||
let pickedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
@@ -421,7 +733,57 @@ function getHtml(): string {
|
||||
}[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', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
@@ -429,9 +791,22 @@ function getHtml(): string {
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
folderEl.value = '';
|
||||
pickedFolder = '';
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
@@ -445,32 +820,53 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
pickedFolder = msg.path;
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} 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 = '';
|
||||
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') {
|
||||
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;
|
||||
statusEl.classList.remove('is-busy');
|
||||
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 skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML 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>';
|
||||
resultsEl.innerHTML = '';
|
||||
const skippedReason = msg.skippedReason || 'file(s).';
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||
if (!total) {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>File</th><th>Results</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of msg.results) {
|
||||
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML =
|
||||
'<div class="file-head">' +
|
||||
'<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>';
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = r.file;
|
||||
const issues = document.createElement('td');
|
||||
issues.className = 'wrap';
|
||||
if (!r.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
@@ -479,14 +875,17 @@ function getHtml(): string {
|
||||
item.className = 'issue';
|
||||
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||
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>' +
|
||||
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,13 +2,14 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cp from 'child_process';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
interface ConvertOptions {
|
||||
sourcePath: string;
|
||||
outputDir: string;
|
||||
baseName?: string;
|
||||
networks: string[];
|
||||
}
|
||||
|
||||
@@ -38,7 +39,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
if (!picked?.[0]) return;
|
||||
const srcPath = picked[0].fsPath;
|
||||
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;
|
||||
}
|
||||
case 'pickOutput': {
|
||||
@@ -67,6 +68,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' });
|
||||
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||
} catch (e: any) {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||
@@ -74,7 +76,15 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
}
|
||||
|
||||
const results: NetworkResult[] = [];
|
||||
for (const network of opts.networks) {
|
||||
try {
|
||||
panel.webview.postMessage({ type: 'status', message: 'Converting Unity...' });
|
||||
results.push({ network: 'un', file: copyUnityInput(opts), ok: true });
|
||||
} catch (e: any) {
|
||||
results.push({ network: 'un', file: '', ok: false, error: e.message });
|
||||
}
|
||||
for (let i = 0; i < opts.networks.length; i++) {
|
||||
const network = opts.networks[i];
|
||||
panel.webview.postMessage({ type: 'status', message: `Converting ${i + 1}/${opts.networks.length} ${network}` });
|
||||
try {
|
||||
const filePath = await convertNetwork(html, opts, network);
|
||||
results.push({ network, file: filePath, ok: true });
|
||||
@@ -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 {
|
||||
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> {
|
||||
if (!SUPPORTED_NETWORKS.has(network)) {
|
||||
throw new Error(`Unsupported Playworks target network: ${network}`);
|
||||
}
|
||||
const transformed = transformHtml(html, network);
|
||||
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||
const baseName = sourceBaseName(opts.sourcePath);
|
||||
const baseName = outputBaseName(opts);
|
||||
const htmlFileName = `${baseName}_${network}.html`;
|
||||
const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (needsZip) {
|
||||
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
|
||||
const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||
const zipPath = path.join(outputDir, `${baseName}_${network}.zip`);
|
||||
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||
try {
|
||||
await createZip(tmpPath, 'index.html', zipPath);
|
||||
@@ -118,45 +154,135 @@ async function convertNetwork(html: string, opts: ConvertOptions, network: strin
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
const outPath = path.join(opts.outputDir, htmlFileName);
|
||||
const outPath = path.join(outputDir, htmlFileName);
|
||||
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function copyUnityInput(opts: ConvertOptions): string {
|
||||
const baseName = outputBaseName(opts);
|
||||
const outputDir = path.join(opts.outputDir, 'Unity');
|
||||
const outPath = path.join(outputDir, `${baseName}_un.html`);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
if (path.resolve(opts.sourcePath) === path.resolve(outPath)) {
|
||||
return outPath;
|
||||
}
|
||||
fs.copyFileSync(opts.sourcePath, outPath);
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function transformHtml(html: string, network: string): string {
|
||||
let result = html;
|
||||
let result = sanitizePlayworksHtml(html);
|
||||
|
||||
// Inject into </head>: lifecycle stubs only (must register luna:build listener
|
||||
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
|
||||
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
|
||||
result = removeNetworkSdkScripts(result);
|
||||
let headInject = buildLifecycleScript();
|
||||
if (network === 'al' || network === 'is') {
|
||||
headInject += '<script src="mraid.js"></script>';
|
||||
} else if (network === 'gg') {
|
||||
if (MRAID_NETWORKS.has(network)) {
|
||||
headInject += '<script src="mraid.js"></script>' + buildMraidComplianceScript();
|
||||
} else if (EXITAPI_NETWORKS.has(network)) {
|
||||
headInject += '<script src="exitapi.js"></script>';
|
||||
}
|
||||
result = result.replace('</head>', headInject + '\n</head>');
|
||||
result = injectBeforeHeadClose(result, headInject);
|
||||
|
||||
// Body-level flags
|
||||
if (network === 'vu') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
|
||||
result = injectAfterBodyOpen(result, '<script>window.__VUNGLE__=true;</script>');
|
||||
} else if (network === 'mtg') {
|
||||
result = result.replace('<body>', '<body onload="gameReady()">');
|
||||
result = addBodyOnload(result, 'gameReady()');
|
||||
} else if (network === 'tt') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>');
|
||||
result = injectAfterBodyOpen(result, '<script>window.__TIKTOK__=true;</script>');
|
||||
}
|
||||
|
||||
// Replace the CTA script
|
||||
result = replaceCTAScript(result, network);
|
||||
|
||||
// Unity: rewrite window.top → window.self (Luna static scan requirement)
|
||||
if (network === 'un') {
|
||||
result = result.split('window.top').join('window.self');
|
||||
}
|
||||
return finalizeNetworkHtml(result, network);
|
||||
}
|
||||
|
||||
function sanitizePlayworksHtml(html: string): string {
|
||||
let result = cleanupPlayworksHtml(html);
|
||||
return removeNetworkSdkScripts(result);
|
||||
}
|
||||
|
||||
function cleanupPlayworksHtml(html: string): string {
|
||||
let result = html;
|
||||
result = trimAfterFirstHtmlClose(result);
|
||||
result = result.replace(/<script\b[^>]*>[\s\S]*?insertYourRemoteDebuggingTokenHere[\s\S]*?<\/script>\s*/gi, '');
|
||||
result = result.replace(/https:\/\/mrdoob\.github\.io\/stats\.js\/build\/stats\.min\.js/gi, 'data:text/javascript,');
|
||||
result = result.replace(/\s+crossorigin(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
|
||||
result = result.replace(/\s+type\s*=\s*["']module["']/gi, '');
|
||||
result = result.replace(/\bconsole\.error\s*\(/g, 'console.warn(');
|
||||
return result;
|
||||
}
|
||||
|
||||
function trimAfterFirstHtmlClose(html: string): string {
|
||||
const idx = html.search(/<\/html\s*>/i);
|
||||
if (idx === -1) return html;
|
||||
const close = html.match(/<\/html\s*>/i);
|
||||
return close ? html.slice(0, idx + close[0].length) : html;
|
||||
}
|
||||
|
||||
function finalizeNetworkHtml(html: string, network: string): string {
|
||||
let result = cleanupPlayworksHtml(html);
|
||||
result = dedupeScriptSrc(result, 'mraid.js', MRAID_NETWORKS.has(network));
|
||||
result = dedupeScriptSrc(result, 'exitapi.js', EXITAPI_NETWORKS.has(network));
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeNetworkSdkScripts(html: string): string {
|
||||
let result = html;
|
||||
for (const src of ['mraid.js', 'exitapi.js']) {
|
||||
const escaped = src.replace('.', '\\.');
|
||||
result = result.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dedupeScriptSrc(html: string, src: string, shouldExist: boolean): string {
|
||||
const escaped = src.replace('.', '\\.');
|
||||
let seen = false;
|
||||
let result = html.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), (match) => {
|
||||
if (shouldExist && !seen) {
|
||||
seen = true;
|
||||
return `<script src="${src}"></script>`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
if (shouldExist && !seen) {
|
||||
result = injectBeforeHeadClose(result, `<script src="${src}"></script>`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function injectBeforeHeadClose(html: string, injection: string): string {
|
||||
if (/<\/head>/i.test(html)) {
|
||||
return html.replace(/<\/head>/i, `${injection}\n</head>`);
|
||||
}
|
||||
return `${injection}\n${html}`;
|
||||
}
|
||||
|
||||
function injectAfterBodyOpen(html: string, injection: string): string {
|
||||
if (/<body\b[^>]*>/i.test(html)) {
|
||||
return html.replace(/<body\b[^>]*>/i, match => `${match}\n${injection}`);
|
||||
}
|
||||
return `${injection}\n${html}`;
|
||||
}
|
||||
|
||||
function addBodyOnload(html: string, handler: string): string {
|
||||
if (!/<body\b[^>]*>/i.test(html)) {
|
||||
return `<body onload="${handler}">\n${html}`;
|
||||
}
|
||||
return html.replace(/<body\b([^>]*)>/i, (match, attrs: string) => {
|
||||
const onloadMatch = attrs.match(/\bonload\s*=\s*(["'])(.*?)\1/i);
|
||||
if (!onloadMatch) return `<body${attrs} onload="${handler}">`;
|
||||
if (onloadMatch[2].includes(handler)) return match;
|
||||
const updated = `${onloadMatch[2]};${handler}`;
|
||||
return match.replace(onloadMatch[0], `onload=${onloadMatch[1]}${updated}${onloadMatch[1]}`);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceCTAScript(html: string, network: string): string {
|
||||
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||
const markerIdx = html.lastIndexOf(marker);
|
||||
@@ -214,6 +340,28 @@ function buildLifecycleScript(): string {
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildMraidComplianceScript(): string {
|
||||
return [
|
||||
'<script>(function(){',
|
||||
'var m=null,scene=null,viewable=true,exposed=true,volume=null;',
|
||||
'function get(){return window.mraid||m;}',
|
||||
'function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}',
|
||||
'function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}',
|
||||
'function onViewable(v){viewable=!!v;apply();}',
|
||||
'function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}',
|
||||
'function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}',
|
||||
'function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}',
|
||||
'function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}',
|
||||
'window.__hplMraidUnload=unload;',
|
||||
'function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}',
|
||||
'function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}',
|
||||
'window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};',
|
||||
'if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}',
|
||||
'setTimeout(setup,2000);',
|
||||
'})();</script>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildCTAScript(network: string): string {
|
||||
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
|
||||
// gameClose must fire before every redirect
|
||||
@@ -224,21 +372,18 @@ function buildCTAScript(network: string): string {
|
||||
case 'al':
|
||||
case 'is':
|
||||
// MRAID — mraid.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`;
|
||||
break;
|
||||
case 'un':
|
||||
// Unity Ads — MRAID-based; window.top already rewritten separately
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'fb':
|
||||
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'gg':
|
||||
case 'ap':
|
||||
// Google — exitapi.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'mo':
|
||||
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
||||
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'vu':
|
||||
// Vungle — window.__VUNGLE__ flag set in body
|
||||
@@ -293,131 +438,118 @@ function getHtml(): string {
|
||||
const networks = [
|
||||
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
|
||||
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
||||
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
||||
{ tag: 'mo', label: 'Moloco', note: 'HTML' },
|
||||
{ tag: 'ap', label: 'Appreciate', note: 'ZIP + exitapi.js in head' },
|
||||
{ tag: 'mo', label: 'Moloco', note: 'HTML + FbPlayableAd CTA' },
|
||||
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
|
||||
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
||||
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
||||
];
|
||||
|
||||
const checkboxRows = networks.map(n => `
|
||||
<label class="net-row">
|
||||
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
|
||||
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
|
||||
<span class="net-note">${n.note}</span>
|
||||
</label>`).join('');
|
||||
<tr>
|
||||
<td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
|
||||
<td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
|
||||
<td><span class="net-note">${n.note}</span></td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; }
|
||||
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; }
|
||||
${getToolWebviewStyles()}
|
||||
.net-label { min-width: 100px; font-weight: 500; }
|
||||
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||
.net-note { font-size: 11px; opacity: 0.6; }
|
||||
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||
.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>
|
||||
</head>
|
||||
<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">
|
||||
<div class="section-label">Source HTML (Playworks export)</div>
|
||||
<div class="row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
|
||||
<button id="pickSrc">Browse...</button>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Output Folder</div>
|
||||
<div class="row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||
<button id="pickOut">Browse...</button>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Source HTML (Playworks export)</div>
|
||||
<div class="field-row">
|
||||
<input id="src" type="hidden" />
|
||||
<button id="pickSrc" class="secondary">Select File</button>
|
||||
<span id="srcName" class="file-name">(no file)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Base Filename</div>
|
||||
<div class="field-row">
|
||||
<input id="baseName" type="text" placeholder="Base filename..." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Output Folder</div>
|
||||
<div class="field-row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||
<button id="pickOut" class="secondary">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Networks to Generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll" class="secondary">Select All</button>
|
||||
<button id="selectNone" class="secondary">Select None</button>
|
||||
</div>
|
||||
<div class="results-panel" style="margin-top:0;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th style="width:70px;">Use</th><th style="width:160px;">Network</th><th>Output behavior</th></tr></thead>
|
||||
<tbody>${checkboxRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="convert" disabled>Convert</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
<div class="action-row">
|
||||
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
let outputDir = '';
|
||||
|
||||
const srcEl = document.getElementById('src');
|
||||
const srcNameEl = document.getElementById('srcName');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const baseNameEl = document.getElementById('baseName');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
@@ -426,23 +558,32 @@ function getHtml(): string {
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||
});
|
||||
|
||||
function outputDisplayName(file) {
|
||||
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
|
||||
document.getElementById('convert').addEventListener('click', () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')]
|
||||
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
convertBtn.disabled = true;
|
||||
vscode.postMessage({
|
||||
type: 'convert',
|
||||
opts: {
|
||||
sourcePath: srcEl.value,
|
||||
outputDir: outEl.value,
|
||||
baseName: baseNameEl.value,
|
||||
networks,
|
||||
},
|
||||
});
|
||||
@@ -452,6 +593,8 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'sourcePicked') {
|
||||
srcEl.value = msg.path;
|
||||
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
|
||||
baseNameEl.value = msg.baseName || '';
|
||||
if (!outEl.value && msg.dir) {
|
||||
outEl.value = msg.dir;
|
||||
outputDir = msg.dir;
|
||||
@@ -461,36 +604,56 @@ function getHtml(): string {
|
||||
outEl.value = msg.path;
|
||||
outputDir = msg.path;
|
||||
updateConvertBtn();
|
||||
} else if (msg.type === 'status') {
|
||||
statusEl.textContent = msg.message;
|
||||
statusEl.className = 'status-panel is-busy';
|
||||
} else if (msg.type === 'convertDone') {
|
||||
convertBtn.disabled = false;
|
||||
updateConvertBtn();
|
||||
if (msg.error) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
const results = msg.results || [];
|
||||
statusEl.classList.remove('is-busy');
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
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) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'res-row';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const net = document.createElement('span');
|
||||
net.className = 'res-net';
|
||||
const row = document.createElement('tr');
|
||||
const net = document.createElement('td');
|
||||
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) {
|
||||
detail.className = 'res-file';
|
||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||
outputType.textContent = /\\.zip$/i.test(r.file) ? 'ZIP' : 'HTML';
|
||||
detail.textContent = outputDisplayName(r.file);
|
||||
} else {
|
||||
detail.className = 'res-err';
|
||||
detail.textContent = r.error || 'Unknown error';
|
||||
outputType.textContent = '-';
|
||||
detail.innerHTML = '<span class="muted">-</span>';
|
||||
error.textContent = r.error || 'Unknown error';
|
||||
}
|
||||
row.append(mark, net, detail);
|
||||
resultsEl.appendChild(row);
|
||||
if (r.ok) error.innerHTML = '<span class="muted">-</span>';
|
||||
row.append(net, status, outputType, detail, error);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
if (ok > 0) {
|
||||
outputDir = outEl.value;
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { File } from 'node:buffer';
|
||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -15,6 +15,24 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
||||
try {
|
||||
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') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: true,
|
||||
@@ -83,11 +101,13 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
const r = valid[i];
|
||||
const idx = i + 1;
|
||||
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${path.basename(r.path)}` });
|
||||
const buf = fs.readFileSync(r.path);
|
||||
const file = new File([buf], stampedNames[i], { type: 'text/html' });
|
||||
form.append(`fileUpload_${idx}`, file as any);
|
||||
form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, '')));
|
||||
}
|
||||
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -159,78 +179,100 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||
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 {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
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; }
|
||||
${getToolWebviewStyles()}
|
||||
#rows input[type=text] { width: 100%; }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; }
|
||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
#status { margin-top: 12px; min-height: 20px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.result {
|
||||
margin-top: 12px; padding: 10px;
|
||||
background: var(--vscode-textBlockQuote-background);
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
word-break: break-all;
|
||||
.pick { min-width: 82px; }
|
||||
.remove-btn {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.result a { color: var(--vscode-textLink-foreground); }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<h2>PLEC Upload</h2>
|
||||
<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>
|
||||
<main class="tool-page">
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:45%">HTML File</th>
|
||||
<th style="width:45%">Iteration Name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="actions">
|
||||
<button id="addRow" class="secondary">+ Add Row</button>
|
||||
<button id="upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<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="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
|
||||
<table id="rows" class="data-table">
|
||||
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</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>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const PAGE_SIZE = 5;
|
||||
let nextId = 1;
|
||||
let rows = [];
|
||||
let page = 0;
|
||||
let previewUrl = '';
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
const rowsPanel = document.getElementById('rowsPanel');
|
||||
const emptySelection = document.getElementById('emptySelection');
|
||||
|
||||
function parseIterationName(filename) {
|
||||
const base = filename.replace(/\\.html?$/i, '');
|
||||
@@ -241,135 +283,167 @@ function getHtml(): string {
|
||||
return base;
|
||||
}
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const openResultBtn = document.getElementById('openResult');
|
||||
const copyResultBtn = document.getElementById('copyResult');
|
||||
|
||||
function addRow() {
|
||||
const id = 'r' + (nextId++);
|
||||
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);
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
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', () => {
|
||||
clearErrors();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
||||
id: tr.dataset.id,
|
||||
path: tr.querySelector('[data-path]').value,
|
||||
iteration: tr.querySelector('[data-iter]').value,
|
||||
})).filter(r => r.path || r.iteration);
|
||||
statusEl.classList.remove('is-busy');
|
||||
setPreview('');
|
||||
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;
|
||||
}
|
||||
statusEl.textContent = 'Uploading...';
|
||||
statusEl.classList.add('is-busy');
|
||||
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() {
|
||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
||||
rows.forEach(row => row.error = '');
|
||||
renderRows();
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
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];
|
||||
});
|
||||
}
|
||||
}
|
||||
addFiles(m.files || []);
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
statusEl.className = 'status-panel is-busy';
|
||||
} else if (m.type === 'error') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'err';
|
||||
div.textContent = m.message;
|
||||
statusEl.appendChild(div);
|
||||
} else if (m.type === 'rowError') {
|
||||
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
if (tr) tr.querySelector('[data-error]').textContent = m.message;
|
||||
statusEl.classList.remove('is-busy');
|
||||
const row = rows.find(r => r.id === m.rowId);
|
||||
if (row) row.error = m.message;
|
||||
renderRows();
|
||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||
} else if (m.type === 'result') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
if (m.preview) {
|
||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + m.preview + '">' + m.preview + '</a></div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copy = document.createElement('button');
|
||||
copy.textContent = 'Copy';
|
||||
copy.onclick = () => vscode.postMessage({ type: 'copy', text: m.preview });
|
||||
const open = document.createElement('button');
|
||||
open.textContent = 'Open';
|
||||
open.className = 'secondary';
|
||||
open.onclick = () => vscode.postMessage({ type: 'open', text: m.preview });
|
||||
const showRaw = document.createElement('button');
|
||||
showRaw.textContent = 'Show server response';
|
||||
showRaw.className = 'secondary';
|
||||
showRaw.onclick = () => {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.marginTop = '8px';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = m.rawText || JSON.stringify(m.raw, null, 2);
|
||||
wrap.appendChild(pre);
|
||||
showRaw.disabled = true;
|
||||
};
|
||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
||||
wrap.appendChild(actions);
|
||||
} else {
|
||||
wrap.innerHTML = '<div>No preview field in response. Raw JSON:</div><pre>' +
|
||||
(m.rawText || JSON.stringify(m.raw, null, 2)).replace(/</g, '<') + '</pre>';
|
||||
}
|
||||
resultsEl.innerHTML = '';
|
||||
resultsEl.appendChild(wrap);
|
||||
setPreview(m.preview);
|
||||
}
|
||||
});
|
||||
|
||||
addRow();
|
||||
renderRows();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import { handleClipboardAndOpen } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -12,7 +12,11 @@ interface ActiveShare {
|
||||
server: http.Server;
|
||||
port: number;
|
||||
token: string;
|
||||
fileBuf: Buffer;
|
||||
files: SharedFile[];
|
||||
}
|
||||
|
||||
interface SharedFile {
|
||||
buf: Buffer;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
@@ -57,19 +61,35 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
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') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
canSelectMany: true,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const fp = picked[0].fsPath;
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({
|
||||
type: 'fileSelected',
|
||||
path: fp,
|
||||
name: path.basename(fp),
|
||||
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -77,13 +97,22 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
|
||||
if (msg.type === 'startShare') {
|
||||
stopActive();
|
||||
const filePath: string = msg.path;
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
const filePaths: string[] = Array.isArray(msg.paths) ? msg.paths : (msg.path ? [msg.path] : []);
|
||||
if (!filePaths.length) {
|
||||
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||
return;
|
||||
}
|
||||
const fileBuf = fs.readFileSync(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
const files: SharedFile[] = [];
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||
return;
|
||||
}
|
||||
panel.webview.postMessage({ type: 'status', message: `Loading ${i + 1}/${filePaths.length} ${path.basename(filePath)}` });
|
||||
files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) });
|
||||
}
|
||||
panel.webview.postMessage({ type: 'status', message: 'Starting server...' });
|
||||
const token = crypto.randomBytes(6).toString('base64url');
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
|
||||
@@ -93,8 +122,7 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
server: http.createServer(),
|
||||
port: 0,
|
||||
token,
|
||||
fileBuf,
|
||||
filename,
|
||||
files,
|
||||
};
|
||||
share.server.on('request', (req, res) => handleRequest(req, res, share));
|
||||
share.server.on('error', (err) => {
|
||||
@@ -125,7 +153,11 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
iface: ip.iface,
|
||||
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;
|
||||
}
|
||||
@@ -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) {
|
||||
if (req.method !== 'GET' || !req.url) {
|
||||
res.statusCode = 404;
|
||||
@@ -156,7 +212,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
|
||||
|
||||
if (rest === '' || rest === '/') {
|
||||
const body = chooserPage(share.filename);
|
||||
const body = chooserPage(share.files);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -165,24 +221,40 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
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, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(share.fileBuf);
|
||||
res.end(file.buf);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rest === '/file') {
|
||||
const safeName = share.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
const fileMatch = rest.match(/^\/file\/(\d+)$/);
|
||||
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, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${safeName}"`,
|
||||
'Content-Length': String(share.fileBuf.length),
|
||||
'Content-Length': String(file.buf.length),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(share.fileBuf);
|
||||
res.end(file.buf);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,10 +262,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
res.end();
|
||||
}
|
||||
|
||||
function chooserPage(filename: string): string {
|
||||
const escName = filename.replace(/[&<>"]/g, (c) =>
|
||||
function chooserPage(files: SharedFile[]): string {
|
||||
const esc = (value: string) => value.replace(/[&<>"]/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -203,7 +283,8 @@ function chooserPage(filename: string): string {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||
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;
|
||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||
a.view { background: #2d7dff; color: white; }
|
||||
@@ -211,9 +292,7 @@ function chooserPage(filename: string): string {
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>HPL Toolbox</h1>
|
||||
<div class="file">${escName}</div>
|
||||
<a class="btn view" href="view">View in browser</a>
|
||||
<a class="btn dl" href="file" download="${escName}">Download .html</a>
|
||||
${items}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
@@ -250,26 +329,8 @@ function getHtml(qrScriptUri: string): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
.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; }
|
||||
${getToolWebviewStyles()}
|
||||
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: var(--tool-radius); }
|
||||
#qr svg, #qr img { display: block; }
|
||||
.url {
|
||||
margin-top: 10px; padding: 8px;
|
||||
@@ -277,99 +338,170 @@ function getHtml(qrScriptUri: string): string {
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
|
||||
}
|
||||
.url-actions { margin-top: 6px; display: flex; gap: 6px; }
|
||||
#status { margin-top: 12px; min-height: 18px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.hint { font-size: 12px; opacity: 0.7; margin-top: 8px; }
|
||||
.url-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.share-panel { padding: 12px; }
|
||||
.iface-radio { text-align: center; }
|
||||
#qr { margin-top: 0; }
|
||||
.selected-list { margin-top: 10px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
<script src="${qrScriptUri}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Send To Mobile</h2>
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">
|
||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
||||
</p>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Send To Mobile</h2>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<div class="file-cell">
|
||||
<button id="pick" class="secondary">Choose...</button>
|
||||
<span class="file-name" id="fileName">(no file)</span>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<input type="hidden" id="filePath" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button id="start">Start sharing</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="ifaceWrap" class="row" style="display:none;">
|
||||
<div style="font-size:12px;opacity:0.8;">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
||||
<div class="iface-list" id="ifaceList"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" style="display:none;">
|
||||
<div id="qr"></div>
|
||||
<div class="url" id="urlText"></div>
|
||||
<div class="url-actions">
|
||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
||||
<button id="openUrl" class="secondary">Open in browser</button>
|
||||
<div 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-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 class="hint">
|
||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
||||
Sharing stops automatically when this panel is closed.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const startBtn = document.getElementById('start');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const shareInfo = document.getElementById('shareInfo');
|
||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||
const ifaceList = document.getElementById('ifaceList');
|
||||
const qrEl = document.getElementById('qr');
|
||||
const urlTextEl = document.getElementById('urlText');
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
let currentUrls = [];
|
||||
let selectedIdx = 0;
|
||||
let selectedPaths = [];
|
||||
let selectedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
|
||||
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', () => {
|
||||
statusEl.innerHTML = '';
|
||||
const path = filePathEl.value;
|
||||
if (!path) {
|
||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
statusEl.textContent = 'Starting server...';
|
||||
vscode.postMessage({ type: 'startShare', path });
|
||||
statusEl.classList.add('is-busy');
|
||||
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
|
||||
});
|
||||
|
||||
stopBtn.addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'stopShare' });
|
||||
});
|
||||
|
||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'copy', text: currentUrls[selectedIdx]?.url || '' });
|
||||
});
|
||||
document.getElementById('openUrl').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'open', text: currentUrls[selectedIdx]?.url || '' });
|
||||
});
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedFiles.length) {
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const 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) {
|
||||
qrEl.innerHTML = '';
|
||||
urlTextEl.textContent = url;
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
@@ -389,41 +521,62 @@ function getHtml(qrScriptUri: string): string {
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
const id = 'iface_' + i;
|
||||
const label = document.createElement('label');
|
||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
||||
u.ip + '<span class="meta">(' + u.iface + ')</span>';
|
||||
label.querySelector('input').addEventListener('change', () => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML =
|
||||
'<td class="iface-radio"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||
'<td class="mono wrap">' + u.ip + '</td>' +
|
||||
'<td class="wrap">' + u.iface + '</td>';
|
||||
row.querySelector('input').addEventListener('change', () => {
|
||||
selectedIdx = i;
|
||||
renderQr(urls[i].url);
|
||||
});
|
||||
ifaceList.appendChild(label);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'fileSelected') {
|
||||
filePathEl.value = m.path;
|
||||
fileNameEl.textContent = m.name;
|
||||
const existing = new Set(selectedFiles.map(f => f.path));
|
||||
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') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
currentUrls = m.urls;
|
||||
selectedIdx = 0;
|
||||
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
shareInfo.style.display = '';
|
||||
renderIfaces(m.urls);
|
||||
renderQr(m.urls[0].url);
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
} else if (m.type === 'stopped') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = 'Stopped.';
|
||||
shareInfo.style.display = 'none';
|
||||
ifaceWrap.style.display = 'none';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
currentUrls = [];
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
} else if (m.type === 'error') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '<') + '</span>';
|
||||
startBtn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,306 @@ export function handleClipboardAndOpen(msg: any): boolean {
|
||||
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(
|
||||
store: { panel: vscode.WebviewPanel | null },
|
||||
viewType: string,
|
||||
|
||||
@@ -35,31 +35,53 @@ func pickApplovinUserAgent(randomize bool) string {
|
||||
|
||||
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>AppLovin Playable Preview (QR)</h2>
|
||||
<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>
|
||||
<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 style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span id="fileName" class="hint">(no files)</span>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<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>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||
</div>
|
||||
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-files"></div>
|
||||
</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" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.qr-cell { text-align:center; }
|
||||
.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>
|
||||
<div id="qrModal" class="qr-modal"><img alt="Expanded QR" /></div>
|
||||
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
let selectedPage = 0;
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
@@ -68,6 +90,7 @@ const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
const qrModal = document.getElementById('qrModal');
|
||||
let selectedPaths = [];
|
||||
let resultItems = [];
|
||||
|
||||
@@ -76,15 +99,38 @@ function basename(p) {
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
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 () => {
|
||||
@@ -111,33 +157,57 @@ regenAllBtn.addEventListener('click', () => {
|
||||
img.src = base + '&_=' + Date.now();
|
||||
});
|
||||
});
|
||||
setupDropZone('dropZone', statusEl, (j) => {
|
||||
if (j.files && j.files.length) {
|
||||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||
selectedPaths = selectedPaths.concat(added);
|
||||
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
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 () => {
|
||||
const r = await fetch('/api/applovin/pick', { 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();
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
statusEl.textContent = 'Uploading...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/applovin/upload', {
|
||||
method: 'POST',
|
||||
@@ -145,6 +215,7 @@ uploadBtn.addEventListener('click', async () => {
|
||||
body: JSON.stringify({ paths: selectedPaths }),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
|
||||
uploadBtn.disabled = false;
|
||||
return;
|
||||
@@ -164,6 +235,7 @@ uploadBtn.addEventListener('click', async () => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||
} finally {
|
||||
uploadBtn.disabled = false;
|
||||
@@ -174,24 +246,41 @@ function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function ensureResultsTable() {
|
||||
let tbody = resultsEl.querySelector('tbody');
|
||||
if (tbody) return tbody;
|
||||
resultsEl.innerHTML = '<table class="data-table qr-results"><thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead><tbody></tbody></table>';
|
||||
return resultsEl.querySelector('tbody');
|
||||
}
|
||||
qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
|
||||
|
||||
function handle(m) {
|
||||
if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.classList.add('is-busy');
|
||||
} else if (m.type === 'fileError') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
||||
resultsEl.appendChild(wrap);
|
||||
const tbody = ensureResultsTable();
|
||||
resultsEl.querySelector('table').classList.add('has-errors');
|
||||
const row = document.createElement('tr');
|
||||
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>';
|
||||
tbody.appendChild(row);
|
||||
} else if (m.type === 'fileResult') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const tbody = ensureResultsTable();
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = m.name;
|
||||
const qr = document.createElement('td');
|
||||
qr.className = 'qr-cell';
|
||||
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');
|
||||
actions.className = 'result-actions';
|
||||
const openBtn = document.createElement('button');
|
||||
@@ -218,21 +307,21 @@ function handle(m) {
|
||||
}
|
||||
};
|
||||
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
actionCell.appendChild(actions);
|
||||
row.append(file, qr, actionCell, error);
|
||||
tbody.appendChild(row);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
saveAllBtn.disabled = resultItems.length === 0;
|
||||
regenAllBtn.disabled = resultItems.length === 0;
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
`
|
||||
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) {
|
||||
@@ -253,6 +342,23 @@ func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
var req struct {
|
||||
Paths []string `json:"paths"`
|
||||
@@ -345,11 +451,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if i > 0 && delayMs > 0 {
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Waiting %dms before %s...", idx, len(req.Paths), delayMs, fileName)})
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Waiting %d/%d %s", idx, len(req.Paths), fileName)})
|
||||
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)})
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Validating %d/%d %s", idx, len(req.Paths), fileName)})
|
||||
|
||||
body, ct, err := buildForm(filePath)
|
||||
if err != nil {
|
||||
@@ -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)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -12,83 +13,164 @@ import (
|
||||
|
||||
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Base64 Scanner</h2>
|
||||
<p class="tool-description">Scan selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row"><button id="scan">Scan</button></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
|
||||
.mark { width:14px; flex-shrink:0; font-weight:bold; }
|
||||
.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; }
|
||||
.asset-list { margin:0; padding-left:16px; }
|
||||
.asset-list li + li { margin-top:3px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const folderEl = document.getElementById('folder');
|
||||
const PAGE_SIZE = 5;
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
let selectedPage = 0;
|
||||
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
|
||||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!pickedFiles.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
|
||||
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||
visible.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||
});
|
||||
if (pickedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addPaths(paths) {
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
async function readJSONStream(response, handle) {
|
||||
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline;
|
||||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line.trim()) handle(JSON.parse(line));
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||
}
|
||||
renderSelection();
|
||||
setupDropZone('dropZone', statusEl, (j) => {
|
||||
addPaths(j.paths || []);
|
||||
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||
});
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
|
||||
const payload = { files: pickedFiles };
|
||||
let j = null;
|
||||
try {
|
||||
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
await readJSONStream(r, (msg) => {
|
||||
if (msg.type === 'status') {
|
||||
statusEl.textContent = msg.message;
|
||||
statusEl.classList.add('is-busy');
|
||||
} else if (msg.type === 'done') {
|
||||
j = msg;
|
||||
} else if (msg.type === 'error') {
|
||||
j = { error: msg.message };
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = 'Error: ' + (e.message || e);
|
||||
return;
|
||||
}
|
||||
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
const j = await r.json();
|
||||
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
|
||||
if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
const total = j.results.length;
|
||||
const flagged = j.results.filter(r => !r.ok).length;
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||
if (!total) { resultsEl.innerHTML = ''; return; }
|
||||
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead><tbody></tbody></table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const rr of j.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
|
||||
mark.textContent = rr.ok ? '✓' : '✗';
|
||||
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);
|
||||
const row = document.createElement('tr');
|
||||
const assets = rr.ok ? '<span class="muted">None</span>' : '<ul class="asset-list mono">' + rr.assets.map(a => '<li>' + escapeHtml(a) + '</li>').join('') + '</ul>';
|
||||
row.innerHTML = '<td>' + (rr.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>') + '</td>' +
|
||||
'<td class="mono wrap">' + escapeHtml(rr.file) + '</td><td>' + rr.assets.length + '</td><td class="wrap">' + assets + '</td>';
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -99,7 +181,11 @@ document.getElementById('scan').addEventListener('click', async () => {
|
||||
|
||||
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
@@ -118,12 +204,13 @@ type scanResult struct {
|
||||
}
|
||||
|
||||
func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
||||
send := newJSONStream(w)
|
||||
var req struct {
|
||||
Folder string `json:"folder"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,12 +230,13 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
||||
targets = collectHTMLFiles(req.Folder)
|
||||
baseDir = req.Folder
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"})
|
||||
send(map[string]any{"type": "error", "message": "Pick a folder or files first"})
|
||||
return
|
||||
}
|
||||
|
||||
results := []scanResult{}
|
||||
for _, file := range targets {
|
||||
for i, file := range targets {
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -169,7 +257,7 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
||||
Assets: offenders,
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
send(map[string]any{"type": "done", "results": results})
|
||||
}
|
||||
|
||||
func commonBaseDir(files []string) string {
|
||||
|
||||
116
standalone/changelog.go
Normal file
116
standalone/changelog.go
Normal file
@@ -0,0 +1,116 @@
|
||||
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 CHANGELOG.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 ul { margin: 6px 0 12px; padding-left: 20px; }
|
||||
.changelog-content li { margin: 4px 0; line-height: 1.45; }
|
||||
.changelog-content p { margin: 8px 0; line-height: 1.5; }
|
||||
</style>`
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/changelog", "Changelog", body)))
|
||||
}
|
||||
|
||||
func readChangelog() string {
|
||||
for _, start := range changelogSearchRoots() {
|
||||
for _, candidate := range changelogCandidates(start) {
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "# Changelog\n\nCHANGELOG.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, "CHANGELOG.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
|
||||
closeList := func() {
|
||||
if inList {
|
||||
out.WriteString("</ul>")
|
||||
inList = false
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "### ") {
|
||||
closeList()
|
||||
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[4:])) + "</h2>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
closeList()
|
||||
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[3:])) + "</h2>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
closeList()
|
||||
out.WriteString("<h1>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</h1>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "- ") {
|
||||
if !inList {
|
||||
out.WriteString("<ul>")
|
||||
inList = true
|
||||
}
|
||||
out.WriteString("<li>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</li>")
|
||||
continue
|
||||
}
|
||||
closeList()
|
||||
out.WriteString("<p>" + html.EscapeString(line) + "</p>")
|
||||
}
|
||||
closeList()
|
||||
return out.String()
|
||||
}
|
||||
@@ -12,19 +12,40 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
||||
if n.Path == "/" {
|
||||
continue
|
||||
}
|
||||
label := n.Label
|
||||
badge := ""
|
||||
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 := `
|
||||
<h2>HPL Toolbox</h2>
|
||||
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
||||
<ul style="line-height:1.9;">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">HPL Toolbox</h2>
|
||||
<p class="tool-description">Standalone build. Pick a tool to open.</p>
|
||||
</header>
|
||||
<div class="home-tools">
|
||||
` + 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.Write([]byte(Page("/", "Home", body)))
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -9,3 +10,27 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func newJSONStream(w http.ResponseWriter) func(any) {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
return func(v any) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
data = []byte(fmt.Sprintf(`{"type":"error","message":%q}`, err.Error()))
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
_, _ = w.Write([]byte("\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pluralS(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
@@ -2,14 +2,31 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1e1e1e; color: #ddd;
|
||||
margin: 0; padding: 0;
|
||||
@@ -19,17 +36,61 @@ const SharedCSS = `
|
||||
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
|
||||
font-size: 13px;
|
||||
}
|
||||
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
||||
.topbar a:hover { background: #2a2d2e; }
|
||||
.topbar a.active { background: #094771; color: #fff; }
|
||||
.topbar .nav-link {
|
||||
background: transparent;
|
||||
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; }
|
||||
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; }
|
||||
.content { padding: 16px; max-width: 980px; margin: 0 auto; }
|
||||
.beta-tools-footer { max-width: 980px; margin: 24px auto 16px auto; padding: 12px 16px 0; border-top: 1px solid #333; }
|
||||
.beta-tools-footer button { width: 100%; text-align: left; background: #3a3d41; color: #ddd; }
|
||||
.beta-tools-footer button:hover { background: #45494e; }
|
||||
.beta-note { display: block; margin-top: 3px; opacity: 0.65; font-size: 11px; }
|
||||
h2 { margin-top: 0; font-weight: 500; }
|
||||
.topbar-spacer { margin-left: auto; }
|
||||
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
|
||||
.app-footer {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: auto auto 0;
|
||||
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 {
|
||||
box-sizing: border-box; padding: 6px 8px;
|
||||
background: #3c3c3c; color: #ddd;
|
||||
@@ -46,12 +107,214 @@ const SharedCSS = `
|
||||
button:hover { background: #1177bb; }
|
||||
button.secondary { background: #3a3d41; color: #ddd; }
|
||||
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; }
|
||||
.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; }
|
||||
.ok { color: #89d185; }
|
||||
.hint { font-size: 12px; opacity: 0.7; }
|
||||
`
|
||||
|
||||
const SharedDropZoneScript = `
|
||||
function extractDroppedPaths(dataTransfer) {
|
||||
const paths = [];
|
||||
const add = (value) => {
|
||||
if (!value) return;
|
||||
const trimmed = String(value).trim();
|
||||
if (trimmed && !paths.includes(trimmed)) paths.push(trimmed);
|
||||
};
|
||||
const addPathText = (text) => {
|
||||
const value = String(text || '');
|
||||
if (!value) return;
|
||||
const uriMatches = value.match(/file:\/\/(?:\/[A-Za-z]:)?[^\\r\\n\\0]+/gi) || [];
|
||||
uriMatches.forEach(add);
|
||||
const quotedMatches = value.match(/"([^"]+)"|'([^']+)'/g) || [];
|
||||
quotedMatches.forEach(match => add(match.replace(/^["']|["']$/g, '')));
|
||||
value.split(/[\r\n\0]+/).forEach(line => {
|
||||
let rest = line.trim();
|
||||
if (!rest || rest.startsWith('#')) return;
|
||||
while (rest.length) {
|
||||
const driveMatches = [...rest.matchAll(/[A-Za-z]:\\/g)].map(m => m.index).filter(i => typeof i === 'number');
|
||||
if (driveMatches.length <= 1) {
|
||||
add(rest);
|
||||
break;
|
||||
}
|
||||
const next = driveMatches[1];
|
||||
add(rest.slice(0, next).trim());
|
||||
rest = rest.slice(next).trim();
|
||||
}
|
||||
});
|
||||
};
|
||||
const addLines = (text) => {
|
||||
addPathText(text);
|
||||
};
|
||||
try { addLines(dataTransfer.getData('text/uri-list')); } catch {}
|
||||
try { addLines(dataTransfer.getData('text/plain')); } catch {}
|
||||
for (const file of Array.from(dataTransfer.files || [])) {
|
||||
add(file.path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function extractDroppedHtmlFiles(dataTransfer) {
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
const droppedFiles = Array.from(dataTransfer.files || []);
|
||||
const entries = [];
|
||||
const handlePromises = [];
|
||||
for (const item of Array.from(dataTransfer.items || [])) {
|
||||
if (typeof item.getAsFileSystemHandle === 'function') {
|
||||
handlePromises.push(item.getAsFileSystemHandle().catch(() => null));
|
||||
}
|
||||
let entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
|
||||
if (!entry && typeof item.getAsEntry === 'function') entry = item.getAsEntry();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
const addFile = async (file, name) => {
|
||||
const fileName = name || file.name || 'dropped.html';
|
||||
if (!/\.html?$/i.test(fileName)) return;
|
||||
const key = fileName + ':' + file.size + ':' + file.lastModified;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
files.push({ name: fileName, content: await file.text() });
|
||||
};
|
||||
const readEntry = async (entry, prefix) => {
|
||||
if (!entry) return;
|
||||
if (entry.isFile) {
|
||||
await new Promise((resolve) => {
|
||||
entry.file(async (file) => {
|
||||
await addFile(file, (prefix || '') + file.name);
|
||||
resolve();
|
||||
}, () => resolve());
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
while (true) {
|
||||
const entries = await new Promise(resolve => reader.readEntries(resolve, () => resolve([])));
|
||||
if (!entries.length) break;
|
||||
for (const child of entries) await readEntry(child, (prefix || '') + entry.name + '/');
|
||||
}
|
||||
}
|
||||
};
|
||||
const readHandle = async (handle, prefix) => {
|
||||
if (!handle) return;
|
||||
if (handle.kind === 'file') {
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
await addFile(file, (prefix || '') + file.name);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (handle.kind === 'directory') {
|
||||
try {
|
||||
for await (const child of handle.values()) {
|
||||
await readHandle(child, (prefix || '') + handle.name + '/');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
for (const file of droppedFiles) await addFile(file, file.name);
|
||||
for (const entry of entries) await readEntry(entry, '');
|
||||
for (const handle of await Promise.all(handlePromises)) await readHandle(handle, '');
|
||||
return files;
|
||||
}
|
||||
|
||||
function setupDropZone(id, statusElement, onResolved) {
|
||||
const zone = document.getElementById(id);
|
||||
if (!zone) return;
|
||||
const fallback = 'This view could not read dropped file paths. Use Select File(s) or Select Folder instead.';
|
||||
const setMessage = (text) => {
|
||||
if (statusElement) statusElement.textContent = text || '';
|
||||
};
|
||||
['dragenter', 'dragover'].forEach(type => {
|
||||
zone.addEventListener(type, (event) => {
|
||||
if (event.target && event.target.closest && event.target.closest('button, input')) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zone.classList.add('is-dragover');
|
||||
});
|
||||
});
|
||||
['dragleave', 'dragend'].forEach(type => {
|
||||
zone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zone.classList.remove('is-dragover');
|
||||
});
|
||||
});
|
||||
zone.addEventListener('drop', async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zone.classList.remove('is-dragover');
|
||||
const paths = extractDroppedPaths(event.dataTransfer);
|
||||
const files = await extractDroppedHtmlFiles(event.dataTransfer);
|
||||
if (!paths.length && !files.length) {
|
||||
setMessage(fallback);
|
||||
return;
|
||||
}
|
||||
setMessage('');
|
||||
try {
|
||||
const r = await fetch('/api/drop/resolve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths, files }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (typeof onResolved === 'function') onResolved(j);
|
||||
} catch (e) {
|
||||
setMessage('Could not read dropped items: ' + (e.message || e));
|
||||
}
|
||||
});
|
||||
}
|
||||
`
|
||||
|
||||
type navItem struct {
|
||||
Path string
|
||||
Label string
|
||||
@@ -62,10 +325,10 @@ type navItem struct {
|
||||
var navItems = []navItem{
|
||||
{Path: "/", Label: "Home"},
|
||||
{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: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
|
||||
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
|
||||
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
|
||||
{Path: "/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) {
|
||||
cls := ""
|
||||
if n.Path == activePath {
|
||||
cls = " class=\"active\""
|
||||
cls = " active"
|
||||
}
|
||||
label := n.Label
|
||||
if n.Beta {
|
||||
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"
|
||||
betaButtonNote := "Hidden beta tools: " + strconv.Itoa(betaToolCount())
|
||||
betaButtonLabel := "Show beta"
|
||||
if betaToolsEnabled {
|
||||
betaButtonLabel = "Disable Beta Tools"
|
||||
betaButtonNote = "Beta tools are visible in this standalone launcher."
|
||||
betaButtonLabel = "Hide beta"
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -130,13 +391,22 @@ func Page(activePath, title, body string) string {
|
||||
<meta charset="UTF-8" />
|
||||
<title>` + title + ` — HPL Toolbox</title>
|
||||
<style>` + SharedCSS + `</style>
|
||||
<script>` + SharedDropZoneScript + `</script>
|
||||
</head><body>
|
||||
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
|
||||
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
|
||||
<div class="content">` + body + `</div>
|
||||
<div class="beta-tools-footer">
|
||||
<button id="betaToolsToggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `<span class="beta-note">` + betaButtonNote + `</span></button>
|
||||
<div class="app-footer">
|
||||
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
|
||||
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
|
||||
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
|
||||
</div>
|
||||
<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) => {
|
||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||
await fetch('/api/betaTools', {
|
||||
@@ -157,6 +427,153 @@ func boolAttr(v bool) string {
|
||||
return "false"
|
||||
}
|
||||
|
||||
func DropResolveEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
type droppedFile struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
var req struct {
|
||||
Paths []string `json:"paths"`
|
||||
Files []droppedFile `json:"files"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"files": []any{}, "paths": []string{}, "skipped": 0, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var outPaths []string
|
||||
skipped := 0
|
||||
addFile := func(p string) {
|
||||
if !isHTMLPath(p) {
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(filepath.Clean(p))
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
outPaths = append(outPaths, p)
|
||||
}
|
||||
|
||||
for _, raw := range req.Paths {
|
||||
p := normalizeDroppedPath(raw)
|
||||
if p == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
for _, filePath := range collectHTMLFiles(p) {
|
||||
addFile(filePath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
addFile(p)
|
||||
continue
|
||||
}
|
||||
skipped++
|
||||
}
|
||||
|
||||
if len(req.Files) > 0 {
|
||||
cleanupStaleStandaloneDropDirs()
|
||||
dir, err := os.MkdirTemp("", "hpltoolbox-drop-")
|
||||
if err != nil {
|
||||
skipped += len(req.Files)
|
||||
} else {
|
||||
for _, file := range req.Files {
|
||||
if !isHTMLPath(file.Name) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
name := safeStandaloneDropName(file.Name)
|
||||
target := filepath.Join(dir, name)
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
for i := 1; fileExists(target); i++ {
|
||||
target = filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, i, ext))
|
||||
}
|
||||
if err := os.WriteFile(target, []byte(file.Content), 0644); err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
addFile(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files := make([]map[string]string, 0, len(outPaths))
|
||||
for _, p := range outPaths {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files, "paths": outPaths, "skipped": skipped})
|
||||
}
|
||||
|
||||
func safeStandaloneDropName(name string) string {
|
||||
base := filepath.Base(name)
|
||||
base = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
|
||||
return '_'
|
||||
default:
|
||||
if r < 32 {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}
|
||||
}, base)
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
return "dropped.html"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func cleanupStaleStandaloneDropDirs() {
|
||||
root := os.TempDir()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "hpltoolbox-drop-") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(root, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err == nil && info.ModTime().Before(cutoff) {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDroppedPath(value string) string {
|
||||
result := strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
if strings.HasPrefix(strings.ToLower(result), "file://") {
|
||||
result = strings.TrimPrefix(result, "file://")
|
||||
if decoded, err := url.PathUnescape(result); err == nil {
|
||||
result = decoded
|
||||
}
|
||||
if len(result) >= 3 && result[0] == '/' && result[2] == ':' {
|
||||
result = result[1:]
|
||||
}
|
||||
result = filepath.FromSlash(result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isHTMLPath(p string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(p))
|
||||
return ext == ".html" || ext == ".htm"
|
||||
}
|
||||
|
||||
func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
@@ -113,6 +113,7 @@ func buildMux() *http.ServeMux {
|
||||
mux.HandleFunc("GET /mraid", MraidPage)
|
||||
mux.HandleFunc("GET /mobile", MobilePage)
|
||||
mux.HandleFunc("GET /playworks", PlayworksPage)
|
||||
mux.HandleFunc("GET /changelog", ChangelogPage)
|
||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||
|
||||
// Shared API
|
||||
@@ -120,13 +121,16 @@ func buildMux() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
||||
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
||||
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
|
||||
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
|
||||
|
||||
// PLEC
|
||||
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
||||
mux.HandleFunc("POST /api/plec/pickFolder", PlecPickFolder)
|
||||
mux.HandleFunc("POST /api/plec/upload", PlecUpload)
|
||||
|
||||
// AppLovin
|
||||
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/saveQr", ApplovinSaveQR)
|
||||
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
|
||||
@@ -143,6 +147,7 @@ func buildMux() *http.ServeMux {
|
||||
|
||||
// Send To Mobile
|
||||
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/stop", MobileStop)
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"html"
|
||||
"net"
|
||||
@@ -27,7 +27,11 @@ type activeShare struct {
|
||||
listener net.Listener
|
||||
port int
|
||||
token string
|
||||
fileBuf []byte
|
||||
files []sharedFile
|
||||
}
|
||||
|
||||
type sharedFile struct {
|
||||
buf []byte
|
||||
filename string
|
||||
}
|
||||
|
||||
@@ -54,89 +58,198 @@ func MobileQrScript(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func MobilePage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Send To Mobile</h2>
|
||||
<p class="hint" style="margin-top:0;">
|
||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
||||
</p>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Send To Mobile</h2>
|
||||
<p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button id="pick" class="secondary">Choose...</button>
|
||||
<span id="fileName" class="hint">(no file)</span>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="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>
|
||||
<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 style="margin-bottom:14px;">
|
||||
<button id="start">Start sharing</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
|
||||
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
||||
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" style="display:none;">
|
||||
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
||||
<button id="openUrl" class="secondary">Open in browser</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:8px;">
|
||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
||||
<div id="shareInfo" class="result-card" style="display:none;">
|
||||
<div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||||
First run on Windows may show a firewall prompt - allow access for Private networks.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:18px;"></div>
|
||||
|
||||
<script src="/assets/qrcode.min.js"></script>
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const startBtn = document.getElementById('start');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const shareInfo = document.getElementById('shareInfo');
|
||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||
const ifaceList = document.getElementById('ifaceList');
|
||||
const qrEl = document.getElementById('qr');
|
||||
const urlTextEl = document.getElementById('urlText');
|
||||
|
||||
let currentUrls = [];
|
||||
let selectedIdx = 0;
|
||||
let selectedFiles = [];
|
||||
let selectedPaths = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedFiles.length) {
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||
visible.map((file, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = Number(btn.dataset.index);
|
||||
selectedFiles.splice(index, 1);
|
||||
selectedPaths.splice(index, 1);
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (selectedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addFiles(files) {
|
||||
const existing = new Set(selectedFiles.map(f => f.path));
|
||||
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
|
||||
selectedPaths = selectedFiles.map(f => f.path);
|
||||
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
async function readJSONStream(response, handle) {
|
||||
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline;
|
||||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line.trim()) handle(JSON.parse(line));
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||
}
|
||||
renderSelection();
|
||||
setupDropZone('dropZone', statusEl, (j) => {
|
||||
addFiles(j.files || []);
|
||||
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||
});
|
||||
|
||||
pickFolderBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
|
||||
const j = await r.json();
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
pickBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) {
|
||||
filePathEl.value = j.path;
|
||||
fileNameEl.textContent = j.name;
|
||||
}
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedFiles = [];
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
statusEl.innerHTML = '';
|
||||
if (!filePathEl.value) {
|
||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
statusEl.textContent = 'Starting server...';
|
||||
const r = await fetch('/api/mobile/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ path: filePathEl.value }),
|
||||
});
|
||||
const j = await r.json();
|
||||
statusEl.classList.add('is-busy');
|
||||
let j = null;
|
||||
try {
|
||||
const r = await fetch('/api/mobile/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ paths: selectedPaths }),
|
||||
});
|
||||
await readJSONStream(r, (msg) => {
|
||||
if (msg.type === 'status') {
|
||||
statusEl.textContent = msg.message;
|
||||
statusEl.classList.add('is-busy');
|
||||
} else if (msg.type === 'done') {
|
||||
j = msg;
|
||||
} else if (msg.type === 'error') {
|
||||
j = { error: msg.message };
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||
startBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; startBtn.disabled = false; return; }
|
||||
if (j.error) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||||
startBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
currentUrls = j.urls;
|
||||
statusEl.classList.remove('is-busy');
|
||||
selectedIdx = 0;
|
||||
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
|
||||
shareInfo.style.display = '';
|
||||
@@ -147,6 +260,7 @@ startBtn.addEventListener('click', async () => {
|
||||
|
||||
stopBtn.addEventListener('click', async () => {
|
||||
await fetch('/api/mobile/stop', { method:'POST' });
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = 'Stopped.';
|
||||
shareInfo.style.display = 'none';
|
||||
currentUrls = [];
|
||||
@@ -154,18 +268,8 @@ stopBtn.addEventListener('click', async () => {
|
||||
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) {
|
||||
qrEl.innerHTML = '';
|
||||
urlTextEl.textContent = url;
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
@@ -182,19 +286,18 @@ function renderIfaces(urls) {
|
||||
ifaceList.innerHTML = '';
|
||||
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
||||
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) => {
|
||||
const id = 'iface_' + i;
|
||||
const label = document.createElement('label');
|
||||
label.style.fontSize = '12px';
|
||||
label.style.cursor = 'pointer';
|
||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
||||
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
|
||||
label.querySelector('input').addEventListener('change', () => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||
'<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
|
||||
row.querySelector('input').addEventListener('change', () => {
|
||||
selectedIdx = i;
|
||||
renderQr(urls[i].url);
|
||||
});
|
||||
ifaceList.appendChild(label);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -207,48 +310,81 @@ func MobilePick(w http.ResponseWriter, r *http.Request) {
|
||||
picked := PickFiles(
|
||||
"Select HTML",
|
||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||
false, "",
|
||||
true, "",
|
||||
)
|
||||
if len(picked) == 0 {
|
||||
writeJSON(w, map[string]any{"path": nil})
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
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) {
|
||||
stopActiveShare()
|
||||
send := newJSONStream(w)
|
||||
|
||||
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 {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Path == "" || !fileExists(req.Path) {
|
||||
writeJSON(w, map[string]any{"error": "File missing."})
|
||||
paths := req.Paths
|
||||
if len(paths) == 0 && req.Path != "" {
|
||||
paths = []string{req.Path}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
send(map[string]any{"type": "error", "message": "File missing."})
|
||||
return
|
||||
}
|
||||
fileBuf, err := os.ReadFile(req.Path)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
||||
return
|
||||
files := make([]sharedFile, 0, len(paths))
|
||||
for i, p := range paths {
|
||||
if p == "" || !fileExists(p) {
|
||||
send(map[string]any{"type": "error", "message": "File missing."})
|
||||
return
|
||||
}
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Loading %d/%d %s", i+1, len(paths), filepath.Base(p))})
|
||||
fileBuf, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
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
|
||||
tokenBytes := make([]byte, 6)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()})
|
||||
send(map[string]any{"type": "error", "message": "Token gen failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
|
||||
|
||||
cfgPort := LoadConfig().SendToMobile.Port
|
||||
send(map[string]any{"type": "status", "message": "Starting server..."})
|
||||
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()})
|
||||
send(map[string]any{"type": "error", "message": "Failed to bind: " + err.Error()})
|
||||
return
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
@@ -257,8 +393,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
listener: listener,
|
||||
port: port,
|
||||
token: token,
|
||||
fileBuf: fileBuf,
|
||||
filename: filename,
|
||||
files: files,
|
||||
}
|
||||
share.server = &http.Server{Handler: shareHandler(share)}
|
||||
|
||||
@@ -267,7 +402,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
ips := getLanIPs()
|
||||
if len(ips) == 0 {
|
||||
_ = share.server.Close()
|
||||
writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"})
|
||||
send(map[string]any{"type": "error", "message": "No non-loopback IPv4 interface found. Are you on a network?"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -288,7 +423,11 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
@@ -308,19 +447,53 @@ func shareHandler(s *activeShare) http.Handler {
|
||||
case "", "/":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(chooserPage(s.filename)))
|
||||
_, _ = w.Write([]byte(chooserPage(s.files)))
|
||||
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("Cache-Control", "no-store")
|
||||
_, _ = w.Write(s.fileBuf)
|
||||
_, _ = w.Write(s.files[0].buf)
|
||||
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-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.Write(s.fileBuf)
|
||||
_, _ = w.Write(s.files[0].buf)
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -330,8 +503,14 @@ var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||
|
||||
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
||||
|
||||
func chooserPage(filename string) string {
|
||||
escName := html.EscapeString(filename)
|
||||
func chooserPage(files []sharedFile) string {
|
||||
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>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -341,7 +520,8 @@ func chooserPage(filename string) string {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||
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;
|
||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||
a.view { background: #2d7dff; color: white; }
|
||||
@@ -349,9 +529,7 @@ func chooserPage(filename string) string {
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>HPL Toolbox</h1>
|
||||
<div class="file">` + escName + `</div>
|
||||
<a class="btn view" href="view">View in browser</a>
|
||||
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
|
||||
` + items.String() + `
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -34,6 +35,7 @@ type mraidRule struct {
|
||||
}
|
||||
|
||||
type mraidContext struct {
|
||||
FilePath string
|
||||
HTML string
|
||||
ScriptText string
|
||||
MraidCallLines map[string]int
|
||||
@@ -41,17 +43,29 @@ type mraidContext struct {
|
||||
|
||||
func MraidPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>MRAID Checker</h2>
|
||||
<p class="hint">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">MRAID Checker</h2>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity HTML builds based on GUIDE.md plus the MRAID 3.0 specification and Best Practices Guide.</p>
|
||||
</header>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row"><button id="scan">Scan</button></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.row-result { padding:8px 0; border-bottom:1px solid #333; }
|
||||
@@ -75,52 +89,136 @@ const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
let selectedPage = 0;
|
||||
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 () => {
|
||||
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
|
||||
const payload = { files: pickedFiles };
|
||||
let j = null;
|
||||
try {
|
||||
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
await readJSONStream(r, (msg) => {
|
||||
if (msg.type === 'status') {
|
||||
statusEl.textContent = msg.message;
|
||||
statusEl.classList.add('is-busy');
|
||||
} else if (msg.type === 'done') {
|
||||
j = msg;
|
||||
} else if (msg.type === 'error') {
|
||||
j = { error: msg.message, results: msg.results || [], skipped: msg.skipped, skippedReason: msg.skippedReason };
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = 'Error: ' + (e.message || e);
|
||||
return;
|
||||
}
|
||||
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
const j = await r.json();
|
||||
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
|
||||
if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
const total = j.results.length;
|
||||
const withIssues = j.results.filter(r => !r.ok).length;
|
||||
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(j.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML 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>';
|
||||
resultsEl.innerHTML = '';
|
||||
const skippedReason = j.skippedReason || 'file(s).';
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||
if (!total) { resultsEl.innerHTML = ''; return; }
|
||||
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const rr of j.results) {
|
||||
const reqCount = rr.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bpCount = rr.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
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 row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = rr.file;
|
||||
const result = document.createElement('td');
|
||||
result.className = 'wrap';
|
||||
if (!rr.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
@@ -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>';
|
||||
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>
|
||||
@@ -143,7 +244,18 @@ document.getElementById('scan').addEventListener('click', async () => {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -152,18 +264,21 @@ func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
send := newJSONStream(w)
|
||||
var req struct {
|
||||
Folder string `json:"folder"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error(), "results": []mraidResult{}})
|
||||
return
|
||||
}
|
||||
|
||||
var targets []string
|
||||
var baseDir string
|
||||
skipped := 0
|
||||
skippedReason := ""
|
||||
noTargetsError := "No HTML files were found."
|
||||
if len(req.Files) > 0 {
|
||||
var existing []string
|
||||
for _, f := range req.Files {
|
||||
@@ -171,40 +286,41 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
existing = append(existing, f)
|
||||
}
|
||||
}
|
||||
for _, f := range existing {
|
||||
if isSupportedMraidBuild(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
skipped = len(existing) - len(targets)
|
||||
targets = existing
|
||||
skipped = len(req.Files) - len(existing)
|
||||
skippedReason = "missing file(s)."
|
||||
noTargetsError = "No selected HTML files were found."
|
||||
if len(targets) > 0 {
|
||||
baseDir = commonBaseDir(targets)
|
||||
}
|
||||
} else if req.Folder != "" && fileExists(req.Folder) {
|
||||
all := collectHTMLFiles(req.Folder)
|
||||
for _, f := range all {
|
||||
if isSupportedMraidBuild(f) {
|
||||
if isInSupportedMraidFolderPath(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
skipped = len(all) - len(targets)
|
||||
skippedReason = "HTML file(s) outside AppLovin/ironSource/Unity folders."
|
||||
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
|
||||
baseDir = req.Folder
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
|
||||
send(map[string]any{"type": "error", "message": "Pick a folder or files first", "results": []mraidResult{}})
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "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
|
||||
}
|
||||
|
||||
results := []mraidResult{}
|
||||
for _, file := range targets {
|
||||
for i, file := range targets {
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
issues := checkMraid(string(data))
|
||||
issues := checkMraid(string(data), file)
|
||||
display := file
|
||||
if baseDir != "" {
|
||||
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
|
||||
@@ -215,30 +331,71 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results, "skipped": skipped})
|
||||
send(map[string]any{"type": "done", "results": results, "skipped": skipped, "skippedReason": skippedReason})
|
||||
}
|
||||
|
||||
var (
|
||||
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||
mraidBuildNameRx = regexp.MustCompile(`(?i)(^|[/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([/._ -]|$)`)
|
||||
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||
mraidJsRx = regexp.MustCompile(`(?i)\bmraid\.js\b`)
|
||||
mraidViewportRx = regexp.MustCompile(`(?i)<meta\b[^>]*\bname\s*=\s*['"]viewport['"][^>]*>`)
|
||||
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||
mraidHyperlinkRx = regexp.MustCompile(`(?i)<a\b[^>]*\bhref\s*=`)
|
||||
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||
mraidDomReadyRx = regexp.MustCompile(`(?i)document\s*\.\s*readyState|DOMContentLoaded|\bwindow\s*\.\s*(addEventListener\s*\(\s*['"]load['"]|onload\b)`)
|
||||
mraidTypeofGuardRx = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*!={1,2}\s*['"]undefined['"]`)
|
||||
mraidReadyTimeoutRx = regexp.MustCompile(`(?is)setTimeout\s*\(.*mraid|mraid.*setTimeout\s*\(`)
|
||||
mraidLateDetectionRx = regexp.MustCompile(`(?i)(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\().*(window\s*\.\s*mraid|\bmraid\b)|(window\s*\.\s*mraid|\bmraid\b).*(setInterval\s*\(|setTimeout\s*\(|requestAnimationFrame\s*\()`)
|
||||
mraidOpenGuardRx = regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*open\s*!={1,2}\s*['"]undefined['"]`)
|
||||
mraidOpenLoadingGuardRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*mraid\s*\.\s*getState\s*\(\s*\)|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*getState\s*!={1,2}\s*['"]function['"]`)
|
||||
mraidVariableLoadingGuardRx = regexp.MustCompile(`(?i)\b\w+\s*!={1,2}\s*['"]loading['"]|['"]loading['"]\s*!={1,2}\s*\w+\b`)
|
||||
mraidLoadingFallbackRx = regexp.MustCompile(`(?is)\b\w+\s*={2,3}\s*['"]loading['"].{0,500}\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidAudioSafeRx = regexp.MustCompile(`(?i)typeof\s+\w+\s*={2,3}\s*['"]number['"]|!=\s*null|!==\s*null`)
|
||||
mraidTwoPartExpandRx = regexp.MustCompile(`(?i)mraid\s*\.\s*expand\s*\(\s*[^)\s]`)
|
||||
mraidVideoOpenRx = regexp.MustCompile(`(?i)mraid\s*\.\s*open\s*\(\s*[^)]*\.(mp4|m4v|mov|webm|avi|m3u8)(['"` + "`" + `?#)]|$)`)
|
||||
mraidRejectedScriptAttrsRx = regexp.MustCompile(`(?i)<script\b[^>]*(\btype\s*=\s*['"]module['"]|\bcrossorigin\b)`)
|
||||
mraidConsoleErrorRx = regexp.MustCompile(`(?i)\bconsole\s*\.\s*error\s*\(`)
|
||||
)
|
||||
|
||||
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
|
||||
const mraidReference = "GUIDE.md, MRAID 3.0 specification, and MRAID 3.0 Best Practices Guide."
|
||||
|
||||
func isSupportedMraidBuild(filePath string) bool {
|
||||
normalized := strings.ReplaceAll(strings.ToLower(filePath), `\`, `/`)
|
||||
return mraidBuildNameRx.MatchString(normalized)
|
||||
func isInSupportedMraidFolderPath(filePath string) bool {
|
||||
supported := map[string]bool{
|
||||
"al": true,
|
||||
"applovin": true,
|
||||
"app-lovin": true,
|
||||
"is": true,
|
||||
"ironsource": true,
|
||||
"iron-source": true,
|
||||
"iron_source": true,
|
||||
"un": true,
|
||||
"unity": true,
|
||||
"unityads": true,
|
||||
"unity-ads": true,
|
||||
"unity_ads": true,
|
||||
}
|
||||
parts := strings.FieldsFunc(strings.ToLower(filepath.Dir(filePath)), func(r rune) bool {
|
||||
return r == '/' || r == '\\'
|
||||
})
|
||||
for _, part := range parts {
|
||||
if supported[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkMraid(htmlSrc string) []mraidIssue {
|
||||
func checkMraid(htmlSrc string, filePath ...string) []mraidIssue {
|
||||
sourcePath := ""
|
||||
if len(filePath) > 0 {
|
||||
sourcePath = filePath[0]
|
||||
}
|
||||
ctx := mraidContext{
|
||||
FilePath: sourcePath,
|
||||
HTML: htmlSrc,
|
||||
ScriptText: extractMraidScriptText(htmlSrc),
|
||||
MraidCallLines: collectMraidCallLines(htmlSrc),
|
||||
@@ -276,12 +433,31 @@ func collectMraidCallLines(htmlSrc string) map[string]int {
|
||||
}
|
||||
|
||||
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
|
||||
func mraidCountJSReferences(ctx mraidContext) int {
|
||||
return len(mraidJsRx.FindAllStringIndex(ctx.HTML, -1))
|
||||
}
|
||||
func mraidHasViewport(ctx mraidContext) bool { return mraidViewportRx.MatchString(ctx.HTML) }
|
||||
func mraidIsNetworkTarget(ctx mraidContext) bool {
|
||||
if ctx.FilePath == "" {
|
||||
return true
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(ctx.FilePath))
|
||||
return isInSupportedMraidFolderPath(ctx.FilePath) ||
|
||||
strings.HasSuffix(name, "_al.html") || strings.HasSuffix(name, "_is.html") || strings.HasSuffix(name, "_un.html") ||
|
||||
strings.HasSuffix(name, "_al.htm") || strings.HasSuffix(name, "_is.htm") || strings.HasSuffix(name, "_un.htm")
|
||||
}
|
||||
func mraidHasCall(ctx mraidContext, method string) bool {
|
||||
_, ok := ctx.MraidCallLines[method]
|
||||
return ok
|
||||
}
|
||||
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
|
||||
|
||||
func mraidHasFunctionGuard(ctx mraidContext, method string) bool {
|
||||
escaped := regexp.QuoteMeta(method)
|
||||
rx := regexp.MustCompile(`(?i)typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*={2,3}\s*['"]function['"]|typeof\s+(window\s*\.\s*)?mraid\s*\.\s*` + escaped + `\s*!={1,2}\s*['"]undefined['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
|
||||
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
@@ -296,6 +472,38 @@ func mraidHasReadyGate(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasDomReadyGate(ctx mraidContext) bool { return mraidDomReadyRx.MatchString(ctx.ScriptText) }
|
||||
func mraidHasReadyStateFallback(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "ready") && mraidHasCall(ctx, "getState")
|
||||
}
|
||||
func mraidHasTypeofGuard(ctx mraidContext) bool {
|
||||
return mraidTypeofGuardRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasReadyTimeout(ctx mraidContext) bool {
|
||||
return mraidReadyTimeoutRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasLateDetection(ctx mraidContext) bool {
|
||||
return mraidLateDetectionRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasOpenGuard(ctx mraidContext) bool { return mraidOpenGuardRx.MatchString(ctx.ScriptText) }
|
||||
func mraidHasOpenLoadingGuard(ctx mraidContext) bool {
|
||||
return mraidOpenLoadingGuardRx.MatchString(ctx.ScriptText) ||
|
||||
(mraidHasCall(ctx, "getState") && mraidVariableLoadingGuardRx.MatchString(ctx.ScriptText)) ||
|
||||
(mraidHasCall(ctx, "getState") && mraidLoadingFallbackRx.MatchString(ctx.ScriptText))
|
||||
}
|
||||
func mraidUsesBrowserNavigation(ctx mraidContext) bool {
|
||||
return mraidWindowOpenRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidUsesLocationNavigation(ctx mraidContext) bool {
|
||||
return mraidLocationNavRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasAudioVolumeSafeHandling(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "audioVolumeChange") && mraidAudioSafeRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
func mraidHasLifecycleSignal(ctx mraidContext, name string) bool {
|
||||
return regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\b`).MatchString(ctx.HTML)
|
||||
}
|
||||
|
||||
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
|
||||
if line > 0 {
|
||||
@@ -305,56 +513,171 @@ func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||
}
|
||||
|
||||
var mraidRules = []mraidRule{
|
||||
{ID: "mraid-js-missing", Severity: "requirement", Title: "mraid.js reference not found", Detail: "MRAID creatives for AppLovin, ironSource, and Unity must request mraid.js early in the generated HTML.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidIsNetworkTarget(ctx) && mraidCountJSReferences(ctx) == 0 {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "mraid-js-duplicate", Severity: "requirement", Title: "Multiple mraid.js references found", Detail: "Request mraid.js exactly once; duplicate references can inject multiple MRAID libraries and slow or destabilize the ad.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidCountJSReferences(ctx) > 1 {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "mraid-reference", Severity: "requirement", Title: "MRAID API reference not found", Detail: "Playable HTML should use the injected mraid object when it depends on an MRAID container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "viewport-meta", Severity: "best-practice", Title: "Viewport meta tag not found", Detail: "MRAID creatives should include a mobile viewport meta tag so the ad scales predictably on mobile WebViews.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasViewport(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "mraid-typeof-guard", Severity: "requirement", Title: "MRAID object is not guarded", Detail: "Guard MRAID access with typeof mraid !== \"undefined\" or an equivalent window.mraid guard before making MRAID calls.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) && !mraidHasTypeofGuard(ctx) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "dom-ready-gate", Severity: "requirement", Title: "DOM readiness is not guarded", Detail: "MRAID startup should wait for DOM readiness as well as MRAID readiness before building or binding rich media content.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasDomReadyGate(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "ready-gate", Severity: "requirement", Title: "MRAID ready state is not guarded", Detail: "Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "ready-state-fallback", Severity: "requirement", Title: "MRAID ready listener/state fallback pair not found", Detail: "Use both mraid.addEventListener(\"ready\", ...) and mraid.getState() so startup works when ready fires before the listener attaches.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyStateFallback(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "ready-timeout-fallback", Severity: "best-practice", Title: "MRAID ready timeout fallback not found", Detail: "A timeout fallback prevents startup hangs when a container never fires ready.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyTimeout(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "late-mraid-detection", Severity: "best-practice", Title: "Late MRAID injection detection not found", Detail: "Poll or retry briefly for late window.mraid injection before deciding the creative is running without MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasLateDetection(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "error-listener", Severity: "best-practice", Title: "No MRAID error listener found", Detail: "Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "viewability", Severity: "best-practice", Title: "Viewability handling not found", Detail: "Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") {
|
||||
{ID: "viewability", Severity: "best-practice", Title: "MRAID viewability fallback chain not found", Detail: "Use exposureChange for MRAID 3.0 and keep viewableChange or isViewable() as MRAID 2.0 fallback before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
hasVisibility := mraidHasEventListener(ctx, "exposureChange") && (mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange"))
|
||||
if !mraidHasReference(ctx) || hasVisibility {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") {
|
||||
{ID: "audio-volume-change", Severity: "best-practice", Title: "audioVolumeChange handling not found", Detail: "Listen for audioVolumeChange and ignore null values; only apply volume math when the value is numeric.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasAudioVolumeSafeHandling(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container; avoid hyperlinks, location redirects, and unguarded browser navigation.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHyperlinkRx.MatchString(ctx.HTML) || mraidUsesLocationNavigation(ctx) || (mraidUsesBrowserNavigation(ctx) && !mraidHasCall(ctx, "open")) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "open-guarded-fallback", Severity: "requirement", Title: "mraid.open guarded fallback not found", Detail: "Guard mraid.open with function/state checks and provide a window.open fallback when MRAID is unavailable or still loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "open") && (!mraidHasOpenGuard(ctx) || !mraidHasOpenLoadingGuard(ctx) || !mraidUsesBrowserNavigation(ctx)) {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "open"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "open-video-url", Severity: "best-practice", Title: "mraid.open appears to target video media", Detail: "Use mraid.playVideo(url) for native video playback cases instead of mraid.open(videoUrl).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidVideoOpenRx.MatchString(ctx.ScriptText) && !mraidHasCall(ctx, "playVideo") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "open"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "close-api", Severity: "best-practice", Title: "No mraid.close call found", Detail: "If the creative renders its own close control, wire that control to mraid.close().", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without supports check", Detail: "Check mraid.supports(\"expand\") before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
{ID: "unload-fallback", Severity: "best-practice", Title: "No mraid.unload graceful failure path found", Detail: "Use mraid.unload() when the ad cannot run or refuses to show, instead of leaving a broken or blank creative.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) && !mraidHasCall(ctx, "unload") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without supports check", Detail: "Check mraid.supports(\"resize\") before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasSupportCheck(ctx, "resize") {
|
||||
{ID: "resize-properties", Severity: "requirement", Title: "mraid.resize used without setResizeProperties", Detail: "Call mraid.setResizeProperties(...) before mraid.resize(); otherwise the container should emit an error.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasCall(ctx, "setResizeProperties") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without supports check", Detail: "Check mraid.supports(\"orientation\") before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") {
|
||||
{ID: "use-custom-close", Severity: "best-practice", Title: "Deprecated useCustomClose found", Detail: "MRAID 3.0 ignores useCustomClose; the host provides the mandatory close control.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "useCustomClose") || regexp.MustCompile(`(?i)useCustomClose`).MatchString(ctx.ScriptText) {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "useCustomClose"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "two-part-expand", Severity: "best-practice", Title: "Two-part expand appears to be used", Detail: "Two-part expandable ads are deprecated in MRAID 3.0; use self-contained one-part expandables.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidTwoPartExpandRx.MatchString(ctx.ScriptText) {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without function guard", Detail: "Guard mraid.expand with a function check before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "expand") && !mraidHasFunctionGuard(ctx, "expand") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without function guard", Detail: "Guard mraid.resize with a function check before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasFunctionGuard(ctx, "resize") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "store-picture-support", Severity: "requirement", Title: "storePicture used without supports check", Detail: "Call mraid.supports(\"storePicture\") before mraid.storePicture(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "storePicture") && !mraidHasSupportCheck(ctx, "storePicture") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "storePicture"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "calendar-support", Severity: "requirement", Title: "createCalendarEvent used without supports check", Detail: "Call mraid.supports(\"calendar\") before mraid.createCalendarEvent(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "createCalendarEvent") && !mraidHasSupportCheck(ctx, "calendar") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "createCalendarEvent"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "location-support", Severity: "requirement", Title: "getLocation used without supports check", Detail: "Call mraid.supports(\"location\") before mraid.getLocation(). Do not use HTML5 geolocation as a substitute in MRAID.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "getLocation") && !mraidHasSupportCheck(ctx, "location") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "getLocation"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "vpaid-support", Severity: "requirement", Title: "VPAID integration used without supports check", Detail: "Call mraid.supports(\"vpaid\") before mraid.initVpaid(...).", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "initVpaid") && !mraidHasSupportCheck(ctx, "vpaid") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "initVpaid"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without function guard", Detail: "Guard mraid.setOrientationProperties with a function check before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasFunctionGuard(ctx, "setOrientationProperties") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
|
||||
}
|
||||
return nil
|
||||
@@ -373,4 +696,30 @@ var mraidRules = []mraidRule{
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "output-script-hygiene", Severity: "requirement", Title: "Rejected script attributes found", Detail: "Playable output should not contain type=\"module\" or crossorigin on script tags; ad networks can reject ES modules/CORS attributes.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidRejectedScriptAttrsRx.MatchString(ctx.HTML) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "console-error", Severity: "best-practice", Title: "console.error found", Detail: "The ship checklist expects no console.error calls in output; use guarded logging or remove debug error logs before submission.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidConsoleErrorRx.MatchString(ctx.ScriptText) {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "lifecycle-stubs", Severity: "requirement", Title: "Playable lifecycle signals not found", Detail: "Expose or call gameReady, gameStart, gameEnd, and gameClose so network preview tools can detect the playable lifecycle.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
missing := []string{}
|
||||
for _, name := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} {
|
||||
if !mraidHasLifecycleSignal(ctx, name) {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
found := mraidIssueFrom(rule, 0)
|
||||
found.Detail = found.Detail + " Missing: " + strings.Join(missing, ", ") + "."
|
||||
return found
|
||||
}},
|
||||
}
|
||||
|
||||
93
standalone/mraid_dogcat3_test.go
Normal file
93
standalone/mraid_dogcat3_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDogCat3MraidChecker(t *testing.T) {
|
||||
root := filepath.Join("..", "aiAssets", "DogCat3")
|
||||
files, err := collectDogCat3MraidTargets(root)
|
||||
if err != nil {
|
||||
t.Fatalf("collect targets: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("no DogCat3 MRAID targets found")
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
t.Run(filepath.Base(file), func(t *testing.T) {
|
||||
html, displayPath, err := readDogCat3HTML(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read target: %v", err)
|
||||
}
|
||||
issues := checkMraid(html, displayPath)
|
||||
if len(issues) == 0 {
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
for _, issue := range issues {
|
||||
parts = append(parts, issue.ID+": "+issue.Title+" ("+issue.Severity+")")
|
||||
}
|
||||
t.Fatalf("MRAID checker issues:\n%s", strings.Join(parts, "\n"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func collectDogCat3MraidTargets(root string) ([]string, error) {
|
||||
var out []string
|
||||
for _, folder := range []string{"Applovin", "Ironsource", "Unity"} {
|
||||
dir := filepath.Join(root, folder)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(entry.Name())
|
||||
if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".htm") || strings.HasSuffix(name, ".zip") {
|
||||
out = append(out, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readDogCat3HTML(file string) (html string, displayPath string, err error) {
|
||||
if strings.HasSuffix(strings.ToLower(file), ".zip") {
|
||||
zr, err := zip.OpenReader(file)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, entry := range zr.File {
|
||||
if strings.EqualFold(entry.Name, "index.html") {
|
||||
rc, err := entry.Open()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return string(data), file + "#index.html", nil
|
||||
}
|
||||
}
|
||||
return "", "", os.ErrNotExist
|
||||
}
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return string(data), file, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type PlecConfig struct {
|
||||
@@ -113,13 +114,12 @@ func SaveConfig(cfg AppConfig) error {
|
||||
}
|
||||
|
||||
func OpenInBrowser(url string) {
|
||||
// `start` is a cmd builtin. Quoted empty arg is the window title.
|
||||
cmd := exec.Command("cmd", "/c", "start", "", url)
|
||||
cmd := hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
func CopyToClipboard(text string) error {
|
||||
cmd := exec.Command("clip")
|
||||
cmd := hiddenCommand("clip")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -131,6 +131,15 @@ type FileFilter struct {
|
||||
|
||||
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 {
|
||||
filterStr := "All files|*.*"
|
||||
if len(filters) > 0 {
|
||||
@@ -159,7 +168,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$dlg.FileNames | ForEach-Object { Write-Output $_ }
|
||||
}
|
||||
`, 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 {
|
||||
return nil
|
||||
}
|
||||
@@ -198,7 +207,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.FileName
|
||||
}
|
||||
`, 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 {
|
||||
return ""
|
||||
}
|
||||
@@ -215,7 +224,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.SelectedPath
|
||||
}
|
||||
`, 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 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type playworksConvertOptions struct {
|
||||
SourcePath string `json:"sourcePath"`
|
||||
OutputDir string `json:"outputDir"`
|
||||
BaseName string `json:"baseName"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
@@ -26,107 +27,169 @@ type playworksNetworkResult struct {
|
||||
|
||||
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||
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>
|
||||
.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; }
|
||||
.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-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:4px 12px; }
|
||||
.net-row { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; user-select:none; border:1px solid #333; border-radius:4px; background:rgba(128,128,128,0.06); }
|
||||
.net-label { min-width:100px; font-weight:500; }
|
||||
.net-tag { font-size:10px; opacity:0.6; font-weight:400; }
|
||||
.net-note { font-size:11px; opacity:0.6; }
|
||||
.toggle-row { display:flex; gap:8px; margin-bottom:6px; }
|
||||
.toggle-row button { font-size:11px; padding:2px 8px; }
|
||||
.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>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Source HTML (Playworks export)</div>
|
||||
<div class="row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" />
|
||||
<button id="pickSrc">Browse...</button>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Source HTML (Playworks export)</div>
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<div class="file-row">
|
||||
<button id="pickSrc" class="secondary">Select File</button>
|
||||
<button id="clearSrc" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="drop-zone-text">or drop a Playworks HTML file here</div>
|
||||
</div>
|
||||
<input id="src" type="hidden" />
|
||||
<div id="sourceSelection" class="selected-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Output Folder</div>
|
||||
<div class="row">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Base Filename</div>
|
||||
<div class="field-row">
|
||||
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="control-label">Output Folder</div>
|
||||
<div class="field-row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
||||
<button id="pickOut">Browse...</button>
|
||||
<button id="pickOut" class="secondary">Select Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Networks to generate</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Networks to generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll">All</button>
|
||||
<button id="selectNone">None</button>
|
||||
<button id="selectAll" class="secondary">All</button>
|
||||
<button id="selectNone" class="secondary">None</button>
|
||||
</div>
|
||||
<div class="net-grid">
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="is" checked /><span class="net-label">Ironsource <span class="net-tag">is</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="un" checked /><span class="net-label">Unity <span class="net-tag">un</span></span><span class="net-note">HTML (window.top to self)</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="fb" checked /><span class="net-label">Facebook <span class="net-tag">fb</span></span><span class="net-note">HTML</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="gg" checked /><span class="net-label">Google Ads <span class="net-tag">gg</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="ap" checked /><span class="net-label">Appreciate <span class="net-tag">ap</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML + FbPlayableAd CTA</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" 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 class="actions">
|
||||
<div class="action-row">
|
||||
<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 id="status"></div>
|
||||
<div class="results" id="results"></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<script>
|
||||
let outputDir = '';
|
||||
const srcEl = document.getElementById('src');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const baseNameEl = document.getElementById('baseName');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const sourceSelectionEl = document.getElementById('sourceSelection');
|
||||
|
||||
async function readJSONStream(response, handle) {
|
||||
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline;
|
||||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line.trim()) handle(JSON.parse(line));
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) handle(JSON.parse(buffer));
|
||||
}
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderSourceSelection() {
|
||||
if (!srcEl.value) {
|
||||
sourceSelectionEl.innerHTML = '<span class="file-name">(no file selected)</span>';
|
||||
return;
|
||||
}
|
||||
sourceSelectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||
'<tr><td class="mono wrap">' + escapeHtml(basename(srcEl.value)) + '</td><td><button id="removeSource" class="remove-selected danger" title="Remove">×</button></td></tr>' +
|
||||
'</tbody></table></div>';
|
||||
document.getElementById('removeSource').addEventListener('click', clearSource);
|
||||
}
|
||||
|
||||
function setSource(path, baseName, dir) {
|
||||
srcEl.value = path || '';
|
||||
baseNameEl.value = baseName || '';
|
||||
if (!outEl.value && dir) { outEl.value = dir.replace(/[\\\/]+$/, '') + '\\\\Output'; outputDir = outEl.value; }
|
||||
renderSourceSelection();
|
||||
updateConvertBtn();
|
||||
}
|
||||
|
||||
function clearSource() {
|
||||
srcEl.value = '';
|
||||
baseNameEl.value = '';
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('is-busy');
|
||||
renderSourceSelection();
|
||||
updateConvertBtn();
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/playworks/pickSource', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) {
|
||||
srcEl.value = j.path;
|
||||
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
|
||||
updateConvertBtn();
|
||||
setSource(j.path, j.baseName || '', j.dir || '');
|
||||
}
|
||||
});
|
||||
document.getElementById('clearSrc').addEventListener('click', clearSource);
|
||||
document.getElementById('pickOut').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
|
||||
});
|
||||
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();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
@@ -135,50 +198,77 @@ document.getElementById('selectNone').addEventListener('click', () => {
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
||||
});
|
||||
|
||||
function outputDisplayName(file) {
|
||||
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
setupDropZone('dropZone', statusEl, (j) => {
|
||||
const paths = j.paths || [];
|
||||
if (!paths.length) {
|
||||
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||||
return;
|
||||
}
|
||||
const sourcePath = paths[0];
|
||||
const droppedBase = basename(sourcePath).replace(/\\.html?$/i, '').replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/i, '');
|
||||
const i = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\\\'));
|
||||
setSource(sourcePath, droppedBase, i >= 0 ? sourcePath.slice(0, i) : '');
|
||||
const skipped = Number(j.skipped || 0) + Math.max(0, paths.length - 1);
|
||||
if (skipped) statusEl.textContent = 'Using first dropped HTML file. Skipped ' + skipped + ' other item(s).';
|
||||
});
|
||||
renderSourceSelection();
|
||||
|
||||
document.getElementById('convert').addEventListener('click', async () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
statusEl.classList.add('is-busy');
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
openOutBtn.disabled = true;
|
||||
convertBtn.disabled = true;
|
||||
const res = await fetch('/api/playworks/convert', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, 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;
|
||||
updateConvertBtn();
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
if (!msg) { msg = { error: 'No response.', results: [] }; }
|
||||
if (msg.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
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) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'res-row';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const net = document.createElement('span');
|
||||
net.className = 'res-net';
|
||||
net.textContent = r.network;
|
||||
const detail = document.createElement('span');
|
||||
if (r.ok) {
|
||||
detail.className = 'res-file';
|
||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||
} else {
|
||||
detail.className = 'res-err';
|
||||
detail.textContent = r.error || 'Unknown error';
|
||||
}
|
||||
row.append(mark, net, detail);
|
||||
resultsEl.appendChild(row);
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td class="mono">' + r.network + '</td>' +
|
||||
'<td>' + (r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Error</span>') + '</td>' +
|
||||
'<td class="mono wrap">' + (r.ok ? outputDisplayName(r.file) : '<span class="muted">-</span>') + '</td>' +
|
||||
'<td class="err wrap">' + (r.ok ? '' : (r.error || 'Unknown error')) + '</td>';
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
if (ok > 0) { outputDir = outEl.value; openOutBtn.style.display = ''; }
|
||||
if (ok > 0) { outputDir = outEl.value; openOutBtn.disabled = false; }
|
||||
});
|
||||
</script>
|
||||
`
|
||||
@@ -192,7 +282,7 @@ func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{})
|
||||
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) {
|
||||
@@ -200,27 +290,37 @@ func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
|
||||
send := newJSONStream(w)
|
||||
var opts playworksConvertOptions
|
||||
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error(), "results": []playworksNetworkResult{}})
|
||||
return
|
||||
}
|
||||
if !fileExists(opts.SourcePath) {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."})
|
||||
send(map[string]any{"type": "error", "message": "Source file not found.", "results": []playworksNetworkResult{}})
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."})
|
||||
send(map[string]any{"type": "error", "message": "Could not create output folder.", "results": []playworksNetworkResult{}})
|
||||
return
|
||||
}
|
||||
send(map[string]any{"type": "status", "message": "Reading source HTML..."})
|
||||
data, err := os.ReadFile(opts.SourcePath)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()})
|
||||
send(map[string]any{"type": "error", "message": "Could not read source: " + err.Error(), "results": []playworksNetworkResult{}})
|
||||
return
|
||||
}
|
||||
|
||||
results := []playworksNetworkResult{}
|
||||
for _, network := range opts.Networks {
|
||||
send(map[string]any{"type": "status", "message": "Converting Unity..."})
|
||||
unityPath, err := copyPlayworksUnityInput(data, opts)
|
||||
if err != nil {
|
||||
results = append(results, playworksNetworkResult{Network: "un", OK: false, Error: err.Error()})
|
||||
} else {
|
||||
results = append(results, playworksNetworkResult{Network: "un", File: unityPath, OK: true})
|
||||
}
|
||||
for i, network := range opts.Networks {
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Converting %d/%d %s", i+1, len(opts.Networks), network)})
|
||||
filePath, err := convertPlayworksNetwork(string(data), opts, network)
|
||||
if err != nil {
|
||||
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
|
||||
@@ -228,67 +328,286 @@ func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
send(map[string]any{"type": "done", "results": results})
|
||||
}
|
||||
|
||||
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`)
|
||||
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$`)
|
||||
var playworksMraidScriptRx = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']mraid\.js["'][^>]*>\s*</script>\s*`)
|
||||
var playworksExitapiScriptRx = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']exitapi\.js["'][^>]*>\s*</script>\s*`)
|
||||
var playworksHeadCloseRx = regexp.MustCompile(`(?i)</head>`)
|
||||
var playworksBodyOpenRx = regexp.MustCompile(`(?i)<body\b[^>]*>`)
|
||||
var playworksBodyTagRx = regexp.MustCompile(`(?i)<body\b([^>]*)>`)
|
||||
var playworksHtmlCloseRx = regexp.MustCompile(`(?i)</html\s*>`)
|
||||
var playworksRemoteDebugScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?insertYourRemoteDebuggingTokenHere.*?</script>\s*`)
|
||||
var playworksStatsURLRx = regexp.MustCompile(`(?i)https://mrdoob\.github\.io/stats\.js/build/stats\.min\.js`)
|
||||
var playworksCrossoriginRx = regexp.MustCompile(`(?i)\s+crossorigin(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?`)
|
||||
var playworksTypeModuleRx = regexp.MustCompile(`(?i)\s+type\s*=\s*["']module["']`)
|
||||
var playworksConsoleErrorRx = regexp.MustCompile(`\bconsole\.error\s*\(`)
|
||||
|
||||
func playworksSourceBaseName(sourcePath string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||
return playworksSuffixRx.ReplaceAllString(base, "")
|
||||
}
|
||||
|
||||
func playworksOutputBaseName(opts playworksConvertOptions) string {
|
||||
baseName := strings.TrimSpace(opts.BaseName)
|
||||
if baseName == "" {
|
||||
baseName = playworksSourceBaseName(opts.SourcePath)
|
||||
}
|
||||
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||
baseName = filepath.Base(baseName)
|
||||
baseName = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
|
||||
return '_'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, baseName)
|
||||
baseName = strings.TrimSpace(baseName)
|
||||
if baseName == "" {
|
||||
return playworksSourceBaseName(opts.SourcePath)
|
||||
}
|
||||
return baseName
|
||||
}
|
||||
|
||||
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
||||
if !playworksIsSupportedNetwork(network) {
|
||||
return "", fmt.Errorf("unsupported Playworks target network: %s", network)
|
||||
}
|
||||
transformed := transformPlayworksHTML(htmlSrc, network)
|
||||
baseName := playworksSourceBaseName(opts.SourcePath)
|
||||
baseName := playworksOutputBaseName(opts)
|
||||
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
||||
outputDir := filepath.Join(opts.OutputDir, playworksNetworkOutputFolder(network))
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if playworksNeedsZip(network) {
|
||||
zipPath := filepath.Join(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 {
|
||||
return "", err
|
||||
}
|
||||
return zipPath, nil
|
||||
}
|
||||
|
||||
outPath := filepath.Join(opts.OutputDir, htmlFileName)
|
||||
outPath := filepath.Join(outputDir, htmlFileName)
|
||||
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func copyPlayworksUnityInput(data []byte, opts playworksConvertOptions) (string, error) {
|
||||
baseName := playworksOutputBaseName(opts)
|
||||
outputDir := filepath.Join(opts.OutputDir, "Unity")
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
outPath := filepath.Join(outputDir, fmt.Sprintf("%s_un.html", baseName))
|
||||
if err := os.WriteFile(outPath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func playworksNetworkOutputFolder(network string) string {
|
||||
switch network {
|
||||
case "al":
|
||||
return "Applovin"
|
||||
case "is":
|
||||
return "Ironsource"
|
||||
case "fb":
|
||||
return "Facebook"
|
||||
case "gg":
|
||||
return "GoogleAds"
|
||||
case "ap":
|
||||
return "Appreciate"
|
||||
case "mo":
|
||||
return "Moloco"
|
||||
case "vu":
|
||||
return "Vungle"
|
||||
case "mtg":
|
||||
return "Mintegral"
|
||||
case "tt":
|
||||
return "TikTok"
|
||||
default:
|
||||
return network
|
||||
}
|
||||
}
|
||||
|
||||
func playworksNeedsZip(network string) bool {
|
||||
return network == "gg" || network == "vu" || network == "mtg"
|
||||
return network == "gg" || network == "ap" || network == "vu" || network == "mtg"
|
||||
}
|
||||
|
||||
func playworksIsMraidNetwork(network string) bool {
|
||||
return network == "al" || network == "is"
|
||||
}
|
||||
|
||||
func playworksIsExitapiNetwork(network string) bool {
|
||||
return network == "gg" || network == "ap"
|
||||
}
|
||||
|
||||
func playworksIsSupportedNetwork(network string) bool {
|
||||
switch network {
|
||||
case "al", "is", "fb", "gg", "ap", "mo", "vu", "mtg", "tt":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func transformPlayworksHTML(htmlSrc, network string) string {
|
||||
result := htmlSrc
|
||||
result := sanitizePlayworksHTML(htmlSrc)
|
||||
headInject := buildPlayworksLifecycleScript()
|
||||
if network == "al" || network == "is" {
|
||||
headInject += `<script src="mraid.js"></script>`
|
||||
} else if network == "gg" {
|
||||
if playworksIsMraidNetwork(network) {
|
||||
headInject += `<script src="mraid.js"></script>` + buildPlayworksMraidComplianceScript()
|
||||
} else if playworksIsExitapiNetwork(network) {
|
||||
headInject += `<script src="exitapi.js"></script>`
|
||||
}
|
||||
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1)
|
||||
result = injectPlayworksBeforeHeadClose(result, headInject)
|
||||
|
||||
if network == "vu" {
|
||||
result = strings.Replace(result, "<body>", `<body>
|
||||
<script>window.__VUNGLE__=true;</script>`, 1)
|
||||
result = injectPlayworksAfterBodyOpen(result, `<script>window.__VUNGLE__=true;</script>`)
|
||||
} else if network == "mtg" {
|
||||
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1)
|
||||
result = addPlayworksBodyOnload(result, "gameReady()")
|
||||
} else if network == "tt" {
|
||||
result = strings.Replace(result, "<body>", `<body>
|
||||
<script>window.__TIKTOK__=true;</script>`, 1)
|
||||
result = injectPlayworksAfterBodyOpen(result, `<script>window.__TIKTOK__=true;</script>`)
|
||||
}
|
||||
|
||||
result = replacePlayworksCTAScript(result, network)
|
||||
if network == "un" {
|
||||
result = strings.ReplaceAll(result, "window.top", "window.self")
|
||||
return finalizePlayworksNetworkHTML(result, network)
|
||||
}
|
||||
|
||||
func sanitizePlayworksHTML(htmlSrc string) string {
|
||||
return removePlayworksNetworkScripts(cleanupPlayworksHTML(htmlSrc))
|
||||
}
|
||||
|
||||
func cleanupPlayworksHTML(htmlSrc string) string {
|
||||
result := trimPlayworksAfterFirstHTMLClose(htmlSrc)
|
||||
result = playworksRemoteDebugScriptRx.ReplaceAllString(result, "")
|
||||
result = playworksStatsURLRx.ReplaceAllString(result, "data:text/javascript,")
|
||||
result = playworksCrossoriginRx.ReplaceAllString(result, "")
|
||||
result = playworksTypeModuleRx.ReplaceAllString(result, "")
|
||||
result = playworksConsoleErrorRx.ReplaceAllString(result, "console.warn(")
|
||||
return result
|
||||
}
|
||||
|
||||
func trimPlayworksAfterFirstHTMLClose(htmlSrc string) string {
|
||||
loc := playworksHtmlCloseRx.FindStringIndex(htmlSrc)
|
||||
if loc == nil {
|
||||
return htmlSrc
|
||||
}
|
||||
return htmlSrc[:loc[1]]
|
||||
}
|
||||
|
||||
func finalizePlayworksNetworkHTML(htmlSrc, network string) string {
|
||||
result := cleanupPlayworksHTML(htmlSrc)
|
||||
result = dedupePlayworksScriptSrc(result, "mraid.js", playworksIsMraidNetwork(network))
|
||||
result = dedupePlayworksScriptSrc(result, "exitapi.js", playworksIsExitapiNetwork(network))
|
||||
return result
|
||||
}
|
||||
|
||||
func removePlayworksNetworkScripts(htmlSrc string) string {
|
||||
result := playworksMraidScriptRx.ReplaceAllString(htmlSrc, "")
|
||||
result = playworksExitapiScriptRx.ReplaceAllString(result, "")
|
||||
return result
|
||||
}
|
||||
|
||||
func dedupePlayworksScriptSrc(htmlSrc, src string, shouldExist bool) string {
|
||||
rx := regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']` + regexp.QuoteMeta(src) + `["'][^>]*>\s*</script>\s*`)
|
||||
seen := false
|
||||
result := rx.ReplaceAllStringFunc(htmlSrc, func(match string) string {
|
||||
if shouldExist && !seen {
|
||||
seen = true
|
||||
return `<script src="` + src + `"></script>`
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if shouldExist && !seen {
|
||||
result = injectPlayworksBeforeHeadClose(result, `<script src="`+src+`"></script>`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string {
|
||||
loc := playworksHeadCloseRx.FindStringIndex(htmlSrc)
|
||||
if loc != nil {
|
||||
return htmlSrc[:loc[0]] + injection + "\n" + htmlSrc[loc[0]:]
|
||||
}
|
||||
return injection + "\n" + htmlSrc
|
||||
}
|
||||
|
||||
func injectPlayworksAfterBodyOpen(htmlSrc, injection string) string {
|
||||
loc := playworksBodyOpenRx.FindStringIndex(htmlSrc)
|
||||
if loc == nil {
|
||||
return injection + "\n" + htmlSrc
|
||||
}
|
||||
return htmlSrc[:loc[1]] + "\n" + injection + htmlSrc[loc[1]:]
|
||||
}
|
||||
|
||||
func addPlayworksBodyOnload(htmlSrc, handler string) string {
|
||||
loc := playworksBodyTagRx.FindStringSubmatchIndex(htmlSrc)
|
||||
if loc == nil {
|
||||
return `<body onload="` + handler + `">` + "\n" + htmlSrc
|
||||
}
|
||||
attrs := htmlSrc[loc[2]:loc[3]]
|
||||
onloadStart, onloadEnd, quote, valueStart, valueEnd := findPlayworksOnloadAttr(attrs)
|
||||
if onloadStart == -1 {
|
||||
updated := `<body` + attrs + ` onload="` + handler + `">`
|
||||
return htmlSrc[:loc[0]] + updated + htmlSrc[loc[1]:]
|
||||
}
|
||||
value := attrs[valueStart:valueEnd]
|
||||
if strings.Contains(value, handler) {
|
||||
return htmlSrc
|
||||
}
|
||||
replacement := `onload=` + quote + value + `;` + handler + quote
|
||||
updatedAttrs := attrs[:onloadStart] + replacement + attrs[onloadEnd:]
|
||||
updatedTag := `<body` + updatedAttrs + `>`
|
||||
return htmlSrc[:loc[0]] + updatedTag + htmlSrc[loc[1]:]
|
||||
}
|
||||
|
||||
func findPlayworksOnloadAttr(attrs string) (start int, end int, quote string, valueStart int, valueEnd int) {
|
||||
lower := strings.ToLower(attrs)
|
||||
idx := strings.Index(lower, "onload")
|
||||
for idx != -1 {
|
||||
if idx > 0 {
|
||||
prev := lower[idx-1]
|
||||
if (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '-' || prev == '_' {
|
||||
next := strings.Index(lower[idx+len("onload"):], "onload")
|
||||
if next == -1 {
|
||||
return -1, -1, "", -1, -1
|
||||
}
|
||||
idx += len("onload") + next
|
||||
continue
|
||||
}
|
||||
}
|
||||
pos := idx + len("onload")
|
||||
for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') {
|
||||
pos++
|
||||
}
|
||||
if pos >= len(attrs) || attrs[pos] != '=' {
|
||||
return -1, -1, "", -1, -1
|
||||
}
|
||||
pos++
|
||||
for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') {
|
||||
pos++
|
||||
}
|
||||
if pos >= len(attrs) || (attrs[pos] != '"' && attrs[pos] != '\'') {
|
||||
return -1, -1, "", -1, -1
|
||||
}
|
||||
q := attrs[pos]
|
||||
valueStart = pos + 1
|
||||
valueEnd = strings.IndexByte(attrs[valueStart:], q)
|
||||
if valueEnd == -1 {
|
||||
return -1, -1, "", -1, -1
|
||||
}
|
||||
valueEnd += valueStart
|
||||
return idx, valueEnd + 1, string(q), valueStart, valueEnd
|
||||
}
|
||||
return -1, -1, "", -1, -1
|
||||
}
|
||||
|
||||
func replacePlayworksCTAScript(htmlSrc, network string) string {
|
||||
marker := "Luna.Unity.Playable.InstallFullGame=function"
|
||||
markerIdx := strings.LastIndex(htmlSrc, marker)
|
||||
@@ -338,21 +657,41 @@ func buildPlayworksLifecycleScript() string {
|
||||
}, "")
|
||||
}
|
||||
|
||||
func buildPlayworksMraidComplianceScript() string {
|
||||
return strings.Join([]string{
|
||||
`<script>(function(){`,
|
||||
`var m=null,scene=null,viewable=true,exposed=true,volume=null;`,
|
||||
`function get(){return window.mraid||m;}`,
|
||||
`function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}`,
|
||||
`function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}`,
|
||||
`function onViewable(v){viewable=!!v;apply();}`,
|
||||
`function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}`,
|
||||
`function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}`,
|
||||
`function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}`,
|
||||
`function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}`,
|
||||
`window.__hplMraidUnload=unload;`,
|
||||
`function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}`,
|
||||
`function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}`,
|
||||
`window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};`,
|
||||
`if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}`,
|
||||
`setTimeout(setup,2000);`,
|
||||
`})();</script>`,
|
||||
}, "")
|
||||
}
|
||||
|
||||
func buildPlayworksCTAScript(network string) string {
|
||||
urlSetup := `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`
|
||||
closeCall := `try{window.gameClose();}catch(e){}`
|
||||
ctaLogic := closeCall + `window.open(o,"_blank");`
|
||||
switch network {
|
||||
case "al", "is":
|
||||
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`
|
||||
case "un":
|
||||
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`
|
||||
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`
|
||||
case "fb":
|
||||
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
|
||||
case "gg":
|
||||
case "gg", "ap":
|
||||
ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`
|
||||
case "mo":
|
||||
ctaLogic = closeCall + `if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`
|
||||
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`
|
||||
case "vu":
|
||||
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
|
||||
case "mtg":
|
||||
|
||||
36
standalone/playworks_export_test.go
Normal file
36
standalone/playworks_export_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportPlayworksNetworksForRuntimeCheck(t *testing.T) {
|
||||
outDir := os.Getenv("PLAYWORKS_EXPORT_DIR")
|
||||
if outDir == "" {
|
||||
t.Skip("PLAYWORKS_EXPORT_DIR not set")
|
||||
}
|
||||
|
||||
sourcePath := filepath.Join("..", "aiAssets", "DogCat3_Full.html")
|
||||
data, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read source: %v", err)
|
||||
}
|
||||
|
||||
opts := playworksConvertOptions{
|
||||
SourcePath: sourcePath,
|
||||
OutputDir: outDir,
|
||||
BaseName: "wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na",
|
||||
}
|
||||
|
||||
if _, err := copyPlayworksUnityInput(data, opts); err != nil {
|
||||
t.Fatalf("copy unity input: %v", err)
|
||||
}
|
||||
|
||||
for _, network := range []string{"al", "ap", "fb", "gg", "is", "mo", "mtg", "vu"} {
|
||||
if _, err := convertPlayworksNetwork(string(data), opts, network); err != nil {
|
||||
t.Fatalf("convert %s: %v", network, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
75
standalone/playworks_test.go
Normal file
75
standalone/playworks_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTransformPlayworksHTMLNetworkRequirements(t *testing.T) {
|
||||
sourcePath := filepath.Join("..", "aiAssets", "DogCat3_Full.html")
|
||||
data, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read source: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
network string
|
||||
wantMraid int
|
||||
wantExitapi int
|
||||
}{
|
||||
{network: "al", wantMraid: 1},
|
||||
{network: "is", wantMraid: 1},
|
||||
{network: "gg", wantExitapi: 1},
|
||||
{network: "ap", wantExitapi: 1},
|
||||
{network: "fb"},
|
||||
{network: "mo"},
|
||||
{network: "vu"},
|
||||
{network: "mtg"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.network, func(t *testing.T) {
|
||||
html := transformPlayworksHTML(string(data), tt.network)
|
||||
assertCount(t, html, `src="mraid.js"`, tt.wantMraid)
|
||||
assertCount(t, html, `src="exitapi.js"`, tt.wantExitapi)
|
||||
assertCount(t, strings.ToLower(html), "</html>", 1)
|
||||
assertCount(t, strings.ToLower(html), "</head>", 1)
|
||||
|
||||
for _, forbidden := range []string{
|
||||
`type="module"`,
|
||||
`crossorigin`,
|
||||
`console.error(`,
|
||||
`stats.min.js`,
|
||||
`insertYourRemoteDebuggingTokenHere`,
|
||||
`console.re/connector.js`,
|
||||
} {
|
||||
if strings.Contains(html, forbidden) {
|
||||
t.Fatalf("forbidden content remains: %s", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
for _, required := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} {
|
||||
if !strings.Contains(html, required) {
|
||||
t.Fatalf("missing lifecycle symbol: %s", required)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.wantMraid == 1 {
|
||||
for _, required := range []string{"exposureChange", "viewableChange", "audioVolumeChange", "[MRAID error]", "stateChange"} {
|
||||
if !strings.Contains(html, required) {
|
||||
t.Fatalf("missing MRAID requirement marker: %s", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, haystack, needle string, want int) {
|
||||
t.Helper()
|
||||
if got := strings.Count(haystack, needle); got != want {
|
||||
t.Fatalf("%q count = %d, want %d", needle, got, want)
|
||||
}
|
||||
}
|
||||
@@ -17,42 +17,57 @@ import (
|
||||
|
||||
func PlecPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>PLEC Upload</h2>
|
||||
<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>
|
||||
<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" style="width:100%;border-collapse:collapse;margin-bottom:12px;">
|
||||
<thead><tr>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="addRow" class="secondary">+ Add Row</button>
|
||||
<button id="upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||
<div id="results"></div>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||||
</div>
|
||||
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
|
||||
<table id="rows" class="data-table">
|
||||
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</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>
|
||||
#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; }
|
||||
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result a { color:#9cdcfe; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; }
|
||||
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
|
||||
#rows input[type=text] { width: 100%; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
let nextId = 1;
|
||||
let rows = [];
|
||||
let page = 0;
|
||||
let previewUrl = '';
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
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) {
|
||||
const base = filename.replace(/\.html?$/i, '');
|
||||
@@ -63,121 +78,175 @@ function parseIterationName(filename) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const id = 'r' + (nextId++);
|
||||
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 escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
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.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
||||
rows.forEach(row => row.error = '');
|
||||
renderRows();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
||||
id: tr.dataset.id,
|
||||
path: tr.querySelector('[data-path]').value,
|
||||
iteration: tr.querySelector('[data-iter]').value,
|
||||
})).filter(r => r.path || r.iteration);
|
||||
statusEl.classList.remove('is-busy');
|
||||
setPreview('');
|
||||
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;
|
||||
}
|
||||
statusEl.textContent = 'Uploading...';
|
||||
const res = await fetch('/api/plec/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rows }),
|
||||
});
|
||||
const j = await res.json();
|
||||
statusEl.classList.add('is-busy');
|
||||
let j = null;
|
||||
try {
|
||||
const res = await fetch('/api/plec/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rows }),
|
||||
});
|
||||
await readJSONStream(res, (msg) => {
|
||||
if (msg.type === 'status') {
|
||||
statusEl.textContent = msg.message;
|
||||
statusEl.classList.add('is-busy');
|
||||
} else if (msg.type === 'done') {
|
||||
j = msg;
|
||||
} else if (msg.type === 'error') {
|
||||
j = { error: msg.message };
|
||||
} else if (msg.type === 'rowErrors') {
|
||||
j = { rowErrors: msg.rowErrors };
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||
return;
|
||||
}
|
||||
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; return; }
|
||||
if (j.rowErrors) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
for (const re of j.rowErrors) {
|
||||
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
|
||||
if (tr) tr.querySelector('[data-error]').textContent = re.message;
|
||||
const row = rows.find(r => r.id === re.id);
|
||||
if (row) row.error = re.message;
|
||||
}
|
||||
renderRows();
|
||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||
return;
|
||||
}
|
||||
if (j.error) {
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||||
return;
|
||||
}
|
||||
statusEl.classList.remove('is-busy');
|
||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
if (j.preview) {
|
||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copy = document.createElement('button');
|
||||
copy.textContent = 'Copy';
|
||||
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
|
||||
const open = document.createElement('button');
|
||||
open.textContent = 'Open';
|
||||
open.className = 'secondary';
|
||||
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
|
||||
const showRaw = document.createElement('button');
|
||||
showRaw.textContent = 'Show server response';
|
||||
showRaw.className = 'secondary';
|
||||
showRaw.onclick = () => {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.marginTop = '8px';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = j.rawText;
|
||||
wrap.appendChild(pre);
|
||||
showRaw.disabled = true;
|
||||
};
|
||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
||||
wrap.appendChild(actions);
|
||||
} else {
|
||||
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'<') + '</pre>';
|
||||
}
|
||||
resultsEl.innerHTML = '';
|
||||
resultsEl.appendChild(wrap);
|
||||
setPreview(j.preview);
|
||||
});
|
||||
|
||||
addRow();
|
||||
renderRows();
|
||||
</script>
|
||||
`
|
||||
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})
|
||||
}
|
||||
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -211,11 +297,12 @@ type plecRow struct {
|
||||
}
|
||||
|
||||
func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||
send := newJSONStream(w)
|
||||
var req struct {
|
||||
Rows []plecRow `json:"rows"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := LoadConfig().Plec
|
||||
@@ -240,11 +327,11 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if len(rowErrors) > 0 {
|
||||
writeJSON(w, map[string]any{"rowErrors": rowErrors})
|
||||
send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors})
|
||||
return
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
writeJSON(w, map[string]any{"error": "Nothing to upload."})
|
||||
send(map[string]any{"type": "error", "message": "Nothing to upload."})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -254,37 +341,39 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for i, row := range valid {
|
||||
idx := i + 1
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filepath.Base(row.Path))})
|
||||
data, err := os.ReadFile(row.Path)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
||||
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
|
||||
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
|
||||
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
|
||||
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
|
||||
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
|
||||
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
send(map[string]any{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
@@ -296,18 +385,18 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()})
|
||||
send(map[string]any{"type": "error", "message": "Network error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
|
||||
send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
|
||||
return
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
|
||||
send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
|
||||
return
|
||||
}
|
||||
var preview any
|
||||
@@ -316,7 +405,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||
} else if v, ok := parsed["previewURL"]; ok {
|
||||
preview = v
|
||||
}
|
||||
writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)})
|
||||
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
|
||||
}
|
||||
|
||||
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
50
tools.json
Normal file
50
tools.json
Normal 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
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user