Compare commits
13 Commits
859c7d9e7c
...
playworks-
| Author | SHA1 | Date | |
|---|---|---|---|
| 75a506a505 | |||
| 698223234d | |||
| 9e82656eeb | |||
| c7ad1e14ca | |||
| d384c1be3a | |||
| 6be55255b2 | |||
| 9d6ed4b0a5 | |||
| 1336695068 | |||
| 40f13989c2 | |||
| 716dac31ef | |||
| f367b66b60 | |||
| 1cfdcb03ab | |||
| f123cbfc4a |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go build *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -93,7 +93,6 @@ out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
|
||||
11
.vscodeignore
Normal file
11
.vscodeignore
Normal 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
4
LICENSE
Normal file
@@ -0,0 +1,4 @@
|
||||
Copyright (c) 2026 HPL
|
||||
|
||||
All rights reserved. This software is proprietary and confidential.
|
||||
Unauthorized copying, distribution, or use is prohibited.
|
||||
232
aiAssets/GUIDE.md
Normal file
232
aiAssets/GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# AGENTS.md — Playable Ads
|
||||
|
||||
## Stack
|
||||
Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps
|
||||
Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt)
|
||||
|
||||
## Porting to a new project
|
||||
1. Update naming fields (3LC, vendor, concept name) in [scripts/build-all.mjs](scripts/build-all.mjs) — drives the output filename
|
||||
2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android)
|
||||
3. Adjust the Depth Map below to match the project's layer needs
|
||||
4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants
|
||||
5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/BootScene.ts)
|
||||
6. Rewrite `game/` modules for the new mechanic(s) — keep the single-responsibility split
|
||||
7. Update this AGENTS.md's Depth Map and any project-specific pitfalls
|
||||
|
||||
## Structure
|
||||
```
|
||||
src/
|
||||
main.ts # Bootstrap, resize, MRAID gating, lifecycle stubs on window
|
||||
constants.ts # Design coords (1080×1920), store URLs
|
||||
networks.ts # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX()
|
||||
analytics.ts # trackEvent() → SDK
|
||||
iteration.ts # A/B iteration config
|
||||
utils/
|
||||
responsive.ts # sx(), sy(), sd() — coordinate helpers
|
||||
scenes/
|
||||
BootScene.ts # Critical preload + deferred asset manifest
|
||||
GameScene.ts # Orchestrator only — wires game/ modules, handles lifecycle
|
||||
cta.ts # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA()
|
||||
game/ # Single-responsibility modules (no direct ad-SDK calls)
|
||||
# …one file per distinct mechanic or UI widget
|
||||
Assets/
|
||||
scripts/build-all.mjs # Single build → per-network HTML variants
|
||||
```
|
||||
|
||||
### Responsibility split
|
||||
- **`game/`** modules each own a single concern (one mechanic, one widget, one system).
|
||||
They receive the Phaser scene as a constructor arg and own their own game objects.
|
||||
They never call ad-SDK functions (`triggerCTA`, `notifyGameX`, `trackEvent`).
|
||||
- **`GameScene.ts`** is the wiring layer: creates modules, passes data between them,
|
||||
and calls SDK helpers at the correct lifecycle moments.
|
||||
- **`scenes/cta.ts`** owns end-card presentation and the store-redirect sequence.
|
||||
|
||||
## Rules
|
||||
- 60 FPS target; never below 30 on mid-range Android
|
||||
- `dist/index.html` < 5 MB; WebP images, short audio
|
||||
- No hardcoded px — `sx()`, `sy()`, `sd()` only
|
||||
- No audio autoplay — unmute on first `pointerdown`
|
||||
- `for` loops in game logic; pool objects; no GC
|
||||
- `setDisplaySize(sd())` on images — never `setScale`
|
||||
- Never dim via `body`/page background
|
||||
|
||||
## Phaser Config
|
||||
```ts
|
||||
{ type: Phaser.AUTO, transparent: true,
|
||||
scale: { mode: Phaser.Scale.NONE, width: 1080, height: 1920 },
|
||||
render: { antialias: true, pixelArt: false } }
|
||||
```
|
||||
`Scale.NONE` — manual sizing; CSS = viewport px; internal = viewport × DPR.
|
||||
`antialias: true` — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to `false`.
|
||||
|
||||
## Responsive (1080×1920 ref)
|
||||
```ts
|
||||
const s = Math.min(viewW / 1080, viewH / 1920)
|
||||
const offX = (viewW - 1080 * s) / 2
|
||||
const offY = (viewH - 1920 * s) / 2
|
||||
// sx/sy/sd apply offX, offY, s
|
||||
```
|
||||
- `update()` polls `visualViewport` per-frame for rotation
|
||||
- On change: `game.scale.resize(vw*dpr, vh*dpr)` → `relayout()`
|
||||
- `bindResponsiveResize()`: rAF debounce + 100/300/600ms retries
|
||||
|
||||
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||
```
|
||||
1. ExitApi.exit() → GoogleAds (gg)
|
||||
2. FbPlayableAd.onCTAClick() → Facebook (fb)
|
||||
3. Luna.Unity.Playable → Unity (un)
|
||||
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
||||
5. window.install() → Mintegral (mtg)
|
||||
6. window.openAppStore() → (runtime fallback)
|
||||
7. window.clickTag → Moloco (mo)
|
||||
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
||||
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
||||
10. mraid.open(url) → Applovin (al) / Ironsource (is)
|
||||
11. window.open(storeUrl) → Fallback (all others)
|
||||
```
|
||||
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
||||
|
||||
## Analytics — SDK Priority
|
||||
```
|
||||
ALPlayableAnalytics.trackEvent(e) → Applovin (al)
|
||||
playableSDK.reportEvent(e) → (runtime fallback if SDK present)
|
||||
console.log('[Analytics]', e) → All other networks
|
||||
```
|
||||
Events: `DISPLAYED` · `CTA_CLICKED` · `ENDCARD_SHOWN` · `CHALLENGE_STARTED` · `CHALLENGE_SOLVED`
|
||||
|
||||
## Game Lifecycle (all networks)
|
||||
`main.ts` exposes stubs on `window` so every network's preview tool detects them.
|
||||
Stubs only set if the SDK hasn't already provided its own (`typeof` guard).
|
||||
```
|
||||
gameReady() → stub in main.ts; Mintegral also gets onload body attr from build
|
||||
gameStart() → notifyGameStart() in GameScene.startPlaying()
|
||||
gameEnd() → notifyGameEnd() in GameScene.triggerFail() / triggerWin()
|
||||
gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA()
|
||||
```
|
||||
|
||||
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||
| Network | Mechanism |
|
||||
|---|---|
|
||||
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
|
||||
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
|
||||
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
|
||||
|
||||
## Build
|
||||
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
||||
|
||||
| Network | Tag | Injected | Zipped | Included |
|
||||
|---|---|---|---|---|
|
||||
| Applovin | al | `mraid.js` | — | ✓ |
|
||||
| Google | gg | `exitapi.js` | ✓ | ✓ |
|
||||
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||
| Facebook | fb | — | — | ✓ |
|
||||
| Unity | un | — | — | ✓ |
|
||||
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||
| Moloco | mo | — | — | ✓ |
|
||||
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||
|
||||
|
||||
Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](scripts/build-all.mjs) writes a `.zip` containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via `zip: true` in the `NETWORKS` array.
|
||||
|
||||
Networks marked **Included** are built by default. Networks not marked are prepared for but not built. Toggle per-network via `included: true` in the `NETWORKS` array.
|
||||
|
||||
Output is grouped by **iteration length** (e.g. `2words/`, `4words/`, `full/`) then by **network** (e.g. `Applovin/`, `Google/`, `Mintegral/`), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder:
|
||||
|
||||
`dist/<length>/<3lc>_<creative-type>_<vendor>_<concept-name>_<concept-num>_<gameplay>_<ugc>_<seasonal>_<lang>_<length>_<size>_<network>`
|
||||
|
||||
Example layout:
|
||||
```
|
||||
dist/
|
||||
2words/
|
||||
Applovin/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||
Google/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||
index.zip
|
||||
Ironsource/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||
…one HTML or ZIP per network…
|
||||
4words/
|
||||
full/
|
||||
```
|
||||
|
||||
| Field | Description | Example |
|
||||
|---|---|---|
|
||||
| 3LC | 3-letter project code | `ws` (Wordscapes), `pp` (PeoplePuzzle) |
|
||||
| Creative Type | Format identifier | `mip` |
|
||||
| Vendor | Studio / vendor code | `grhpl` |
|
||||
| Concept Name | Creative concept (append `var1`, `var2`… for variants) | `wbinspiredvar1`, `wbinspiredvar2`, `burglar` |
|
||||
| Concept # | Iteration number, zero-padded | `01`, `02`, `03` |
|
||||
| Gameplay | Gameplay style | `real`, `cartoon` |
|
||||
| UGC | UGC presence | `na`, `ugc`, `nougc` |
|
||||
| Seasonal | Seasonal tie-in | `noseason`, `xmas` |
|
||||
| Language | Locale code | `en` |
|
||||
| Length | Run length | `2words`, `4words`, `7words`, `full` (Wordscapes); `2clk`, `10clk`, `120sec` (other projects) |
|
||||
| Size | Region / size code | `na` |
|
||||
| Network | Network tag (last segment) | `al`, `gg`, `is`, `mtg`, `fb`, `un`, `vu`, `mo`, `tt` |
|
||||
|
||||
Examples (this project):
|
||||
- `ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html` (cta2, default theme)
|
||||
- `ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html` (full, default theme)
|
||||
- `ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html` (cta2, v1 theme)
|
||||
|
||||
Naming fields are defined in [scripts/build-all.mjs](scripts/build-all.mjs) — update them when porting.
|
||||
|
||||
## Asset Loading
|
||||
- **Critical** (BootScene): first-frame assets, target < 200 KB
|
||||
- **Deferred**: audio, overlays, secondary UI — loads during intro
|
||||
- Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading
|
||||
|
||||
## Depth Map (adjust per project)
|
||||
Define depths as named constants in [constants.ts](src/constants.ts) — never use magic numbers in scene code.
|
||||
|
||||
| Layer | Depth |
|
||||
|---|---|
|
||||
| Background | 0 |
|
||||
| Game objects | 1–10 |
|
||||
| Logo | 17 |
|
||||
| Dim overlay | 20 |
|
||||
| Fail/win UI | 21 |
|
||||
| EndCard input | 25 |
|
||||
|
||||
## Ship Checklist
|
||||
- [ ] < 5 MB, all WebP
|
||||
- [ ] FPS ≥ 55 under CPU throttle
|
||||
- [ ] Portrait + landscape pass
|
||||
- [ ] MRAID ready before gameplay
|
||||
- [ ] Audio muted by default
|
||||
- [ ] CTA fires in fallback
|
||||
- [ ] No `console.error`
|
||||
- [ ] No `type="module"` or `crossorigin` on `<script>` in output HTML
|
||||
- [ ] Lifecycle: gameReady / gameStart / gameEnd / gameClose detected by all network preview tools
|
||||
|
||||
## Pitfalls
|
||||
| Issue | Fix |
|
||||
|---|---|
|
||||
| Rotation stuck | `Scale.NONE` + per-frame poll |
|
||||
| Mobile aliasing | Canvas = viewport × DPR |
|
||||
| FPS drop on resize | rAF debounce |
|
||||
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||
| Bundle > 5 MB | WebP q75, shorter audio |
|
||||
| Audio blocked | Gate on `pointerdown` |
|
||||
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||
| Network preview checklist fail | Expose lifecycle stubs on `window` + call `notifyX()` with `typeof` guards |
|
||||
| `playableSDK.reportEvent is not a function` | Applovin preview injects `playableSDK` without `reportEvent` — guard with `typeof playableSDK.reportEvent === 'function'`, not just `typeof playableSDK !== 'undefined'` |
|
||||
| iPhone black screen (Android works) | **Only one `Phaser.Game` per page, ever.** iOS WKWebView — used by Applovin/Ironsource preview apps and most in-app ad containers — caps or outright rejects the 2nd WebGL context. If a background scene is spun up as a separate `new Phaser.Game()` synchronously at module load, it can throw before `await initMraid()` and before the font promise resolves, so the rest of `main.ts` never runs → black screen with no visible error. Render the background as a CSS `background-image` on `#bg-container` (cover-sized) instead — zero WebGL contexts for the background, and nothing to throw at module-load time. iOS-debug path: enable Safari → Advanced → Web Inspector on the iPhone, connect to a Mac, open Develop → iPhone → preview WebView, read the Console. Without a Mac, temporarily install top-of-`main.ts` `window.addEventListener('error',…)` and `unhandledrejection` handlers that write the error into `document.body.innerHTML` so the failure is visible on-screen. |
|
||||
| iOS hangs on font load | `document.fonts.load()` silently stalls in WKWebView when `@font-face` uses base64 `data:` URLs. Race against a 1.5 s timeout: `Promise.race([Promise.all([…fonts]), new Promise(r=>setTimeout(r,1500))]).then(startGame)` |
|
||||
| iOS hangs on MRAID ready | `mraid.addEventListener('ready', …)` can miss the event if the container fires it before the listener attaches. `initMraid()` must self-resolve after ~2 s so `startGame()` always runs; also check `mraid.getState() !== 'loading'` first. |
|
||||
| External `src=` survives build | `vite-plugin-singlefile` only inlines assets *imported from JS*. A raw `<img src="src/assets/…">` in `index.html` ships as a literal relative path and fails in ad sandboxes, producing inconsistent per-device load failures. Leave DOM `<img>` elements with no `src`; assign via JS from `assets.ts` at boot (`setEndcardHtmlAssets()`). |
|
||||
| Audio keeps playing when ad hides | Add `visibilitychange` listener in the sound module — mute Phaser's sound system and `suspend()` the `AudioContext` when `document.hidden`, resume on return. Spec requires audio stop on hide/close. |
|
||||
| `mraid.open()` silently no-ops | Guard with `mraid.getState() !== 'loading'` before calling; fall through to `window.open()` if still loading. |
|
||||
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed** — `document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
|
||||
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
|
||||
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top` → `window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
|
||||
|
||||
## Ironsource — Runtime Analysis
|
||||
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.
|
||||
7
aiAssets/Playworks_UnityNetwork.html
Normal file
7
aiAssets/Playworks_UnityNetwork.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
Binary file not shown.
132
build.ps1
Normal file
132
build.ps1
Normal 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
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
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
Normal file
Binary file not shown.
Binary file not shown.
28
package.json
28
package.json
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"name": "hpl-toolbox",
|
||||
"displayName": "HPL Toolbox",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, Daily Update. Local HTML Host.",
|
||||
"version": "0.1.2",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker. Local HTML Host.",
|
||||
"version": "0.1.5",
|
||||
"publisher": "hesukastro",
|
||||
"license": "UNLICENSED",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
@@ -26,12 +31,16 @@
|
||||
"title": "HPL Toolbox: Open Base64 Scanner"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openDailyUpdate",
|
||||
"title": "HPL Toolbox: Open Daily Update"
|
||||
"command": "hplToolbox.openMraidChecker",
|
||||
"title": "HPL Toolbox: Open MRAID Checker"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openSendToMobile",
|
||||
"title": "HPL Toolbox: Open Send To Mobile"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openPlayworksConverter",
|
||||
"title": "HPL Toolbox: Open Playworks Converter"
|
||||
}
|
||||
],
|
||||
"viewsContainers": {
|
||||
@@ -85,6 +94,17 @@
|
||||
"type": "string",
|
||||
"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)."
|
||||
},
|
||||
"hplToolbox.applovin.delayMs": {
|
||||
"type": "number",
|
||||
"default": 5000,
|
||||
"minimum": 0,
|
||||
"description": "Delay (in milliseconds) between consecutive file uploads, to avoid AppLovin rate limiting / 403 responses."
|
||||
},
|
||||
"hplToolbox.applovin.randomizeUserAgent": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Randomize the User-Agent header on every request to p.applov.in to reduce bot detection."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,17 +3,19 @@ import { LauncherViewProvider } from './launcherView';
|
||||
import { openPlecUpload } from './tools/plecUpload';
|
||||
import { openApplovinUpload } from './tools/applovinUpload';
|
||||
import { openBase64Scanner } from './tools/base64Scanner';
|
||||
import { openDailyUpdate } from './tools/dailyUpdate';
|
||||
import { openMraidChecker } from './tools/mraidChecker';
|
||||
import { openSendToMobile } from './tools/sendToMobile';
|
||||
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(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.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider())
|
||||
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,87 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
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 {
|
||||
constructor(private readonly context: vscode.ExtensionContext) { }
|
||||
|
||||
resolveWebviewView(view: vscode.WebviewView) {
|
||||
view.webview.options = { enableScripts: true };
|
||||
view.webview.html = getHtml();
|
||||
view.webview.onDidReceiveMessage((msg) => {
|
||||
const version = this.context.extension.packageJSON.version as string;
|
||||
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') {
|
||||
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>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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; }
|
||||
.tools { flex: 1; }
|
||||
.tool-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
@@ -31,31 +93,48 @@ function getHtml(): string {
|
||||
.tool-btn:hover {
|
||||
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; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<h3>HPL Toolbox</h3>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openPlecUpload">
|
||||
PLEC Upload
|
||||
<span class="tool-desc">Upload HTML to internal PLEC server</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openApplovinUpload">
|
||||
AppLovin Demo Upload
|
||||
<span class="tool-desc">Upload to p.applov.in (QR preview)</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openBase64Scanner">
|
||||
Base64 Scanner
|
||||
<span class="tool-desc">Find non-base64 assets in HTML</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openDailyUpdate">
|
||||
Daily Update
|
||||
<span class="tool-desc">Compose & copy a daily status</span>
|
||||
</button>
|
||||
<button class="tool-btn" data-cmd="hplToolbox.openSendToMobile">
|
||||
Send To Mobile
|
||||
<span class="tool-desc">Share HTML to a phone via LAN + QR</span>
|
||||
</button>
|
||||
<h3>HPL Toolbox ${version}</h3>
|
||||
<div class="tools">
|
||||
${toolButtons}
|
||||
</div>
|
||||
<div class="beta-toggle">
|
||||
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">
|
||||
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'}
|
||||
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.querySelectorAll('.tool-btn').forEach((btn) => {
|
||||
@@ -63,7 +142,32 @@ function getHtml(): string {
|
||||
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>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
|
||||
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta)));
|
||||
}
|
||||
|
||||
function renderToolButton(tool: ToolDefinition): string {
|
||||
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
|
||||
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
||||
<span class="tool-desc">${escapeHtml(tool.description)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch] ?? ch));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
canSelectMany: true,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({
|
||||
@@ -34,15 +35,46 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
await handleUpload(panel, msg.paths);
|
||||
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) {
|
||||
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[]) {
|
||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.applovin');
|
||||
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];
|
||||
if (!paths.length) {
|
||||
@@ -50,14 +82,17 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Origin': 'https://p.applov.in',
|
||||
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Origin': 'https://p.applov.in',
|
||||
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': pickUserAgent(randomizeUA),
|
||||
};
|
||||
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
|
||||
return headers;
|
||||
};
|
||||
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
|
||||
|
||||
const buildForm = (filePath: string): FormData => {
|
||||
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 (!/\.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}...` });
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
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; }
|
||||
const validateText = await res.text();
|
||||
@@ -100,7 +140,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
|
||||
try {
|
||||
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; }
|
||||
const cacheText = await res.text();
|
||||
@@ -124,6 +164,79 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
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 {
|
||||
return `<!DOCTYPE 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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div class="actions">
|
||||
<button id="upload">Upload to AppLovin</button>
|
||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
@@ -175,13 +292,51 @@ function getHtml(): string {
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
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', () => {
|
||||
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' }));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
statusEl.textContent = '';
|
||||
@@ -191,6 +346,9 @@ function getHtml(): string {
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
||||
});
|
||||
|
||||
@@ -201,13 +359,14 @@ function getHtml(): string {
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
selectedPaths = m.files.map(f => f.path);
|
||||
const names = m.files.map(f => f.name);
|
||||
fileNameEl.textContent = names.length === 1
|
||||
? names[0]
|
||||
: names.length + ' files: ' + names.slice(0, 3).join(', ') + (names.length > 3 ? '...' : '');
|
||||
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||
selectedPaths = selectedPaths.concat(added);
|
||||
renderSelection();
|
||||
} else if (m.type === 'start') {
|
||||
resultsEl.innerHTML = '';
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
@@ -230,27 +389,28 @@ function getHtml(): string {
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" 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 style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const actions = document.createElement('div');
|
||||
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');
|
||||
openPreview.textContent = 'Open';
|
||||
openPreview.className = 'secondary';
|
||||
openPreview.onclick = () => vscode.postMessage({ type: 'open', text: m.previewUrl });
|
||||
actions.appendChild(copyHash);
|
||||
actions.appendChild(copyPreview);
|
||||
const saveQr = document.createElement('button');
|
||||
saveQr.textContent = 'Save QR';
|
||||
saveQr.className = 'secondary';
|
||||
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
||||
actions.appendChild(openPreview);
|
||||
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>';
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { singletonPanel } from './shared';
|
||||
|
||||
const PROJECT_KEY = 'hplToolbox.dailyUpdate.lastProject';
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
export function openDailyUpdate(context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.dailyUpdate', 'Daily Update');
|
||||
if (!isNew) return;
|
||||
|
||||
const lastProject = context.globalState.get<string>(PROJECT_KEY, '');
|
||||
panel.webview.html = getHtml(lastProject);
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg.type === 'submit') {
|
||||
const text = formatMessage(msg.payload);
|
||||
await vscode.env.clipboard.writeText(text);
|
||||
await context.globalState.update(PROJECT_KEY, msg.payload.project);
|
||||
vscode.window.showInformationMessage('Daily update copied to clipboard.');
|
||||
panel.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatMessage(p: { date: string; status: string; project: string; remarks: string }): string {
|
||||
const bullets = p.remarks
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/^\s*[-*]\s*/, '').trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.map((l) => `- ${l}`)
|
||||
.join('\n');
|
||||
|
||||
const lines = [
|
||||
`Date: ${formatDate(p.date)}`,
|
||||
`Status: ${p.status}`,
|
||||
`Project: ${p.project}`,
|
||||
`Remarks:`,
|
||||
bullets,
|
||||
];
|
||||
|
||||
if (p.status === 'For OT') {
|
||||
lines.push('@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!m) return iso;
|
||||
return `${m[2]}/${m[3]}/${m[1]}`;
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function getHtml(lastProject: string): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; max-width: 520px; }
|
||||
label { display: block; margin-top: 12px; font-size: 12px; opacity: 0.85; }
|
||||
input, select, textarea {
|
||||
width: 100%; box-sizing: border-box; margin-top: 4px; padding: 6px 8px;
|
||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, transparent); font-family: inherit; font-size: 13px;
|
||||
}
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
button {
|
||||
margin-top: 16px; padding: 8px 16px; cursor: pointer;
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; font-size: 13px;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 style="margin:0 0 4px 0;">Daily Update</h2>
|
||||
<div style="font-size:12px;opacity:0.7;">Submit copies the formatted text to your clipboard.</div>
|
||||
|
||||
<label for="date">Date</label>
|
||||
<input type="date" id="date" value="${today}" />
|
||||
|
||||
<label for="status">Status</label>
|
||||
<select id="status">
|
||||
<option>Started</option>
|
||||
<option>Progress</option>
|
||||
<option>Finished</option>
|
||||
<option>Client Feedback</option>
|
||||
<option>For OT</option>
|
||||
</select>
|
||||
|
||||
<label for="project">Project</label>
|
||||
<input type="text" id="project" placeholder="Project name" value="${escapeAttr(lastProject)}" />
|
||||
|
||||
<label for="remarks">Remarks</label>
|
||||
<textarea id="remarks" placeholder="What did you do?"></textarea>
|
||||
|
||||
<button id="submit">Submit</button>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.getElementById('submit').addEventListener('click', () => {
|
||||
vscode.postMessage({
|
||||
type: 'submit',
|
||||
payload: {
|
||||
date: document.getElementById('date').value,
|
||||
status: document.getElementById('status').value,
|
||||
project: document.getElementById('project').value.trim(),
|
||||
remarks: document.getElementById('remarks').value.trim(),
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
496
src/tools/mraidChecker.ts
Normal file
496
src/tools/mraidChecker.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
type Severity = 'requirement' | 'best-practice';
|
||||
|
||||
interface MraidIssue {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
title: string;
|
||||
detail: string;
|
||||
line?: number;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
interface MraidResult {
|
||||
file: string;
|
||||
ok: boolean;
|
||||
issues: MraidIssue[];
|
||||
}
|
||||
|
||||
interface MraidRule {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
title: string;
|
||||
detail: string;
|
||||
reference: string;
|
||||
test: (ctx: ScanContext) => MraidIssue | null;
|
||||
}
|
||||
|
||||
interface ScanContext {
|
||||
html: string;
|
||||
scriptText: string;
|
||||
lowerHtml: string;
|
||||
lowerScript: string;
|
||||
mraidCallLines: Map<string, number>;
|
||||
}
|
||||
|
||||
export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.mraidChecker', 'MRAID Checker');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg.type === 'ready') {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0];
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||
}
|
||||
} else if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: false,
|
||||
canSelectFiles: true,
|
||||
canSelectMany: true,
|
||||
openLabel: 'Select Files',
|
||||
filters: { 'HTML': ['html', 'htm'] },
|
||||
});
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) });
|
||||
}
|
||||
} else if (msg.type === 'scan') {
|
||||
const folder: string | undefined = msg.folder;
|
||||
const files: string[] | undefined = msg.files;
|
||||
let targets: string[] = [];
|
||||
let baseDir = '';
|
||||
let skipped = 0;
|
||||
if (files && files.length) {
|
||||
const existing = files.filter(f => fs.existsSync(f));
|
||||
targets = existing.filter(isSupportedMraidBuild);
|
||||
skipped = existing.length - targets.length;
|
||||
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||
} else if (folder && fs.existsSync(folder)) {
|
||||
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||
targets = allHtmlFiles.filter(isSupportedMraidBuild);
|
||||
skipped = allHtmlFiles.length - targets.length;
|
||||
baseDir = folder;
|
||||
} else {
|
||||
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
panel.webview.postMessage({
|
||||
type: 'results',
|
||||
results: [],
|
||||
skipped,
|
||||
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const results: MraidResult[] = [];
|
||||
for (const file of targets) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const issues = checkMraid(content);
|
||||
const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
|
||||
results.push({ file: display, ok: issues.length === 0, issues });
|
||||
} catch {
|
||||
// Skip unreadable files, matching the scanner pattern used elsewhere.
|
||||
}
|
||||
}
|
||||
panel.webview.postMessage({ type: 'results', results, skipped });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function commonBaseDir(files: string[]): string {
|
||||
if (files.length === 0) return '';
|
||||
if (files.length === 1) return path.dirname(files[0]);
|
||||
const split = files.map(f => path.dirname(f).split(path.sep));
|
||||
const first = split[0];
|
||||
let i = 0;
|
||||
while (i < first.length && split.every(parts => parts[i] === first[i])) i++;
|
||||
return first.slice(0, i).join(path.sep);
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkMraid(html: string): MraidIssue[] {
|
||||
const scriptText = extractScriptText(html);
|
||||
const ctx: ScanContext = {
|
||||
html,
|
||||
scriptText,
|
||||
lowerHtml: html.toLowerCase(),
|
||||
lowerScript: scriptText.toLowerCase(),
|
||||
mraidCallLines: collectMraidCallLines(html),
|
||||
};
|
||||
|
||||
return MRAID_RULES
|
||||
.map(rule => rule.test(ctx))
|
||||
.filter((issue): issue is MraidIssue => issue !== null);
|
||||
}
|
||||
|
||||
function extractScriptText(html: string): string {
|
||||
const scripts: string[] = [];
|
||||
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = scriptRegex.exec(html)) !== null) {
|
||||
scripts.push(m[1]);
|
||||
}
|
||||
return scripts.join('\n');
|
||||
}
|
||||
|
||||
function collectMraidCallLines(html: string): Map<string, number> {
|
||||
const calls = new Map<string, number>();
|
||||
const lines = html.split(/\r?\n/);
|
||||
const callRegex = /\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(/g;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = callRegex.exec(lines[i])) !== null) {
|
||||
if (!calls.has(m[1])) calls.set(m[1], i + 1);
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isSupportedMraidBuild(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized);
|
||||
}
|
||||
|
||||
function hasMraidReference(ctx: ScanContext): boolean {
|
||||
return /\bmraid\b/i.test(ctx.html);
|
||||
}
|
||||
|
||||
function hasMraidCall(ctx: ScanContext, method: string): boolean {
|
||||
return ctx.mraidCallLines.has(method);
|
||||
}
|
||||
|
||||
function mraidLine(ctx: ScanContext, method: string): number | undefined {
|
||||
return ctx.mraidCallLines.get(method);
|
||||
}
|
||||
|
||||
function issue(rule: MraidRule, line?: number): MraidIssue {
|
||||
return {
|
||||
id: rule.id,
|
||||
severity: rule.severity,
|
||||
title: rule.title,
|
||||
detail: rule.detail,
|
||||
line,
|
||||
reference: rule.reference,
|
||||
};
|
||||
}
|
||||
|
||||
function hasReadyGate(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/mraid\s*\.\s*addEventListener\s*\(\s*['"]ready['"]/i.test(ctx.scriptText) ||
|
||||
/mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]/i.test(ctx.scriptText) ||
|
||||
/['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText)
|
||||
);
|
||||
}
|
||||
|
||||
function hasEventListener(ctx: ScanContext, eventName: string): boolean {
|
||||
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
|
||||
return pattern.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
|
||||
const pattern = new RegExp(`mraid\\s*\\.\\s*supports\\s*\\(\\s*['"]${feature}['"]`, 'i');
|
||||
return pattern.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) ||
|
||||
/\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText)
|
||||
);
|
||||
}
|
||||
|
||||
const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/';
|
||||
|
||||
const MRAID_RULES: MraidRule[] = [
|
||||
{
|
||||
id: 'mraid-reference',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID API reference not found',
|
||||
detail: 'Playable HTML should use the injected mraid object when it depends on an MRAID container.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']),
|
||||
},
|
||||
{
|
||||
id: 'ready-gate',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID ready state is not guarded',
|
||||
detail: 'Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']),
|
||||
},
|
||||
{
|
||||
id: 'error-listener',
|
||||
severity: 'best-practice',
|
||||
title: 'No MRAID error listener found',
|
||||
detail: 'Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasEventListener(ctx, 'error') ? null : issue(MRAID_RULES_BY_ID['error-listener']),
|
||||
},
|
||||
{
|
||||
id: 'viewability',
|
||||
severity: 'best-practice',
|
||||
title: 'Viewability handling not found',
|
||||
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange');
|
||||
return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-api',
|
||||
severity: 'requirement',
|
||||
title: 'Browser navigation used instead of mraid.open',
|
||||
detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||
},
|
||||
{
|
||||
id: 'close-api',
|
||||
severity: 'best-practice',
|
||||
title: 'No mraid.close call found',
|
||||
detail: 'If the creative renders its own close control, wire that control to mraid.close().',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const appearsToHaveCloseUi = /\b(close|dismiss|skip)\b/i.test(ctx.html);
|
||||
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'expand-support',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.expand used without supports check',
|
||||
detail: 'Check mraid.supports("expand") before using expand behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'expand') && !hasSupportCheck(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null,
|
||||
},
|
||||
{
|
||||
id: 'resize-support',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.resize used without supports check',
|
||||
detail: 'Check mraid.supports("resize") before using resize behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasSupportCheck(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
|
||||
},
|
||||
{
|
||||
id: 'orientation-support',
|
||||
severity: 'best-practice',
|
||||
title: 'Orientation properties set without supports check',
|
||||
detail: 'Check mraid.supports("orientation") before requiring orientation behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasSupportCheck(ctx, 'orientation') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
|
||||
},
|
||||
{
|
||||
id: 'size-change',
|
||||
severity: 'best-practice',
|
||||
title: 'No sizeChange listener found',
|
||||
detail: 'Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const dependsOnSize = hasMraidCall(ctx, 'getMaxSize') || hasMraidCall(ctx, 'getScreenSize') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'setOrientationProperties');
|
||||
return dependsOnSize && !hasEventListener(ctx, 'sizeChange') ? issue(MRAID_RULES_BY_ID['size-change']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'state-change',
|
||||
severity: 'best-practice',
|
||||
title: 'No stateChange listener found',
|
||||
detail: 'Listen for stateChange when using expand, resize, close, or other state-changing container behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const changesState = hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'close');
|
||||
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
|
||||
acc[rule.id] = rule;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
function getHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.summary { margin-top: 12px; opacity: 0.85; }
|
||||
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
|
||||
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
|
||||
.issues { margin: 6px 0 0 26px; padding: 0; }
|
||||
.issue { margin: 6px 0; list-style: none; }
|
||||
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
|
||||
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
|
||||
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
|
||||
.issue-title { font-weight: 600; }
|
||||
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MRAID Checker</h2>
|
||||
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
vscode.postMessage({ type: 'scan', folder });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
} else if (msg.type === 'filesPicked') {
|
||||
pickedFiles = msg.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
} else if (msg.type === 'results') {
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
const total = msg.results.length;
|
||||
const withIssues = msg.results.filter(r => !r.ok).length;
|
||||
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(msg.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of msg.results) {
|
||||
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML =
|
||||
'<div class="file-head">' +
|
||||
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
|
||||
'<span class="file-name">' + escapeText(r.file) + '</span>' +
|
||||
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
|
||||
'</div>';
|
||||
if (!r.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
for (const i of r.issues) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'issue';
|
||||
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||
item.innerHTML =
|
||||
'<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
|
||||
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
|
||||
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
list.appendChild(item);
|
||||
}
|
||||
row.appendChild(list);
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
504
src/tools/playworksConverter.ts
Normal file
504
src/tools/playworksConverter.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cp from 'child_process';
|
||||
import { singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
interface ConvertOptions {
|
||||
sourcePath: string;
|
||||
outputDir: string;
|
||||
networks: string[];
|
||||
}
|
||||
|
||||
interface NetworkResult {
|
||||
network: string;
|
||||
file: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'pickSource': {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: false,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select Playworks HTML',
|
||||
});
|
||||
if (!picked?.[0]) return;
|
||||
const srcPath = picked[0].fsPath;
|
||||
const dir = path.dirname(srcPath);
|
||||
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir });
|
||||
break;
|
||||
}
|
||||
case 'pickOutput': {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Output Folder',
|
||||
});
|
||||
if (!picked?.[0]) return;
|
||||
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
|
||||
break;
|
||||
}
|
||||
case 'convert': {
|
||||
const opts: ConvertOptions = msg.opts;
|
||||
if (!fs.existsSync(opts.sourcePath)) {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(opts.outputDir)) {
|
||||
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||
} catch (e: any) {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const results: NetworkResult[] = [];
|
||||
for (const network of opts.networks) {
|
||||
try {
|
||||
const filePath = await convertNetwork(html, opts, network);
|
||||
results.push({ network, file: filePath, ok: true });
|
||||
} catch (e: any) {
|
||||
results.push({ network, file: '', ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
panel.webview.postMessage({ type: 'convertDone', results });
|
||||
break;
|
||||
}
|
||||
case 'openOutput': {
|
||||
vscode.env.openExternal(vscode.Uri.file(msg.dir));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
|
||||
|
||||
function sourceBaseName(sourcePath: string): string {
|
||||
return path.basename(sourcePath, path.extname(sourcePath))
|
||||
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
|
||||
}
|
||||
|
||||
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
|
||||
const transformed = transformHtml(html, network);
|
||||
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||
const baseName = sourceBaseName(opts.sourcePath);
|
||||
const htmlFileName = `${baseName}_${network}.html`;
|
||||
|
||||
if (needsZip) {
|
||||
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
|
||||
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||
try {
|
||||
await createZip(tmpPath, 'index.html', zipPath);
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
const outPath = path.join(opts.outputDir, htmlFileName);
|
||||
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function transformHtml(html: string, network: string): string {
|
||||
let result = html;
|
||||
|
||||
// Inject into </head>: lifecycle stubs only (must register luna:build listener
|
||||
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
|
||||
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
|
||||
let headInject = buildLifecycleScript();
|
||||
if (network === 'al' || network === 'is') {
|
||||
headInject += '<script src="mraid.js"></script>';
|
||||
} else if (network === 'gg') {
|
||||
headInject += '<script src="exitapi.js"></script>';
|
||||
}
|
||||
result = result.replace('</head>', headInject + '\n</head>');
|
||||
|
||||
// Body-level flags
|
||||
if (network === 'vu') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
|
||||
} else if (network === 'mtg') {
|
||||
result = result.replace('<body>', '<body onload="gameReady()">');
|
||||
} else if (network === 'tt') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>');
|
||||
}
|
||||
|
||||
// Replace the CTA script
|
||||
result = replaceCTAScript(result, network);
|
||||
|
||||
// Unity: rewrite window.top → window.self (Luna static scan requirement)
|
||||
if (network === 'un') {
|
||||
result = result.split('window.top').join('window.self');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceCTAScript(html: string, network: string): string {
|
||||
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||
const markerIdx = html.lastIndexOf(marker);
|
||||
if (markerIdx === -1) return html;
|
||||
|
||||
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
|
||||
if (scriptOpenIdx === -1) return html;
|
||||
|
||||
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
|
||||
if (scriptCloseIdx === -1) return html;
|
||||
|
||||
const before = html.slice(0, scriptOpenIdx);
|
||||
const after = html.slice(scriptCloseIdx + '</script>'.length);
|
||||
|
||||
// Console restore runs after the bundle (which overrides console), before CTA
|
||||
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
|
||||
}
|
||||
|
||||
// Restores native console methods overridden by the Playworks bundle,
|
||||
// using a temporary iframe to get a fresh un-patched console reference.
|
||||
function buildConsoleRestoreScript(): string {
|
||||
return [
|
||||
'<script>(function(){',
|
||||
'try{',
|
||||
'var f=document.createElement("iframe");',
|
||||
'f.style.display="none";',
|
||||
'document.documentElement.appendChild(f);',
|
||||
'var nc=f.contentWindow.console;',
|
||||
'document.documentElement.removeChild(f);',
|
||||
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
|
||||
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
|
||||
'}catch(e){}',
|
||||
'})();</script>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
|
||||
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
|
||||
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
|
||||
// One-time guards prevent double-calls:
|
||||
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
|
||||
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
|
||||
function buildLifecycleScript(): string {
|
||||
return [
|
||||
'<script>(function(){',
|
||||
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
|
||||
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
|
||||
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
|
||||
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
|
||||
'var _grOnce=false,_gsOnce=false;',
|
||||
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
|
||||
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
|
||||
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
|
||||
'})();</script>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildCTAScript(network: string): string {
|
||||
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
|
||||
// gameClose must fire before every redirect
|
||||
const closeCall = `try{window.gameClose();}catch(e){}`;
|
||||
|
||||
let ctaLogic: string;
|
||||
switch (network) {
|
||||
case 'al':
|
||||
case 'is':
|
||||
// MRAID — mraid.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`;
|
||||
break;
|
||||
case 'un':
|
||||
// Unity Ads — MRAID-based; window.top already rewritten separately
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
|
||||
break;
|
||||
case 'fb':
|
||||
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'gg':
|
||||
// Google — exitapi.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'mo':
|
||||
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'vu':
|
||||
// Vungle — window.__VUNGLE__ flag set in body
|
||||
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
|
||||
break;
|
||||
case 'mtg':
|
||||
// Mintegral — onload="gameReady()" set on body
|
||||
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'tt':
|
||||
// TikTok — window.__TIKTOK__ flag set in body
|
||||
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
default:
|
||||
ctaLogic = `${closeCall}window.open(o,"_blank");`;
|
||||
}
|
||||
|
||||
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
|
||||
}
|
||||
|
||||
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Remove existing zip if present (Compress-Archive would fail otherwise)
|
||||
if (fs.existsSync(destZipPath)) {
|
||||
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const psScript = [
|
||||
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
|
||||
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
|
||||
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
|
||||
'$zip.Dispose()',
|
||||
].join(' ');
|
||||
|
||||
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Webview UI ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getHtml(): string {
|
||||
const networks = [
|
||||
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
|
||||
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
||||
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
||||
{ tag: 'mo', label: 'Moloco', note: 'HTML' },
|
||||
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
|
||||
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
||||
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
||||
];
|
||||
|
||||
const checkboxRows = networks.map(n => `
|
||||
<label class="net-row">
|
||||
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
|
||||
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
|
||||
<span class="net-note">${n.note}</span>
|
||||
</label>`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; }
|
||||
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
|
||||
.section { margin-bottom: 14px; }
|
||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
|
||||
.row { display: flex; gap: 6px; align-items: center; }
|
||||
input[type=text] {
|
||||
flex: 1; padding: 4px 7px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, #555);
|
||||
border-radius: 2px; font-size: 12px;
|
||||
}
|
||||
button {
|
||||
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border, #444);
|
||||
}
|
||||
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
button.primary {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 18px; font-size: 13px;
|
||||
}
|
||||
button.primary:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
.net-grid { display: flex; flex-direction: column; gap: 4px; }
|
||||
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
|
||||
.net-label { min-width: 100px; font-weight: 500; }
|
||||
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||
.net-note { font-size: 11px; opacity: 0.6; }
|
||||
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||
.toggle-row button { font-size: 11px; padding: 2px 8px; }
|
||||
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
|
||||
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
|
||||
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
|
||||
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.res-net { min-width: 70px; font-weight: 500; }
|
||||
.res-file { opacity: 0.65; word-break: break-all; }
|
||||
.res-err { color: #f85149; opacity: 0.9; }
|
||||
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Playworks Converter</h2>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Source HTML (Playworks export)</div>
|
||||
<div class="row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
|
||||
<button id="pickSrc">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Output Folder</div>
|
||||
<div class="row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||
<button id="pickOut">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Networks to generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll">All</button>
|
||||
<button id="selectNone">None</button>
|
||||
</div>
|
||||
<div class="net-grid">${checkboxRows}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" id="convert" disabled>Convert</button>
|
||||
<button id="openOut" style="display:none">Open Output Folder</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div class="results" id="results"></div>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
let outputDir = '';
|
||||
|
||||
const srcEl = document.getElementById('src');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||
});
|
||||
|
||||
document.getElementById('convert').addEventListener('click', () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')]
|
||||
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
convertBtn.disabled = true;
|
||||
vscode.postMessage({
|
||||
type: 'convert',
|
||||
opts: {
|
||||
sourcePath: srcEl.value,
|
||||
outputDir: outEl.value,
|
||||
networks,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'sourcePicked') {
|
||||
srcEl.value = msg.path;
|
||||
if (!outEl.value && msg.dir) {
|
||||
outEl.value = msg.dir;
|
||||
outputDir = msg.dir;
|
||||
}
|
||||
updateConvertBtn();
|
||||
} else if (msg.type === 'outputPicked') {
|
||||
outEl.value = msg.path;
|
||||
outputDir = msg.path;
|
||||
updateConvertBtn();
|
||||
} else if (msg.type === 'convertDone') {
|
||||
convertBtn.disabled = false;
|
||||
updateConvertBtn();
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
return;
|
||||
}
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'res-row';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const net = document.createElement('span');
|
||||
net.className = 'res-net';
|
||||
net.textContent = r.network;
|
||||
const detail = document.createElement('span');
|
||||
if (r.ok) {
|
||||
detail.className = 'res-file';
|
||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||
} else {
|
||||
detail.className = 'res-err';
|
||||
detail.textContent = r.error || 'Unknown error';
|
||||
}
|
||||
row.append(mark, net, detail);
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
if (ok > 0) {
|
||||
outputDir = outEl.value;
|
||||
openOutBtn.style.display = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -17,18 +17,16 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
||||
|
||||
if (msg.type === 'pickFile') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
canSelectMany: true,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const fp = picked[0].fsPath;
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({
|
||||
type: 'fileSelected',
|
||||
type: 'filesSelected',
|
||||
rowId: msg.rowId,
|
||||
path: fp,
|
||||
name: path.basename(fp),
|
||||
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -233,6 +231,15 @@ function getHtml(): string {
|
||||
const vscode = acquireVsCodeApi();
|
||||
let nextId = 1;
|
||||
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 resultsEl = document.getElementById('results');
|
||||
|
||||
@@ -286,13 +293,32 @@ function getHtml(): string {
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'fileSelected') {
|
||||
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
if (!tr) return;
|
||||
tr.querySelector('[data-path]').value = m.path;
|
||||
tr.querySelector('[data-name]').textContent = m.name;
|
||||
const iter = tr.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = m.name.replace(/\\.html?$/i, '');
|
||||
if (m.type === 'filesSelected') {
|
||||
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
const affected = [];
|
||||
m.files.forEach(function(f, i) {
|
||||
let tr;
|
||||
if (i === 0) {
|
||||
tr = firstTr;
|
||||
} else {
|
||||
addRow();
|
||||
tr = tbody.lastElementChild;
|
||||
}
|
||||
if (!tr) return;
|
||||
tr.querySelector('[data-path]').value = f.path;
|
||||
tr.querySelector('[data-name]').textContent = f.name;
|
||||
const iter = tr.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||
affected.push(tr);
|
||||
});
|
||||
if (affected.length > 1) {
|
||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||
affected.forEach((r, i) => {
|
||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
|
||||
4
standalone/.gitignore
vendored
Normal file
4
standalone/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
hpl-toolbox.exe
|
||||
config.json
|
||||
.hpltoolbox.lock
|
||||
debug.log
|
||||
525
standalone/applovin.go
Normal file
525
standalone/applovin.go
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
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
289
standalone/base64.go
Normal 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
9
standalone/go.mod
Normal 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
7
standalone/go.sum
Normal 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
31
standalone/home.go
Normal 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
BIN
standalone/hpl.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
standalone/hpltoolbox.exe
Normal file
BIN
standalone/hpltoolbox.exe
Normal file
Binary file not shown.
11
standalone/json.go
Normal file
11
standalone/json.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
175
standalone/layout.go
Normal file
175
standalone/layout.go
Normal 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
191
standalone/main.go
Normal 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
390
standalone/mobile.go
Normal 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
376
standalone/mraid.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mraidIssue struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
type mraidResult struct {
|
||||
File string `json:"file"`
|
||||
OK bool `json:"ok"`
|
||||
Issues []mraidIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type mraidRule struct {
|
||||
ID string
|
||||
Severity string
|
||||
Title string
|
||||
Detail string
|
||||
Reference string
|
||||
Test func(mraidContext, mraidRule) *mraidIssue
|
||||
}
|
||||
|
||||
type mraidContext struct {
|
||||
HTML string
|
||||
ScriptText string
|
||||
MraidCallLines map[string]int
|
||||
}
|
||||
|
||||
func MraidPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>MRAID Checker</h2>
|
||||
<p class="hint">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
|
||||
<style>
|
||||
.row-result { padding:8px 0; border-bottom:1px solid #333; }
|
||||
.file-head { display:flex; gap:8px; align-items:baseline; font-family:Consolas, monospace; font-size:12px; }
|
||||
.mark { width:20px; flex-shrink:0; font-weight:bold; }
|
||||
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||
.file-name { word-break:break-all; }
|
||||
.counts { opacity:0.7; margin-left:auto; white-space:nowrap; }
|
||||
.issues { margin:6px 0 0 28px; padding:0; }
|
||||
.issue { list-style:none; margin:6px 0; }
|
||||
.badge { display:inline-block; min-width:82px; margin-right:6px; padding:1px 5px; border-radius:3px; font-size:11px; text-align:center; }
|
||||
.requirement { color:#ffb4ad; background:rgba(248,81,73,0.18); }
|
||||
.best-practice { color:#ffd580; background:rgba(210,153,34,0.18); }
|
||||
.issue-title { font-weight:600; }
|
||||
.issue-detail { opacity:0.72; margin-top:2px; }
|
||||
.summary { opacity:0.85; margin-bottom:6px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); }
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
}
|
||||
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
const j = await r.json();
|
||||
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
const total = j.results.length;
|
||||
const withIssues = j.results.filter(r => !r.ok).length;
|
||||
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(j.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const rr of j.results) {
|
||||
const reqCount = rr.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bpCount = rr.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML = '<div class="file-head"><span class="mark ' + (rr.ok ? 'ok' : 'bad') + '">' + (rr.ok ? 'OK' : 'X') + '</span><span class="file-name">' + escapeText(rr.file) + '</span><span class="counts">' + reqCount + ' req / ' + bpCount + ' bp</span></div>';
|
||||
if (!rr.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
for (const i of rr.issues) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'issue';
|
||||
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
list.appendChild(item);
|
||||
}
|
||||
row.appendChild(list);
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/mraid", "MRAID Checker", body)))
|
||||
}
|
||||
|
||||
func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"path": PickFolder("Select folder to scan", "")})
|
||||
}
|
||||
|
||||
func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
|
||||
paths := PickFiles("Select HTML files", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, true, "")
|
||||
writeJSON(w, map[string]any{"paths": paths})
|
||||
}
|
||||
|
||||
func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Folder string `json:"folder"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var targets []string
|
||||
var baseDir string
|
||||
skipped := 0
|
||||
if len(req.Files) > 0 {
|
||||
var existing []string
|
||||
for _, f := range req.Files {
|
||||
if fileExists(f) {
|
||||
existing = append(existing, f)
|
||||
}
|
||||
}
|
||||
for _, f := range existing {
|
||||
if isSupportedMraidBuild(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
skipped = len(existing) - len(targets)
|
||||
if len(targets) > 0 {
|
||||
baseDir = commonBaseDir(targets)
|
||||
}
|
||||
} else if req.Folder != "" && fileExists(req.Folder) {
|
||||
all := collectHTMLFiles(req.Folder)
|
||||
for _, f := range all {
|
||||
if isSupportedMraidBuild(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
skipped = len(all) - len(targets)
|
||||
baseDir = req.Folder
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "error": "No AppLovin, ironSource, or Unity HTML builds were found."})
|
||||
return
|
||||
}
|
||||
|
||||
results := []mraidResult{}
|
||||
for _, file := range targets {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
issues := checkMraid(string(data))
|
||||
display := file
|
||||
if baseDir != "" {
|
||||
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
|
||||
display = rel
|
||||
} else {
|
||||
display = filepath.Base(file)
|
||||
}
|
||||
}
|
||||
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results, "skipped": skipped})
|
||||
}
|
||||
|
||||
var (
|
||||
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||
mraidBuildNameRx = regexp.MustCompile(`(?i)(^|[/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([/._ -]|$)`)
|
||||
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||
)
|
||||
|
||||
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
|
||||
|
||||
func isSupportedMraidBuild(filePath string) bool {
|
||||
normalized := strings.ReplaceAll(strings.ToLower(filePath), `\`, `/`)
|
||||
return mraidBuildNameRx.MatchString(normalized)
|
||||
}
|
||||
|
||||
func checkMraid(htmlSrc string) []mraidIssue {
|
||||
ctx := mraidContext{
|
||||
HTML: htmlSrc,
|
||||
ScriptText: extractMraidScriptText(htmlSrc),
|
||||
MraidCallLines: collectMraidCallLines(htmlSrc),
|
||||
}
|
||||
var out []mraidIssue
|
||||
for _, rule := range mraidRules {
|
||||
if found := rule.Test(ctx, rule); found != nil {
|
||||
out = append(out, *found)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []mraidIssue{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractMraidScriptText(htmlSrc string) string {
|
||||
var parts []string
|
||||
for _, m := range mraidScriptRx.FindAllStringSubmatch(htmlSrc, -1) {
|
||||
parts = append(parts, m[1])
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func collectMraidCallLines(htmlSrc string) map[string]int {
|
||||
out := map[string]int{}
|
||||
for idx, line := range strings.Split(htmlSrc, "\n") {
|
||||
for _, m := range mraidCallRx.FindAllStringSubmatch(line, -1) {
|
||||
if _, ok := out[m[1]]; !ok {
|
||||
out[m[1]] = idx + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
|
||||
func mraidHasCall(ctx mraidContext, method string) bool {
|
||||
_, ok := ctx.MraidCallLines[method]
|
||||
return ok
|
||||
}
|
||||
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
|
||||
|
||||
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
|
||||
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasSupportCheck(ctx mraidContext, feature string) bool {
|
||||
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*supports\s*\(\s*['"]` + regexp.QuoteMeta(feature) + `['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasReadyGate(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
|
||||
if line > 0 {
|
||||
found.Line = line
|
||||
}
|
||||
return &found
|
||||
}
|
||||
|
||||
var mraidRules = []mraidRule{
|
||||
{ID: "mraid-reference", Severity: "requirement", Title: "MRAID API reference not found", Detail: "Playable HTML should use the injected mraid object when it depends on an MRAID container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "ready-gate", Severity: "requirement", Title: "MRAID ready state is not guarded", Detail: "Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "error-listener", Severity: "best-practice", Title: "No MRAID error listener found", Detail: "Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "viewability", Severity: "best-practice", Title: "Viewability handling not found", Detail: "Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "close-api", Severity: "best-practice", Title: "No mraid.close call found", Detail: "If the creative renders its own close control, wire that control to mraid.close().", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without supports check", Detail: "Check mraid.supports(\"expand\") before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without supports check", Detail: "Check mraid.supports(\"resize\") before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasSupportCheck(ctx, "resize") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without supports check", Detail: "Check mraid.supports(\"orientation\") before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "size-change", Severity: "best-practice", Title: "No sizeChange listener found", Detail: "Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
depends := mraidHasCall(ctx, "getMaxSize") || mraidHasCall(ctx, "getScreenSize") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "setOrientationProperties")
|
||||
if depends && !mraidHasEventListener(ctx, "sizeChange") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "state-change", Severity: "best-practice", Title: "No stateChange listener found", Detail: "Listen for stateChange when using expand, resize, close, or other state-changing container behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
changes := mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "close")
|
||||
if changes && !mraidHasEventListener(ctx, "stateChange") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
223
standalone/platform.go
Normal file
223
standalone/platform.go
Normal 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
385
standalone/playworks.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type playworksConvertOptions struct {
|
||||
SourcePath string `json:"sourcePath"`
|
||||
OutputDir string `json:"outputDir"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
type playworksNetworkResult struct {
|
||||
Network string `json:"network"`
|
||||
File string `json:"file"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Playworks Converter</h2>
|
||||
|
||||
<style>
|
||||
.section { margin-bottom:14px; }
|
||||
.section-label { font-size:11px; text-transform:uppercase; letter-spacing:0.5px; opacity:0.65; margin-bottom:5px; }
|
||||
.row { display:flex; gap:6px; align-items:center; }
|
||||
.net-grid { display:flex; flex-direction:column; gap:4px; }
|
||||
.net-row { display:flex; align-items:center; gap:8px; padding:3px 0; cursor:pointer; user-select:none; }
|
||||
.net-label { min-width:100px; font-weight:500; }
|
||||
.net-tag { font-size:10px; opacity:0.6; font-weight:400; }
|
||||
.net-note { font-size:11px; opacity:0.6; }
|
||||
.toggle-row { display:flex; gap:8px; margin-bottom:6px; }
|
||||
.toggle-row button { font-size:11px; padding:2px 8px; }
|
||||
.actions { display:flex; gap:10px; align-items:center; margin-top:16px; }
|
||||
#status { margin-top:12px; font-size:12px; opacity:0.75; min-height:16px; }
|
||||
.results { margin-top:10px; display:flex; flex-direction:column; gap:3px; }
|
||||
.res-row { display:flex; align-items:center; gap:8px; font-size:12px; font-family:Consolas, monospace; }
|
||||
.mark { font-weight:bold; width:14px; flex-shrink:0; }
|
||||
.ok { color:#3fb950; }
|
||||
.bad { color:#f85149; }
|
||||
.res-net { min-width:70px; font-weight:500; }
|
||||
.res-file { opacity:0.65; word-break:break-all; }
|
||||
.res-err { color:#f85149; opacity:0.9; }
|
||||
hr { border:none; border-top:1px solid #333; margin:14px 0; }
|
||||
</style>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Source HTML (Playworks export)</div>
|
||||
<div class="row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" />
|
||||
<button id="pickSrc">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Output Folder</div>
|
||||
<div class="row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
||||
<button id="pickOut">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Networks to generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll">All</button>
|
||||
<button id="selectNone">None</button>
|
||||
</div>
|
||||
<div class="net-grid">
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="is" checked /><span class="net-label">Ironsource <span class="net-tag">is</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="un" checked /><span class="net-label">Unity <span class="net-tag">un</span></span><span class="net-note">HTML (window.top to self)</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="fb" checked /><span class="net-label">Facebook <span class="net-tag">fb</span></span><span class="net-note">HTML</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="gg" checked /><span class="net-label">Google Ads <span class="net-tag">gg</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" checked /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="convert" disabled>Convert</button>
|
||||
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div class="results" id="results"></div>
|
||||
|
||||
<script>
|
||||
let outputDir = '';
|
||||
const srcEl = document.getElementById('src');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/playworks/pickSource', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) {
|
||||
srcEl.value = j.path;
|
||||
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
|
||||
updateConvertBtn();
|
||||
}
|
||||
});
|
||||
document.getElementById('pickOut').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
|
||||
});
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
||||
});
|
||||
|
||||
document.getElementById('convert').addEventListener('click', async () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
convertBtn.disabled = true;
|
||||
const res = await fetch('/api/playworks/convert', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, networks })
|
||||
});
|
||||
const msg = await res.json();
|
||||
convertBtn.disabled = false;
|
||||
updateConvertBtn();
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'res-row';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const net = document.createElement('span');
|
||||
net.className = 'res-net';
|
||||
net.textContent = r.network;
|
||||
const detail = document.createElement('span');
|
||||
if (r.ok) {
|
||||
detail.className = 'res-file';
|
||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||
} else {
|
||||
detail.className = 'res-err';
|
||||
detail.textContent = r.error || 'Unknown error';
|
||||
}
|
||||
row.append(mark, net, detail);
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
if (ok > 0) { outputDir = outEl.value; openOutBtn.style.display = ''; }
|
||||
});
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/playworks", "Playworks Converter", body)))
|
||||
}
|
||||
|
||||
func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
|
||||
picked := PickFiles("Select Playworks HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
||||
if len(picked) == 0 {
|
||||
writeJSON(w, map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0])})
|
||||
}
|
||||
|
||||
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"path": PickFolder("Select Output Folder", "")})
|
||||
}
|
||||
|
||||
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
|
||||
var opts playworksConvertOptions
|
||||
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !fileExists(opts.SourcePath) {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."})
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."})
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(opts.SourcePath)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
results := []playworksNetworkResult{}
|
||||
for _, network := range opts.Networks {
|
||||
filePath, err := convertPlayworksNetwork(string(data), opts, network)
|
||||
if err != nil {
|
||||
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`)
|
||||
|
||||
func playworksSourceBaseName(sourcePath string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||
return playworksSuffixRx.ReplaceAllString(base, "")
|
||||
}
|
||||
|
||||
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
||||
transformed := transformPlayworksHTML(htmlSrc, network)
|
||||
baseName := playworksSourceBaseName(opts.SourcePath)
|
||||
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
||||
|
||||
if playworksNeedsZip(network) {
|
||||
zipPath := filepath.Join(opts.OutputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
|
||||
if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return zipPath, nil
|
||||
}
|
||||
|
||||
outPath := filepath.Join(opts.OutputDir, htmlFileName)
|
||||
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func playworksNeedsZip(network string) bool {
|
||||
return network == "gg" || network == "vu" || network == "mtg"
|
||||
}
|
||||
|
||||
func transformPlayworksHTML(htmlSrc, network string) string {
|
||||
result := htmlSrc
|
||||
headInject := buildPlayworksLifecycleScript()
|
||||
if network == "al" || network == "is" {
|
||||
headInject += `<script src="mraid.js"></script>`
|
||||
} else if network == "gg" {
|
||||
headInject += `<script src="exitapi.js"></script>`
|
||||
}
|
||||
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1)
|
||||
|
||||
if network == "vu" {
|
||||
result = strings.Replace(result, "<body>", `<body>
|
||||
<script>window.__VUNGLE__=true;</script>`, 1)
|
||||
} else if network == "mtg" {
|
||||
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1)
|
||||
} else if network == "tt" {
|
||||
result = strings.Replace(result, "<body>", `<body>
|
||||
<script>window.__TIKTOK__=true;</script>`, 1)
|
||||
}
|
||||
|
||||
result = replacePlayworksCTAScript(result, network)
|
||||
if network == "un" {
|
||||
result = strings.ReplaceAll(result, "window.top", "window.self")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func replacePlayworksCTAScript(htmlSrc, network string) string {
|
||||
marker := "Luna.Unity.Playable.InstallFullGame=function"
|
||||
markerIdx := strings.LastIndex(htmlSrc, marker)
|
||||
if markerIdx == -1 {
|
||||
return htmlSrc
|
||||
}
|
||||
scriptOpenIdx := strings.LastIndex(htmlSrc[:markerIdx], "<script>")
|
||||
if scriptOpenIdx == -1 {
|
||||
return htmlSrc
|
||||
}
|
||||
scriptCloseRel := strings.Index(htmlSrc[markerIdx:], "</script>")
|
||||
if scriptCloseRel == -1 {
|
||||
return htmlSrc
|
||||
}
|
||||
scriptCloseIdx := markerIdx + scriptCloseRel
|
||||
return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len("</script>"):]
|
||||
}
|
||||
|
||||
func buildPlayworksConsoleRestoreScript() string {
|
||||
return strings.Join([]string{
|
||||
`<script>(function(){`,
|
||||
`try{`,
|
||||
`var f=document.createElement("iframe");`,
|
||||
`f.style.display="none";`,
|
||||
`document.documentElement.appendChild(f);`,
|
||||
`var nc=f.contentWindow.console;`,
|
||||
`document.documentElement.removeChild(f);`,
|
||||
`var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];`,
|
||||
`methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});`,
|
||||
`}catch(e){}`,
|
||||
`})();</script>`,
|
||||
}, "")
|
||||
}
|
||||
|
||||
func buildPlayworksLifecycleScript() string {
|
||||
return strings.Join([]string{
|
||||
`<script>(function(){`,
|
||||
`if(typeof window.gameReady!=="function")window.gameReady=function(){};`,
|
||||
`if(typeof window.gameStart!=="function")window.gameStart=function(){};`,
|
||||
`if(typeof window.gameEnd!=="function")window.gameEnd=function(){};`,
|
||||
`if(typeof window.gameClose!=="function")window.gameClose=function(){};`,
|
||||
`var _grOnce=false,_gsOnce=false;`,
|
||||
`window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});`,
|
||||
`window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});`,
|
||||
`window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});`,
|
||||
`})();</script>`,
|
||||
}, "")
|
||||
}
|
||||
|
||||
func buildPlayworksCTAScript(network string) string {
|
||||
urlSetup := `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`
|
||||
closeCall := `try{window.gameClose();}catch(e){}`
|
||||
ctaLogic := closeCall + `window.open(o,"_blank");`
|
||||
switch network {
|
||||
case "al", "is":
|
||||
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`
|
||||
case "un":
|
||||
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`
|
||||
case "fb":
|
||||
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
|
||||
case "gg":
|
||||
ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`
|
||||
case "mo":
|
||||
ctaLogic = closeCall + `if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`
|
||||
case "vu":
|
||||
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
|
||||
case "mtg":
|
||||
ctaLogic = closeCall + `if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`
|
||||
case "tt":
|
||||
ctaLogic = closeCall + `if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`
|
||||
}
|
||||
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),` + urlSetup + ctaLogic + `}}))}));</script>`
|
||||
}
|
||||
|
||||
func createSingleFileZip(data []byte, nameInZip, destZipPath string) error {
|
||||
_ = os.Remove(destZipPath)
|
||||
out, err := os.Create(destZipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
entry, err := zw.Create(nameInZip)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := entry.Write(data); err != nil {
|
||||
_ = zw.Close()
|
||||
return err
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
405
standalone/plec.go
Normal file
405
standalone/plec.go
Normal 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,'<') + '</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
BIN
standalone/rsrc.syso
Normal file
Binary file not shown.
68
standalone/single_instance.go
Normal file
68
standalone/single_instance.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user