Compare commits
10 Commits
f367b66b60
...
playworks-
| Author | SHA1 | Date | |
|---|---|---|---|
| 75a506a505 | |||
| 698223234d | |||
| 9e82656eeb | |||
| c7ad1e14ca | |||
| d384c1be3a | |||
| 6be55255b2 | |||
| 9d6ed4b0a5 | |||
| 1336695068 | |||
| 40f13989c2 | |||
| 716dac31ef |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go build *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
LICENSE
Normal file
4
LICENSE
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Copyright (c) 2026 HPL
|
||||||
|
|
||||||
|
All rights reserved. This software is proprietary and confidential.
|
||||||
|
Unauthorized copying, distribution, or use is prohibited.
|
||||||
232
aiAssets/GUIDE.md
Normal file
232
aiAssets/GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# AGENTS.md — Playable Ads
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps
|
||||||
|
Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt)
|
||||||
|
|
||||||
|
## Porting to a new project
|
||||||
|
1. Update naming fields (3LC, vendor, concept name) in [scripts/build-all.mjs](scripts/build-all.mjs) — drives the output filename
|
||||||
|
2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android)
|
||||||
|
3. Adjust the Depth Map below to match the project's layer needs
|
||||||
|
4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants
|
||||||
|
5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/BootScene.ts)
|
||||||
|
6. Rewrite `game/` modules for the new mechanic(s) — keep the single-responsibility split
|
||||||
|
7. Update this AGENTS.md's Depth Map and any project-specific pitfalls
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.ts # Bootstrap, resize, MRAID gating, lifecycle stubs on window
|
||||||
|
constants.ts # Design coords (1080×1920), store URLs
|
||||||
|
networks.ts # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX()
|
||||||
|
analytics.ts # trackEvent() → SDK
|
||||||
|
iteration.ts # A/B iteration config
|
||||||
|
utils/
|
||||||
|
responsive.ts # sx(), sy(), sd() — coordinate helpers
|
||||||
|
scenes/
|
||||||
|
BootScene.ts # Critical preload + deferred asset manifest
|
||||||
|
GameScene.ts # Orchestrator only — wires game/ modules, handles lifecycle
|
||||||
|
cta.ts # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA()
|
||||||
|
game/ # Single-responsibility modules (no direct ad-SDK calls)
|
||||||
|
# …one file per distinct mechanic or UI widget
|
||||||
|
Assets/
|
||||||
|
scripts/build-all.mjs # Single build → per-network HTML variants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsibility split
|
||||||
|
- **`game/`** modules each own a single concern (one mechanic, one widget, one system).
|
||||||
|
They receive the Phaser scene as a constructor arg and own their own game objects.
|
||||||
|
They never call ad-SDK functions (`triggerCTA`, `notifyGameX`, `trackEvent`).
|
||||||
|
- **`GameScene.ts`** is the wiring layer: creates modules, passes data between them,
|
||||||
|
and calls SDK helpers at the correct lifecycle moments.
|
||||||
|
- **`scenes/cta.ts`** owns end-card presentation and the store-redirect sequence.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- 60 FPS target; never below 30 on mid-range Android
|
||||||
|
- `dist/index.html` < 5 MB; WebP images, short audio
|
||||||
|
- No hardcoded px — `sx()`, `sy()`, `sd()` only
|
||||||
|
- No audio autoplay — unmute on first `pointerdown`
|
||||||
|
- `for` loops in game logic; pool objects; no GC
|
||||||
|
- `setDisplaySize(sd())` on images — never `setScale`
|
||||||
|
- Never dim via `body`/page background
|
||||||
|
|
||||||
|
## Phaser Config
|
||||||
|
```ts
|
||||||
|
{ type: Phaser.AUTO, transparent: true,
|
||||||
|
scale: { mode: Phaser.Scale.NONE, width: 1080, height: 1920 },
|
||||||
|
render: { antialias: true, pixelArt: false } }
|
||||||
|
```
|
||||||
|
`Scale.NONE` — manual sizing; CSS = viewport px; internal = viewport × DPR.
|
||||||
|
`antialias: true` — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to `false`.
|
||||||
|
|
||||||
|
## Responsive (1080×1920 ref)
|
||||||
|
```ts
|
||||||
|
const s = Math.min(viewW / 1080, viewH / 1920)
|
||||||
|
const offX = (viewW - 1080 * s) / 2
|
||||||
|
const offY = (viewH - 1920 * s) / 2
|
||||||
|
// sx/sy/sd apply offX, offY, s
|
||||||
|
```
|
||||||
|
- `update()` polls `visualViewport` per-frame for rotation
|
||||||
|
- On change: `game.scale.resize(vw*dpr, vh*dpr)` → `relayout()`
|
||||||
|
- `bindResponsiveResize()`: rAF debounce + 100/300/600ms retries
|
||||||
|
|
||||||
|
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||||
|
```
|
||||||
|
1. ExitApi.exit() → GoogleAds (gg)
|
||||||
|
2. FbPlayableAd.onCTAClick() → Facebook (fb)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
11. window.open(storeUrl) → Fallback (all others)
|
||||||
|
```
|
||||||
|
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
||||||
|
|
||||||
|
## Analytics — SDK Priority
|
||||||
|
```
|
||||||
|
ALPlayableAnalytics.trackEvent(e) → Applovin (al)
|
||||||
|
playableSDK.reportEvent(e) → (runtime fallback if SDK present)
|
||||||
|
console.log('[Analytics]', e) → All other networks
|
||||||
|
```
|
||||||
|
Events: `DISPLAYED` · `CTA_CLICKED` · `ENDCARD_SHOWN` · `CHALLENGE_STARTED` · `CHALLENGE_SOLVED`
|
||||||
|
|
||||||
|
## Game Lifecycle (all networks)
|
||||||
|
`main.ts` exposes stubs on `window` so every network's preview tool detects them.
|
||||||
|
Stubs only set if the SDK hasn't already provided its own (`typeof` guard).
|
||||||
|
```
|
||||||
|
gameReady() → stub in main.ts; Mintegral also gets onload body attr from build
|
||||||
|
gameStart() → notifyGameStart() in GameScene.startPlaying()
|
||||||
|
gameEnd() → notifyGameEnd() in GameScene.triggerFail() / triggerWin()
|
||||||
|
gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||||
|
| Network | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
|
||||||
|
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||||
|
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||||
|
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
|
||||||
|
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||||
|
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
||||||
|
|
||||||
|
| Network | Tag | Injected | Zipped | Included |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Applovin | al | `mraid.js` | — | ✓ |
|
||||||
|
| Google | gg | `exitapi.js` | ✓ | ✓ |
|
||||||
|
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||||
|
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||||
|
| Facebook | fb | — | — | ✓ |
|
||||||
|
| Unity | un | — | — | ✓ |
|
||||||
|
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||||
|
| Moloco | mo | — | — | ✓ |
|
||||||
|
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||||
|
|
||||||
|
|
||||||
|
Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](scripts/build-all.mjs) writes a `.zip` containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via `zip: true` in the `NETWORKS` array.
|
||||||
|
|
||||||
|
Networks marked **Included** are built by default. Networks not marked are prepared for but not built. Toggle per-network via `included: true` in the `NETWORKS` array.
|
||||||
|
|
||||||
|
Output is grouped by **iteration length** (e.g. `2words/`, `4words/`, `full/`) then by **network** (e.g. `Applovin/`, `Google/`, `Mintegral/`), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder:
|
||||||
|
|
||||||
|
`dist/<length>/<3lc>_<creative-type>_<vendor>_<concept-name>_<concept-num>_<gameplay>_<ugc>_<seasonal>_<lang>_<length>_<size>_<network>`
|
||||||
|
|
||||||
|
Example layout:
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
2words/
|
||||||
|
Applovin/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||||
|
Google/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||||
|
index.zip
|
||||||
|
Ironsource/
|
||||||
|
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||||
|
…one HTML or ZIP per network…
|
||||||
|
4words/
|
||||||
|
full/
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| 3LC | 3-letter project code | `ws` (Wordscapes), `pp` (PeoplePuzzle) |
|
||||||
|
| Creative Type | Format identifier | `mip` |
|
||||||
|
| Vendor | Studio / vendor code | `grhpl` |
|
||||||
|
| Concept Name | Creative concept (append `var1`, `var2`… for variants) | `wbinspiredvar1`, `wbinspiredvar2`, `burglar` |
|
||||||
|
| Concept # | Iteration number, zero-padded | `01`, `02`, `03` |
|
||||||
|
| Gameplay | Gameplay style | `real`, `cartoon` |
|
||||||
|
| UGC | UGC presence | `na`, `ugc`, `nougc` |
|
||||||
|
| Seasonal | Seasonal tie-in | `noseason`, `xmas` |
|
||||||
|
| Language | Locale code | `en` |
|
||||||
|
| Length | Run length | `2words`, `4words`, `7words`, `full` (Wordscapes); `2clk`, `10clk`, `120sec` (other projects) |
|
||||||
|
| Size | Region / size code | `na` |
|
||||||
|
| Network | Network tag (last segment) | `al`, `gg`, `is`, `mtg`, `fb`, `un`, `vu`, `mo`, `tt` |
|
||||||
|
|
||||||
|
Examples (this project):
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html` (cta2, default theme)
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html` (full, default theme)
|
||||||
|
- `ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html` (cta2, v1 theme)
|
||||||
|
|
||||||
|
Naming fields are defined in [scripts/build-all.mjs](scripts/build-all.mjs) — update them when porting.
|
||||||
|
|
||||||
|
## Asset Loading
|
||||||
|
- **Critical** (BootScene): first-frame assets, target < 200 KB
|
||||||
|
- **Deferred**: audio, overlays, secondary UI — loads during intro
|
||||||
|
- Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading
|
||||||
|
|
||||||
|
## Depth Map (adjust per project)
|
||||||
|
Define depths as named constants in [constants.ts](src/constants.ts) — never use magic numbers in scene code.
|
||||||
|
|
||||||
|
| Layer | Depth |
|
||||||
|
|---|---|
|
||||||
|
| Background | 0 |
|
||||||
|
| Game objects | 1–10 |
|
||||||
|
| Logo | 17 |
|
||||||
|
| Dim overlay | 20 |
|
||||||
|
| Fail/win UI | 21 |
|
||||||
|
| EndCard input | 25 |
|
||||||
|
|
||||||
|
## Ship Checklist
|
||||||
|
- [ ] < 5 MB, all WebP
|
||||||
|
- [ ] FPS ≥ 55 under CPU throttle
|
||||||
|
- [ ] Portrait + landscape pass
|
||||||
|
- [ ] MRAID ready before gameplay
|
||||||
|
- [ ] Audio muted by default
|
||||||
|
- [ ] CTA fires in fallback
|
||||||
|
- [ ] No `console.error`
|
||||||
|
- [ ] No `type="module"` or `crossorigin` on `<script>` in output HTML
|
||||||
|
- [ ] Lifecycle: gameReady / gameStart / gameEnd / gameClose detected by all network preview tools
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
| Issue | Fix |
|
||||||
|
|---|---|
|
||||||
|
| Rotation stuck | `Scale.NONE` + per-frame poll |
|
||||||
|
| Mobile aliasing | Canvas = viewport × DPR |
|
||||||
|
| FPS drop on resize | rAF debounce |
|
||||||
|
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||||
|
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||||
|
| Bundle > 5 MB | WebP q75, shorter audio |
|
||||||
|
| Audio blocked | Gate on `pointerdown` |
|
||||||
|
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||||
|
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||||
|
| Network preview checklist fail | Expose lifecycle stubs on `window` + call `notifyX()` with `typeof` guards |
|
||||||
|
| `playableSDK.reportEvent is not a function` | Applovin preview injects `playableSDK` without `reportEvent` — guard with `typeof playableSDK.reportEvent === 'function'`, not just `typeof playableSDK !== 'undefined'` |
|
||||||
|
| iPhone black screen (Android works) | **Only one `Phaser.Game` per page, ever.** iOS WKWebView — used by Applovin/Ironsource preview apps and most in-app ad containers — caps or outright rejects the 2nd WebGL context. If a background scene is spun up as a separate `new Phaser.Game()` synchronously at module load, it can throw before `await initMraid()` and before the font promise resolves, so the rest of `main.ts` never runs → black screen with no visible error. Render the background as a CSS `background-image` on `#bg-container` (cover-sized) instead — zero WebGL contexts for the background, and nothing to throw at module-load time. iOS-debug path: enable Safari → Advanced → Web Inspector on the iPhone, connect to a Mac, open Develop → iPhone → preview WebView, read the Console. Without a Mac, temporarily install top-of-`main.ts` `window.addEventListener('error',…)` and `unhandledrejection` handlers that write the error into `document.body.innerHTML` so the failure is visible on-screen. |
|
||||||
|
| iOS hangs on font load | `document.fonts.load()` silently stalls in WKWebView when `@font-face` uses base64 `data:` URLs. Race against a 1.5 s timeout: `Promise.race([Promise.all([…fonts]), new Promise(r=>setTimeout(r,1500))]).then(startGame)` |
|
||||||
|
| iOS hangs on MRAID ready | `mraid.addEventListener('ready', …)` can miss the event if the container fires it before the listener attaches. `initMraid()` must self-resolve after ~2 s so `startGame()` always runs; also check `mraid.getState() !== 'loading'` first. |
|
||||||
|
| External `src=` survives build | `vite-plugin-singlefile` only inlines assets *imported from JS*. A raw `<img src="src/assets/…">` in `index.html` ships as a literal relative path and fails in ad sandboxes, producing inconsistent per-device load failures. Leave DOM `<img>` elements with no `src`; assign via JS from `assets.ts` at boot (`setEndcardHtmlAssets()`). |
|
||||||
|
| Audio keeps playing when ad hides | Add `visibilitychange` listener in the sound module — mute Phaser's sound system and `suspend()` the `AudioContext` when `document.hidden`, resume on return. Spec requires audio stop on hide/close. |
|
||||||
|
| `mraid.open()` silently no-ops | Guard with `mraid.getState() !== 'loading'` before calling; fall through to `window.open()` if still loading. |
|
||||||
|
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed** — `document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
|
||||||
|
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
|
||||||
|
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top` → `window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
|
||||||
|
|
||||||
|
## 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.
|
||||||
7
aiAssets/Playworks_UnityNetwork.html
Normal file
7
aiAssets/Playworks_UnityNetwork.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
Binary file not shown.
15
build.ps1
15
build.ps1
@@ -25,8 +25,14 @@ if (-not $Extension -and -not $Standalone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$distDir = Join-Path $repoRoot "dist"
|
$distDir = Join-Path $repoRoot "dist"
|
||||||
|
if (Test-Path $distDir) {
|
||||||
|
Remove-Item -Path (Join-Path $distDir "*") -Recurse -Force
|
||||||
|
}
|
||||||
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
|
||||||
|
|
||||||
|
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
|
||||||
|
$version = $pkg.version
|
||||||
|
|
||||||
function Write-Step($msg) {
|
function Write-Step($msg) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "==> $msg" -ForegroundColor Cyan
|
Write-Host "==> $msg" -ForegroundColor Cyan
|
||||||
@@ -104,13 +110,14 @@ if ($Standalone) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$exeOut = Join-Path $distDir "hpl-toolbox.exe"
|
$exeName = "hpl-toolbox-$version.exe"
|
||||||
go build -ldflags="-s -w -H windowsgui" -trimpath -o $exeOut .
|
$exeOut = Join-Path $distDir $exeName
|
||||||
|
go build -ldflags="-s -w -H windowsgui -X main.AppVersion=$version" -trimpath -o $exeOut .
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
|
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
|
||||||
|
|
||||||
$summary += [pscustomobject]@{
|
$summary += [pscustomobject]@{
|
||||||
Artifact = "hpl-toolbox.exe"
|
Artifact = $exeName
|
||||||
Path = "dist\hpl-toolbox.exe"
|
Path = "dist\$exeName"
|
||||||
Size = Get-FileSize $exeOut
|
Size = Get-FileSize $exeOut
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
BIN
dist/hpl-toolbox-0.1.2.vsix
vendored
BIN
dist/hpl-toolbox-0.1.2.vsix
vendored
Binary file not shown.
BIN
dist/hpl-toolbox.exe → dist/hpl-toolbox-0.1.5.exe
vendored
BIN
dist/hpl-toolbox.exe → dist/hpl-toolbox-0.1.5.exe
vendored
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
Normal file
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
Normal file
Binary file not shown.
28
package.json
28
package.json
@@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "hpl-toolbox",
|
"name": "hpl-toolbox",
|
||||||
"displayName": "HPL Toolbox",
|
"displayName": "HPL Toolbox",
|
||||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, Daily Update. Local HTML Host.",
|
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker. Local HTML Host.",
|
||||||
"version": "0.1.2",
|
"version": "0.1.5",
|
||||||
"publisher": "hesukastro",
|
"publisher": "hesukastro",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox.git"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.85.0"
|
"vscode": "^1.85.0"
|
||||||
},
|
},
|
||||||
@@ -26,12 +31,16 @@
|
|||||||
"title": "HPL Toolbox: Open Base64 Scanner"
|
"title": "HPL Toolbox: Open Base64 Scanner"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "hplToolbox.openDailyUpdate",
|
"command": "hplToolbox.openMraidChecker",
|
||||||
"title": "HPL Toolbox: Open Daily Update"
|
"title": "HPL Toolbox: Open MRAID Checker"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "hplToolbox.openSendToMobile",
|
"command": "hplToolbox.openSendToMobile",
|
||||||
"title": "HPL Toolbox: Open Send To Mobile"
|
"title": "HPL Toolbox: Open Send To Mobile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hplToolbox.openPlayworksConverter",
|
||||||
|
"title": "HPL Toolbox: Open Playworks Converter"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
@@ -85,6 +94,17 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Value of the __Host-APPLOVINID cookie for p.applov.in (log in at https://p.applov.in/playablePreview?create=1&qr=1 and copy the cookie from DevTools)."
|
"description": "Value of the __Host-APPLOVINID cookie for p.applov.in (log in at https://p.applov.in/playablePreview?create=1&qr=1 and copy the cookie from DevTools)."
|
||||||
|
},
|
||||||
|
"hplToolbox.applovin.delayMs": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 5000,
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Delay (in milliseconds) between consecutive file uploads, to avoid AppLovin rate limiting / 403 responses."
|
||||||
|
},
|
||||||
|
"hplToolbox.applovin.randomizeUserAgent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Randomize the User-Agent header on every request to p.applov.in to reduce bot detection."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ import { LauncherViewProvider } from './launcherView';
|
|||||||
import { openPlecUpload } from './tools/plecUpload';
|
import { openPlecUpload } from './tools/plecUpload';
|
||||||
import { openApplovinUpload } from './tools/applovinUpload';
|
import { openApplovinUpload } from './tools/applovinUpload';
|
||||||
import { openBase64Scanner } from './tools/base64Scanner';
|
import { openBase64Scanner } from './tools/base64Scanner';
|
||||||
import { openDailyUpdate } from './tools/dailyUpdate';
|
import { openMraidChecker } from './tools/mraidChecker';
|
||||||
import { openSendToMobile } from './tools/sendToMobile';
|
import { openSendToMobile } from './tools/sendToMobile';
|
||||||
|
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
|
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)),
|
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
||||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider())
|
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||||
|
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,87 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
|
||||||
|
|
||||||
|
interface ToolDefinition {
|
||||||
|
command: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
beta?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS: ToolDefinition[] = [
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openPlecUpload',
|
||||||
|
title: 'PLEC Upload',
|
||||||
|
description: 'Upload HTML to internal PLEC server',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openApplovinUpload',
|
||||||
|
title: 'AppLovin Demo Upload',
|
||||||
|
description: 'Upload to p.applov.in (QR preview)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'hplToolbox.openBase64Scanner',
|
||||||
|
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.openPlayworksConverter',
|
||||||
|
title: 'Playworks Converter',
|
||||||
|
description: 'Convert Playworks HTML to per-network variants',
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||||
|
constructor(private readonly context: vscode.ExtensionContext) { }
|
||||||
|
|
||||||
resolveWebviewView(view: vscode.WebviewView) {
|
resolveWebviewView(view: vscode.WebviewView) {
|
||||||
view.webview.options = { enableScripts: true };
|
view.webview.options = { enableScripts: true };
|
||||||
view.webview.html = getHtml();
|
const version = this.context.extension.packageJSON.version as string;
|
||||||
view.webview.onDidReceiveMessage((msg) => {
|
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
|
||||||
|
view.webview.html = getHtml(version, betaToolsEnabled);
|
||||||
|
view.webview.onDidReceiveMessage(async (msg) => {
|
||||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||||
vscode.commands.executeCommand(msg.command);
|
vscode.commands.executeCommand(msg.command);
|
||||||
|
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||||
|
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
||||||
|
view.webview.html = getHtml(version, msg.enabled);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(): string {
|
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;
|
||||||
|
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 8px; }
|
body {
|
||||||
|
min-height: calc(100vh - 24px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
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; }
|
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
|
||||||
|
.tools { flex: 1; }
|
||||||
.tool-btn {
|
.tool-btn {
|
||||||
display: block; width: 100%; text-align: left;
|
display: block; width: 100%; text-align: left;
|
||||||
padding: 8px 10px; margin-bottom: 6px;
|
padding: 8px 10px; margin-bottom: 6px;
|
||||||
@@ -31,31 +93,48 @@ function getHtml(): string {
|
|||||||
.tool-btn:hover {
|
.tool-btn:hover {
|
||||||
background: var(--vscode-button-secondaryHoverBackground);
|
background: var(--vscode-button-secondaryHoverBackground);
|
||||||
}
|
}
|
||||||
|
.tool-title { display: flex; align-items: center; gap: 6px; }
|
||||||
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
||||||
|
.beta-badge {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.beta-toggle {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
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;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.beta-toggle button:hover {
|
||||||
|
background: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
.beta-note { display: block; margin-top: 3px; color: var(--vscode-descriptionForeground); font-size: 11px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h3>HPL Toolbox</h3>
|
<h3>HPL Toolbox ${version}</h3>
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openPlecUpload">
|
<div class="tools">
|
||||||
PLEC Upload
|
${toolButtons}
|
||||||
<span class="tool-desc">Upload HTML to internal PLEC server</span>
|
</div>
|
||||||
</button>
|
<div class="beta-toggle">
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openApplovinUpload">
|
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">
|
||||||
AppLovin Demo Upload
|
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'}
|
||||||
<span class="tool-desc">Upload to p.applov.in (QR preview)</span>
|
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
|
||||||
</button>
|
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openBase64Scanner">
|
|
||||||
Base64 Scanner
|
|
||||||
<span class="tool-desc">Find non-base64 assets in HTML</span>
|
|
||||||
</button>
|
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openDailyUpdate">
|
|
||||||
Daily Update
|
|
||||||
<span class="tool-desc">Compose & copy a daily status</span>
|
|
||||||
</button>
|
|
||||||
<button class="tool-btn" data-cmd="hplToolbox.openSendToMobile">
|
|
||||||
Send To Mobile
|
|
||||||
<span class="tool-desc">Share HTML to a phone via LAN + QR</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||||
@@ -63,7 +142,32 @@ function getHtml(): string {
|
|||||||
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.getElementById('betaToggle').addEventListener('click', (event) => {
|
||||||
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
|
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
|
||||||
|
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolButton(tool: ToolDefinition): string {
|
||||||
|
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
|
||||||
|
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
||||||
|
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>"']/g, ch => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[ch] ?? ch));
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
|||||||
canSelectMany: true,
|
canSelectMany: true,
|
||||||
filters: { HTML: ['html', 'htm'] },
|
filters: { HTML: ['html', 'htm'] },
|
||||||
openLabel: 'Select',
|
openLabel: 'Select',
|
||||||
|
defaultUri: getPickerDefaultUri(),
|
||||||
});
|
});
|
||||||
if (picked && picked.length) {
|
if (picked && picked.length) {
|
||||||
panel.webview.postMessage({
|
panel.webview.postMessage({
|
||||||
@@ -34,15 +35,46 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
|||||||
await handleUpload(panel, msg.paths);
|
await handleUpload(panel, msg.paths);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'saveQr') {
|
||||||
|
await handleSaveQr(panel, msg.url, msg.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'saveAllQrs') {
|
||||||
|
await handleSaveAllQrs(panel, msg.items || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
panel.webview.postMessage({ type: 'error', message: err?.message || String(err) });
|
panel.webview.postMessage({ type: 'error', message: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const USER_AGENTS = [
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0',
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickUserAgent(randomize: boolean): string {
|
||||||
|
if (!randomize) return USER_AGENTS[0];
|
||||||
|
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.applovin');
|
const cfg = vscode.workspace.getConfiguration('hplToolbox.applovin');
|
||||||
const cookie = (cfg.get<string>('cookie') || '').trim();
|
const cookie = (cfg.get<string>('cookie') || '').trim();
|
||||||
|
const delayMs = Math.max(0, cfg.get<number>('delayMs') ?? 1500);
|
||||||
|
const randomizeUA = cfg.get<boolean>('randomizeUserAgent') ?? true;
|
||||||
|
|
||||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||||
if (!paths.length) {
|
if (!paths.length) {
|
||||||
@@ -50,14 +82,17 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildHeaders = (): Record<string, string> => {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||||
'Origin': 'https://p.applov.in',
|
'Origin': 'https://p.applov.in',
|
||||||
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
|
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
|
'User-Agent': pickUserAgent(randomizeUA),
|
||||||
};
|
};
|
||||||
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
|
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
const buildForm = (filePath: string): FormData => {
|
const buildForm = (filePath: string): FormData => {
|
||||||
const buf = fs.readFileSync(filePath);
|
const buf = fs.readFileSync(filePath);
|
||||||
@@ -80,12 +115,17 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
|||||||
if (!filePath || !fs.existsSync(filePath)) { reportError('File missing'); continue; }
|
if (!filePath || !fs.existsSync(filePath)) { reportError('File missing'); continue; }
|
||||||
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
|
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
|
||||||
|
|
||||||
|
if (i > 0 && delayMs > 0) {
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` });
|
||||||
|
await sleep(delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` });
|
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` });
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch('https://p.applov.in/validateHTMLContent', {
|
res = await fetch('https://p.applov.in/validateHTMLContent', {
|
||||||
method: 'POST', body: buildForm(filePath), headers,
|
method: 'POST', body: buildForm(filePath), headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
} catch (err: any) { reportError('Network error (validate): ' + (err?.message || err)); continue; }
|
} catch (err: any) { reportError('Network error (validate): ' + (err?.message || err)); continue; }
|
||||||
const validateText = await res.text();
|
const validateText = await res.text();
|
||||||
@@ -100,7 +140,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
res = await fetch('https://p.applov.in/getCachedAdURL', {
|
res = await fetch('https://p.applov.in/getCachedAdURL', {
|
||||||
method: 'POST', body: buildForm(filePath), headers,
|
method: 'POST', body: buildForm(filePath), headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
} catch (err: any) { reportError('Network error (cache): ' + (err?.message || err)); continue; }
|
} catch (err: any) { reportError('Network error (cache): ' + (err?.message || err)); continue; }
|
||||||
const cacheText = await res.text();
|
const cacheText = await res.text();
|
||||||
@@ -124,6 +164,79 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
|||||||
panel.webview.postMessage({ type: 'done' });
|
panel.webview.postMessage({ type: 'done' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
if (!folders || folders.length === 0) return undefined;
|
||||||
|
const root = folders[0].uri.fsPath;
|
||||||
|
const dist = path.join(root, 'dist');
|
||||||
|
if (fs.existsSync(dist) && fs.statSync(dist).isDirectory()) {
|
||||||
|
return vscode.Uri.file(dist);
|
||||||
|
}
|
||||||
|
return vscode.Uri.file(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qrBaseName(name: string): string {
|
||||||
|
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadQr(url: string, destPath: string): Promise<void> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
await fs.promises.writeFile(destPath, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveQr(panel: vscode.WebviewPanel, url: string, name: string) {
|
||||||
|
if (!url) return;
|
||||||
|
const base = qrBaseName(name);
|
||||||
|
const defaultUri = vscode.Uri.file(path.join(require('os').homedir(), 'Downloads', `${base}.png`));
|
||||||
|
const target = await vscode.window.showSaveDialog({
|
||||||
|
defaultUri,
|
||||||
|
filters: { 'PNG Image': ['png'] },
|
||||||
|
saveLabel: 'Save QR',
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await downloadQr(url, target.fsPath);
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Saved QR: ${target.fsPath}` });
|
||||||
|
} catch (err: any) {
|
||||||
|
panel.webview.postMessage({ type: 'error', message: 'Save QR failed: ' + (err?.message || err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAllQrs(panel: vscode.WebviewPanel, items: Array<{ name: string; url: string }>) {
|
||||||
|
if (!items.length) return;
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Save QRs Here',
|
||||||
|
});
|
||||||
|
if (!picked || !picked.length) return;
|
||||||
|
const dir = picked[0].fsPath;
|
||||||
|
let saved = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
const base = qrBaseName(it.name);
|
||||||
|
let dest = path.join(dir, `${base}.png`);
|
||||||
|
let n = 1;
|
||||||
|
while (fs.existsSync(dest)) {
|
||||||
|
dest = path.join(dir, `${base} (${n++}).png`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await downloadQr(it.url, dest);
|
||||||
|
saved++;
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`${it.name}: ${err?.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length) {
|
||||||
|
panel.webview.postMessage({ type: 'error', message: `Saved ${saved}/${items.length}. Errors:\n` + errors.join('\n') });
|
||||||
|
} else {
|
||||||
|
panel.webview.postMessage({ type: 'status', message: `Saved ${saved} QR${saved === 1 ? '' : 's'} to ${dir}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getHtml(): string {
|
function getHtml(): string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -163,11 +276,15 @@ function getHtml(): string {
|
|||||||
<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>
|
<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>
|
||||||
|
|
||||||
<div class="file-cell" style="margin-bottom:8px;">
|
<div class="file-cell" style="margin-bottom:8px;">
|
||||||
<button id="pick" class="secondary">Choose files...</button>
|
<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>
|
<span class="file-name" id="fileName">(no files)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="upload">Upload to AppLovin</button>
|
<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>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
<div id="results"></div>
|
<div id="results"></div>
|
||||||
@@ -175,13 +292,51 @@ function getHtml(): string {
|
|||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
const pickBtn = document.getElementById('pick');
|
const pickBtn = document.getElementById('pick');
|
||||||
|
const clearBtn = document.getElementById('clear');
|
||||||
const uploadBtn = document.getElementById('upload');
|
const uploadBtn = document.getElementById('upload');
|
||||||
const fileNameEl = document.getElementById('fileName');
|
const fileNameEl = document.getElementById('fileName');
|
||||||
|
const fileListEl = document.getElementById('fileList');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
|
const saveAllBtn = document.getElementById('saveAll');
|
||||||
|
const regenAllBtn = document.getElementById('regenAll');
|
||||||
let selectedPaths = [];
|
let selectedPaths = [];
|
||||||
|
let resultItems = [];
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||||
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
|
}
|
||||||
|
function renderSelection() {
|
||||||
|
if (!selectedPaths.length) {
|
||||||
|
fileNameEl.textContent = '(no files)';
|
||||||
|
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 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAllBtn.addEventListener('click', () => {
|
||||||
|
if (!resultItems.length) return;
|
||||||
|
vscode.postMessage({ type: 'saveAllQrs', items: resultItems });
|
||||||
|
});
|
||||||
|
|
||||||
|
regenAllBtn.addEventListener('click', () => {
|
||||||
|
const imgs = resultsEl.querySelectorAll('img[data-qr]');
|
||||||
|
imgs.forEach(img => {
|
||||||
|
const base = img.getAttribute('data-qr');
|
||||||
|
img.src = base + '&_=' + Date.now();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
|
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
selectedPaths = [];
|
||||||
|
renderSelection();
|
||||||
|
});
|
||||||
|
|
||||||
uploadBtn.addEventListener('click', () => {
|
uploadBtn.addEventListener('click', () => {
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
@@ -191,6 +346,9 @@ function getHtml(): string {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
|
resultItems = [];
|
||||||
|
saveAllBtn.style.display = 'none';
|
||||||
|
regenAllBtn.style.display = 'none';
|
||||||
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,13 +359,14 @@ function getHtml(): string {
|
|||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (m.type === 'filesSelected') {
|
if (m.type === 'filesSelected') {
|
||||||
selectedPaths = m.files.map(f => f.path);
|
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||||
const names = m.files.map(f => f.name);
|
selectedPaths = selectedPaths.concat(added);
|
||||||
fileNameEl.textContent = names.length === 1
|
renderSelection();
|
||||||
? names[0]
|
|
||||||
: names.length + ' files: ' + names.slice(0, 3).join(', ') + (names.length > 3 ? '...' : '');
|
|
||||||
} else if (m.type === 'start') {
|
} else if (m.type === 'start') {
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
|
resultItems = [];
|
||||||
|
saveAllBtn.style.display = 'none';
|
||||||
|
regenAllBtn.style.display = 'none';
|
||||||
} else if (m.type === 'status') {
|
} else if (m.type === 'status') {
|
||||||
statusEl.textContent = m.message;
|
statusEl.textContent = m.message;
|
||||||
statusEl.className = '';
|
statusEl.className = '';
|
||||||
@@ -230,27 +389,28 @@ function getHtml(): string {
|
|||||||
wrap.innerHTML =
|
wrap.innerHTML =
|
||||||
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
||||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||||
'<img src="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'result-actions';
|
actions.className = 'result-actions';
|
||||||
const copyHash = document.createElement('button');
|
|
||||||
copyHash.textContent = 'Copy Hash';
|
|
||||||
copyHash.onclick = () => vscode.postMessage({ type: 'copy', text: m.hash });
|
|
||||||
const copyPreview = document.createElement('button');
|
|
||||||
copyPreview.textContent = 'Copy URL';
|
|
||||||
copyPreview.className = 'secondary';
|
|
||||||
copyPreview.onclick = () => vscode.postMessage({ type: 'copy', text: m.previewUrl });
|
|
||||||
const openPreview = document.createElement('button');
|
const openPreview = document.createElement('button');
|
||||||
openPreview.textContent = 'Open';
|
openPreview.textContent = 'Open';
|
||||||
openPreview.className = 'secondary';
|
openPreview.className = 'secondary';
|
||||||
openPreview.onclick = () => vscode.postMessage({ type: 'open', text: m.previewUrl });
|
openPreview.onclick = () => vscode.postMessage({ type: 'open', text: m.previewUrl });
|
||||||
actions.appendChild(copyHash);
|
const saveQr = document.createElement('button');
|
||||||
actions.appendChild(copyPreview);
|
saveQr.textContent = 'Save QR';
|
||||||
|
saveQr.className = 'secondary';
|
||||||
|
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
||||||
actions.appendChild(openPreview);
|
actions.appendChild(openPreview);
|
||||||
|
actions.appendChild(saveQr);
|
||||||
wrap.appendChild(actions);
|
wrap.appendChild(actions);
|
||||||
resultsEl.appendChild(wrap);
|
resultsEl.appendChild(wrap);
|
||||||
|
resultItems.push({ name: m.name, url: m.qrImg });
|
||||||
|
if (resultItems.length >= 2) {
|
||||||
|
saveAllBtn.style.display = '';
|
||||||
|
regenAllBtn.style.display = '';
|
||||||
|
}
|
||||||
} else if (m.type === 'done') {
|
} else if (m.type === 'done') {
|
||||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import * as vscode from 'vscode';
|
|
||||||
import { singletonPanel } from './shared';
|
|
||||||
|
|
||||||
const PROJECT_KEY = 'hplToolbox.dailyUpdate.lastProject';
|
|
||||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
|
||||||
|
|
||||||
export function openDailyUpdate(context: vscode.ExtensionContext) {
|
|
||||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.dailyUpdate', 'Daily Update');
|
|
||||||
if (!isNew) return;
|
|
||||||
|
|
||||||
const lastProject = context.globalState.get<string>(PROJECT_KEY, '');
|
|
||||||
panel.webview.html = getHtml(lastProject);
|
|
||||||
|
|
||||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
|
||||||
if (msg.type === 'submit') {
|
|
||||||
const text = formatMessage(msg.payload);
|
|
||||||
await vscode.env.clipboard.writeText(text);
|
|
||||||
await context.globalState.update(PROJECT_KEY, msg.payload.project);
|
|
||||||
vscode.window.showInformationMessage('Daily update copied to clipboard.');
|
|
||||||
panel.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessage(p: { date: string; status: string; project: string; remarks: string }): string {
|
|
||||||
const bullets = p.remarks
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((l) => l.replace(/^\s*[-*]\s*/, '').trim())
|
|
||||||
.filter((l) => l.length > 0)
|
|
||||||
.map((l) => `- ${l}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`Date: ${formatDate(p.date)}`,
|
|
||||||
`Status: ${p.status}`,
|
|
||||||
`Project: ${p.project}`,
|
|
||||||
`Remarks:`,
|
|
||||||
bullets,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (p.status === 'For OT') {
|
|
||||||
lines.push('@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace');
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
||||||
if (!m) return iso;
|
|
||||||
return `${m[2]}/${m[3]}/${m[1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttr(s: string): string {
|
|
||||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHtml(lastProject: string): string {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
|
||||||
<style>
|
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; max-width: 520px; }
|
|
||||||
label { display: block; margin-top: 12px; font-size: 12px; opacity: 0.85; }
|
|
||||||
input, select, textarea {
|
|
||||||
width: 100%; box-sizing: border-box; margin-top: 4px; padding: 6px 8px;
|
|
||||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
|
||||||
border: 1px solid var(--vscode-input-border, transparent); font-family: inherit; font-size: 13px;
|
|
||||||
}
|
|
||||||
textarea { min-height: 96px; resize: vertical; }
|
|
||||||
button {
|
|
||||||
margin-top: 16px; padding: 8px 16px; cursor: pointer;
|
|
||||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
|
||||||
border: none; font-size: 13px;
|
|
||||||
}
|
|
||||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2 style="margin:0 0 4px 0;">Daily Update</h2>
|
|
||||||
<div style="font-size:12px;opacity:0.7;">Submit copies the formatted text to your clipboard.</div>
|
|
||||||
|
|
||||||
<label for="date">Date</label>
|
|
||||||
<input type="date" id="date" value="${today}" />
|
|
||||||
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status">
|
|
||||||
<option>Started</option>
|
|
||||||
<option>Progress</option>
|
|
||||||
<option>Finished</option>
|
|
||||||
<option>Client Feedback</option>
|
|
||||||
<option>For OT</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="project">Project</label>
|
|
||||||
<input type="text" id="project" placeholder="Project name" value="${escapeAttr(lastProject)}" />
|
|
||||||
|
|
||||||
<label for="remarks">Remarks</label>
|
|
||||||
<textarea id="remarks" placeholder="What did you do?"></textarea>
|
|
||||||
|
|
||||||
<button id="submit">Submit</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const vscode = acquireVsCodeApi();
|
|
||||||
document.getElementById('submit').addEventListener('click', () => {
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'submit',
|
|
||||||
payload: {
|
|
||||||
date: document.getElementById('date').value,
|
|
||||||
status: document.getElementById('status').value,
|
|
||||||
project: document.getElementById('project').value.trim(),
|
|
||||||
remarks: document.getElementById('remarks').value.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
496
src/tools/mraidChecker.ts
Normal file
496
src/tools/mraidChecker.ts
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { singletonPanel } from './shared';
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
type Severity = 'requirement' | 'best-practice';
|
||||||
|
|
||||||
|
interface MraidIssue {
|
||||||
|
id: string;
|
||||||
|
severity: Severity;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
line?: number;
|
||||||
|
reference: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MraidResult {
|
||||||
|
file: string;
|
||||||
|
ok: boolean;
|
||||||
|
issues: MraidIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MraidRule {
|
||||||
|
id: string;
|
||||||
|
severity: Severity;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
reference: string;
|
||||||
|
test: (ctx: ScanContext) => MraidIssue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanContext {
|
||||||
|
html: string;
|
||||||
|
scriptText: string;
|
||||||
|
lowerHtml: string;
|
||||||
|
lowerScript: string;
|
||||||
|
mraidCallLines: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||||
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.mraidChecker', 'MRAID Checker');
|
||||||
|
if (!isNew) return;
|
||||||
|
|
||||||
|
panel.webview.html = getHtml();
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
const ws = vscode.workspace.workspaceFolders?.[0];
|
||||||
|
if (ws) {
|
||||||
|
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||||
|
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||||
|
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'pickFolder') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Folder',
|
||||||
|
});
|
||||||
|
if (picked && picked[0]) {
|
||||||
|
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'pickFiles') {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectMany: true,
|
||||||
|
openLabel: 'Select Files',
|
||||||
|
filters: { 'HTML': ['html', 'htm'] },
|
||||||
|
});
|
||||||
|
if (picked && picked.length) {
|
||||||
|
panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) });
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'scan') {
|
||||||
|
const folder: string | undefined = msg.folder;
|
||||||
|
const files: string[] | undefined = msg.files;
|
||||||
|
let targets: string[] = [];
|
||||||
|
let baseDir = '';
|
||||||
|
let skipped = 0;
|
||||||
|
if (files && files.length) {
|
||||||
|
const existing = files.filter(f => fs.existsSync(f));
|
||||||
|
targets = existing.filter(isSupportedMraidBuild);
|
||||||
|
skipped = existing.length - targets.length;
|
||||||
|
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||||
|
} else if (folder && fs.existsSync(folder)) {
|
||||||
|
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||||
|
targets = allHtmlFiles.filter(isSupportedMraidBuild);
|
||||||
|
skipped = allHtmlFiles.length - targets.length;
|
||||||
|
baseDir = folder;
|
||||||
|
} else {
|
||||||
|
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
panel.webview.postMessage({
|
||||||
|
type: 'results',
|
||||||
|
results: [],
|
||||||
|
skipped,
|
||||||
|
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: MraidResult[] = [];
|
||||||
|
for (const file of targets) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(file, 'utf8');
|
||||||
|
const issues = checkMraid(content);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonBaseDir(files: string[]): string {
|
||||||
|
if (files.length === 0) return '';
|
||||||
|
if (files.length === 1) return path.dirname(files[0]);
|
||||||
|
const split = files.map(f => path.dirname(f).split(path.sep));
|
||||||
|
const first = split[0];
|
||||||
|
let i = 0;
|
||||||
|
while (i < first.length && split.every(parts => parts[i] === first[i])) i++;
|
||||||
|
return first.slice(0, i).join(path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const stack: string[] = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||||
|
stack.push(full);
|
||||||
|
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkMraid(html: string): MraidIssue[] {
|
||||||
|
const scriptText = extractScriptText(html);
|
||||||
|
const ctx: ScanContext = {
|
||||||
|
html,
|
||||||
|
scriptText,
|
||||||
|
lowerHtml: html.toLowerCase(),
|
||||||
|
lowerScript: scriptText.toLowerCase(),
|
||||||
|
mraidCallLines: collectMraidCallLines(html),
|
||||||
|
};
|
||||||
|
|
||||||
|
return MRAID_RULES
|
||||||
|
.map(rule => rule.test(ctx))
|
||||||
|
.filter((issue): issue is MraidIssue => issue !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScriptText(html: string): string {
|
||||||
|
const scripts: string[] = [];
|
||||||
|
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = scriptRegex.exec(html)) !== null) {
|
||||||
|
scripts.push(m[1]);
|
||||||
|
}
|
||||||
|
return scripts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMraidCallLines(html: string): Map<string, number> {
|
||||||
|
const calls = new Map<string, number>();
|
||||||
|
const lines = html.split(/\r?\n/);
|
||||||
|
const callRegex = /\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(/g;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = callRegex.exec(lines[i])) !== null) {
|
||||||
|
if (!calls.has(m[1])) calls.set(m[1], i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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 hasMraidReference(ctx: ScanContext): boolean {
|
||||||
|
return /\bmraid\b/i.test(ctx.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMraidCall(ctx: ScanContext, method: string): boolean {
|
||||||
|
return ctx.mraidCallLines.has(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mraidLine(ctx: ScanContext, method: string): number | undefined {
|
||||||
|
return ctx.mraidCallLines.get(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issue(rule: MraidRule, line?: number): MraidIssue {
|
||||||
|
return {
|
||||||
|
id: rule.id,
|
||||||
|
severity: rule.severity,
|
||||||
|
title: rule.title,
|
||||||
|
detail: rule.detail,
|
||||||
|
line,
|
||||||
|
reference: rule.reference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReadyGate(ctx: ScanContext): boolean {
|
||||||
|
return (
|
||||||
|
/mraid\s*\.\s*addEventListener\s*\(\s*['"]ready['"]/i.test(ctx.scriptText) ||
|
||||||
|
/mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]/i.test(ctx.scriptText) ||
|
||||||
|
/['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEventListener(ctx: ScanContext, eventName: string): boolean {
|
||||||
|
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
|
||||||
|
return pattern.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
|
||||||
|
const pattern = new RegExp(`mraid\\s*\\.\\s*supports\\s*\\(\\s*['"]${feature}['"]`, 'i');
|
||||||
|
return pattern.test(ctx.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
|
||||||
|
return (
|
||||||
|
/\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) ||
|
||||||
|
/\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/';
|
||||||
|
|
||||||
|
const MRAID_RULES: MraidRule[] = [
|
||||||
|
{
|
||||||
|
id: 'mraid-reference',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'MRAID API reference not found',
|
||||||
|
detail: 'Playable HTML should use the injected mraid object when it depends on an MRAID container.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ready-gate',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'MRAID ready state is not guarded',
|
||||||
|
detail: 'Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'error-listener',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No MRAID error listener found',
|
||||||
|
detail: 'Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => !hasMraidReference(ctx) || hasEventListener(ctx, 'error') ? null : issue(MRAID_RULES_BY_ID['error-listener']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'viewability',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'Viewability handling not found',
|
||||||
|
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange');
|
||||||
|
return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'close-api',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No mraid.close call found',
|
||||||
|
detail: 'If the creative renders its own close control, wire that control to mraid.close().',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const appearsToHaveCloseUi = /\b(close|dismiss|skip)\b/i.test(ctx.html);
|
||||||
|
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expand-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'mraid.expand used without supports check',
|
||||||
|
detail: 'Check mraid.supports("expand") 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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resize-support',
|
||||||
|
severity: 'requirement',
|
||||||
|
title: 'mraid.resize used without supports check',
|
||||||
|
detail: 'Check mraid.supports("resize") 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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orientation-support',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'Orientation properties set without supports check',
|
||||||
|
detail: 'Check mraid.supports("orientation") 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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'size-change',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No sizeChange listener found',
|
||||||
|
detail: 'Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const dependsOnSize = hasMraidCall(ctx, 'getMaxSize') || hasMraidCall(ctx, 'getScreenSize') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'setOrientationProperties');
|
||||||
|
return dependsOnSize && !hasEventListener(ctx, 'sizeChange') ? issue(MRAID_RULES_BY_ID['size-change']) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'state-change',
|
||||||
|
severity: 'best-practice',
|
||||||
|
title: 'No stateChange listener found',
|
||||||
|
detail: 'Listen for stateChange when using expand, resize, close, or other state-changing container behavior.',
|
||||||
|
reference: MRAID_REFERENCE,
|
||||||
|
test: (ctx) => {
|
||||||
|
const changesState = hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'close');
|
||||||
|
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
|
||||||
|
acc[rule.id] = rule;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
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); }
|
||||||
|
.issue-title { font-weight: 600; }
|
||||||
|
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div id="selection" class="summary"></div>
|
||||||
|
<div id="status"></div>
|
||||||
|
<div id="results"></div>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
vscode.postMessage({ type: 'ready' });
|
||||||
|
const folderEl = document.getElementById('folder');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
const selectionEl = document.getElementById('selection');
|
||||||
|
let pickedFiles = [];
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
pickedFiles = [];
|
||||||
|
selectionEl.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
folderEl.addEventListener('input', clearFiles);
|
||||||
|
|
||||||
|
document.getElementById('pick').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'pickFolder' });
|
||||||
|
});
|
||||||
|
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||||
|
vscode.postMessage({ type: 'pickFiles' });
|
||||||
|
});
|
||||||
|
document.getElementById('scan').addEventListener('click', () => {
|
||||||
|
statusEl.textContent = 'Scanning...';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
if (pickedFiles.length) {
|
||||||
|
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||||
|
} else {
|
||||||
|
const folder = folderEl.value.trim();
|
||||||
|
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||||
|
vscode.postMessage({ type: 'scan', folder });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (msg.type === 'folderPicked') {
|
||||||
|
folderEl.value = msg.path;
|
||||||
|
clearFiles();
|
||||||
|
} else if (msg.type === 'filesPicked') {
|
||||||
|
pickedFiles = msg.paths;
|
||||||
|
folderEl.value = '';
|
||||||
|
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||||
|
} else if (msg.type === 'results') {
|
||||||
|
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||||
|
const total = msg.results.length;
|
||||||
|
const withIssues = msg.results.filter(r => !r.ok).length;
|
||||||
|
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||||
|
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||||
|
const skipped = Number(msg.skipped || 0);
|
||||||
|
const 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 = '';
|
||||||
|
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>';
|
||||||
|
if (!r.ok) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'issues';
|
||||||
|
for (const i of r.issues) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'issue';
|
||||||
|
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||||
|
item.innerHTML =
|
||||||
|
'<span class="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);
|
||||||
|
}
|
||||||
|
resultsEl.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
504
src/tools/playworksConverter.ts
Normal file
504
src/tools/playworksConverter.ts
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||||
|
|
||||||
|
interface ConvertOptions {
|
||||||
|
sourcePath: string;
|
||||||
|
outputDir: string;
|
||||||
|
networks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkResult {
|
||||||
|
network: string;
|
||||||
|
file: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||||
|
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
|
||||||
|
if (!isNew) return;
|
||||||
|
|
||||||
|
panel.webview.html = getHtml();
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'pickSource': {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: true,
|
||||||
|
canSelectFolders: false,
|
||||||
|
canSelectMany: false,
|
||||||
|
filters: { HTML: ['html', 'htm'] },
|
||||||
|
openLabel: 'Select Playworks HTML',
|
||||||
|
});
|
||||||
|
if (!picked?.[0]) return;
|
||||||
|
const srcPath = picked[0].fsPath;
|
||||||
|
const dir = path.dirname(srcPath);
|
||||||
|
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pickOutput': {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectFolders: true,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: 'Select Output Folder',
|
||||||
|
});
|
||||||
|
if (!picked?.[0]) return;
|
||||||
|
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'convert': {
|
||||||
|
const opts: ConvertOptions = msg.opts;
|
||||||
|
if (!fs.existsSync(opts.sourcePath)) {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(opts.outputDir)) {
|
||||||
|
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
try {
|
||||||
|
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||||
|
} catch (e: any) {
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: NetworkResult[] = [];
|
||||||
|
for (const network of opts.networks) {
|
||||||
|
try {
|
||||||
|
const filePath = await convertNetwork(html, opts, network);
|
||||||
|
results.push({ network, file: filePath, ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push({ network, file: '', ok: false, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panel.webview.postMessage({ type: 'convertDone', results });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'openOutput': {
|
||||||
|
vscode.env.openExternal(vscode.Uri.file(msg.dir));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
|
||||||
|
|
||||||
|
function sourceBaseName(sourcePath: string): string {
|
||||||
|
return path.basename(sourcePath, path.extname(sourcePath))
|
||||||
|
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
|
||||||
|
const transformed = transformHtml(html, network);
|
||||||
|
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||||
|
const baseName = sourceBaseName(opts.sourcePath);
|
||||||
|
const htmlFileName = `${baseName}_${network}.html`;
|
||||||
|
|
||||||
|
if (needsZip) {
|
||||||
|
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||||
|
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
|
||||||
|
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||||
|
try {
|
||||||
|
await createZip(tmpPath, 'index.html', zipPath);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = path.join(opts.outputDir, htmlFileName);
|
||||||
|
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformHtml(html: string, network: string): string {
|
||||||
|
let result = 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.
|
||||||
|
let headInject = buildLifecycleScript();
|
||||||
|
if (network === 'al' || network === 'is') {
|
||||||
|
headInject += '<script src="mraid.js"></script>';
|
||||||
|
} else if (network === 'gg') {
|
||||||
|
headInject += '<script src="exitapi.js"></script>';
|
||||||
|
}
|
||||||
|
result = result.replace('</head>', headInject + '\n</head>');
|
||||||
|
|
||||||
|
// Body-level flags
|
||||||
|
if (network === 'vu') {
|
||||||
|
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
|
||||||
|
} else if (network === 'mtg') {
|
||||||
|
result = result.replace('<body>', '<body onload="gameReady()">');
|
||||||
|
} else if (network === 'tt') {
|
||||||
|
result = result.replace('<body>', '<body>\n<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 result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCTAScript(html: string, network: string): string {
|
||||||
|
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||||
|
const markerIdx = html.lastIndexOf(marker);
|
||||||
|
if (markerIdx === -1) return html;
|
||||||
|
|
||||||
|
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
|
||||||
|
if (scriptOpenIdx === -1) return html;
|
||||||
|
|
||||||
|
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
|
||||||
|
if (scriptCloseIdx === -1) return html;
|
||||||
|
|
||||||
|
const before = html.slice(0, scriptOpenIdx);
|
||||||
|
const after = html.slice(scriptCloseIdx + '</script>'.length);
|
||||||
|
|
||||||
|
// Console restore runs after the bundle (which overrides console), before CTA
|
||||||
|
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores native console methods overridden by the Playworks bundle,
|
||||||
|
// using a temporary iframe to get a fresh un-patched console reference.
|
||||||
|
function buildConsoleRestoreScript(): string {
|
||||||
|
return [
|
||||||
|
'<script>(function(){',
|
||||||
|
'try{',
|
||||||
|
'var f=document.createElement("iframe");',
|
||||||
|
'f.style.display="none";',
|
||||||
|
'document.documentElement.appendChild(f);',
|
||||||
|
'var nc=f.contentWindow.console;',
|
||||||
|
'document.documentElement.removeChild(f);',
|
||||||
|
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
|
||||||
|
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
|
||||||
|
'}catch(e){}',
|
||||||
|
'})();</script>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
|
||||||
|
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
|
||||||
|
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
|
||||||
|
// One-time guards prevent double-calls:
|
||||||
|
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
|
||||||
|
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
|
||||||
|
function buildLifecycleScript(): string {
|
||||||
|
return [
|
||||||
|
'<script>(function(){',
|
||||||
|
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
|
||||||
|
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
|
||||||
|
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
|
||||||
|
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
|
||||||
|
'var _grOnce=false,_gsOnce=false;',
|
||||||
|
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
|
||||||
|
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
|
||||||
|
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
|
||||||
|
'})();</script>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCTAScript(network: string): string {
|
||||||
|
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
|
||||||
|
// gameClose must fire before every redirect
|
||||||
|
const closeCall = `try{window.gameClose();}catch(e){}`;
|
||||||
|
|
||||||
|
let ctaLogic: string;
|
||||||
|
switch (network) {
|
||||||
|
case 'al':
|
||||||
|
case 'is':
|
||||||
|
// MRAID — mraid.js injected in <head>
|
||||||
|
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){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");}`;
|
||||||
|
break;
|
||||||
|
case 'fb':
|
||||||
|
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'gg':
|
||||||
|
// 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");`;
|
||||||
|
break;
|
||||||
|
case 'vu':
|
||||||
|
// Vungle — window.__VUNGLE__ flag set in body
|
||||||
|
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
|
||||||
|
break;
|
||||||
|
case 'mtg':
|
||||||
|
// Mintegral — onload="gameReady()" set on body
|
||||||
|
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
case 'tt':
|
||||||
|
// TikTok — window.__TIKTOK__ flag set in body
|
||||||
|
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ctaLogic = `${closeCall}window.open(o,"_blank");`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Remove existing zip if present (Compress-Archive would fail otherwise)
|
||||||
|
if (fs.existsSync(destZipPath)) {
|
||||||
|
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const psScript = [
|
||||||
|
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
|
||||||
|
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
|
||||||
|
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
|
||||||
|
'$zip.Dispose()',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stderr = '';
|
||||||
|
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Webview UI ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
const networks = [
|
||||||
|
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||||
|
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||||
|
{ tag: '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: '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('');
|
||||||
|
|
||||||
|
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; }
|
||||||
|
.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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let outputDir = '';
|
||||||
|
|
||||||
|
const srcEl = document.getElementById('src');
|
||||||
|
const outEl = document.getElementById('outDir');
|
||||||
|
const convertBtn = document.getElementById('convert');
|
||||||
|
const openOutBtn = document.getElementById('openOut');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
|
||||||
|
function updateConvertBtn() {
|
||||||
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||||
|
convertBtn.disabled = !srcEl.value || !outEl.value || !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);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.getElementById('selectNone').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||||
|
outEl.addEventListener('input', updateConvertBtn);
|
||||||
|
|
||||||
|
openOutBtn.addEventListener('click', () => {
|
||||||
|
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('convert').addEventListener('click', () => {
|
||||||
|
const networks = [...document.querySelectorAll('.net-cb')]
|
||||||
|
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||||
|
statusEl.textContent = 'Converting...';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
openOutBtn.style.display = 'none';
|
||||||
|
convertBtn.disabled = true;
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'convert',
|
||||||
|
opts: {
|
||||||
|
sourcePath: srcEl.value,
|
||||||
|
outputDir: outEl.value,
|
||||||
|
networks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (msg.type === 'sourcePicked') {
|
||||||
|
srcEl.value = msg.path;
|
||||||
|
if (!outEl.value && msg.dir) {
|
||||||
|
outEl.value = msg.dir;
|
||||||
|
outputDir = msg.dir;
|
||||||
|
}
|
||||||
|
updateConvertBtn();
|
||||||
|
} else if (msg.type === 'outputPicked') {
|
||||||
|
outEl.value = msg.path;
|
||||||
|
outputDir = msg.path;
|
||||||
|
updateConvertBtn();
|
||||||
|
} else if (msg.type === 'convertDone') {
|
||||||
|
convertBtn.disabled = false;
|
||||||
|
updateConvertBtn();
|
||||||
|
if (msg.error) {
|
||||||
|
statusEl.textContent = 'Error: ' + msg.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = msg.results || [];
|
||||||
|
const ok = results.filter(r => r.ok).length;
|
||||||
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (ok > 0) {
|
||||||
|
outputDir = outEl.value;
|
||||||
|
openOutBtn.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -17,18 +17,16 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
if (msg.type === 'pickFile') {
|
if (msg.type === 'pickFile') {
|
||||||
const picked = await vscode.window.showOpenDialog({
|
const picked = await vscode.window.showOpenDialog({
|
||||||
canSelectMany: false,
|
canSelectMany: true,
|
||||||
filters: { HTML: ['html', 'htm'] },
|
filters: { HTML: ['html', 'htm'] },
|
||||||
openLabel: 'Select',
|
openLabel: 'Select',
|
||||||
defaultUri: getPickerDefaultUri(),
|
defaultUri: getPickerDefaultUri(),
|
||||||
});
|
});
|
||||||
if (picked && picked[0]) {
|
if (picked && picked.length) {
|
||||||
const fp = picked[0].fsPath;
|
|
||||||
panel.webview.postMessage({
|
panel.webview.postMessage({
|
||||||
type: 'fileSelected',
|
type: 'filesSelected',
|
||||||
rowId: msg.rowId,
|
rowId: msg.rowId,
|
||||||
path: fp,
|
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
|
||||||
name: path.basename(fp),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -233,6 +231,15 @@ function getHtml(): string {
|
|||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const tbody = document.querySelector('#rows tbody');
|
const tbody = document.querySelector('#rows tbody');
|
||||||
|
|
||||||
|
function parseIterationName(filename) {
|
||||||
|
const base = filename.replace(/\\.html?$/i, '');
|
||||||
|
const tokens = base.split('_');
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (/^(full|\\d+clk|\\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
|
|
||||||
@@ -286,13 +293,32 @@ function getHtml(): string {
|
|||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (m.type === 'fileSelected') {
|
if (m.type === 'filesSelected') {
|
||||||
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
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;
|
if (!tr) return;
|
||||||
tr.querySelector('[data-path]').value = m.path;
|
tr.querySelector('[data-path]').value = f.path;
|
||||||
tr.querySelector('[data-name]').textContent = m.name;
|
tr.querySelector('[data-name]').textContent = f.name;
|
||||||
const iter = tr.querySelector('[data-iter]');
|
const iter = tr.querySelector('[data-iter]');
|
||||||
if (!iter.value) iter.value = m.name.replace(/\\.html?$/i, '');
|
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||||
|
affected.push(tr);
|
||||||
|
});
|
||||||
|
if (affected.length > 1) {
|
||||||
|
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||||
|
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||||
|
affected.forEach((r, i) => {
|
||||||
|
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (m.type === 'status') {
|
} else if (m.type === 'status') {
|
||||||
statusEl.textContent = m.message;
|
statusEl.textContent = m.message;
|
||||||
statusEl.className = '';
|
statusEl.className = '';
|
||||||
|
|||||||
@@ -5,25 +5,49 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
urlpkg "net/url"
|
urlpkg "net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var applovinUserAgents = []string{
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickApplovinUserAgent(randomize bool) string {
|
||||||
|
if !randomize {
|
||||||
|
return applovinUserAgents[0]
|
||||||
|
}
|
||||||
|
return applovinUserAgents[rand.Intn(len(applovinUserAgents))]
|
||||||
|
}
|
||||||
|
|
||||||
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
||||||
body := `
|
body := `
|
||||||
<h2>AppLovin Playable Preview (QR)</h2>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||||
<button id="pick" class="secondary">Choose files...</button>
|
<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>
|
<span id="fileName" class="hint">(no files)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;">
|
||||||
<button id="upload">Upload to AppLovin</button>
|
<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>
|
||||||
|
|
||||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||||
@@ -36,21 +60,70 @@ func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const pickBtn = document.getElementById('pick');
|
const pickBtn = document.getElementById('pick');
|
||||||
|
const clearBtn = document.getElementById('clear');
|
||||||
const uploadBtn = document.getElementById('upload');
|
const uploadBtn = document.getElementById('upload');
|
||||||
const fileNameEl = document.getElementById('fileName');
|
const fileNameEl = document.getElementById('fileName');
|
||||||
|
const fileListEl = document.getElementById('fileList');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
|
const saveAllBtn = document.getElementById('saveAll');
|
||||||
|
const regenAllBtn = document.getElementById('regenAll');
|
||||||
let selectedPaths = [];
|
let selectedPaths = [];
|
||||||
|
let resultItems = [];
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||||
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
|
}
|
||||||
|
function renderSelection() {
|
||||||
|
if (!selectedPaths.length) {
|
||||||
|
fileNameEl.textContent = '(no files)';
|
||||||
|
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 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAllBtn.addEventListener('click', async () => {
|
||||||
|
if (!resultItems.length) return;
|
||||||
|
saveAllBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/applovin/saveAllQrs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ items: resultItems }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
statusEl.innerHTML = j.ok
|
||||||
|
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
|
||||||
|
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
|
||||||
|
} finally {
|
||||||
|
saveAllBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
regenAllBtn.addEventListener('click', () => {
|
||||||
|
resultsEl.querySelectorAll('img[data-qr]').forEach(img => {
|
||||||
|
const base = img.getAttribute('data-qr');
|
||||||
|
img.src = base + '&_=' + Date.now();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
selectedPaths = [];
|
||||||
|
renderSelection();
|
||||||
|
});
|
||||||
|
|
||||||
pickBtn.addEventListener('click', async () => {
|
pickBtn.addEventListener('click', async () => {
|
||||||
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.files && j.files.length) {
|
if (j.files && j.files.length) {
|
||||||
selectedPaths = j.files.map(f => f.path);
|
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||||
const names = j.files.map(f => f.name);
|
selectedPaths = selectedPaths.concat(added);
|
||||||
fileNameEl.textContent = names.length === 1
|
renderSelection();
|
||||||
? names[0]
|
|
||||||
: names.length + ' files: ' + names.slice(0,3).join(', ') + (names.length > 3 ? '...' : '');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,6 +135,9 @@ uploadBtn.addEventListener('click', async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
|
resultItems = [];
|
||||||
|
saveAllBtn.style.display = 'none';
|
||||||
|
regenAllBtn.style.display = 'none';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/applovin/upload', {
|
const res = await fetch('/api/applovin/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -113,25 +189,42 @@ function handle(m) {
|
|||||||
wrap.innerHTML =
|
wrap.innerHTML =
|
||||||
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
||||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||||
'<img src="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'result-actions';
|
actions.className = 'result-actions';
|
||||||
const copyHash = document.createElement('button');
|
|
||||||
copyHash.textContent = 'Copy Hash';
|
|
||||||
copyHash.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.hash})});
|
|
||||||
const copyUrl = document.createElement('button');
|
|
||||||
copyUrl.textContent = 'Copy URL';
|
|
||||||
copyUrl.className = 'secondary';
|
|
||||||
copyUrl.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.previewUrl})});
|
|
||||||
const openBtn = document.createElement('button');
|
const openBtn = document.createElement('button');
|
||||||
openBtn.textContent = 'Open';
|
openBtn.textContent = 'Open';
|
||||||
openBtn.className = 'secondary';
|
openBtn.className = 'secondary';
|
||||||
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
|
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
|
||||||
actions.appendChild(copyHash); actions.appendChild(copyUrl); actions.appendChild(openBtn);
|
const saveQr = document.createElement('button');
|
||||||
|
saveQr.textContent = 'Save QR';
|
||||||
|
saveQr.className = 'secondary';
|
||||||
|
saveQr.onclick = async () => {
|
||||||
|
saveQr.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/applovin/saveQr', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ name: m.name, url: m.qrImg }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
statusEl.innerHTML = j.ok
|
||||||
|
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
|
||||||
|
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
|
||||||
|
} finally {
|
||||||
|
saveQr.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
||||||
wrap.appendChild(actions);
|
wrap.appendChild(actions);
|
||||||
resultsEl.appendChild(wrap);
|
resultsEl.appendChild(wrap);
|
||||||
|
resultItems.push({ name: m.name, url: m.qrImg });
|
||||||
|
if (resultItems.length >= 2) {
|
||||||
|
saveAllBtn.style.display = '';
|
||||||
|
regenAllBtn.style.display = '';
|
||||||
|
}
|
||||||
} else if (m.type === 'done') {
|
} else if (m.type === 'done') {
|
||||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||||
}
|
}
|
||||||
@@ -143,11 +236,16 @@ function handle(m) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
picked := PickFiles(
|
picked := PickFiles(
|
||||||
"Select HTML files",
|
"Select HTML files",
|
||||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||||
true, "",
|
true, cfg.LastPickDir,
|
||||||
)
|
)
|
||||||
|
if len(picked) > 0 {
|
||||||
|
cfg.LastPickDir = filepath.Dir(picked[0])
|
||||||
|
_ = SaveConfig(cfg)
|
||||||
|
}
|
||||||
files := make([]map[string]string, 0, len(picked))
|
files := make([]map[string]string, 0, len(picked))
|
||||||
for _, p := range picked {
|
for _, p := range picked {
|
||||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||||
@@ -163,7 +261,16 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cookie := LoadConfig().Applovin.Cookie
|
cfg := LoadConfig().Applovin
|
||||||
|
cookie := cfg.Cookie
|
||||||
|
delayMs := cfg.DelayMs
|
||||||
|
if delayMs < 0 {
|
||||||
|
delayMs = 0
|
||||||
|
}
|
||||||
|
randomizeUA := true
|
||||||
|
if cfg.RandomizeUserAgent != nil {
|
||||||
|
randomizeUA = *cfg.RandomizeUserAgent
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
flusher, _ := w.(http.Flusher)
|
flusher, _ := w.(http.Flusher)
|
||||||
@@ -208,7 +315,7 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Header.Set("Origin", "https://p.applov.in")
|
req.Header.Set("Origin", "https://p.applov.in")
|
||||||
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
|
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
|
||||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
|
req.Header.Set("User-Agent", pickApplovinUserAgent(randomizeUA))
|
||||||
if cookie != "" {
|
if cookie != "" {
|
||||||
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
|
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
|
||||||
}
|
}
|
||||||
@@ -237,6 +344,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)})
|
||||||
|
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("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)})
|
||||||
|
|
||||||
body, ct, err := buildForm(filePath)
|
body, ct, err := buildForm(filePath)
|
||||||
@@ -300,3 +412,114 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
send(map[string]any{"type": "done"})
|
send(map[string]any{"type": "done"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func qrBaseName(name string) string {
|
||||||
|
base := name
|
||||||
|
if base == "" {
|
||||||
|
base = "qr"
|
||||||
|
}
|
||||||
|
base = regexp.MustCompile(`(?i)\.html?$`).ReplaceAllString(base, "")
|
||||||
|
base = regexp.MustCompile(`[\\/:*?"<>|]`).ReplaceAllString(base, "_")
|
||||||
|
if base == "" {
|
||||||
|
base = "qr"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadQR(url, destPath string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(destPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplovinSaveQR(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.URL == "" {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "Missing URL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := qrBaseName(req.Name)
|
||||||
|
dest := PickSaveFile("Save QR", base+".png", []FileFilter{{Name: "PNG Image", Extensions: []string{"png"}}}, "")
|
||||||
|
if dest == "" {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := downloadQR(req.URL, dest); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "Save failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "message": "Saved QR: " + dest})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplovinSaveAllQRs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Items []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "No items"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := PickFolder("Choose folder to save QRs", "")
|
||||||
|
if dir == "" {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved := 0
|
||||||
|
errs := []string{}
|
||||||
|
for _, it := range req.Items {
|
||||||
|
base := qrBaseName(it.Name)
|
||||||
|
dest := filepath.Join(dir, base+".png")
|
||||||
|
n := 1
|
||||||
|
for fileExists(dest) {
|
||||||
|
dest = filepath.Join(dir, fmt.Sprintf("%s (%d).png", base, n))
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if err := downloadQR(it.URL, dest); err != nil {
|
||||||
|
errs = append(errs, it.Name+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
saved++
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"message": fmt.Sprintf("Saved %d/%d. Errors: %s", saved, len(req.Items), strings.Join(errs, "; ")),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Saved %d QR%s to %s", saved, plural(saved), dir),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DailyPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg := LoadConfig()
|
|
||||||
today := time.Now().Format("2006-01-02")
|
|
||||||
lastProject := html.EscapeString(cfg.DailyUpdate.LastProject)
|
|
||||||
|
|
||||||
body := `
|
|
||||||
<h2>Daily Update</h2>
|
|
||||||
<div class="hint" style="margin-bottom:12px;">Submit copies the formatted text to your clipboard.</div>
|
|
||||||
|
|
||||||
<div style="max-width:520px;">
|
|
||||||
<label style="display:block;margin-top:8px;font-size:12px;opacity:0.85;">Date</label>
|
|
||||||
<input type="date" id="date" value="` + today + `" style="width:100%;" />
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Status</label>
|
|
||||||
<select id="status" style="width:100%;">
|
|
||||||
<option>Started</option><option>Progress</option><option>Finished</option>
|
|
||||||
<option>Client Feedback</option><option>For OT</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Project</label>
|
|
||||||
<input type="text" id="project" placeholder="Project name" value="` + lastProject + `" style="width:100%;" />
|
|
||||||
|
|
||||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Remarks</label>
|
|
||||||
<textarea id="remarks" placeholder="What did you do?" style="width:100%;min-height:96px;"></textarea>
|
|
||||||
|
|
||||||
<button id="submit" style="margin-top:16px;">Submit</button>
|
|
||||||
<div id="status-msg" style="margin-top:12px;min-height:18px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.getElementById('submit').addEventListener('click', async () => {
|
|
||||||
const payload = {
|
|
||||||
date: document.getElementById('date').value,
|
|
||||||
status: document.getElementById('status').value,
|
|
||||||
project: document.getElementById('project').value.trim(),
|
|
||||||
remarks: document.getElementById('remarks').value.trim(),
|
|
||||||
};
|
|
||||||
const statusEl = document.getElementById('status-msg');
|
|
||||||
statusEl.textContent = 'Copying...';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/daily/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const j = await res.json();
|
|
||||||
if (j.ok) {
|
|
||||||
statusEl.innerHTML = '<span class="ok">Copied to clipboard.</span>';
|
|
||||||
} else {
|
|
||||||
statusEl.innerHTML = '<span class="err">' + (j.error || 'Failed') + '</span>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
`
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_, _ = w.Write([]byte(Page("/daily", "Daily Update", body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type dailySubmitReq struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Project string `json:"project"`
|
|
||||||
Remarks string `json:"remarks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func DailySubmit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req dailySubmitReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text := formatDailyMessage(req)
|
|
||||||
if err := CopyToClipboard(text); err != nil {
|
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg := LoadConfig()
|
|
||||||
cfg.DailyUpdate.LastProject = req.Project
|
|
||||||
_ = SaveConfig(cfg)
|
|
||||||
writeJSON(w, map[string]any{"ok": true, "text": text})
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDailyMessage(p dailySubmitReq) string {
|
|
||||||
bullets := []string{}
|
|
||||||
for _, line := range strings.Split(p.Remarks, "\n") {
|
|
||||||
t := strings.TrimRight(line, "\r")
|
|
||||||
t = regexp.MustCompile(`^\s*[-*]\s*`).ReplaceAllString(t, "")
|
|
||||||
t = strings.TrimSpace(t)
|
|
||||||
if t != "" {
|
|
||||||
bullets = append(bullets, "- "+t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines := []string{
|
|
||||||
"Date: " + formatDailyDate(p.Date),
|
|
||||||
"Status: " + p.Status,
|
|
||||||
"Project: " + p.Project,
|
|
||||||
"Remarks:",
|
|
||||||
strings.Join(bullets, "\n"),
|
|
||||||
}
|
|
||||||
if p.Status == "For OT" {
|
|
||||||
lines = append(lines, "@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace")
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDailyDate(iso string) string {
|
|
||||||
t, err := time.Parse("2006-01-02", iso)
|
|
||||||
if err != nil {
|
|
||||||
return iso
|
|
||||||
}
|
|
||||||
return t.Format("01/02/2006")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
func HomePage(w http.ResponseWriter, r *http.Request) {
|
func HomePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
var items strings.Builder
|
||||||
|
for _, n := range visibleNavItems(cfg.BetaToolsEnabled) {
|
||||||
|
if n.Path == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := n.Label
|
||||||
|
if n.Beta {
|
||||||
|
label += ` <span style="font-size:10px;opacity:0.65;text-transform:uppercase;">Beta</span>`
|
||||||
|
}
|
||||||
|
items.WriteString(`<li><a href="` + n.Path + `" style="color:#9cdcfe;">` + label + `</a> — ` + n.Description + `</li>`)
|
||||||
|
}
|
||||||
|
|
||||||
body := `
|
body := `
|
||||||
<h2>HPL Toolbox</h2>
|
<h2>HPL Toolbox</h2>
|
||||||
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
||||||
<ul style="line-height:1.9;">
|
<ul style="line-height:1.9;">
|
||||||
<li><a href="/plec" style="color:#9cdcfe;">PLEC Upload</a> — Upload HTML to the internal PLEC server</li>
|
` + items.String() + `
|
||||||
<li><a href="/applovin" style="color:#9cdcfe;">AppLovin Demo Upload</a> — Upload to p.applov.in (QR preview)</li>
|
|
||||||
<li><a href="/base64" style="color:#9cdcfe;">Base64 Scanner</a> — Find non-base64 assets in HTML</li>
|
|
||||||
<li><a href="/daily" style="color:#9cdcfe;">Daily Update</a> — Compose & copy a daily status</li>
|
|
||||||
<li><a href="/mobile" style="color:#9cdcfe;">Send To Mobile</a> — Share HTML to a phone via LAN + QR</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
`
|
`
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|||||||
BIN
standalone/hpltoolbox.exe
Normal file
BIN
standalone/hpltoolbox.exe
Normal file
Binary file not shown.
11
standalone/json.go
Normal file
11
standalone/json.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
const SharedCSS = `
|
const SharedCSS = `
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
@@ -17,7 +22,13 @@ const SharedCSS = `
|
|||||||
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
||||||
.topbar a:hover { background: #2a2d2e; }
|
.topbar a:hover { background: #2a2d2e; }
|
||||||
.topbar a.active { background: #094771; color: #fff; }
|
.topbar a.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; }
|
.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; }
|
h2 { margin-top: 0; font-weight: 500; }
|
||||||
input[type=text], input[type=date], select, textarea {
|
input[type=text], input[type=date], select, textarea {
|
||||||
box-sizing: border-box; padding: 6px 8px;
|
box-sizing: border-box; padding: 6px 8px;
|
||||||
@@ -41,33 +52,124 @@ const SharedCSS = `
|
|||||||
.hint { font-size: 12px; opacity: 0.7; }
|
.hint { font-size: 12px; opacity: 0.7; }
|
||||||
`
|
`
|
||||||
|
|
||||||
type navItem struct{ Path, Label string }
|
type navItem struct {
|
||||||
|
Path string
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Beta bool
|
||||||
|
}
|
||||||
|
|
||||||
var navItems = []navItem{
|
var navItems = []navItem{
|
||||||
{"/", "Home"},
|
{Path: "/", Label: "Home"},
|
||||||
{"/plec", "PLEC Upload"},
|
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
||||||
{"/applovin", "AppLovin Upload"},
|
{Path: "/applovin", Label: "AppLovin Upload", Description: "Upload to p.applov.in (QR preview)"},
|
||||||
{"/base64", "Base64 Scanner"},
|
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
|
||||||
{"/daily", "Daily Update"},
|
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
|
||||||
{"/mobile", "Send To Mobile"},
|
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
|
||||||
|
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
||||||
|
out := make([]navItem, 0, len(navItems))
|
||||||
|
for _, n := range navItems {
|
||||||
|
if n.Beta && !betaToolsEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return sortNavItemsForDisplay(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortNavItemsForDisplay(items []navItem) []navItem {
|
||||||
|
out := append([]navItem(nil), items...)
|
||||||
|
for i := 0; i < len(out); i++ {
|
||||||
|
for j := i + 1; j < len(out); j++ {
|
||||||
|
if out[i].Beta && !out[j].Beta {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func betaToolCount() int {
|
||||||
|
count := 0
|
||||||
|
for _, n := range navItems {
|
||||||
|
if n.Beta {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func Page(activePath, title, body string) string {
|
func Page(activePath, title, body string) string {
|
||||||
|
cfg := LoadConfig()
|
||||||
|
betaToolsEnabled := cfg.BetaToolsEnabled
|
||||||
var tabs strings.Builder
|
var tabs strings.Builder
|
||||||
for _, n := range navItems {
|
for _, n := range visibleNavItems(betaToolsEnabled) {
|
||||||
cls := ""
|
cls := ""
|
||||||
if n.Path == activePath {
|
if n.Path == activePath {
|
||||||
cls = " class=\"active\""
|
cls = " class=\"active\""
|
||||||
}
|
}
|
||||||
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + n.Label + `</a>`)
|
label := n.Label
|
||||||
|
if n.Beta {
|
||||||
|
label += `<span class="beta-pill">Beta</span>`
|
||||||
}
|
}
|
||||||
|
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + label + `</a>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
betaButtonLabel := "Enable Beta Tools"
|
||||||
|
betaButtonNote := "Hidden beta tools: " + strconv.Itoa(betaToolCount())
|
||||||
|
if betaToolsEnabled {
|
||||||
|
betaButtonLabel = "Disable Beta Tools"
|
||||||
|
betaButtonNote = "Beta tools are visible in this standalone launcher."
|
||||||
|
}
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html><head>
|
<html><head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>` + title + ` — HPL Toolbox</title>
|
<title>` + title + ` — HPL Toolbox</title>
|
||||||
<style>` + SharedCSS + `</style>
|
<style>` + SharedCSS + `</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
<div class="topbar">` + tabs.String() + `</div>
|
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
|
||||||
<div class="content">` + body + `</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>
|
||||||
|
<script>
|
||||||
|
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
||||||
|
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||||
|
await fetch('/api/betaTools', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: !enabled }),
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body></html>`
|
</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolAttr(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := LoadConfig()
|
||||||
|
cfg.BetaToolsEnabled = req.Enabled
|
||||||
|
if err := SaveConfig(cfg); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
webview "github.com/jchv/go-webview2"
|
webview "github.com/jchv/go-webview2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var AppVersion = "dev"
|
||||||
|
|
||||||
var currentHwnd atomic.Uintptr
|
var currentHwnd atomic.Uintptr
|
||||||
|
|
||||||
func setupFileLogging() {
|
func setupFileLogging() {
|
||||||
logPath := filepath.Join(appDir(), "debug.log")
|
logPath := filepath.Join(userDataDir(), "debug.log")
|
||||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -57,11 +59,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer removeLockFile()
|
defer removeLockFile()
|
||||||
|
|
||||||
|
webviewTmp, err := os.MkdirTemp("", "hpltoolbox-wv2-*")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create webview temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(webviewTmp)
|
||||||
|
|
||||||
w := webview.NewWithOptions(webview.WebViewOptions{
|
w := webview.NewWithOptions(webview.WebViewOptions{
|
||||||
Debug: false,
|
Debug: false,
|
||||||
AutoFocus: true,
|
AutoFocus: true,
|
||||||
|
DataPath: webviewTmp,
|
||||||
WindowOptions: webview.WindowOptions{
|
WindowOptions: webview.WindowOptions{
|
||||||
Title: "HPL Toolbox",
|
Title: "HPL Toolbox " + AppVersion,
|
||||||
Width: 1100,
|
Width: 1100,
|
||||||
Height: 720,
|
Height: 720,
|
||||||
IconId: 1,
|
IconId: 1,
|
||||||
@@ -101,17 +110,16 @@ func buildMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /plec", PlecPage)
|
mux.HandleFunc("GET /plec", PlecPage)
|
||||||
mux.HandleFunc("GET /applovin", ApplovinPage)
|
mux.HandleFunc("GET /applovin", ApplovinPage)
|
||||||
mux.HandleFunc("GET /base64", Base64Page)
|
mux.HandleFunc("GET /base64", Base64Page)
|
||||||
mux.HandleFunc("GET /daily", DailyPage)
|
mux.HandleFunc("GET /mraid", MraidPage)
|
||||||
mux.HandleFunc("GET /mobile", MobilePage)
|
mux.HandleFunc("GET /mobile", MobilePage)
|
||||||
|
mux.HandleFunc("GET /playworks", PlayworksPage)
|
||||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||||
|
|
||||||
// Shared API
|
// Shared API
|
||||||
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
||||||
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
||||||
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
||||||
|
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
|
||||||
// Daily Update
|
|
||||||
mux.HandleFunc("POST /api/daily/submit", DailySubmit)
|
|
||||||
|
|
||||||
// PLEC
|
// PLEC
|
||||||
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
||||||
@@ -120,17 +128,29 @@ func buildMux() *http.ServeMux {
|
|||||||
// AppLovin
|
// AppLovin
|
||||||
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
|
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
|
||||||
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
|
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
|
||||||
|
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
|
||||||
|
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
|
||||||
|
|
||||||
// Base64
|
// Base64
|
||||||
mux.HandleFunc("POST /api/base64/pickFolder", Base64PickFolder)
|
mux.HandleFunc("POST /api/base64/pickFolder", Base64PickFolder)
|
||||||
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
||||||
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
||||||
|
|
||||||
|
// MRAID Checker
|
||||||
|
mux.HandleFunc("POST /api/mraid/pickFolder", MraidPickFolder)
|
||||||
|
mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles)
|
||||||
|
mux.HandleFunc("POST /api/mraid/scan", MraidScan)
|
||||||
|
|
||||||
// Send To Mobile
|
// Send To Mobile
|
||||||
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
||||||
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
||||||
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
||||||
|
|
||||||
|
// Playworks Converter
|
||||||
|
mux.HandleFunc("POST /api/playworks/pickSource", PlayworksPickSource)
|
||||||
|
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
||||||
|
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
376
standalone/mraid.go
Normal file
376
standalone/mraid.go
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mraidIssue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidResult struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Issues []mraidIssue `json:"issues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidRule struct {
|
||||||
|
ID string
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
Reference string
|
||||||
|
Test func(mraidContext, mraidRule) *mraidIssue
|
||||||
|
}
|
||||||
|
|
||||||
|
type mraidContext struct {
|
||||||
|
HTML string
|
||||||
|
ScriptText string
|
||||||
|
MraidCallLines map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-result { padding:8px 0; border-bottom:1px solid #333; }
|
||||||
|
.file-head { display:flex; gap:8px; align-items:baseline; font-family:Consolas, monospace; font-size:12px; }
|
||||||
|
.mark { width:20px; flex-shrink:0; font-weight:bold; }
|
||||||
|
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||||
|
.file-name { word-break:break-all; }
|
||||||
|
.counts { opacity:0.7; margin-left:auto; white-space:nowrap; }
|
||||||
|
.issues { margin:6px 0 0 28px; padding:0; }
|
||||||
|
.issue { list-style:none; margin:6px 0; }
|
||||||
|
.badge { display:inline-block; min-width:82px; margin-right:6px; padding:1px 5px; border-radius:3px; font-size:11px; text-align:center; }
|
||||||
|
.requirement { color:#ffb4ad; background:rgba(248,81,73,0.18); }
|
||||||
|
.best-practice { color:#ffd580; background:rgba(210,153,34,0.18); }
|
||||||
|
.issue-title { font-weight:600; }
|
||||||
|
.issue-detail { opacity:0.72; margin-top:2px; }
|
||||||
|
.summary { opacity:0.85; margin-bottom:6px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const folderEl = document.getElementById('folder');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
const selectionEl = document.getElementById('selection');
|
||||||
|
let pickedFiles = [];
|
||||||
|
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||||
|
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); }
|
||||||
|
folderEl.addEventListener('input', clearFiles);
|
||||||
|
|
||||||
|
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(); }
|
||||||
|
});
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('scan').addEventListener('click', async () => {
|
||||||
|
statusEl.textContent = 'Scanning...';
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
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 = '';
|
||||||
|
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>';
|
||||||
|
if (!rr.ok) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'issues';
|
||||||
|
for (const i of rr.issues) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'issue';
|
||||||
|
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||||
|
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
row.appendChild(list);
|
||||||
|
}
|
||||||
|
resultsEl.appendChild(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/mraid", "MRAID Checker", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{"path": PickFolder("Select folder to scan", "")})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := PickFiles("Select HTML files", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, true, "")
|
||||||
|
writeJSON(w, map[string]any{"paths": paths})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []string
|
||||||
|
var baseDir string
|
||||||
|
skipped := 0
|
||||||
|
if len(req.Files) > 0 {
|
||||||
|
var existing []string
|
||||||
|
for _, f := range req.Files {
|
||||||
|
if fileExists(f) {
|
||||||
|
existing = append(existing, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range existing {
|
||||||
|
if isSupportedMraidBuild(f) {
|
||||||
|
targets = append(targets, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipped = len(existing) - len(targets)
|
||||||
|
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) {
|
||||||
|
targets = append(targets, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipped = len(all) - len(targets)
|
||||||
|
baseDir = req.Folder
|
||||||
|
} else {
|
||||||
|
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "error": "No AppLovin, ironSource, or Unity HTML builds were found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []mraidResult{}
|
||||||
|
for _, file := range targets {
|
||||||
|
data, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
issues := checkMraid(string(data))
|
||||||
|
display := file
|
||||||
|
if baseDir != "" {
|
||||||
|
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
|
||||||
|
display = rel
|
||||||
|
} else {
|
||||||
|
display = filepath.Base(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"results": results, "skipped": skipped})
|
||||||
|
}
|
||||||
|
|
||||||
|
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*\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
|
||||||
|
|
||||||
|
func isSupportedMraidBuild(filePath string) bool {
|
||||||
|
normalized := strings.ReplaceAll(strings.ToLower(filePath), `\`, `/`)
|
||||||
|
return mraidBuildNameRx.MatchString(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMraid(htmlSrc string) []mraidIssue {
|
||||||
|
ctx := mraidContext{
|
||||||
|
HTML: htmlSrc,
|
||||||
|
ScriptText: extractMraidScriptText(htmlSrc),
|
||||||
|
MraidCallLines: collectMraidCallLines(htmlSrc),
|
||||||
|
}
|
||||||
|
var out []mraidIssue
|
||||||
|
for _, rule := range mraidRules {
|
||||||
|
if found := rule.Test(ctx, rule); found != nil {
|
||||||
|
out = append(out, *found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []mraidIssue{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMraidScriptText(htmlSrc string) string {
|
||||||
|
var parts []string
|
||||||
|
for _, m := range mraidScriptRx.FindAllStringSubmatch(htmlSrc, -1) {
|
||||||
|
parts = append(parts, m[1])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMraidCallLines(htmlSrc string) map[string]int {
|
||||||
|
out := map[string]int{}
|
||||||
|
for idx, line := range strings.Split(htmlSrc, "\n") {
|
||||||
|
for _, m := range mraidCallRx.FindAllStringSubmatch(line, -1) {
|
||||||
|
if _, ok := out[m[1]]; !ok {
|
||||||
|
out[m[1]] = idx + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
|
||||||
|
func mraidHasCall(ctx mraidContext, method string) bool {
|
||||||
|
_, ok := ctx.MraidCallLines[method]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
|
||||||
|
|
||||||
|
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
|
||||||
|
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
|
||||||
|
return rx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasSupportCheck(ctx mraidContext, feature string) bool {
|
||||||
|
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*supports\s*\(\s*['"]` + regexp.QuoteMeta(feature) + `['"]`)
|
||||||
|
return rx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidHasReadyGate(ctx mraidContext) bool {
|
||||||
|
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||||
|
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
|
||||||
|
if line > 0 {
|
||||||
|
found.Line = line
|
||||||
|
}
|
||||||
|
return &found
|
||||||
|
}
|
||||||
|
|
||||||
|
var mraidRules = []mraidRule{
|
||||||
|
{ID: "mraid-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: "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: "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") {
|
||||||
|
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") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
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") {
|
||||||
|
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") {
|
||||||
|
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "size-change", Severity: "best-practice", Title: "No sizeChange listener found", Detail: "Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
depends := mraidHasCall(ctx, "getMaxSize") || mraidHasCall(ctx, "getScreenSize") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "setOrientationProperties")
|
||||||
|
if depends && !mraidHasEventListener(ctx, "sizeChange") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{ID: "state-change", Severity: "best-practice", Title: "No stateChange listener found", Detail: "Listen for stateChange when using expand, resize, close, or other state-changing container behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||||
|
changes := mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "close")
|
||||||
|
if changes && !mraidHasEventListener(ctx, "stateChange") {
|
||||||
|
return mraidIssueFrom(rule, 0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
@@ -19,25 +19,41 @@ type PlecConfig struct {
|
|||||||
|
|
||||||
type ApplovinConfig struct {
|
type ApplovinConfig struct {
|
||||||
Cookie string `json:"cookie"`
|
Cookie string `json:"cookie"`
|
||||||
|
DelayMs int `json:"delayMs"`
|
||||||
|
RandomizeUserAgent *bool `json:"randomizeUserAgent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendToMobileConfig struct {
|
type SendToMobileConfig struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyUpdateConfig struct {
|
|
||||||
LastProject string `json:"lastProject"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
Plec PlecConfig `json:"plec"`
|
Plec PlecConfig `json:"plec"`
|
||||||
Applovin ApplovinConfig `json:"applovin"`
|
Applovin ApplovinConfig `json:"applovin"`
|
||||||
SendToMobile SendToMobileConfig `json:"sendToMobile"`
|
SendToMobile SendToMobileConfig `json:"sendToMobile"`
|
||||||
DailyUpdate DailyUpdateConfig `json:"dailyUpdate"`
|
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
||||||
|
LastPickDir string `json:"lastPickDir,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configMu sync.Mutex
|
var configMu sync.Mutex
|
||||||
|
|
||||||
|
// userDataDir returns %LOCALAPPDATA%\HPLToolbox, creating it if needed.
|
||||||
|
// All mutable app files (config, lock, logs, WebView2 cache) live here so
|
||||||
|
// they are never placed next to the executable, which may be on a synced or
|
||||||
|
// antivirus-watched drive and would cause message-loop stalls.
|
||||||
|
func userDataDir() string {
|
||||||
|
local := os.Getenv("LOCALAPPDATA")
|
||||||
|
if local == "" {
|
||||||
|
local = os.Getenv("APPDATA")
|
||||||
|
}
|
||||||
|
if local == "" {
|
||||||
|
return appDir()
|
||||||
|
}
|
||||||
|
dir := filepath.Join(local, "HPLToolbox")
|
||||||
|
_ = os.MkdirAll(dir, 0755)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
func defaultConfig() AppConfig {
|
func defaultConfig() AppConfig {
|
||||||
return AppConfig{
|
return AppConfig{
|
||||||
Plec: PlecConfig{
|
Plec: PlecConfig{
|
||||||
@@ -46,9 +62,8 @@ func defaultConfig() AppConfig {
|
|||||||
PreviewBase: "https://playable.applovindemo.com/phaser/",
|
PreviewBase: "https://playable.applovindemo.com/phaser/",
|
||||||
SkipExistsCheck: false,
|
SkipExistsCheck: false,
|
||||||
},
|
},
|
||||||
Applovin: ApplovinConfig{Cookie: ""},
|
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
||||||
SendToMobile: SendToMobileConfig{Port: 0},
|
SendToMobile: SendToMobileConfig{Port: 0},
|
||||||
DailyUpdate: DailyUpdateConfig{LastProject: ""},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +87,7 @@ func appDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configPath() string {
|
func configPath() string {
|
||||||
return filepath.Join(appDir(), "config.json")
|
return filepath.Join(userDataDir(), "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() AppConfig {
|
func LoadConfig() AppConfig {
|
||||||
@@ -159,6 +174,37 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PickSaveFile(title, defaultName string, filters []FileFilter, initialDir string) string {
|
||||||
|
filterStr := "All files|*.*"
|
||||||
|
if len(filters) > 0 {
|
||||||
|
parts := make([]string, 0, len(filters))
|
||||||
|
for _, f := range filters {
|
||||||
|
exts := make([]string, 0, len(f.Extensions))
|
||||||
|
for _, e := range f.Extensions {
|
||||||
|
exts = append(exts, "*."+e)
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s|%s", f.Name, strings.Join(exts, ";")))
|
||||||
|
}
|
||||||
|
filterStr = strings.Join(parts, "|")
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$dlg = New-Object System.Windows.Forms.SaveFileDialog
|
||||||
|
$dlg.Title = '%s'
|
||||||
|
$dlg.Filter = '%s'
|
||||||
|
$dlg.FileName = '%s'
|
||||||
|
if ('%s'.Length -gt 0) { $dlg.InitialDirectory = '%s' }
|
||||||
|
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()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
func PickFolder(title, initialDir string) string {
|
func PickFolder(title, initialDir string) string {
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
|||||||
385
standalone/playworks.go
Normal file
385
standalone/playworks.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type playworksConvertOptions struct {
|
||||||
|
SourcePath string `json:"sourcePath"`
|
||||||
|
OutputDir string `json:"outputDir"`
|
||||||
|
Networks []string `json:"networks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type playworksNetworkResult struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
File string `json:"file"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := `
|
||||||
|
<h2>Playworks Converter</h2>
|
||||||
|
|
||||||
|
<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-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Output Folder</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
||||||
|
<button id="pickOut">Browse...</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">
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="convert" disabled>Convert</button>
|
||||||
|
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let outputDir = '';
|
||||||
|
const srcEl = document.getElementById('src');
|
||||||
|
const outEl = document.getElementById('outDir');
|
||||||
|
const convertBtn = document.getElementById('convert');
|
||||||
|
const openOutBtn = document.getElementById('openOut');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const resultsEl = document.getElementById('results');
|
||||||
|
|
||||||
|
function updateConvertBtn() {
|
||||||
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||||
|
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.getElementById('selectNone').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||||
|
updateConvertBtn();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||||
|
outEl.addEventListener('input', updateConvertBtn);
|
||||||
|
openOutBtn.addEventListener('click', () => {
|
||||||
|
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('convert').addEventListener('click', async () => {
|
||||||
|
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
||||||
|
statusEl.textContent = 'Converting...';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
openOutBtn.style.display = 'none';
|
||||||
|
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 })
|
||||||
|
});
|
||||||
|
const msg = await res.json();
|
||||||
|
convertBtn.disabled = false;
|
||||||
|
updateConvertBtn();
|
||||||
|
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||||
|
const results = msg.results || [];
|
||||||
|
const ok = results.filter(r => r.ok).length;
|
||||||
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (ok > 0) { outputDir = outEl.value; openOutBtn.style.display = ''; }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(Page("/playworks", "Playworks Converter", body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
picked := PickFiles("Select Playworks HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
||||||
|
if len(picked) == 0 {
|
||||||
|
writeJSON(w, map[string]any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0])})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{"path": PickFolder("Select Output Folder", "")})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var opts playworksConvertOptions
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fileExists(opts.SourcePath) {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(opts.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []playworksNetworkResult{}
|
||||||
|
for _, network := range opts.Networks {
|
||||||
|
filePath, err := convertPlayworksNetwork(string(data), opts, network)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`)
|
||||||
|
|
||||||
|
func playworksSourceBaseName(sourcePath string) string {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||||
|
return playworksSuffixRx.ReplaceAllString(base, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
||||||
|
transformed := transformPlayworksHTML(htmlSrc, network)
|
||||||
|
baseName := playworksSourceBaseName(opts.SourcePath)
|
||||||
|
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
||||||
|
|
||||||
|
if playworksNeedsZip(network) {
|
||||||
|
zipPath := filepath.Join(opts.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)
|
||||||
|
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return outPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playworksNeedsZip(network string) bool {
|
||||||
|
return network == "gg" || network == "vu" || network == "mtg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformPlayworksHTML(htmlSrc, network string) string {
|
||||||
|
result := htmlSrc
|
||||||
|
headInject := buildPlayworksLifecycleScript()
|
||||||
|
if network == "al" || network == "is" {
|
||||||
|
headInject += `<script src="mraid.js"></script>`
|
||||||
|
} else if network == "gg" {
|
||||||
|
headInject += `<script src="exitapi.js"></script>`
|
||||||
|
}
|
||||||
|
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1)
|
||||||
|
|
||||||
|
if network == "vu" {
|
||||||
|
result = strings.Replace(result, "<body>", `<body>
|
||||||
|
<script>window.__VUNGLE__=true;</script>`, 1)
|
||||||
|
} else if network == "mtg" {
|
||||||
|
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1)
|
||||||
|
} else if network == "tt" {
|
||||||
|
result = strings.Replace(result, "<body>", `<body>
|
||||||
|
<script>window.__TIKTOK__=true;</script>`, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = replacePlayworksCTAScript(result, network)
|
||||||
|
if network == "un" {
|
||||||
|
result = strings.ReplaceAll(result, "window.top", "window.self")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePlayworksCTAScript(htmlSrc, network string) string {
|
||||||
|
marker := "Luna.Unity.Playable.InstallFullGame=function"
|
||||||
|
markerIdx := strings.LastIndex(htmlSrc, marker)
|
||||||
|
if markerIdx == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptOpenIdx := strings.LastIndex(htmlSrc[:markerIdx], "<script>")
|
||||||
|
if scriptOpenIdx == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptCloseRel := strings.Index(htmlSrc[markerIdx:], "</script>")
|
||||||
|
if scriptCloseRel == -1 {
|
||||||
|
return htmlSrc
|
||||||
|
}
|
||||||
|
scriptCloseIdx := markerIdx + scriptCloseRel
|
||||||
|
return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len("</script>"):]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksConsoleRestoreScript() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
`<script>(function(){`,
|
||||||
|
`try{`,
|
||||||
|
`var f=document.createElement("iframe");`,
|
||||||
|
`f.style.display="none";`,
|
||||||
|
`document.documentElement.appendChild(f);`,
|
||||||
|
`var nc=f.contentWindow.console;`,
|
||||||
|
`document.documentElement.removeChild(f);`,
|
||||||
|
`var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];`,
|
||||||
|
`methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});`,
|
||||||
|
`}catch(e){}`,
|
||||||
|
`})();</script>`,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlayworksLifecycleScript() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
`<script>(function(){`,
|
||||||
|
`if(typeof window.gameReady!=="function")window.gameReady=function(){};`,
|
||||||
|
`if(typeof window.gameStart!=="function")window.gameStart=function(){};`,
|
||||||
|
`if(typeof window.gameEnd!=="function")window.gameEnd=function(){};`,
|
||||||
|
`if(typeof window.gameClose!=="function")window.gameClose=function(){};`,
|
||||||
|
`var _grOnce=false,_gsOnce=false;`,
|
||||||
|
`window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});`,
|
||||||
|
`window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});`,
|
||||||
|
`window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});`,
|
||||||
|
`})();</script>`,
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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");}`
|
||||||
|
case "fb":
|
||||||
|
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
|
||||||
|
case "gg":
|
||||||
|
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");`
|
||||||
|
case "vu":
|
||||||
|
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
|
||||||
|
case "mtg":
|
||||||
|
ctaLogic = closeCall + `if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`
|
||||||
|
case "tt":
|
||||||
|
ctaLogic = closeCall + `if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`
|
||||||
|
}
|
||||||
|
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),` + urlSetup + ctaLogic + `}}))}));</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSingleFileZip(data []byte, nameInZip, destZipPath string) error {
|
||||||
|
_ = os.Remove(destZipPath)
|
||||||
|
out, err := os.Create(destZipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(out)
|
||||||
|
entry, err := zw.Create(nameInZip)
|
||||||
|
if err != nil {
|
||||||
|
_ = zw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(data); err != nil {
|
||||||
|
_ = zw.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
@@ -54,6 +54,15 @@ const tbody = document.querySelector('#rows tbody');
|
|||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const resultsEl = document.getElementById('results');
|
const resultsEl = document.getElementById('results');
|
||||||
|
|
||||||
|
function parseIterationName(filename) {
|
||||||
|
const base = filename.replace(/\.html?$/i, '');
|
||||||
|
const tokens = base.split('_');
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (/^(full|\d+clk|\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
function addRow() {
|
function addRow() {
|
||||||
const id = 'r' + (nextId++);
|
const id = 'r' + (nextId++);
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
@@ -73,11 +82,24 @@ function addRow() {
|
|||||||
tr.querySelector('.pick').addEventListener('click', async () => {
|
tr.querySelector('.pick').addEventListener('click', async () => {
|
||||||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (j.path) {
|
if (j.files && j.files.length) {
|
||||||
tr.querySelector('[data-path]').value = j.path;
|
const affected = [];
|
||||||
tr.querySelector('[data-name]').textContent = j.name;
|
j.files.forEach(function(f, i) {
|
||||||
const iter = tr.querySelector('[data-iter]');
|
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
|
||||||
if (!iter.value) iter.value = j.name.replace(/\.html?$/i, '');
|
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.querySelector('.remove-btn').addEventListener('click', () => {
|
||||||
@@ -163,16 +185,23 @@ addRow();
|
|||||||
}
|
}
|
||||||
|
|
||||||
func PlecPick(w http.ResponseWriter, r *http.Request) {
|
func PlecPick(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := LoadConfig()
|
||||||
picked := PickFiles(
|
picked := PickFiles(
|
||||||
"Select HTML",
|
"Select HTML",
|
||||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||||
false, "",
|
true, cfg.LastPickDir,
|
||||||
)
|
)
|
||||||
if len(picked) == 0 {
|
if len(picked) == 0 {
|
||||||
writeJSON(w, map[string]any{"path": nil})
|
writeJSON(w, map[string]any{"files": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
|
cfg.LastPickDir = filepath.Dir(picked[0])
|
||||||
|
_ = SaveConfig(cfg)
|
||||||
|
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})
|
||||||
}
|
}
|
||||||
|
|
||||||
type plecRow struct {
|
type plecRow struct {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func lockFilePath() string {
|
func lockFilePath() string {
|
||||||
return filepath.Join(appDir(), ".hpltoolbox.lock")
|
return filepath.Join(userDataDir(), ".hpltoolbox.lock")
|
||||||
}
|
}
|
||||||
|
|
||||||
// focusExistingInstance returns true if another instance was found and
|
// focusExistingInstance returns true if another instance was found and
|
||||||
|
|||||||
Reference in New Issue
Block a user