13 Commits

Author SHA1 Message Date
75a506a505 Merge branch 'playworks-converter' of https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox into playworks-converter 2026-05-27 18:39:34 +08:00
698223234d updates 2026-05-27 18:37:52 +08:00
9e82656eeb playworks converter 2026-05-27 18:20:37 +08:00
c7ad1e14ca mraidChecker 2026-05-27 18:13:51 +08:00
d384c1be3a playworks converter 2026-05-27 15:13:58 +08:00
6be55255b2 update 2026-05-23 16:57:24 +08:00
9d6ed4b0a5 updated 2026-05-23 16:45:23 +08:00
1336695068 fix 2026-05-22 14:36:34 +08:00
40f13989c2 build script update 2026-05-14 14:38:22 +08:00
716dac31ef update applovin demo upload 2026-05-14 14:33:44 +08:00
f367b66b60 add standalone app 2026-05-12 01:21:36 +08:00
1cfdcb03ab Route build outputs to dist/ and commit release artifacts
build.ps1 now always packages the .vsix and writes both artifacts to
dist/. Added .vscodeignore so the .vsix excludes Go source, TS source,
and other dev-only files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:20:54 +08:00
f123cbfc4a Add standalone Go build alongside VSCode extension
Ports all five tools to a single 7.5 MB Windows exe with an embedded
WebView2 window, file-based config, single-instance focus, and clean
shutdown. Adds build.ps1 to build both targets from one command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:15:39 +08:00
53 changed files with 5329 additions and 193 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(go build *)"
]
}
}

1
.gitignore vendored
View File

@@ -93,7 +93,6 @@ out
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist
# Gatsby files # Gatsby files
.cache/ .cache/

11
.vscodeignore Normal file
View File

