6 Commits

Author SHA1 Message Date
9e24342142 0.2.0 2026-06-02 00:06:11 +08:00
b71e730482 UI changes for standalone 2026-06-01 23:58:44 +08:00
e3f454c5f2 0.1.8 2026-06-01 23:48:21 +08:00
88981d209b standalone mintegral checker 2026-06-01 23:36:46 +08:00
2524bacb5d mintegral checker 2026-06-01 23:18:06 +08:00
9e919756fd Add Mintegral playable checker 2026-06-01 21:59:52 +08:00
33 changed files with 2614 additions and 495 deletions

View File

@@ -9,6 +9,7 @@ Bundled VS Code extension and standalone tools for playable ad workflows.
- **Base64 Scanner** - Scan HTML files for external or non-base64 asset references. - **Base64 Scanner** - Scan HTML files for external or non-base64 asset references.
- **Send To Mobile** - Share selected HTML files to a phone over LAN with a QR code. - **Send To Mobile** - Share selected HTML files to a phone over LAN with a QR code.
- **MRAID Checker** - Check HTML files against MRAID requirements and best practices. - **MRAID Checker** - Check HTML files against MRAID requirements and best practices.
- **Mintegral Checker** - Validate Mintegral playable ZIPs against PlayTurbo requirements.
- **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta. - **Playworks Converter** - Convert Playworks HTML into per-network variants. Beta.
# Changelog # Changelog
@@ -71,3 +72,16 @@ Fixed
- Fixed cleanup behavior for temporary dropped files when clearing or removing rows. - Fixed cleanup behavior for temporary dropped files when clearing or removing rows.
- Fixed standalone PLEC Upload Open button using an incorrect/mangled URL by switching to the Windows URL protocol handler. - Fixed standalone PLEC Upload Open button using an incorrect/mangled URL by switching to the Windows URL protocol handler.
``` ```
**v0.2.0**
```
Added
- Added Mintegral Checker to the VS Code extension.
- Added Mintegral Checker to the standalone app.
- Added PlayTurbo-compatible runtime validation for Mintegral playables using the injected preview utility script.
Changed
- Mintegral Checker is available as a regular tool, not a beta tool.
- Standalone navigation now uses a collapsible sidebar with compact tool initials.
- Mintegral Checker UI now embeds the playable preview, checklist, runtime events, mute, and reload controls in one view.
```

155
TODO.md Normal file
View File