@@ -0,0 +1,11 @@
.git/**
.vscode/**
.gitignore
standalone/**
dist/**
src/**
out/**/*.map
build.ps1
tsconfig.json
**/*.vsix
node_modules/.cache/**

4
LICENSE Normal file
View 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
View 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 | 110 |
| 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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

132
build.ps1 Normal file
View File

@@ -0,0 +1,132 @@
<#
.SYNOPSIS
Builds both the VSCode extension (.vsix) and the standalone Windows exe.
Both artifacts land in dist/.
.EXAMPLE
.\build.ps1
.\build.ps1 -Standalone # Skip the extension; build only the exe
.\build.ps1 -Extension # Skip the standalone; build only the .vsix
#>
[CmdletBinding()]
param(
[switch]$Extension,
[switch]$Standalone
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $repoRoot
# If neither was specified, build both.
if (-not $Extension -and -not $Standalone) {
$Extension = $true
$Standalone = $true
}
$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
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
$version = $pkg.version
function Write-Step($msg) {
Write-Host ""
Write-Host "==> $msg" -ForegroundColor Cyan
}
function Get-FileSize($path) {
if (-not (Test-Path $path)) { return $null }
$bytes = (Get-Item $path).Length
if ($bytes -ge 1MB) { return "{0:N2} MB" -f ($bytes / 1MB) }
if ($bytes -ge 1KB) { return "{0:N1} KB" -f ($bytes / 1KB) }
return "$bytes B"
}
# Ensure Go is on PATH (winget install doesn't update current session)
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
$goBin = "C:\Program Files\Go\bin"
if (Test-Path "$goBin\go.exe") {
$env:Path = "$goBin;$env:Path"
} else {
Write-Error "go not found on PATH. Install Go from https://go.dev/dl/"
exit 1
}
}
$summary = @()
# ----------------------------------------------------------------------
# VSCode extension -> .vsix
# ----------------------------------------------------------------------
if ($Extension) {
Write-Step "Building VSCode extension"
if (-not (Test-Path "node_modules")) {
Write-Host " node_modules missing - running npm install" -ForegroundColor Yellow
npm install
if ($LASTEXITCODE -ne 0) { Write-Error "npm install failed"; exit 1 }
}
npm run compile
if ($LASTEXITCODE -ne 0) { Write-Error "tsc compile failed"; exit 1 }
Write-Step "Packaging .vsix -> dist/"
$vsixTargetDir = $distDir
if (Get-Command vsce -ErrorAction SilentlyContinue) {
vsce package --out $vsixTargetDir
} else {
Write-Host " vsce not found - using npx @vscode/vsce" -ForegroundColor Yellow
npx --yes @vscode/vsce package --out $vsixTargetDir
}
if ($LASTEXITCODE -ne 0) { Write-Error "vsce package failed"; exit 1 }
$vsix = Get-ChildItem -Path $distDir -Filter "*.vsix" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($vsix) {
$summary += [pscustomobject]@{
Artifact = $vsix.Name
Path = "dist\$($vsix.Name)"
Size = Get-FileSize $vsix.FullName
}
}
}
# ----------------------------------------------------------------------
# Standalone Windows exe -> dist/hpl-toolbox.exe
# ----------------------------------------------------------------------
if ($Standalone) {
Write-Step "Building standalone exe -> dist/"
Push-Location (Join-Path $repoRoot "standalone")
try {
# If rsrc.syso is missing (e.g. fresh clone before icon embedding), regenerate.
if (-not (Test-Path "rsrc.syso") -and (Test-Path "hpl.ico")) {
if (Get-Command rsrc -ErrorAction SilentlyContinue) {
Write-Host " Regenerating rsrc.syso" -ForegroundColor Yellow
rsrc -ico hpl.ico -o rsrc.syso
} else {
Write-Host " rsrc tool missing - exe will build without embedded icon" -ForegroundColor Yellow
Write-Host " (install with: go install github.com/akavel/rsrc@latest)" -ForegroundColor Yellow
}
}
$exeName = "hpl-toolbox-$version.exe"
$exeOut = Join-Path $distDir $exeName
go build -ldflags="-s -w -H windowsgui -X main.AppVersion=$version" -trimpath -o $exeOut .
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
$summary += [pscustomobject]@{
Artifact = $exeName
Path = "dist\$exeName"
Size = Get-FileSize $exeOut
}
} finally {
Pop-Location
}
}
# ----------------------------------------------------------------------
# Summary
# ----------------------------------------------------------------------
Write-Step "Build complete"
$summary | Format-Table -AutoSize | Out-Host

BIN
dist/hpl-toolbox-0.1.5.exe vendored Normal file

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

View File

@@ -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."
} }
} }
}, },

View File

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

View File

@@ -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 &amp; 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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ch] ?? ch));
}

View File

@@ -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;

View File

@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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
View 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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[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>`;
}

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

View File

@@ -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 = '';

4
standalone/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
hpl-toolbox.exe
config.json
.hpltoolbox.lock
debug.log

525
standalone/applovin.go Normal file
View File

@@ -0,0 +1,525 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"mime/multipart"
"net/http"
urlpkg "net/url"
"os"
"path/filepath"
"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) {
body := `
<h2>AppLovin Playable Preview (QR)</h2>
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<button id="pick" class="secondary">Add files...</button>
<button id="clear" class="secondary" style="display:none;">Clear</button>
<span id="fileName" class="hint">(no files)</span>
</div>
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
<div style="display:flex;gap:8px;">
<button id="upload">Upload to AppLovin</button>
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
</div>
<div id="status" style="margin-top:12px;min-height:20px;"></div>
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
<style>
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
</style>
<script>
const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const uploadBtn = document.getElementById('upload');
const fileNameEl = document.getElementById('fileName');
const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const saveAllBtn = document.getElementById('saveAll');
const regenAllBtn = document.getElementById('regenAll');
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 () => {
const r = await fetch('/api/applovin/pick', { method: 'POST' });
const j = await r.json();
if (j.files && j.files.length) {
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
renderSelection();
}
});
uploadBtn.addEventListener('click', async () => {
statusEl.textContent = '';
resultsEl.innerHTML = '';
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return;
}
uploadBtn.disabled = true;
resultItems = [];
saveAllBtn.style.display = 'none';
regenAllBtn.style.display = 'none';
try {
const res = await fetch('/api/applovin/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: selectedPaths }),
});
if (!res.ok || !res.body) {
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
uploadBtn.disabled = false;
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
if (!line.trim()) continue;
handle(JSON.parse(line));
}
}
} catch (e) {
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
} finally {
uploadBtn.disabled = false;
}
});
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function handle(m) {
if (m.type === 'status') {
statusEl.textContent = m.message;
} else if (m.type === 'fileError') {
const wrap = document.createElement('div');
wrap.className = 'result';
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
resultsEl.appendChild(wrap);
} else if (m.type === 'fileResult') {
const wrap = document.createElement('div');
wrap.className = 'result';
wrap.innerHTML =
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
'<div style="text-align:center;margin-bottom:8px;">' +
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
'</div>' +
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const openBtn = document.createElement('button');
openBtn.textContent = 'Open';
openBtn.className = 'secondary';
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
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);
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') {
statusEl.innerHTML = '<span class="ok">Done.</span>';
}
}
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body)))
}
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
picked := PickFiles(
"Select HTML files",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
true, cfg.LastPickDir,
)
if len(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})
}
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
var req struct {
Paths []string `json:"paths"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
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")
flusher, _ := w.(http.Flusher)
send := func(obj any) {
data, _ := json.Marshal(obj)
_, _ = w.Write(append(data, '\n'))
if flusher != nil {
flusher.Flush()
}
}
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
buildForm := func(filePath string) (*bytes.Buffer, string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, "", err
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := createFormFileWithType(mw, "file", filepath.Base(filePath), "text/html")
if err != nil {
return nil, "", err
}
if _, err := fw.Write(data); err != nil {
return nil, "", err
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return &buf, mw.FormDataContentType(), nil
}
doPost := func(url string, body *bytes.Buffer, contentType string) (int, string, error) {
req, err := http.NewRequest("POST", url, body)
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
req.Header.Set("Origin", "https://p.applov.in")
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", pickApplovinUserAgent(randomizeUA))
if cookie != "" {
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(out), nil
}
for i, filePath := range req.Paths {
fileName := filepath.Base(filePath)
idx := i + 1
fileErr := func(msg string) {
send(map[string]any{"type": "fileError", "name": fileName, "message": msg})
}
if filePath == "" || !fileExists(filePath) {
fileErr("File missing")
continue
}
if !htmlRx.MatchString(filePath) {
fileErr("Must be .html")
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)})
body, ct, err := buildForm(filePath)
if err != nil {
fileErr("Read failed: " + err.Error())
continue
}
status, respText, err := doPost("https://p.applov.in/validateHTMLContent", body, ct)
if err != nil {
fileErr("Network (validate): " + err.Error())
continue
}
if status < 200 || status >= 300 {
fileErr(fmt.Sprintf("Validate HTTP %d: %s", status, truncate(respText, 300)))
continue
}
var vJSON map[string]any
_ = json.Unmarshal([]byte(respText), &vJSON)
if code, ok := vJSON["code"]; ok {
if codeNum, isNum := code.(float64); isNum && codeNum != 200 {
fileErr("Validation failed: " + truncate(respText, 300))
continue
}
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)})
body, ct, err = buildForm(filePath)
if err != nil {
fileErr("Read failed: " + err.Error())
continue
}
status, respText, err = doPost("https://p.applov.in/getCachedAdURL", body, ct)
if err != nil {
fileErr("Network (cache): " + err.Error())
continue
}
if status < 200 || status >= 300 {
fileErr(fmt.Sprintf("Cache HTTP %d: %s", status, truncate(respText, 300)))
continue
}
var cJSON map[string]any
if err := json.Unmarshal([]byte(respText), &cJSON); err != nil {
fileErr("Non-JSON: " + truncate(respText, 300))
continue
}
hash, _ := cJSON["results"].(string)
if hash == "" {
fileErr("No hash in response: " + truncate(respText, 300))
continue
}
send(map[string]any{
"type": "fileResult",
"name": fileName,
"hash": hash,
"previewUrl": "https://p.applov.in/getPreviewHTML?preview=" + hash,
"pageUrl": "https://p.applov.in/playablePreview?preview=" + hash + "&qr=1",
"qrImg": "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=" + urlpkg.QueryEscape(hash),
})
}
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"
}

8
standalone/assets/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

289
standalone/base64.go Normal file
View File

@@ -0,0 +1,289 @@
package main
import (
"encoding/json"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
func Base64Page(w http.ResponseWriter, r *http.Request) {
body := `
<h2>Base64 Asset Scanner</h2>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
<button id="pickFolder">Browse Folder...</button>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button>
</div>
<div id="selection" class="hint"></div>
<div id="status" style="margin-top:8px;"></div>
<div id="results" style="margin-top:8px;"></div>
<style>
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
.mark { width:14px; flex-shrink:0; font-weight:bold; }
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
.file-name { word-break:break-all; }
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
.summary { opacity:0.8; margin-bottom:6px; }
</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 = ''; }
folderEl.addEventListener('input', clearFiles);
document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
const j = await r.json();
if (j.path) { folderEl.value = j.path; clearFiles(); }
});
document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
const j = await r.json();
if (j.paths && j.paths.length) {
pickedFiles = j.paths;
folderEl.value = '';
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
}
});
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/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json();
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length;
const flagged = j.results.filter(r => !r.ok).length;
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
resultsEl.innerHTML = '';
for (const rr of j.results) {
const row = document.createElement('div');
row.className = 'row-result';
const mark = document.createElement('span');
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
mark.textContent = rr.ok ? '✓' : '✗';
const name = document.createElement('span');
name.className = 'file-name';
name.textContent = rr.file;
row.appendChild(mark); row.appendChild(name);
if (!rr.ok) {
const a = document.createElement('span');
a.className = 'assets';
a.textContent = '— ' + rr.assets.join(', ');
row.appendChild(a);
}
resultsEl.appendChild(row);
}
});
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/base64", "Base64 Scanner", body)))
}
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
p := PickFolder("Select folder to scan", "")
writeJSON(w, map[string]any{"path": p})
}
func Base64PickFiles(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})
}
type scanResult struct {
File string `json:"file"`
OK bool `json:"ok"`
Assets []string `json:"assets"`
}
func Base64Scan(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": []scanResult{}, "error": err.Error()})
return
}
var targets []string
var baseDir string
if len(req.Files) > 0 {
for _, f := range req.Files {
if fileExists(f) {
targets = append(targets, f)
}
}
if len(targets) > 0 {
baseDir = commonBaseDir(targets)
}
} else if req.Folder != "" && fileExists(req.Folder) {
targets = collectHTMLFiles(req.Folder)
baseDir = req.Folder
} else {
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"})
return
}
results := []scanResult{}
for _, file := range targets {
data, err := os.ReadFile(file)
if err != nil {
continue
}
offenders := findNonBase64Assets(string(data))
display := file
if baseDir != "" {
rel, err := filepath.Rel(baseDir, file)
if err == nil && rel != "" {
display = rel
} else {
display = filepath.Base(file)
}
}
results = append(results, scanResult{
File: display,
OK: len(offenders) == 0,
Assets: offenders,
})
}
writeJSON(w, map[string]any{"results": results})
}
func commonBaseDir(files []string) string {
if len(files) == 0 {
return ""
}
if len(files) == 1 {
return filepath.Dir(files[0])
}
split := make([][]string, len(files))
for i, f := range files {
split[i] = strings.Split(filepath.Dir(f), string(filepath.Separator))
}
first := split[0]
i := 0
for i < len(first) {
match := true
for _, parts := range split {
if i >= len(parts) || parts[i] != first[i] {
match = false
break
}
}
if !match {
break
}
i++
}
return strings.Join(first[:i], string(filepath.Separator))
}
func collectHTMLFiles(root string) []string {
var out []string
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
name := d.Name()
if name == "node_modules" || name == ".git" {
return filepath.SkipDir
}
return nil
}
if htmlRx.MatchString(d.Name()) {
out = append(out, path)
}
return nil
})
return out
}
var (
scriptBlockRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script>`)
attrRx = regexp.MustCompile(`(?i)\b(?:src|href|poster|data-src)\s*=\s*("([^"]*)"|'([^']*)')`)
cssUrlRx = regexp.MustCompile(`(?i)url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)`)
base64DataRx = regexp.MustCompile(`(?i)^data:[^;,]*;base64,`)
mailTelRx = regexp.MustCompile(`(?i)^(mailto:|tel:)`)
)
func findNonBase64Assets(htmlSrc string) []string {
markup := scriptBlockRx.ReplaceAllString(htmlSrc, "")
seen := map[string]bool{}
var out []string
add := func(u string) {
if u == "" || seen[u] {
return
}
seen[u] = true
out = append(out, u)
}
for _, m := range attrRx.FindAllStringSubmatch(markup, -1) {
url := m[2]
if url == "" {
url = m[3]
}
url = strings.TrimSpace(url)
if url == "" {
continue
}
if isAssetReference(url) && !base64DataRx.MatchString(url) {
add(url)
}
}
for _, m := range cssUrlRx.FindAllStringSubmatch(markup, -1) {
url := m[1]
if url == "" {
url = m[2]
}
if url == "" {
url = m[3]
}
url = strings.TrimSpace(url)
if url == "" {
continue
}
if !base64DataRx.MatchString(url) {
add(url)
}
}
if out == nil {
out = []string{}
}
return out
}
func isAssetReference(url string) bool {
if strings.HasPrefix(url, "#") || strings.HasPrefix(strings.ToLower(url), "javascript:") {
return false
}
if mailTelRx.MatchString(url) {
return false
}
return true
}

9
standalone/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module hpltoolbox
go 1.22
require (
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e // indirect
)

7
standalone/go.sum Normal file
View File

@@ -0,0 +1,7 @@
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e h1:f5mksnk+hgXHnImpZoWj64ja99j9zV7YUgrVG95uFE4=
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

31
standalone/home.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"net/http"
"strings"
)
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 := `
<h2>HPL Toolbox</h2>
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
<ul style="line-height:1.9;">
` + items.String() + `
</ul>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/", "Home", body)))
}

BIN
standalone/hpl.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
standalone/hpltoolbox.exe Normal file

Binary file not shown.

11
standalone/json.go Normal file
View 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)
}

175
standalone/layout.go Normal file
View File

@@ -0,0 +1,175 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
)
const SharedCSS = `
:root { color-scheme: dark; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e; color: #ddd;
margin: 0; padding: 0;
}
.topbar {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
font-size: 13px;
}
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
.topbar a:hover { background: #2a2d2e; }
.topbar a.active { background: #094771; color: #fff; }
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; }
.content { padding: 16px; max-width: 980px; margin: 0 auto; }
.beta-tools-footer { max-width: 980px; margin: 24px auto 16px auto; padding: 12px 16px 0; border-top: 1px solid #333; }
.beta-tools-footer button { width: 100%; text-align: left; background: #3a3d41; color: #ddd; }
.beta-tools-footer button:hover { background: #45494e; }
.beta-note { display: block; margin-top: 3px; opacity: 0.65; font-size: 11px; }
h2 { margin-top: 0; font-weight: 500; }
input[type=text], input[type=date], select, textarea {
box-sizing: border-box; padding: 6px 8px;
background: #3c3c3c; color: #ddd;
border: 1px solid #3c3c3c; border-radius: 2px;
font-family: inherit; font-size: 13px;
}
input[type=text]:focus, input[type=date]:focus, select:focus, textarea:focus {
outline: 1px solid #007fd4; border-color: #007fd4;
}
button {
background: #0e639c; color: #fff;
border: none; padding: 6px 14px; border-radius: 2px; cursor: pointer; font-size: 13px;
}
button:hover { background: #1177bb; }
button.secondary { background: #3a3d41; color: #ddd; }
button.secondary:hover { background: #45494e; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.err { color: #f48771; white-space: pre-wrap; }
.ok { color: #89d185; }
.hint { font-size: 12px; opacity: 0.7; }
`
type navItem struct {
Path string
Label string
Description string
Beta bool
}
var navItems = []navItem{
{Path: "/", Label: "Home"},
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
{Path: "/applovin", Label: "AppLovin Upload", Description: "Upload to p.applov.in (QR preview)"},
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
{Path: "/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 {
cfg := LoadConfig()
betaToolsEnabled := cfg.BetaToolsEnabled
var tabs strings.Builder
for _, n := range visibleNavItems(betaToolsEnabled) {
cls := ""
if n.Path == activePath {
cls = " class=\"active\""
}
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>
<html><head>
<meta charset="UTF-8" />
<title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style>
</head><body>
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
<div class="content">` + body + `</div>
<div class="beta-tools-footer">
<button id="betaToolsToggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `<span class="beta-note">` + betaButtonNote + `</span></button>
</div>
<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>`
}
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})
}

191
standalone/main.go Normal file
View File

@@ -0,0 +1,191 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"syscall"
"time"
"unsafe"
webview "github.com/jchv/go-webview2"
)
var AppVersion = "dev"
var currentHwnd atomic.Uintptr
func setupFileLogging() {
logPath := filepath.Join(userDataDir(), "debug.log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return
}
log.SetOutput(f)
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
func main() {
setupFileLogging()
log.Printf("HPL Toolbox starting (pid=%d)", os.Getpid())
if focusExistingInstance() {
log.Printf("Another instance is running; focused it and exiting.")
return
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
log.Printf("Server listening on 127.0.0.1:%d", port)
mux := buildMux()
srv := &http.Server{Handler: mux}
go func() {
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("Server error: %v", err)
}
}()
if err := writeLockFile(port); err != nil {
log.Printf("Failed to write lock file: %v", err)
}
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{
Debug: false,
AutoFocus: true,
DataPath: webviewTmp,
WindowOptions: webview.WindowOptions{
Title: "HPL Toolbox " + AppVersion,
Width: 1100,
Height: 720,
IconId: 1,
Center: true,
},
})
if w == nil {
log.Fatalln("Failed to create webview.")
}
defer w.Destroy()
// Stash HWND for the /api/focus handler.
currentHwnd.Store(uintptr(w.Window()))
w.SetSize(1100, 720, webview.HintNone)
w.Navigate(fmt.Sprintf("http://127.0.0.1:%d/", port))
w.Run()
// Window closed — shut the server down cleanly.
log.Printf("Window closed; shutting down server.")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
func buildMux() *http.ServeMux {
mux := http.NewServeMux()
// Pages
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/home" {
http.NotFound(w, r)
return
}
HomePage(w, r)
})
mux.HandleFunc("GET /plec", PlecPage)
mux.HandleFunc("GET /applovin", ApplovinPage)
mux.HandleFunc("GET /base64", Base64Page)
mux.HandleFunc("GET /mraid", MraidPage)
mux.HandleFunc("GET /mobile", MobilePage)
mux.HandleFunc("GET /playworks", PlayworksPage)
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
// Shared API
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
mux.HandleFunc("POST /api/open", OpenEndpoint)
mux.HandleFunc("POST /api/focus", FocusEndpoint)
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
// PLEC
mux.HandleFunc("POST /api/plec/pick", PlecPick)
mux.HandleFunc("POST /api/plec/upload", PlecUpload)
// AppLovin
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
// Base64
mux.HandleFunc("POST /api/base64/pickFolder", Base64PickFolder)
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
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
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
mux.HandleFunc("POST /api/mobile/start", MobileStart)
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
}
// FocusEndpoint brings the webview window to the foreground. Used by a second
// instance that wants to focus the running one before exiting.
func FocusEndpoint(w http.ResponseWriter, r *http.Request) {
hwnd := currentHwnd.Load()
if hwnd != 0 {
restoreAndFocus(hwnd)
}
w.WriteHeader(http.StatusOK)
}
var (
user32 = syscall.NewLazyDLL("user32.dll")
procShowWindow = user32.NewProc("ShowWindow")
procSetForeground = user32.NewProc("SetForegroundWindow")
procIsIconic = user32.NewProc("IsIconic")
)
const (
swRestore = 9
swShow = 5
)
func restoreAndFocus(hwnd uintptr) {
// If minimized, restore. Otherwise just ensure it's shown.
iconic, _, _ := procIsIconic.Call(hwnd)
if iconic != 0 {
_, _, _ = procShowWindow.Call(hwnd, swRestore)
} else {
_, _, _ = procShowWindow.Call(hwnd, swShow)
}
_, _, _ = procSetForeground.Call(hwnd)
}
// Silence "imported and not used" complaints if any.
var _ = unsafe.Sizeof(int(0))

390
standalone/mobile.go Normal file
View File

@@ -0,0 +1,390 @@
package main
import (
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"crypto/rand"
"fmt"
"html"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
//go:embed assets/qrcode.min.js
var qrcodeJS []byte
type activeShare struct {
server *http.Server
listener net.Listener
port int
token string
fileBuf []byte
filename string
}
var (
activeMu sync.Mutex
active *activeShare
)
func stopActiveShare() {
activeMu.Lock()
defer activeMu.Unlock()
if active != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = active.server.Shutdown(ctx)
active = nil
}
}
func MobileQrScript(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write(qrcodeJS)
}
func MobilePage(w http.ResponseWriter, r *http.Request) {
body := `
<h2>Send To Mobile</h2>
<p class="hint" style="margin-top:0;">
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
</p>
<div style="margin-bottom:14px;">
<div style="display:flex;align-items:center;gap:8px;">
<button id="pick" class="secondary">Choose...</button>
<span id="fileName" class="hint">(no file)</span>
</div>
<input type="hidden" id="filePath" />
</div>
<div style="margin-bottom:14px;">
<button id="start">Start sharing</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
</div>
<div id="shareInfo" style="display:none;">
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button id="copyUrl" class="secondary">Copy URL</button>
<button id="openUrl" class="secondary">Open in browser</button>
</div>
<div class="hint" style="margin-top:8px;">
First run on Windows may show a firewall prompt — allow access for "Private networks".
</div>
</div>
<div id="status" style="margin-top:12px;min-height:18px;"></div>
<script src="/assets/qrcode.min.js"></script>
<script>
const pickBtn = document.getElementById('pick');
const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop');
const fileNameEl = document.getElementById('fileName');
const filePathEl = document.getElementById('filePath');
const statusEl = document.getElementById('status');
const shareInfo = document.getElementById('shareInfo');
const ifaceWrap = document.getElementById('ifaceWrap');
const ifaceList = document.getElementById('ifaceList');
const qrEl = document.getElementById('qr');
const urlTextEl = document.getElementById('urlText');
let currentUrls = [];
let selectedIdx = 0;
pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pick', { method:'POST' });
const j = await r.json();
if (j.path) {
filePathEl.value = j.path;
fileNameEl.textContent = j.name;
}
});
startBtn.addEventListener('click', async () => {
statusEl.innerHTML = '';
if (!filePathEl.value) {
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
return;
}
startBtn.disabled = true;
statusEl.textContent = 'Starting server...';
const r = await fetch('/api/mobile/start', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ path: filePathEl.value }),
});
const j = await r.json();
if (j.error) {
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
startBtn.disabled = false;
return;
}
currentUrls = j.urls;
selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
shareInfo.style.display = '';
renderIfaces(j.urls);
renderQr(j.urls[0].url);
stopBtn.disabled = false;
});
stopBtn.addEventListener('click', async () => {
await fetch('/api/mobile/stop', { method:'POST' });
statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none';
currentUrls = [];
startBtn.disabled = false;
stopBtn.disabled = true;
});
document.getElementById('copyUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
});
document.getElementById('openUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
});
function renderQr(url) {
qrEl.innerHTML = '';
urlTextEl.textContent = url;
try {
const qr = qrcode(0, 'M');
qr.addData(url);
qr.make();
qrEl.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 2, scalable: true });
const svg = qrEl.querySelector('svg');
if (svg) { svg.setAttribute('width','240'); svg.setAttribute('height','240'); }
} catch (e) {
qrEl.textContent = 'QR render failed: ' + e.message;
}
}
function renderIfaces(urls) {
ifaceList.innerHTML = '';
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
ifaceWrap.style.display = '';
urls.forEach((u, i) => {
const id = 'iface_' + i;
const label = document.createElement('label');
label.style.fontSize = '12px';
label.style.cursor = 'pointer';
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
(i === selectedIdx ? ' checked' : '') + ' /> ' +
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
label.querySelector('input').addEventListener('change', () => {
selectedIdx = i;
renderQr(urls[i].url);
});
ifaceList.appendChild(label);
});
}
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/mobile", "Send To Mobile", body)))
}
func MobilePick(w http.ResponseWriter, r *http.Request) {
picked := PickFiles(
"Select HTML",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
false, "",
)
if len(picked) == 0 {
writeJSON(w, map[string]any{"path": nil})
return
}
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
}
func MobileStart(w http.ResponseWriter, r *http.Request) {
stopActiveShare()
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
if req.Path == "" || !fileExists(req.Path) {
writeJSON(w, map[string]any{"error": "File missing."})
return
}
fileBuf, err := os.ReadFile(req.Path)
if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
return
}
filename := filepath.Base(req.Path)
// 6 random bytes -> base64url, no padding
tokenBytes := make([]byte, 6)
if _, err := rand.Read(tokenBytes); err != nil {
writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()})
return
}
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
cfgPort := LoadConfig().SendToMobile.Port
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
if err != nil {
writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()})
return
}
port := listener.Addr().(*net.TCPAddr).Port
share := &activeShare{
listener: listener,
port: port,
token: token,
fileBuf: fileBuf,
filename: filename,
}
share.server = &http.Server{Handler: shareHandler(share)}
go func() { _ = share.server.Serve(listener) }()
ips := getLanIPs()
if len(ips) == 0 {
_ = share.server.Close()
writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"})
return
}
activeMu.Lock()
active = share
activeMu.Unlock()
type urlInfo struct {
IP string `json:"ip"`
Iface string `json:"iface"`
URL string `json:"url"`
}
urls := make([]urlInfo, 0, len(ips))
for _, ip := range ips {
urls = append(urls, urlInfo{
IP: ip.address,
Iface: ip.iface,
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
})
}
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
}
func MobileStop(w http.ResponseWriter, r *http.Request) {
stopActiveShare()
writeJSON(w, map[string]any{"ok": true})
}
func shareHandler(s *activeShare) http.Handler {
prefix := "/s/" + s.token
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, prefix) {
http.NotFound(w, r)
return
}
rest := strings.TrimPrefix(r.URL.Path, prefix)
switch rest {
case "", "/":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(chooserPage(s.filename)))
case "/view":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf)
case "/file":
safeName := safeFilename(s.filename)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf)))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf)
default:
http.NotFound(w, r)
}
})
}
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
func chooserPage(filename string) string {
escName := html.EscapeString(filename)
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Send To Mobile</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 24px; background: #111; color: #eee; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
a.view { background: #2d7dff; color: white; }
a.dl { background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a; }
</style></head>
<body>
<h1>HPL Toolbox</h1>
<div class="file">` + escName + `</div>
<a class="btn view" href="view">View in browser</a>
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
</body></html>`
}
type lanIP struct {
address string
iface string
}
func getLanIPs() []lanIP {
var out []lanIP
ifaces, err := net.Interfaces()
if err != nil {
return out
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil || ip4.IsLoopback() {
continue
}
out = append(out, lanIP{address: ip4.String(), iface: iface.Name})
}
}
return out
}

376
standalone/mraid.go Normal file
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
}},
}

223
standalone/platform.go Normal file
View File

@@ -0,0 +1,223 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
type PlecConfig struct {
UploadUrl string `json:"uploadUrl"`
OriginUrl string `json:"originUrl"`
PreviewBase string `json:"previewBase"`
SkipExistsCheck bool `json:"skipExistsCheck"`
}
type ApplovinConfig struct {
Cookie string `json:"cookie"`
DelayMs int `json:"delayMs"`
RandomizeUserAgent *bool `json:"randomizeUserAgent,omitempty"`
}
type SendToMobileConfig struct {
Port int `json:"port"`
}
type AppConfig struct {
Plec PlecConfig `json:"plec"`
Applovin ApplovinConfig `json:"applovin"`
SendToMobile SendToMobileConfig `json:"sendToMobile"`
BetaToolsEnabled bool `json:"betaToolsEnabled"`
LastPickDir string `json:"lastPickDir,omitempty"`
}
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 {
return AppConfig{
Plec: PlecConfig{
UploadUrl: "http://167.99.227.249/src/upload_HTML_Experimental.php",
OriginUrl: "http://167.99.227.249",
PreviewBase: "https://playable.applovindemo.com/phaser/",
SkipExistsCheck: false,
},
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
SendToMobile: SendToMobileConfig{Port: 0},
}
}
func appDir() string {
exe, err := os.Executable()
if err != nil {
wd, _ := os.Getwd()
return wd
}
// `go run` puts the exe in a temp dir; fall back to cwd in that case.
resolved, err := filepath.EvalSymlinks(exe)
if err == nil {
exe = resolved
}
dir := filepath.Dir(exe)
if strings.Contains(strings.ToLower(dir), "go-build") {
wd, _ := os.Getwd()
return wd
}
return dir
}
func configPath() string {
return filepath.Join(userDataDir(), "config.json")
}
func LoadConfig() AppConfig {
configMu.Lock()
defer configMu.Unlock()
cfg := defaultConfig()
data, err := os.ReadFile(configPath())
if err != nil {
return cfg
}
_ = json.Unmarshal(data, &cfg)
return cfg
}
func SaveConfig(cfg AppConfig) error {
configMu.Lock()
defer configMu.Unlock()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath(), data, 0644)
}
func OpenInBrowser(url string) {
// `start` is a cmd builtin. Quoted empty arg is the window title.
cmd := exec.Command("cmd", "/c", "start", "", url)
_ = cmd.Start()
}
func CopyToClipboard(text string) error {
cmd := exec.Command("clip")
cmd.Stdin = strings.NewReader(text)
return cmd.Run()
}
type FileFilter struct {
Name string
Extensions []string
}
func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
func PickFiles(title string, filters []FileFilter, multi bool, 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, "|")
}
multiStr := "false"
if multi {
multiStr = "true"
}
script := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$dlg = New-Object System.Windows.Forms.OpenFileDialog
$dlg.Title = '%s'
$dlg.Filter = '%s'
$dlg.Multiselect = $%s
if ('%s'.Length -gt 0) { $dlg.InitialDirectory = '%s' }
if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$dlg.FileNames | ForEach-Object { Write-Output $_ }
}
`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
if err != nil {
return nil
}
lines := strings.Split(string(out), "\n")
result := make([]string, 0, len(lines))
for _, l := range lines {
t := strings.TrimSpace(l)
if t != "" {
result = append(result, t)
}
}
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 {
script := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = '%s'
if ('%s'.Length -gt 0) { $dlg.SelectedPath = '%s' }
if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dlg.SelectedPath
}
`, psEscape(title), psEscape(initialDir), psEscape(initialDir))
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

385
standalone/playworks.go Normal file
View 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()
}

405
standalone/plec.go Normal file
View File

@@ -0,0 +1,405 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
urlpkg "net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
func PlecPage(w http.ResponseWriter, r *http.Request) {
body := `
<h2>PLEC Upload</h2>
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;">
<thead><tr>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
<th></th>
</tr></thead>
<tbody></tbody>
</table>
<div style="display:flex;gap:8px;">
<button id="addRow" class="secondary">+ Add Row</button>
<button id="upload">Upload</button>
</div>
<div id="status" style="margin-top:12px;min-height:20px;"></div>
<div id="results"></div>
<style>
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
.file-cell { display:flex; align-items:center; gap:8px; }
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
.result a { color:#9cdcfe; }
.result-actions { margin-top:8px; display:flex; gap:8px; }
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
</style>
<script>
let nextId = 1;
const tbody = document.querySelector('#rows tbody');
const statusEl = document.getElementById('status');
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() {
const id = 'r' + (nextId++);
const tr = document.createElement('tr');
tr.dataset.id = id;
tr.innerHTML = ` + "`" + `
<td>
<div class="file-cell">
<button class="pick secondary">Choose...</button>
<span class="file-name" data-name>(no file)</span>
</div>
<input type="hidden" data-path />
<div class="row-error" data-error></div>
</td>
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
<td><button class="remove-btn" title="Remove">×</button></td>
` + "`" + `;
tr.querySelector('.pick').addEventListener('click', async () => {
const res = await fetch('/api/plec/pick', { method: 'POST' });
const j = await res.json();
if (j.files && j.files.length) {
const affected = [];
j.files.forEach(function(f, i) {
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
target.querySelector('[data-path]').value = f.path;
target.querySelector('[data-name]').textContent = f.name;
const iter = target.querySelector('[data-iter]');
if (!iter.value) iter.value = parseIterationName(f.name);
affected.push(target);
});
if (affected.length > 1) {
const iters = affected.map(r => r.querySelector('[data-iter]').value);
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
affected.forEach((r, i) => {
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
});
}
}
}
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
if (!tbody.children.length) addRow();
});
tbody.appendChild(tr);
}
document.getElementById('addRow').addEventListener('click', addRow);
document.getElementById('upload').addEventListener('click', async () => {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
statusEl.textContent = '';
resultsEl.innerHTML = '';
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
id: tr.dataset.id,
path: tr.querySelector('[data-path]').value,
iteration: tr.querySelector('[data-iter]').value,
})).filter(r => r.path || r.iteration);
if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
return;
}
statusEl.textContent = 'Uploading...';
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
const j = await res.json();
if (j.rowErrors) {
for (const re of j.rowErrors) {
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
if (tr) tr.querySelector('[data-error]').textContent = re.message;
}
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
return;
}
if (j.error) {
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
return;
}
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
const wrap = document.createElement('div');
wrap.className = 'result';
if (j.preview) {
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copy = document.createElement('button');
copy.textContent = 'Copy';
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
const open = document.createElement('button');
open.textContent = 'Open';
open.className = 'secondary';
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
const showRaw = document.createElement('button');
showRaw.textContent = 'Show server response';
showRaw.className = 'secondary';
showRaw.onclick = () => {
const pre = document.createElement('pre');
pre.style.marginTop = '8px';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = j.rawText;
wrap.appendChild(pre);
showRaw.disabled = true;
};
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
wrap.appendChild(actions);
} else {
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
});
addRow();
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/plec", "PLEC Upload", body)))
}
func PlecPick(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
picked := PickFiles(
"Select HTML",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
true, cfg.LastPickDir,
)
if len(picked) == 0 {
writeJSON(w, map[string]any{"files": []any{}})
return
}
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 {
ID string `json:"id"`
Path string `json:"path"`
Iteration string `json:"iteration"`
}
func PlecUpload(w http.ResponseWriter, r *http.Request) {
var req struct {
Rows []plecRow `json:"rows"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
cfg := LoadConfig().Plec
type rowErr struct {
ID string `json:"id"`
Message string `json:"message"`
}
var rowErrors []rowErr
var valid []plecRow
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
for _, row := range req.Rows {
if row.Path == "" || !fileExists(row.Path) {
rowErrors = append(rowErrors, rowErr{row.ID, "File missing"})
} else if strings.TrimSpace(row.Iteration) == "" {
rowErrors = append(rowErrors, rowErr{row.ID, "Iteration name required"})
} else if !htmlRx.MatchString(row.Path) {
rowErrors = append(rowErrors, rowErr{row.ID, "Must be .html"})
} else {
valid = append(valid, row)
}
}
if len(rowErrors) > 0 {
writeJSON(w, map[string]any{"rowErrors": rowErrors})
return
}
if len(valid) == 0 {
writeJSON(w, map[string]any{"error": "Nothing to upload."})
return
}
stamp := time.Now().Format("20060102150405")
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for i, row := range valid {
idx := i + 1
data, err := os.ReadFile(row.Path)
if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
return
}
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
if _, err := fw.Write(data); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
}
if err := mw.Close(); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
httpReq.Header.Set("Origin", cfg.OriginUrl)
httpReq.Header.Set("Referer", cfg.OriginUrl+"/")
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
httpReq.Header.Set("Accept", "*/*")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
return
}
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
return
}
var preview any
if v, ok := parsed["preview"]; ok && v != nil {
preview = v
} else if v, ok := parsed["previewURL"]; ok {
preview = v
}
writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)})
}
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := CopyToClipboard(req.Text); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true})
}
func OpenEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
OpenInBrowser(req.URL)
writeJSON(w, map[string]any{"ok": true})
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func appendTimestamp(filename, stamp string) string {
ext := filepath.Ext(filename)
base := filename
if ext != "" {
base = filename[:len(filename)-len(ext)]
}
return base + "_" + stamp + ext
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
// createFormFileWithType is like multipart.Writer.CreateFormFile but lets us
// override the Content-Type (CreateFormFile hardcodes "application/octet-stream").
func createFormFileWithType(mw *multipart.Writer, field, filename, contentType string) (io.Writer, error) {
h := make(map[string][]string)
h["Content-Disposition"] = []string{
fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename),
}
h["Content-Type"] = []string{contentType}
return mw.CreatePart(h)
}
// encodeURICompatible mimics JS encodeURI() — keeps A-Za-z0-9 and these
// reserved/mark chars: ;,/?:@&=+$-_.!~*'()#
func encodeURICompatible(s string) string {
keep := func(c byte) bool {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
return true
}
switch c {
case ';', ',', '/', '?', ':', '@', '&', '=', '+', '$',
'-', '_', '.', '!', '~', '*', '\'', '(', ')', '#':
return true
}
return false
}
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if keep(c) {
b.WriteByte(c)
} else {
b.WriteString(fmt.Sprintf("%%%02X", c))
}
}
return b.String()
}

BIN
standalone/rsrc.syso Normal file

Binary file not shown.

View File

@@ -0,0 +1,68 @@
package main
import (
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func lockFilePath() string {
return filepath.Join(userDataDir(), ".hpltoolbox.lock")
}
// focusExistingInstance returns true if another instance was found and
// successfully asked to focus its window. In that case the caller should exit.
// Returns false if no live instance exists (and clears any stale lock).
func focusExistingInstance() bool {
data, err := os.ReadFile(lockFilePath())
if err != nil {
return false
}
parts := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(parts) != 2 {
_ = os.Remove(lockFilePath())
return false
}
pid, errPid := strconv.Atoi(strings.TrimSpace(parts[0]))
port, errPort := strconv.Atoi(strings.TrimSpace(parts[1]))
if errPid != nil || errPort != nil {
_ = os.Remove(lockFilePath())
return false
}
// Quick liveness check: PID must exist.
if proc, err := os.FindProcess(pid); err != nil || proc == nil {
_ = os.Remove(lockFilePath())
return false
}
// Ask the running instance to focus its window.
client := &http.Client{Timeout: 1500 * time.Millisecond}
url := fmt.Sprintf("http://127.0.0.1:%d/api/focus", port)
resp, err := client.Post(url, "application/json", bytes.NewReader(nil))
if err != nil {
// Lock file is stale (process died but didn't clean up).
_ = os.Remove(lockFilePath())
return false
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = os.Remove(lockFilePath())
return false
}
return true
}
func writeLockFile(port int) error {
content := fmt.Sprintf("%d\n%d\n", os.Getpid(), port)
return os.WriteFile(lockFilePath(), []byte(content), 0644)
}
func removeLockFile() {
_ = os.Remove(lockFilePath())
}