@@ -0,0 +1,155 @@
# Mintegral Checker — TODO
## Task Overview
Build a local VS Code webview equivalent of the Mintegral PlayTurbo checker
(`https://www.playturbo.com/review`). Validates a Mintegral playable ad ZIP against
PlayTurbo requirements. Two check categories:
- **Static** — parse `index.html` + ZIP structure without running the ad.
- **Runtime** — load the ad in a sandboxed `srcdoc` iframe, inject a monitoring stub,
and detect whether lifecycle callbacks (`gameReady`, `gameEnd`, `gameStart`,
`gameClose`, `install`) actually fire.
---
## Finished
- [x] Pure-Node ZIP parser (EOCD → Central Directory → Local File Headers, zlib DEFLATE)
- [x] All 9 static checks:
- HTML Requirements (charset, viewport, single-file)
- CTA Method (`window.install` reference in scripts)
- Game End / Game Ready / Game Start / Game Close (regex on scripts)
- File Handling (no external URLs, base64 assets)
- File Spec (ZIP ≤ 5 MB, single `index.html`)
- Storage (no `localStorage.setItem` / `sessionStorage.setItem`)
- [x] Device-frame simulator (phone mockup around `srcdoc` iframe)
- [x] Event log panel showing runtime events as they fire
- [x] Runtime timer with 60s timeout and per-check waiting badges
- [x] `__ping__` handshake → "Simulator connected" in event log (confirms stub runs)
- [x] Dual notification channels: polling `__hplMtgEvents` + `parent.__hplMtgNotify`
- [x] `MW_INIT` interceptor (wraps `emitCheckData`, forces `_hasReview`/`_isPreview`)
- [x] `window.postMessage` override (catches `previewer:gameReady` self-posts)
- [x] `mkAccess``Object.defineProperty` accessor with undefined getter so AD's
`typeof window.gameReady === 'undefined'` guard passes, setter wraps assignment
- [x] Capture-phase `load` safety net — wraps any SDK function declarations that
overwrote accessors, fires before `body.onload`
- [x] Mute button UI (shows on ZIP load, hidden on reset, toggles Mute/Unmute label)
- [x] Diagnostic lifecycle logging (`post-parse`, `DOMContentLoaded`, `load`,
`load+500ms`, callback assignment/call traces)
- [x] AudioContext construction proxy for mute/unmute of closure-owned engine audio
---
## Bugs
### 1. Callbacks never fire (critical, diagnostics added)
**Symptom:** Event log shows "Simulator connected" (`__ping__` fires) but no lifecycle
events appear. The same ZIP passes all checks on the real PlayTurbo site.
**Root cause candidates (narrowed down but not confirmed):**
A. **`body.onload` vs capture `load` ordering on `window`** — When the event target is
`window` itself, capture and bubble listeners both run at the "at target" phase in
registration order. Our stub is in `<head>` and registers first, so it should fire
before the body's `onload`. But this assumes no edge-case behaviour in VS Code's
Chromium sandbox / srcdoc context.
B. **SDK function declaration closes over a local reference** — If the ad does:
```js
var gameReady = function() { ... };
window.gameReady = gameReady;
```
and `body.onload="gameReady()"` resolves to the local var (via closure), then
reassigning `window.gameReady` in our load-time wrapper has no effect.
C. **CSP in VS Code webview blocks stub execution silently** — The webview panel has
an implicit CSP. The stub is injected into `srcdoc`; it might run in a restricted
context that prevents property interception or `parent` access for later events (even
though `__ping__` works because it runs synchronously at parse time).
D. **Ad calls `gameReady()` before load** — If the ad boots via DOMContentLoaded and
calls `gameReady()` there (rather than body.onload), our load-event safety net fires
too late. The `mkAccess` accessor should still catch it, but only if the ad assigns
`window.gameReady` first.
**What's been added:**
- Injected diagnostic logging for lifecycle function `typeof` at post-parse,
DOMContentLoaded, load capture before/after wrapping, and 500 ms after load.
- Logs callback assignment/wrapping and wrapper call paths to the event log.
- Logs dynamic `document.createElement('script')` calls to help trace script injection.
**What's still needed if callbacks still do not fire:**
- Check if the ad bundles `preview-util.js` inside `index.html` or references it
externally (would fail file-handling check anyway).
- Compare the diagnostic timeline against the real PlayTurbo execution path.
---
### 2. Mute button does nothing (fixed in monitor stub)
**Symptom:** Clicking Mute/Unmute changes the label but has no audible effect.
**Root cause:** The current approach searches `Object.keys(cw)` for an `AudioContext`
instance. AudioContexts are almost always created in closures (inside Phaser or the ad
IIFE) and are never assigned as window properties — `Object.keys` will never find them.
`cw.Phaser.game.sound` also likely fails because Phaser stores the game instance
internally, not at `window.Phaser.game`.
**Fix applied:**
Intercepts `AudioContext`/`webkitAudioContext` construction in the monitoring stub,
stores captured contexts/gain nodes on `window.__hplAudioContexts` and
`window.__hplMasterGains`, and routes `ctx.destination` through a master gain when
possible. The mute button now sets captured master gain values first, then falls back
to suspending/resuming captured contexts and exposed Phaser/global game sound objects.
---
## Improvements / Nice-to-Have
- [ ] **Inject SDK alongside ad** — The real PlayTurbo checker loads `preview-util.js`
next to the ad. Bundling (or downloading once) the 265 KB SDK and injecting it into
the srcdoc would let `MW_INIT`/`emitCheckData` fire natively, which is the most
reliable callback path and matches what the real checker does.
- [ ] **Diagnostic mode** — A "Debug" toggle that logs `typeof` of each lifecycle
function at key moments (post-parse, DCL, load, 500ms after load) to the event log,
making root-cause analysis faster.
- [ ] **Replay button** — Reload only the iframe (not the full ZIP re-parse) to retest
without re-picking the file.
- [ ] **Portrait / landscape toggle** — Flip the device frame aspect ratio.
- [ ] **Multiple HTML entry points** — Some ZIPs have `playable.html` instead of
`index.html`; add a fallback search.
- [ ] **Orientation lock detection** — Check for `screen.orientation.lock()` calls
(Mintegral requires the ad to handle both orientations).
- [ ] **Version bump and changelog entry** — Once callbacks are working, bump to 0.1.8
and add a changelog entry for the Mintegral Checker tool.
---
## Known Issues / Constraints
- VS Code `WebviewPanel` runs in a sandboxed Chromium process. `srcdoc` iframes are
same-origin with the webview, so `parent` access works — but the webview's implicit
CSP may still block certain APIs inside nested iframes.
- The `Object.defineProperty` accessor approach (`mkAccess`) is the only way to let the
AD's `typeof window.X === 'undefined'` guard pass while still intercepting the
assignment. Any early concrete assignment (even `undefined`) breaks the guard in some
runtimes.
- SDK `function` declarations at brace-depth 0 are `[[DefineOwnProperty]]` calls at
parse time; they overwrite our configurable accessors unconditionally. The load-time
safety net is the only recovery path for that scenario.
- The `applyStaticResults` function sets `runtimeStatus = null` for lifecycle checks
that already failed statically. `handleRuntimeEvent` only transitions
`runtimeStatus === 'waiting'` → `'pass'`; it silently ignores `null`. This means a
static-fail check can't be runtime-rescued via the direct lifecycle path (though the
`handleSdkCheck` path does handle `null`). Intentional, but worth documenting.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

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

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "hpl-toolbox", "name": "hpl-toolbox",
"displayName": "HPL Toolbox", "displayName": "HPL Toolbox",
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.", "description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
"version": "0.1.7", "version": "0.2.0",
"publisher": "hesukastro", "publisher": "hesukastro",
"license": "UNLICENSED", "license": "UNLICENSED",
"repository": { "repository": {
@@ -43,6 +43,10 @@
{ {
"command": "hplToolbox.openPlayworksConverter", "command": "hplToolbox.openPlayworksConverter",
"title": "HPL Toolbox: Open Playworks Converter" "title": "HPL Toolbox: Open Playworks Converter"
},
{
"command": "hplToolbox.openMintegralChecker",
"title": "HPL Toolbox: Open Mintegral Checker"
} }
], ],
"viewsContainers": { "viewsContainers": {

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -25,30 +25,110 @@ const SharedCSS = `
html { min-height: 100%; } html { min-height: 100%; }
body { body {
min-height: 100vh; min-height: 100vh;
display: flex; display: grid;
flex-direction: column; grid-template-columns: var(--sidebar-width, 236px) minmax(0, 1fr);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e; color: #ddd; background: #1e1e1e; color: #ddd;
margin: 0; padding: 0; margin: 0; padding: 0;
} }
.topbar { body.sidebar-collapsed { --sidebar-width: 56px; }
display: flex; align-items: center; gap: 8px; .sidebar {
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 8px;
background: #252526;
border-right: 1px solid #333;
font-size: 13px; font-size: 13px;
overflow: hidden;
} }
.topbar .nav-link { .sidebar-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 2px 6px;
border-bottom: 1px solid #333;
}
.sidebar-title {
min-width: 0;
flex: 1;
color: #ddd;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.45px;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-toggle {
width: 32px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
background: transparent;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
font-size: 16px;
line-height: 1;
}
.sidebar-toggle:hover { background: #2a2d2e; }
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: auto;
}
.sidebar .nav-link {
width: 100%;
min-height: 34px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 8px;
background: transparent; background: transparent;
color: #ddd; color: #ddd;
border: 0; border: 0;
padding: 5px 10px; padding: 4px 8px 4px 4px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
text-align: left;
}
.sidebar .nav-link:hover { background: #2a2d2e; }
.sidebar .nav-link.active { background: #094771; color: #fff; }
.nav-icon {
width: 28px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
background: rgba(128,128,128,0.12);
color: #ddd;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.nav-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.topbar .nav-link:hover { background: #2a2d2e; }
.topbar .nav-link.active { background: #094771; color: #fff; }
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; } .beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
.topbar-spacer { margin-left: auto; } .app-shell {
min-width: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; } .content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
.app-footer { .app-footer {
width: 100%; width: 100%;
@@ -78,6 +158,28 @@ const SharedCSS = `
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; } .app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; } .app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; } .app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
body.sidebar-collapsed .sidebar { align-items: stretch; }
body.sidebar-collapsed .sidebar-title,
body.sidebar-collapsed .nav-text,
body.sidebar-collapsed .beta-pill { display: none; }
body.sidebar-collapsed .sidebar .nav-link {
grid-template-columns: 28px;
justify-content: center;
padding: 4px;
}
body.sidebar-collapsed .sidebar-header { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .sidebar-toggle { width: 38px; }
@media (max-width: 760px) {
body { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
max-height: 48vh;
border-right: 0;
border-bottom: 1px solid #333;
}
body.sidebar-collapsed .sidebar { max-height: 50px; }
}
h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; } h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
.tool-header { margin-bottom: var(--tool-gap-lg); } .tool-header { margin-bottom: var(--tool-gap-lg); }
.tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; } .tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
@@ -329,6 +431,7 @@ var navItems = []navItem{
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, {Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"}, {Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"}, {Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true}, {Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
} }
@@ -365,6 +468,34 @@ func betaToolCount() int {
return count return count
} }
func navInitials(label string) string {
switch label {
case "Base64 Scanner":
return "B64"
case "AppLovin Playable Preview":
return "APP"
case "Mintegral Checker":
return "MiC"
}
words := strings.Fields(label)
if len(words) == 0 {
return "?"
}
if len(words) == 1 {
r := []rune(words[0])
if len(r) == 0 {
return "?"
}
return strings.ToUpper(string(r[0]))
}
first := []rune(words[0])
second := []rune(words[1])
if len(first) == 0 || len(second) == 0 {
return strings.ToUpper(string([]rune(label)[0]))
}
return strings.ToUpper(string(first[0]) + string(second[0]))
}
func Page(activePath, title, body string) string { func Page(activePath, title, body string) string {
cfg := LoadConfig() cfg := LoadConfig()
betaToolsEnabled := cfg.BetaToolsEnabled betaToolsEnabled := cfg.BetaToolsEnabled
@@ -378,7 +509,7 @@ func Page(activePath, title, body string) string {
if n.Beta { if n.Beta {
label += `<span class="beta-pill">Beta</span>` label += `<span class="beta-pill">Beta</span>`
} }
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `">` + label + `</button>`) tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
} }
betaButtonLabel := "Show beta" betaButtonLabel := "Show beta"
@@ -393,14 +524,29 @@ func Page(activePath, title, body string) string {
<style>` + SharedCSS + `</style> <style>` + SharedCSS + `</style>
<script>` + SharedDropZoneScript + `</script> <script>` + SharedDropZoneScript + `</script>
</head><body> </head><body>
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div> <aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">HPL Toolbox</span>
<button id="sidebarToggle" class="sidebar-toggle" title="Toggle sidebar" aria-label="Toggle sidebar">☰</button>
</div>
<nav class="sidebar-nav">` + tabs.String() + `</nav>
</aside>
<div class="app-shell">
<div class="content">` + body + `</div> <div class="content">` + body + `</div>
<div class="app-footer"> <div class="app-footer">
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button> <button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span> <span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button> <button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
</div> </div>
</div>
<script> <script>
if (localStorage.getItem('hplSidebarCollapsed') === 'true') {
document.body.classList.add('sidebar-collapsed');
}
document.getElementById('sidebarToggle').addEventListener('click', () => {
const collapsed = document.body.classList.toggle('sidebar-collapsed');
localStorage.setItem('hplSidebarCollapsed', collapsed ? 'true' : 'false');
});
document.querySelectorAll('[data-path]').forEach((element) => { document.querySelectorAll('[data-path]').forEach((element) => {
element.addEventListener('click', () => { element.addEventListener('click', () => {
const path = element.dataset.path; const path = element.dataset.path;

View File

@@ -111,6 +111,7 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("GET /applovin", ApplovinPage) mux.HandleFunc("GET /applovin", ApplovinPage)
mux.HandleFunc("GET /base64", Base64Page) mux.HandleFunc("GET /base64", Base64Page)
mux.HandleFunc("GET /mraid", MraidPage) mux.HandleFunc("GET /mraid", MraidPage)
mux.HandleFunc("GET /mintegral", MintegralPage)
mux.HandleFunc("GET /mobile", MobilePage) mux.HandleFunc("GET /mobile", MobilePage)
mux.HandleFunc("GET /playworks", PlayworksPage) mux.HandleFunc("GET /playworks", PlayworksPage)
mux.HandleFunc("GET /changelog", ChangelogPage) mux.HandleFunc("GET /changelog", ChangelogPage)
@@ -145,6 +146,14 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles) mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles)
mux.HandleFunc("POST /api/mraid/scan", MraidScan) mux.HandleFunc("POST /api/mraid/scan", MraidScan)
// Mintegral Checker
mux.HandleFunc("POST /api/mintegral/pick", MintegralPick)
mux.HandleFunc("POST /api/mintegral/load", MintegralLoad)
mux.HandleFunc("GET /mintegral/runtime", MintegralRuntimePage)
mux.HandleFunc("GET /mintegral/ad", MintegralAd)
mux.HandleFunc("GET /mintegral/monitor.js", MintegralMonitor)
mux.HandleFunc("GET /mintegral/preview-util.js", MintegralPreviewUtil)
// Send To Mobile // Send To Mobile
mux.HandleFunc("POST /api/mobile/pick", MobilePick) mux.HandleFunc("POST /api/mobile/pick", MobilePick)
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder) mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)

545
standalone/mintegral.go Normal file
View File

@@ -0,0 +1,545 @@
package main
import (
"archive/zip"
_ "embed"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
//go:embed assets/mintegral/preview-util.js
var mintegralPreviewUtil string
const (
mintegralZipLimit = 5 * 1024 * 1024
mintegralHTMLLimit = 5 * 1024 * 1024
)
type mintegralCheck struct {
ID string `json:"id"`
Status string `json:"status"`
Detail string `json:"detail"`
}
type mintegralZipEntry struct {
Name string
CompressedSize uint64
UncompressedSize uint64
Data []byte
}
type mintegralRuntimePayload struct {
FileName string `json:"fileName"`
ZipSize int64 `json:"zipSize"`
HTML string `json:"html"`
Checks []mintegralCheck `json:"checks"`
}
var mintegralRuntime = struct {
sync.RWMutex
payload *mintegralRuntimePayload
}{}
func MintegralPage(w http.ResponseWriter, r *http.Request) {
body := `
<header class="tool-header">
<h2 class="tool-title">Mintegral Checker</h2>
<p class="tool-description">Validates a Mintegral playable ad ZIP against PlayTurbo requirements. Runtime checks update live as you interact with the playable.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header"><h3 class="panel-title">Input</h3></div>
<div class="panel-body">
<div class="control-label">Playable ZIP</div>
<div id="dropZone" class="drop-zone">
<div class="file-row">
<button id="pickBtn" class="secondary">Select File</button>
<button id="reloadBtn" class="secondary" style="display:none">Reload</button>
</div>
<div class="drop-zone-text">or drop a Mintegral ZIP here</div>
</div>
<div class="file-name" id="fileName" style="margin-top:8px;">(no file selected)</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<section class="tool-panel embedded-runtime" id="runtimePanel" style="display:none">
<div class="panel-header"><h3 class="panel-title">Runtime Checker</h3></div>
<iframe id="runtimeFrame"></iframe>
</section>
<style>
.embedded-runtime { height: calc(100vh - 260px); min-height: 360px; }
.embedded-runtime iframe { width:100%; height:calc(100% - 39px); min-height:0; border:0; background:#1e1e1e; display:block; }
</style>
<script>
const statusEl = document.getElementById('status');
const fileNameEl = document.getElementById('fileName');
const runtimePanel = document.getElementById('runtimePanel');
const runtimeFrame = document.getElementById('runtimeFrame');
const reloadBtn = document.getElementById('reloadBtn');
let currentPath = '';
function basename(p){ const i=Math.max(p.lastIndexOf('/'),p.lastIndexOf('\\')); return i>=0?p.slice(i+1):p; }
async function loadZip(path){
if(!path){ statusEl.textContent='Pick a ZIP first.'; return; }
currentPath = path;
statusEl.textContent = 'Reading ' + basename(path) + '...';
statusEl.classList.add('is-busy');
runtimeFrame.src = 'about:blank';
runtimePanel.style.display = 'none';
const r = await fetch('/api/mintegral/load', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path})});
const j = await r.json();
statusEl.classList.remove('is-busy');
if(j.error){ statusEl.textContent = 'Error: ' + j.error; return; }
fileNameEl.textContent = j.fileName + ' (' + Math.round(j.zipSize / 1024) + ' KB)';
statusEl.textContent = '';
reloadBtn.style.display = '';
runtimePanel.style.display = 'block';
runtimeFrame.src = j.runtimeUrl;
}
document.getElementById('pickBtn').addEventListener('click', async () => {
const r = await fetch('/api/mintegral/pick', {method:'POST'});
const j = await r.json();
if(j.path) loadZip(j.path);
});
reloadBtn.addEventListener('click', () => loadZip(currentPath));
const dropZone = document.getElementById('dropZone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('is-dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('is-dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('is-dragover');
const paths = extractDroppedPaths(e.dataTransfer).filter(p => /\.zip$/i.test(p));
if(!paths.length){ statusEl.textContent = 'Drop a ZIP file.'; return; }
loadZip(paths[0]);
});
</script>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/mintegral", "Mintegral Checker", body)))
}
func MintegralPick(w http.ResponseWriter, r *http.Request) {
paths := PickFiles("Select Mintegral ZIP", []FileFilter{{Name: "ZIP", Extensions: []string{"zip"}}}, false, "")
path := ""
if len(paths) > 0 {
path = paths[0]
}
writeJSON(w, map[string]any{"path": path})
}
func MintegralLoad(w http.ResponseWriter, r *http.Request) {
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
}
payload, err := loadMintegralZip(req.Path)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
mintegralRuntime.Lock()
mintegralRuntime.payload = payload
mintegralRuntime.Unlock()
writeJSON(w, map[string]any{
"fileName": payload.FileName,
"zipSize": payload.ZipSize,
"runtimeUrl": "/mintegral/runtime",
})
}
func MintegralRuntimePage(w http.ResponseWriter, r *http.Request) {
mintegralRuntime.RLock()
payload := mintegralRuntime.payload
mintegralRuntime.RUnlock()
if payload == nil {
http.Error(w, "No Mintegral ZIP loaded.", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(mintegralRuntimeHTML(payload)))
}
func MintegralAd(w http.ResponseWriter, r *http.Request) {
mintegralRuntime.RLock()
payload := mintegralRuntime.payload
mintegralRuntime.RUnlock()
if payload == nil {
http.Error(w, "No Mintegral ZIP loaded.", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(payload.HTML))
}
func MintegralPreviewUtil(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(mintegralPreviewUtil))
}
func MintegralMonitor(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(mintegralMonitorScript))
}
func loadMintegralZip(zipPath string) (*mintegralRuntimePayload, error) {
if zipPath == "" {
return nil, fmt.Errorf("Pick a ZIP first.")
}
info, err := os.Stat(zipPath)
if err != nil {
return nil, err
}
if info.IsDir() {
return nil, fmt.Errorf("Expected a ZIP file, got a folder.")
}
entries, err := readMintegralZip(zipPath)
if err != nil {
return nil, err
}
var index *mintegralZipEntry
for i := range entries {
if entries[i].Name == "index.html" {
index = &entries[i]
break
}
}
htmlText := ""
if index != nil {
htmlText = string(index.Data)
}
checks := runMintegralStaticChecks(htmlText, entries, info.Size())
return &mintegralRuntimePayload{
FileName: filepath.Base(zipPath),
ZipSize: info.Size(),
HTML: injectMintegralMonitoring(htmlText),
Checks: checks,
}, nil
}
func readMintegralZip(zipPath string) ([]mintegralZipEntry, error) {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer zr.Close()
entries := make([]mintegralZipEntry, 0, len(zr.File))
for _, f := range zr.File {
if strings.HasSuffix(f.Name, "/") {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
data, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
data = nil
}
entries = append(entries, mintegralZipEntry{
Name: filepath.ToSlash(f.Name),
CompressedSize: f.CompressedSize64,
UncompressedSize: f.UncompressedSize64,
Data: data,
})
}
return entries, nil
}
func injectMintegralMonitoring(htmlText string) string {
stub := `<script src="/mintegral/preview-util.js"></script>
<script src="/mintegral/monitor.js"></script>`
if regexp.MustCompile(`(?i)<head\b[^>]*>`).MatchString(htmlText) {
return regexp.MustCompile(`(?i)<head\b[^>]*>`).ReplaceAllStringFunc(htmlText, func(m string) string {
return m + "\n" + stub
})
}
return stub + "\n" + htmlText
}
func runMintegralStaticChecks(htmlText string, entries []mintegralZipEntry, zipSize int64) []mintegralCheck {
scripts := mintegralExtractScripts(htmlText)
return []mintegralCheck{
mintegralCheckHTML(htmlText),
mintegralCheckCTA(scripts),
mintegralCheckGameEnd(scripts),
mintegralCheckGameReady(htmlText, scripts),
mintegralCheckGameStart(scripts),
mintegralCheckGameClose(scripts),
mintegralCheckFileHandling(htmlText),
mintegralCheckFileSpec(entries, zipSize),
mintegralCheckStorage(scripts),
}
}
func mintegralExtractScripts(htmlText string) string {
re := regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
matches := re.FindAllStringSubmatch(htmlText, -1)
parts := make([]string, 0, len(matches))
for _, m := range matches {
parts = append(parts, m[1])
}
return strings.Join(parts, "\n")
}
func mintegralCheckHTML(htmlText string) mintegralCheck {
issues := []string{}
if !regexp.MustCompile(`(?i)^\s*<!DOCTYPE\s+html\s*>`).MatchString(htmlText) {
issues = append(issues, "Missing <!DOCTYPE html>")
}
if !regexp.MustCompile(`(?i)<meta\b[^>]*\bcharset\s*=`).MatchString(htmlText) {
issues = append(issues, "Missing charset meta tag")
}
if !regexp.MustCompile(`(?i)<meta\b[^>]*\bname\s*=\s*["']viewport["']`).MatchString(htmlText) {
issues = append(issues, "Missing viewport meta tag")
}
if regexp.MustCompile(`(?i)<script\b[^>]*\btype\s*=\s*["']module["']`).MatchString(htmlText) {
issues = append(issues, `type="module" on script tag`)
}
if regexp.MustCompile(`(?i)<script\b[^>]*\bcrossorigin\b`).MatchString(htmlText) {
issues = append(issues, "crossorigin attribute on script tag")
}
if !regexp.MustCompile(`(?i)</html\s*>`).MatchString(htmlText) {
issues = append(issues, "Missing </html> closing tag")
}
if len(issues) == 0 {
return mintegralCheck{ID: "html-requirements", Status: "pass", Detail: "Valid HTML structure"}
}
return mintegralCheck{ID: "html-requirements", Status: "fail", Detail: strings.Join(issues, "; ")}
}
func mintegralCheckCTA(scripts string) mintegralCheck {
if regexp.MustCompile(`\bwindow\.install\b|\btypeof\s+window\.install`).MatchString(scripts) {
return mintegralCheck{ID: "cta-method", Status: "pass", Detail: "window.install() referenced in scripts"}
}
return mintegralCheck{ID: "cta-method", Status: "fail", Detail: "window.install() not found - Mintegral CTA requires window.install()"}
}
func mintegralCheckGameEnd(scripts string) mintegralCheck {
if regexp.MustCompile(`\bgameEnd\b`).MatchString(scripts) {
return mintegralCheck{ID: "game-end", Status: "pass", Detail: "gameEnd() found in scripts"}
}
return mintegralCheck{ID: "game-end", Status: "fail", Detail: "gameEnd() not found"}
}
func mintegralCheckGameReady(htmlText, scripts string) mintegralCheck {
inScript := regexp.MustCompile(`\bgameReady\b`).MatchString(scripts)
inOnload := regexp.MustCompile(`(?i)\bonload\s*=\s*["'][^"']*\bgameReady\b`).MatchString(htmlText)
if !inScript && !inOnload {
return mintegralCheck{ID: "game-ready", Status: "fail", Detail: "gameReady() not found"}
}
if !inOnload {
return mintegralCheck{ID: "game-ready", Status: "warn", Detail: `gameReady() found but not wired to body onload - Mintegral requires onload="gameReady()"`}
}
return mintegralCheck{ID: "game-ready", Status: "pass", Detail: "gameReady() wired to body onload"}
}
func mintegralCheckGameStart(scripts string) mintegralCheck {
if regexp.MustCompile(`\bgameStart\b`).MatchString(scripts) {
return mintegralCheck{ID: "game-start", Status: "pass", Detail: "gameStart() found in scripts"}
}
return mintegralCheck{ID: "game-start", Status: "fail", Detail: "gameStart() not found"}
}
func mintegralCheckGameClose(scripts string) mintegralCheck {
if regexp.MustCompile(`\bgameClose\b`).MatchString(scripts) {
return mintegralCheck{ID: "game-close", Status: "pass", Detail: "gameClose() found in scripts"}
}
return mintegralCheck{ID: "game-close", Status: "fail", Detail: "gameClose() not found"}
}
func mintegralCheckFileHandling(htmlText string) mintegralCheck {
re := regexp.MustCompile(`(?i)(?:src|href|action)\s*=\s*["'](https?://[^"']+)["']|url\s*\(\s*["']?(https?://[^)"'\s]+)`)
matches := re.FindAllStringSubmatch(htmlText, -1)
seen := map[string]bool{}
found := []string{}
for _, m := range matches {
u := m[1]
if u == "" {
u = m[2]
}
if u != "" && !seen[u] {
seen[u] = true
found = append(found, u)
}
}
if len(found) == 0 {
return mintegralCheck{ID: "file-handling", Status: "pass", Detail: "All assets are inline - no external URLs detected"}
}
detail := fmt.Sprintf("%d external URL(s): %s", len(found), strings.Join(found[:minInt(2, len(found))], ", "))
if len(found) > 2 {
detail += fmt.Sprintf(" (+%d more)", len(found)-2)
}
return mintegralCheck{ID: "file-handling", Status: "fail", Detail: detail}
}
func mintegralCheckFileSpec(entries []mintegralZipEntry, zipSize int64) mintegralCheck {
issues := []string{}
var index *mintegralZipEntry
for i := range entries {
if entries[i].Name == "index.html" {
index = &entries[i]
break
}
}
if index == nil {
nested := ""
for _, e := range entries {
if regexp.MustCompile(`(?i)/index\.html$`).MatchString(e.Name) {
nested = e.Name
break
}
}
if nested != "" {
issues = append(issues, fmt.Sprintf("index.html found at '%s' - must be at ZIP root", nested))
} else {
issues = append(issues, "index.html not found in ZIP")
}
}
if zipSize > mintegralZipLimit {
issues = append(issues, fmt.Sprintf("ZIP size %.2f MB exceeds 5 MB limit", float64(zipSize)/1024/1024))
}
if index != nil && index.UncompressedSize > mintegralHTMLLimit {
issues = append(issues, fmt.Sprintf("index.html %.2f MB uncompressed exceeds 5 MB", float64(index.UncompressedSize)/1024/1024))
}
if len(issues) > 0 {
return mintegralCheck{ID: "file-spec", Status: "fail", Detail: strings.Join(issues, "; ")}
}
extras := []string{}
for _, e := range entries {
if e.Name != "index.html" {
extras = append(extras, e.Name)
}
}
sizeStr := fmt.Sprintf("index.html %.0f KB, ZIP %.0f KB", float64(index.UncompressedSize)/1024, float64(zipSize)/1024)
if len(extras) > 0 {
return mintegralCheck{ID: "file-spec", Status: "warn", Detail: fmt.Sprintf("%s; %d extra file(s) in ZIP (%s)", sizeStr, len(extras), strings.Join(extras, ", "))}
}
return mintegralCheck{ID: "file-spec", Status: "pass", Detail: sizeStr}
}
func mintegralCheckStorage(scripts string) mintegralCheck {
issues := []string{}
if regexp.MustCompile(`\blocalStorage\s*\.\s*setItem\s*\(`).MatchString(scripts) {
issues = append(issues, "localStorage.setItem")
}
if regexp.MustCompile(`\bsessionStorage\s*\.\s*setItem\s*\(`).MatchString(scripts) {
issues = append(issues, "sessionStorage.setItem")
}
if regexp.MustCompile(`\bdocument\.cookie\s*=`).MatchString(scripts) {
issues = append(issues, "document.cookie write")
}
if len(issues) == 0 {
return mintegralCheck{ID: "storage", Status: "pass", Detail: "No persistent storage writes detected"}
}
return mintegralCheck{ID: "storage", Status: "fail", Detail: "Forbidden storage write(s): " + strings.Join(issues, ", ")}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func mintegralRuntimeHTML(payload *mintegralRuntimePayload) string {
boot, _ := json.Marshal(map[string]any{
"fileName": payload.FileName,
"zipSize": payload.ZipSize,
"checks": payload.Checks,
})
return `<!DOCTYPE html>
<html><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Mintegral Runtime Checker</title>
<style>
:root{color-scheme:dark;--gap-xs:4px;--gap-sm:8px;--gap-md:12px;--radius:4px;--border:#333;--panel:#252526;--panel-soft:rgba(128,128,128,.08);--fg:#ddd;--muted:#a7a7a7;--ok:#89d185;--err:#f48771;--warn:#ffd580}
*{box-sizing:border-box}html,body{width:100%;height:100%;overflow:hidden}body{margin:0;background:#1e1e1e;color:var(--fg);font-family:Segoe UI,Arial,sans-serif;font-size:13px}
main{height:100vh;display:grid;grid-template-columns:minmax(360px,1fr) minmax(320px,420px);gap:var(--gap-md);padding:var(--gap-md);overflow:hidden}
.panel{border:1px solid var(--border);background:var(--panel-soft);border-radius:var(--radius);overflow:hidden}.head{display:flex;align-items:center;justify-content:space-between;gap:var(--gap-md);padding:10px 12px;border-bottom:1px solid var(--border);background:var(--panel)}
h1{margin:0;font-size:12px;font-weight:700;letter-spacing:.45px;text-transform:uppercase}.sub{color:var(--muted);font-size:12px;overflow-wrap:anywhere}.head-meta{display:flex;align-items:center;justify-content:flex-end;gap:6px;min-width:0;white-space:nowrap}.head-meta .sub{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.head-meta .sub+.sub::before{content:"|";margin-right:6px;color:var(--border)}
.title-actions{display:flex;gap:var(--gap-xs)}button{min-height:28px;padding:5px 12px;border:1px solid transparent;border-radius:var(--radius);background:#0e639c;color:#fff;font:inherit;cursor:pointer}.icon-btn{width:28px;height:28px;min-height:28px;padding:0;display:grid;place-items:center;font-size:15px;line-height:1;background:#3a3d41;color:#ddd;border-color:#444}.icon-btn[aria-pressed=true]{color:var(--ok)}
.runtime-side{display:grid;grid-template-rows:minmax(0,1fr);min-height:0}.playable-panel{min-height:0;display:flex;flex-direction:column}.playable-body{min-height:0;flex:1;display:flex;flex-direction:column;padding:10px}.preview-stage{min-height:0;flex:1;display:grid;place-items:center;overflow:hidden;container-type:size}.device{width:min(100cqw,calc(100cqh*.5625));max-width:100%;aspect-ratio:9/16;margin:auto}.screen{position:relative;width:100%;height:100%;background:#000;border-radius:4px;overflow:hidden}iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#000}
.tab-panel{min-height:0;display:flex;flex-direction:column}.tabs{display:flex;gap:var(--gap-xs);padding:8px 8px 0;background:var(--panel);border-bottom:1px solid var(--border)}.tab{min-height:28px;padding:4px 10px;background:#3a3d41;color:#ddd;border-color:#444;border-bottom:0;border-radius:var(--radius) var(--radius) 0 0}.tab.active{background:var(--panel-soft)}.tab-content{min-height:0;flex:1;display:none;overflow:auto}.tab-content.active{display:block}
ol{list-style:none;margin:0;padding:0}.check{display:flex;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border)}.num{flex:0 0 22px;height:22px;border:1px solid var(--border);border-radius:50%;display:grid;place-items:center;color:var(--muted);background:var(--panel);font-size:10px;font-weight:700}.label{font-size:12px;font-weight:600}.detail{margin-top:3px;color:var(--muted);font-size:11px;line-height:1.35;overflow-wrap:anywhere}.badge{display:inline-flex;min-height:18px;margin-left:6px;padding:1px 6px;border:1px solid var(--border);border-radius:999px;font-size:11px}.badge.pass{color:var(--ok);background:rgba(137,209,133,.12)}.badge.fail{color:var(--err);background:rgba(244,135,113,.14)}.badge.warn{color:var(--warn);background:rgba(210,153,34,.16)}.badge.waiting{color:var(--muted)}
ul.log{list-style:none;margin:0;padding:0;overflow:auto;font-family:Consolas,monospace;font-size:12px;height:100%}ul.log li{display:flex;gap:8px;padding:4px 8px;border-bottom:1px solid var(--border)}.time{color:var(--muted);flex:0 0 auto}.msg{white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}.level{font-weight:700}.pass{color:var(--ok)}.fail{color:var(--err)}.warn{color:var(--warn)}
@media(max-width:900px){html,body{overflow:auto}main{height:auto;min-height:100vh;grid-template-columns:1fr;overflow:visible}.playable-panel{height:min(72vh,640px);min-height:360px}.tab-panel{min-height:360px}}@media(max-height:700px) and (max-width:900px){.playable-panel{height:calc(100vh - 24px);min-height:300px}}
</style></head><body>
<main>
<section class="runtime-side"><section class="panel playable-panel"><div class="head"><h1>Playable</h1><div class="title-actions"><button id="muteBtn" class="icon-btn" title="Mute" aria-label="Mute" aria-pressed="false">&#128263;</button><button id="reloadBtn" class="icon-btn" title="Reload" aria-label="Reload">&#8635;</button></div></div><div class="playable-body"><div class="preview-stage"><div class="device"><div class="screen"><iframe id="adFrame" src="/mintegral/ad?preview=true&loading=0&review=1"></iframe></div></div></div></div></section></section>
<section class="panel tab-panel"><div class="head"><h1>Checks</h1><div class="head-meta"><div class="sub" id="bridge">Starting...</div><div class="sub" id="timer">60s</div></div></div><div class="tabs"><button class="tab active" data-tab="checksTab">Checklist</button><button class="tab" data-tab="eventsTab">Events</button></div><ol class="tab-content active" id="checksTab"></ol><ul class="log tab-content" id="eventsTab"></ul></section>
</main>
<script>const BOOT=` + string(boot) + `;</script>
<script>` + mintegralRuntimeJS + `</script>
</body></html>`
}
const mintegralRuntimeJS = `
const CHECKS=[
{id:'html-requirements',num:1,label:'HTML Requirements',runtimeEvent:null},{id:'cta-method',num:2,label:'CTA Call Method',runtimeEvent:'install'},{id:'game-end',num:3,label:'Game End Call Method',runtimeEvent:'gameEnd'},{id:'game-ready',num:4,label:'Game Ready Call Method',runtimeEvent:'gameReady'},{id:'game-start',num:5,label:'Game Start Call Method',runtimeEvent:'gameStart'},{id:'game-close',num:6,label:'Game Close Call Method',runtimeEvent:'gameClose'},{id:'file-handling',num:7,label:'File Handling Method',runtimeEvent:null},{id:'file-spec',num:8,label:'File Spec',runtimeEvent:null},{id:'storage',num:9,label:'Storage Requirements',runtimeEvent:null},{id:'code-exception',num:10,label:'Code Exception',runtimeEvent:'exception'}];
const EVENT_TO_CHECK={};CHECKS.forEach(c=>{if(c.runtimeEvent)EVENT_TO_CHECK[c.runtimeEvent]=c.id;});
const SDK_TYPE_TO_CHECK={game_ready:'game-ready',game_end:'game-end',game_start:'game-start',game_close:'game-close',install:'cta-method',outer_chain:'file-handling',base64:'file-handling',code:'code-exception'};
const state={checks:{},seconds:0,muted:false,connected:false,timer:null,timeout:60,codeCleanAfter:5,hasSentNoRetry:false};
CHECKS.forEach(c=>state.checks[c.id]={status:'pending',detail:'',runtimeStatus:c.runtimeEvent?'waiting':null,runtimeDetail:''});
BOOT.checks.forEach(r=>{const ch=state.checks[r.id];if(ch){ch.status=r.status;ch.detail=r.detail;}});
state.checks['code-exception'].status='pass';state.checks['code-exception'].runtimeStatus='monitoring';
['cta-method','game-end','game-ready','game-start','game-close'].forEach(id=>{if(state.checks[id].status==='fail')state.checks[id].runtimeStatus=null;});
const checksEl=document.getElementById('checksTab'),eventLog=document.getElementById('eventsTab'),adFrame=document.getElementById('adFrame'),muteBtn=document.getElementById('muteBtn');
document.querySelectorAll('.tab').forEach(btn=>btn.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));document.querySelectorAll('.tab-content').forEach(p=>p.classList.remove('active'));btn.classList.add('active');document.getElementById(btn.dataset.tab).classList.add('active');}));
function resetChecks(){if(state.timer)clearInterval(state.timer);state.seconds=0;state.connected=false;state.hasSentNoRetry=false;CHECKS.forEach(c=>state.checks[c.id]={status:'pending',detail:'',runtimeStatus:c.runtimeEvent?'waiting':null,runtimeDetail:''});BOOT.checks.forEach(r=>{const ch=state.checks[r.id];if(ch){ch.status=r.status;ch.detail=r.detail;}});state.checks['code-exception'].status='pass';state.checks['code-exception'].runtimeStatus='monitoring';['cta-method','game-end','game-ready','game-start','game-close'].forEach(id=>{if(state.checks[id].status==='fail')state.checks[id].runtimeStatus=null;});eventLog.innerHTML='';document.getElementById('bridge').textContent='Starting...';document.getElementById('timer').textContent=state.timeout+'s';render();state.timer=startRuntimeTimer();}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function addLog(list,name,cls,msg){const d=new Date(),li=document.createElement('li');li.innerHTML='<span class="time">'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0')+'</span><span class="level '+(cls||'')+'">'+esc(name)+'</span><span class="msg">'+esc(msg||'')+'</span>';list.appendChild(li);list.scrollTop=list.scrollHeight;}
function render(){checksEl.innerHTML=CHECKS.map(c=>{const ch=state.checks[c.id];let s=ch.status,d=ch.detail;if(ch.runtimeStatus==='waiting'&&ch.status==='pass'){s='waiting';d=ch.detail+' - awaiting runtime confirmation';}else if(ch.runtimeStatus==='monitoring'){s='waiting';d='Monitoring for exceptions...';}else if(ch.runtimeStatus==='pass'){s='pass';d=(ch.detail||'')+(ch.runtimeDetail?' - '+ch.runtimeDetail:'');}else if(ch.runtimeStatus==='fail'){s='fail';d=ch.runtimeDetail||ch.detail;}else if(ch.runtimeStatus==='timeout'&&ch.status==='pass'){s='warn';d=ch.detail+' - not fired within '+state.timeout+'s';}else if(ch.runtimeStatus==='clean'){s='pass';d='No exceptions detected';}return '<li class="check"><span class="num">'+c.num+'</span><span><div class="label">'+esc(c.label)+' <span class="badge '+s+'">'+s.toUpperCase()+'</span></div><div class="detail">'+esc(d||'')+'</div></span></li>';}).join('');}
function sdkCheck(type,value,msg){const id=SDK_TYPE_TO_CHECK[type],ch=state.checks[id];if(!ch)return;const pass=value===1||value===true;if(type==='code'){ch.status='fail';ch.runtimeStatus='fail';ch.runtimeDetail=msg||'JS error detected';addLog(eventLog,'code error','fail',ch.runtimeDetail);}else if(type==='outer_chain'){ch.runtimeStatus=pass?'pass':'fail';ch.runtimeDetail=pass?'No external resources at runtime':'External resources detected at runtime';addLog(eventLog,type,pass?'pass':'fail',ch.runtimeDetail);}else if(type==='base64'){if(!pass){ch.status='fail';ch.runtimeStatus='fail';ch.runtimeDetail='Non-JS/HTML assets must be base64';addLog(eventLog,type,'warn',ch.runtimeDetail);}}else{if(pass&&(ch.runtimeStatus==='waiting'||ch.runtimeStatus===null)){ch.runtimeStatus='pass';ch.runtimeDetail='Confirmed by SDK at '+state.seconds+'s';addLog(eventLog,type,'pass','');}else if(!pass&&(ch.runtimeStatus==='waiting'||ch.runtimeStatus===null)){ch.runtimeStatus='fail';ch.runtimeDetail=type+' not defined';addLog(eventLog,type,'warn',ch.runtimeDetail);}}render();done();}
function inferAll(text){const s=String(text||''),out=[];if(/(^|[^a-z])gameReady([^a-z]|$)|previewer:gameReady|game_ready|\bDISPLAYED\b|\bLOADED\b/i.test(s))out.push('gameReady');if(/(^|[^a-z])gameStart([^a-z]|$)|previewer:gameStart|game_start|\bCHALLENGE_STARTED\b/i.test(s))out.push('gameStart');if(/(^|[^a-z])gameEnd([^a-z]|$)|previewer:gameEnd|game_end|\bCHALLENGE_SOLVED\b|\bENDCARD_SHOWN\b/i.test(s))out.push('gameEnd');if(/(^|[^a-z])gameClose([^a-z]|$)|previewer:gameClose|game_close/i.test(s))out.push('gameClose');if(/(^|[^a-z])install([^a-z]|$)|previewer:install|\bCTA_CLICKED\b/i.test(s))out.push('install');return out;}
function handle(msg){const name=msg.name,extra=msg.extra||{};if(name==='__ping__'){state.connected=true;document.getElementById('bridge').textContent='Monitor connected';addLog(eventLog,'Monitor connected','pass','');return;}if(name==='sdkCheck'){sdkCheck(extra.type,extra.value,extra.msg||'');return;}if(name==='console'){inferAll(extra.text||'').forEach(ev=>handle({name:ev,extra:{source:'console'}}));return;}if(name==='diagnostic'){addLog(eventLog,extra.label||'diagnostic','warn',extra.detail||'');return;}if(name==='audioContext'){addLog(eventLog,'Audio captured','pass',extra.routed?'master gain ready':'context only');return;}if(name==='exception'){const ch=state.checks['code-exception'];ch.status='fail';ch.runtimeStatus='fail';ch.runtimeDetail=extra.message||'Unhandled exception';addLog(eventLog,'exception','fail',ch.runtimeDetail);render();done();return;}const id=EVENT_TO_CHECK[name];if(id){const ch=state.checks[id];if(ch.runtimeStatus==='waiting'||ch.runtimeStatus===null){ch.runtimeStatus='pass';ch.runtimeDetail=(extra.source==='console'?'Seen in console at ':'Fired at ')+state.seconds+'s';addLog(eventLog,name,'pass',extra.source==='console'?'from console':'');render();done();}}}
window.__hplMtgNotify=(name,extra)=>handle({name,extra:extra||{}});
window.addEventListener('message',e=>{const m=e.data;if(!m)return;if(m.type==='lifecycle')handle(m);else if(typeof m.type==='string'&&m.type.startsWith('previewer:')){const map={'previewer:gameReady':'gameReady','previewer:gameStart':'gameStart','previewer:install':'install','previewer:gameEnd':'gameEnd','previewer:gameClose':'gameClose'};if(map[m.type])handle({name:map[m.type],extra:{}});if(m.type==='previewer:review'&&m.data&&Array.isArray(m.data.reviewResult)){if(!state.hasSentNoRetry){state.hasSentNoRetry=true;try{adFrame.contentWindow.postMessage({type:'previewer:review',data:{reviewResult:{no_game_retry:true}}},'*');}catch(e){}}m.data.reviewResult.forEach(r=>sdkCheck(r.type,r.value,r.msg||''));}}});
function applyMute(){try{const cw=adFrame.contentWindow;if(!cw)return;cw.__hplMuted=state.muted;if(typeof cw.__hplApplyMutedState==='function')cw.__hplApplyMutedState();(cw.__hplMasterGains||[]).forEach(g=>{try{if(g&&g.gain)g.gain.value=state.muted?0:1;}catch(e){}});(cw.__hplMediaElements||[]).forEach(el=>{try{el.muted=state.muted;el.volume=state.muted?0:1;}catch(e){}});}catch(e){}}
function renderMuteButton(){muteBtn.innerHTML=state.muted?'&#128264;':'&#128263;';muteBtn.title=state.muted?'Unmute':'Mute';muteBtn.setAttribute('aria-label',state.muted?'Unmute':'Mute');muteBtn.setAttribute('aria-pressed',state.muted?'true':'false');}
muteBtn.onclick=()=>{state.muted=!state.muted;renderMuteButton();applyMute();addLog(eventLog,state.muted?'Muted':'Unmuted','pass','');};
document.getElementById('reloadBtn').onclick=()=>{resetChecks();document.getElementById('bridge').textContent='Reloading...';adFrame.src='/mintegral/ad?preview=true&loading=0&review=1&t='+Date.now();};
['pointerdown','click','touchstart','keydown'].forEach(evt=>window.addEventListener(evt,()=>setTimeout(applyMute,0),true));
function markCodeClean(){const ch=state.checks['code-exception'];if(ch&&ch.runtimeStatus==='monitoring'){ch.runtimeStatus='clean';ch.runtimeDetail='No exceptions detected';addLog(eventLog,'code clean','pass',ch.runtimeDetail);render();}}
function done(){const othersDone=CHECKS.every(c=>c.id==='code-exception'||!c.runtimeEvent||state.checks[c.id].runtimeStatus!=='waiting');if(othersDone)markCodeClean();if(CHECKS.every(c=>!c.runtimeEvent||!['waiting','monitoring'].includes(state.checks[c.id].runtimeStatus))){clearInterval(state.timer);document.getElementById('timer').textContent='Runtime complete';}}
function startRuntimeTimer(){return setInterval(()=>{state.seconds++;document.getElementById('timer').textContent=Math.max(0,state.timeout-state.seconds)+'s';if(state.seconds===2&&!state.connected){document.getElementById('bridge').textContent='Monitor not connected';addLog(eventLog,'Monitor not connected','fail','Check browser DevTools console for blocked inline script/CSP errors.');}if(state.seconds>=state.codeCleanAfter)markCodeClean();done();if(state.seconds>=state.timeout){clearInterval(state.timer);CHECKS.forEach(c=>{const ch=state.checks[c.id];if(ch.runtimeStatus==='waiting')ch.runtimeStatus='timeout';if(ch.runtimeStatus==='monitoring'){ch.runtimeStatus='clean';ch.runtimeDetail='No exceptions detected';}});render();document.getElementById('timer').textContent='Runtime complete';}},1000);}
state.timer=startRuntimeTimer();renderMuteButton();render();
`
const mintegralMonitorScript = `
(function(){
window.__hplMtgEvents=window.__hplMtgEvents||[];
function notify(n,extra){window.__hplMtgEvents.push({name:n,extra:extra||{}});try{if(typeof parent.__hplMtgNotify==='function')parent.__hplMtgNotify(n,extra||{});}catch(e){}try{parent.postMessage({type:'lifecycle',name:n,extra:extra||{}},'*');}catch(e){}}
notify('__ping__');
function applyMutedState(){var muted=!!window.__hplMuted;try{(window.__hplMasterGains||[]).forEach(function(g){try{if(g&&g.gain)g.gain.value=muted?0:1;}catch(e){}});(window.__hplMediaElements||[]).forEach(function(el){try{el.muted=muted;el.volume=muted?0:1;}catch(e){}});}catch(e){}}
window.__hplApplyMutedState=applyMutedState;
try{(function(){var OrigAC=window.AudioContext||window.webkitAudioContext;if(!OrigAC||OrigAC.__hplProxy)return;window.__hplAudioContexts=[];window.__hplMasterGains=[];window.__hplAudioDestinations=[];function ACProxy(opts){var ctx=arguments.length?new OrigAC(opts):new OrigAC();var dest=ctx.destination,gain=null;try{gain=ctx.createGain();gain.gain.value=window.__hplMuted?0:1;gain.connect(dest);if(window.AudioNode&&window.AudioNode.prototype&&!window.AudioNode.prototype.connect.__hpl){var origConnect=window.AudioNode.prototype.connect;var connectProxy=function(target){var args=Array.prototype.slice.call(arguments);try{var idx=window.__hplAudioDestinations.indexOf(args[0]);if(idx>=0&&window.__hplMasterGains[idx])args[0]=window.__hplMasterGains[idx];}catch(e){}return origConnect.apply(this,args);};connectProxy.__hpl=true;window.AudioNode.prototype.connect=connectProxy;}}catch(e){}window.__hplAudioContexts.push(ctx);if(gain)window.__hplMasterGains.push(gain);if(gain)window.__hplAudioDestinations.push(dest);notify('audioContext',{state:ctx.state||'',routed:!!gain});return ctx;}ACProxy.prototype=OrigAC.prototype;Object.setPrototypeOf&&Object.setPrototypeOf(ACProxy,OrigAC);ACProxy.__hplProxy=true;window.AudioContext=ACProxy;window.webkitAudioContext=ACProxy;})();}catch(e){}
try{window.__hplMediaElements=[];var mediaPlay=window.HTMLMediaElement&&window.HTMLMediaElement.prototype&&window.HTMLMediaElement.prototype.play;if(mediaPlay&&!mediaPlay.__hpl){var playProxy=function(){window.__hplMediaElements.push(this);applyMutedState();notify('audioContext',{state:'media-play',routed:true});return mediaPlay.apply(this,arguments);};playProxy.__hpl=true;window.HTMLMediaElement.prototype.play=playProxy;}}catch(e){}
function patchMwInit(obj){if(!obj||obj.__hplMwPatched)return;obj.__hplMwPatched=true;obj._hasReview=function(){return true;};obj._isPreview=function(){return true;};var _oe=obj.emitCheckData;if(typeof _oe==='function'){obj.emitCheckData=function(d){_oe.call(obj,d);if(d&&d.type)notify('sdkCheck',{type:d.type,value:d.value,msg:d.msg||''});};}}
var _mwInit=window.MW_INIT||null;patchMwInit(_mwInit);try{Object.defineProperty(window,'MW_INIT',{configurable:true,enumerable:true,get:function(){return _mwInit;},set:function(obj){_mwInit=obj;patchMwInit(obj);}});}catch(e){}
var _owp=window.postMessage;window.postMessage=function(data,origin,transfer){if(data&&typeof data.type==='string'){var pm={'previewer:gameReady':'gameReady','previewer:gameStart':'gameStart','previewer:gameEnd':'gameEnd','previewer:gameClose':'gameClose','previewer:install':'install','previewer:gameRetry':'gameRetry'};if(pm[data.type])notify(pm[data.type]);}return _owp.apply(this,arguments);};
function mkAccess(name,onCall){var existing,_v;try{existing=window[name];Object.defineProperty(window,name,{configurable:true,enumerable:true,get:function(){return _v;},set:function(fn){var orig=fn;if(typeof orig==='function'){var w=function(){onCall();return orig.apply(this,arguments);};w.__hpl=true;_v=w;}else{_v=orig;}}});if(typeof existing==='function')window[name]=existing;}catch(e){}}
var _lh={gameReady:function(){notify('gameReady');setTimeout(function(){notify('sdkCheck',{type:'game_start',value:typeof window.gameStart==='function'?1:0});notify('sdkCheck',{type:'game_close',value:typeof window.gameClose==='function'?1:0});},100);},gameEnd:function(){notify('sdkCheck',{type:'game_end',value:1});},gameStart:function(){notify('sdkCheck',{type:'game_start',value:1});},gameClose:function(){notify('sdkCheck',{type:'game_close',value:1});},install:function(){notify('sdkCheck',{type:'install',value:1});}};
mkAccess('gameReady',_lh.gameReady);mkAccess('gameEnd',_lh.gameEnd);mkAccess('gameStart',_lh.gameStart);mkAccess('gameClose',_lh.gameClose);if(typeof window.install!=='function'){var _installW=function(){_lh.install();};_installW.__hpl=true;window.install=_installW;}
window.addEventListener('load',function(){Object.keys(_lh).forEach(function(name){var fn=window[name];if(typeof fn==='function'&&!fn.__hpl){var orig=fn,h=_lh[name];var w=function(){h();return orig.apply(this,arguments);};w.__hpl=true;try{window[name]=w;}catch(e){}}else if(typeof fn!=='function'){var ww=function(){_lh[name]();};ww.__hpl=true;try{window[name]=ww;}catch(e){}}});},true);
window.onerror=function(msg,src,line){notify('exception',{message:String(msg),line:line||0,source:src||''});return false;};
window.addEventListener('unhandledrejection',function(e){notify('exception',{message:String(e&&e.reason||'Unhandled promise rejection')});});
})();
`
var _ = html.EscapeString

View File

@@ -46,5 +46,13 @@
"command": "hplToolbox.openPlayworksConverter", "command": "hplToolbox.openPlayworksConverter",
"path": "/playworks", "path": "/playworks",
"beta": true "beta": true
},
{
"id": "mintegral",
"title": "Mintegral Checker",
"description": "Validate a Mintegral playable ZIP against PlayTurbo requirements",
"command": "hplToolbox.openMintegralChecker",
"path": "/mintegral",
"beta": false
} }
] ]