18 Commits

Author SHA1 Message Date
8e5d56ab1d v0.2.4 2026-06-22 11:45:36 +08:00
68d15aa208 Update manifest 2026-06-22 11:23:54 +08:00
b254daf06a Test manifest json 2026-06-22 11:17:47 +08:00
16de3cc116 Test Manifest for Project Initialization 2026-06-22 11:03:57 +08:00
5fa1ce949c v0.2.3 2026-06-16 10:21:41 +08:00
e50b4c2133 Added localization in Device Simulator 2026-06-16 10:20:49 +08:00
cebb65fb35 v0.2.2 2026-06-15 13:55:05 +08:00
7094e6c3b0 0.2.1 2026-06-08 22:35:56 +08:00
6c09beea43 0.2.1 2026-06-08 22:31:02 +08:00
ad6403f3e4 device simulator 2026-06-08 19:43:17 +08:00
700bb135ad update icon. 2026-06-05 09:26:49 +08:00
986993a027 0.2.0 2026-06-02 00:07:34 +08:00
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
50 changed files with 12519 additions and 513 deletions

View File

@@ -9,7 +9,9 @@ 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.
- **Initialize Project** - Download team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared Google Drive manifest.
# Changelog # Changelog
@@ -71,3 +73,61 @@ 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.
```
**v0.2.1**
```
Added
- Added Device Simulator to the VS Code extension and standalone app.
- Added editable Device Simulator device lists with add, delete, restore defaults, import, and export.
- Added drag-and-drop reordering for Device Simulator devices.
- Added iPhone 17, iPhone Air, iPhone 17 Pro, and iPhone 17 Pro Max defaults.
- Added drag-and-drop tool reordering with persisted order in the VS Code launcher and standalone Home/sidebar.
Changed
- Device Simulator settings now use a modal list manager with multi-select, add/delete, and move up/down controls.
- Standalone Device Simulator sidebar abbreviation is now DS.
- Send To Mobile browser preview now opens playables in a delayed full-screen preview wrapper while Download still serves the original HTML.
Fixed
- Fixed Device Simulator mute for HTML media and WebAudio playables.
- Fixed Device Simulator Add Device and Remove Device interactions inside embedded webviews.
- Fixed Send To Mobile browser preview spending the View button's initial tap as a premature playable click-through while preserving real user-initiated redirects.
```
**v0.2.2**
```
Added
- Added automatic update checking to the VS Code extension and standalone app.
```
**v0.2.3**
```
Added
- Added Localization dropdown in Device Simulator.
```
**v0.2.4**
```
Added
- Added Initialize Project tool to the VS Code extension and standalone app.
- Downloads team template files (AGENTS.md, LOCALIZATION.md, .gitignore) from a shared manifest.
- Manifest is a JSON file hosted on Google Drive or any direct URL, listing files with name, URL, and description.
- Manifest URL is configurable per-user; defaults to the shared team Google Drive manifest.
- Google Drive share links are automatically converted to direct download URLs.
- Files panel shows a checklist loaded from the manifest with per-file Open button and Refresh/Configure controls.
- Destination folder is selectable via folder picker, defaulting to workspace root in the extension.
- Standalone stores the manifest URL in config.json and exposes an inline URL editor via the settings (⚙) button.
```

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]

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.

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

Binary file not shown.

17
manifest.json Normal file
View File

@@ -0,0 +1,17 @@
[
{
"filename": "AGENTS.md",
"description": "AI coding guidelines for Phaser 3 playable ads. Covers stack (Phaser 3.90, TypeScript, Vite), supported ad networks, project structure, responsibility split between game modules and GameScene, performance rules, responsive coordinate system (sx/sy/sd), and CTA fallback chain.",
"url": "https://drive.google.com/file/d/1O3Rp9HIfVA1Urr3APFyvIM0KDBxhEOOS/view?usp=sharing"
},
{
"filename": "LOCALIZATION.md",
"description": "Runtime localization system for playable ads. Documents the two-layer pattern (reusable runtime + project adapter), 10 supported locales (EN/DE/FR/ES/PT/RU/ZH/KO/JA), locale detection order (URL override → browser language → English fallback), and a drop-in AI prompt for implementing localization in a new or existing project.",
"url": "https://drive.google.com/file/d/1bDldnMJX6Vwngkakeve9cxonG7NU_KZo/view?usp=drive_link"
},
{
"filename": ".gitignore",
"description": "Standard .gitignore for Phaser 3 / Vite playable ad projects.",
"url": "https://drive.google.com/file/d/1wh3Yo_HZDxLqe4EDM6KsvWeG8eFzp5pG/view?usp=drive_link"
}
]

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.4",
"publisher": "hesukastro", "publisher": "hesukastro",
"license": "UNLICENSED", "license": "UNLICENSED",
"repository": { "repository": {
@@ -43,6 +43,22 @@
{ {
"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"
},
{
"command": "hplToolbox.openDeviceSimulator",
"title": "HPL Toolbox: Open Device Simulator"
},
{
"command": "hplToolbox.checkForUpdates",
"title": "HPL Toolbox: Check for Updates"
},
{
"command": "hplToolbox.openProjectInit",
"title": "HPL Toolbox: Initialize Project"
} }
], ],
"viewsContainers": { "viewsContainers": {
@@ -119,6 +135,16 @@
"description": "Port for the temporary LAN HTTP server. 0 (default) = OS-assigned ephemeral port." "description": "Port for the temporary LAN HTTP server. 0 (default) = OS-assigned ephemeral port."
} }
} }
},
{
"title": "HPL Toolbox — Initialize Project",
"properties": {
"hplToolbox.projectInit.manifestUrl": {
"type": "string",
"default": "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link",
"description": "URL to a JSON manifest file listing the files to download. The manifest must be an array of { \"filename\": string, \"url\": string } objects. Can be a raw GitHub/Gitea file URL or any direct JSON URL."
}
}
} }
] ]
}, },

View File

@@ -6,7 +6,11 @@ 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';
import { openDeviceSimulator } from './tools/deviceSimulator';
import { openProjectInit } from './tools/projectInit';
import { checkForUpdates } from './updateChecker';
export function activate(context: vscode.ExtensionContext) { export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push( context.subscriptions.push(
@@ -16,9 +20,15 @@ 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.openDeviceSimulator', () => openDeviceSimulator(context)),
vscode.commands.registerCommand('hplToolbox.openProjectInit', () => openProjectInit(context)),
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)), vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
vscode.commands.registerCommand('hplToolbox.checkForUpdates', () => checkForUpdates(context)),
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context)) vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
); );
void checkForUpdates(context);
} }
export function deactivate() {} export function deactivate() {}

View File

@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled'; const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
interface ToolDefinition { interface ToolDefinition {
id?: string; id?: string;
@@ -13,6 +14,12 @@ interface ToolDefinition {
} }
const FALLBACK_TOOLS: ToolDefinition[] = [ const FALLBACK_TOOLS: ToolDefinition[] = [
{
id: 'project-init',
command: 'hplToolbox.openProjectInit',
title: 'Initialize Project',
description: 'Download AGENTS.md and .gitignore templates from Google Drive',
},
{ {
command: 'hplToolbox.openPlecUpload', command: 'hplToolbox.openPlecUpload',
title: 'PLEC Upload', title: 'PLEC Upload',
@@ -54,15 +61,20 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
const version = this.context.extension.packageJSON.version as string; const version = this.context.extension.packageJSON.version as string;
const tools = loadToolDefinitions(this.context.extensionPath); const tools = loadToolDefinitions(this.context.extensionPath);
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false); const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
view.webview.html = getHtml(version, betaToolsEnabled, tools); const toolOrder = this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []);
view.webview.html = getHtml(version, betaToolsEnabled, tools, toolOrder);
view.webview.onDidReceiveMessage(async (msg) => { view.webview.onDidReceiveMessage(async (msg) => {
if (msg?.type === 'open' && typeof msg.command === 'string') { if (msg?.type === 'open' && typeof msg.command === 'string') {
vscode.commands.executeCommand(msg.command); vscode.commands.executeCommand(msg.command);
} else if (msg?.type === 'openChangelog') { } else if (msg?.type === 'openChangelog') {
vscode.commands.executeCommand('hplToolbox.openChangelog'); vscode.commands.executeCommand('hplToolbox.openChangelog');
} else if (msg?.type === 'checkForUpdates') {
vscode.commands.executeCommand('hplToolbox.checkForUpdates');
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') { } else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled); await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
view.webview.html = getHtml(version, msg.enabled, tools); view.webview.html = getHtml(version, msg.enabled, tools, this.context.globalState.get<string[]>(TOOL_ORDER_KEY, []));
} else if (msg?.type === 'setToolOrder' && Array.isArray(msg.order)) {
await this.context.globalState.update(TOOL_ORDER_KEY, msg.order.filter((id: unknown) => typeof id === 'string'));
} }
}); });
} }
@@ -88,8 +100,8 @@ function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
} }
} }
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[]): string { function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[], toolOrder: string[]): string {
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta)); const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta), toolOrder);
const toolButtons = visibleTools.map(renderToolButton).join('\n'); const toolButtons = visibleTools.map(renderToolButton).join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
@@ -127,6 +139,9 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
} }
.tool-btn.dragging {
opacity: 0.55;
}
.tool-btn:hover { .tool-btn:hover {
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground)); background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
border-color: var(--vscode-focusBorder, var(--vscode-panel-border)); border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
@@ -197,6 +212,7 @@ function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefiniti
opacity: 0.75; opacity: 0.75;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
justify-self: center;
} }
.changelog-link { .changelog-link {
justify-self: end; justify-self: end;
@@ -210,16 +226,51 @@ ${toolButtons}
</div> </div>
<div class="footer"> <div class="footer">
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button> <button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
<span class="footer-version">${version} - JJGC 00784</span> <button id="updateCheck" class="footer-action footer-version" title="Check for updates">${version} - JJGC 00784</button>
<button id="changelogLink" class="footer-action changelog-link">Changelog</button> <button id="changelogLink" class="footer-action changelog-link">Changelog</button>
</div> </div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
document.querySelectorAll('.tool-btn').forEach((btn) => { document.querySelectorAll('.tool-btn').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
if (btn.dataset.dragging === 'true') {
btn.dataset.dragging = 'false';
return;
}
vscode.postMessage({ type: 'open', command: btn.dataset.cmd }); vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
}); });
}); });
const toolsEl = document.querySelector('.tools');
let dragged = null;
toolsEl.querySelectorAll('.tool-btn').forEach((btn) => {
btn.draggable = true;
btn.addEventListener('dragstart', (event) => {
dragged = btn;
btn.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', btn.dataset.id || '');
});
btn.addEventListener('dragend', () => {
btn.classList.remove('dragging');
btn.dataset.dragging = 'true';
dragged = null;
persistToolOrder();
setTimeout(() => { btn.dataset.dragging = 'false'; }, 0);
});
btn.addEventListener('dragover', (event) => {
event.preventDefault();
if (!dragged || dragged === btn) return;
const rect = btn.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
toolsEl.insertBefore(dragged, after ? btn.nextSibling : btn);
});
});
function persistToolOrder() {
vscode.postMessage({
type: 'setToolOrder',
order: Array.from(toolsEl.querySelectorAll('.tool-btn')).map(btn => btn.dataset.id).filter(Boolean),
});
}
document.getElementById('betaToggle').addEventListener('click', (event) => { document.getElementById('betaToggle').addEventListener('click', (event) => {
const enabled = event.currentTarget.dataset.enabled === 'true'; const enabled = event.currentTarget.dataset.enabled === 'true';
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled }); vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
@@ -227,22 +278,37 @@ ${toolButtons}
document.getElementById('changelogLink').addEventListener('click', () => { document.getElementById('changelogLink').addEventListener('click', () => {
vscode.postMessage({ type: 'openChangelog' }); vscode.postMessage({ type: 'openChangelog' });
}); });
document.getElementById('updateCheck').addEventListener('click', () => {
vscode.postMessage({ type: 'checkForUpdates' });
});
</script> </script>
</body> </body>
</html>`; </html>`;
} }
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] { function sortToolsForDisplay(tools: ToolDefinition[], toolOrder: string[]): ToolDefinition[] {
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta))); const order = new Map(toolOrder.map((id, index) => [id, index]));
return [...tools].sort((a, b) => {
const aId = toolId(a);
const bId = toolId(b);
const ai = order.has(aId) ? order.get(aId)! : Number.MAX_SAFE_INTEGER;
const bi = order.has(bId) ? order.get(bId)! : Number.MAX_SAFE_INTEGER;
if (ai !== bi) return ai - bi;
return Number(Boolean(a.beta)) - Number(Boolean(b.beta));
});
} }
function renderToolButton(tool: ToolDefinition): string { function renderToolButton(tool: ToolDefinition): string {
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}"> return ` <button class="tool-btn" data-id="${escapeHtml(toolId(tool))}" data-cmd="${escapeHtml(tool.command)}">
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span> <span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
<span class="tool-desc">${escapeHtml(tool.description)}</span> <span class="tool-desc">${escapeHtml(tool.description)}</span>
</button>`; </button>`;
} }
function toolId(tool: ToolDefinition): string {
return tool.id || tool.command;
}
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, ch => ({ return value.replace(/[&<>"']/g, ch => ({
'&': '&amp;', '&': '&amp;',

1150
src/tools/deviceSimulator.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

266
src/tools/projectInit.ts Normal file
View File

@@ -0,0 +1,266 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { singletonPanel, getToolWebviewStyles, handleClipboardAndOpen } from './shared';
interface FileEntry { filename: string; url: string; description?: string; }
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
function gdriveToDirectUrl(url: string): string {
const fileMatch = url.match(/\/file\/d\/([a-zA-Z0-9_-]+)/);
if (fileMatch) { return `https://drive.google.com/uc?export=download&id=${fileMatch[1]}`; }
const idMatch = url.match(/[?&]id=([a-zA-Z0-9_-]+)/);
if (idMatch) { return `https://drive.google.com/uc?export=download&id=${idMatch[1]}`; }
return url;
}
async function fetchManifest(url: string): Promise<FileEntry[]> {
const res = await fetch(gdriveToDirectUrl(url), { headers: { 'User-Agent': 'Mozilla/5.0' } });
if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); }
const json = await res.json() as unknown;
if (!Array.isArray(json)) { throw new Error('Manifest must be a JSON array of { filename, url } objects.'); }
return (json as FileEntry[]).filter(e => e.filename && e.url);
}
async function downloadFile(url: string, destPath: string): Promise<void> {
const directUrl = gdriveToDirectUrl(url);
const res = await fetch(directUrl, { headers: { 'User-Agent': 'Mozilla/5.0' }, redirect: 'follow' });
if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText}`); }
const contentType = res.headers.get('content-type') ?? '';
const text = await res.text();
const ext = path.extname(destPath).toLowerCase();
if (contentType.includes('text/html') && ext !== '.html' && ext !== '.htm') {
throw new Error(
'Got an HTML page instead of a file — the Google Drive link may require sign-in or a download confirmation. Make sure the file is set to "Anyone with the link can view".'
);
}
await fs.promises.writeFile(destPath, text, 'utf8');
}
function getHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
${getToolWebviewStyles()}
</style>
</head>
<body>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Initialize Project</h2>
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Files</h3>
<div style="display:flex;gap:var(--tool-gap-xs);align-items:center;">
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:var(--tool-gap-xs);"></span>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">&#8635;</button>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="openManifestSettings()">&#9881;</button>
</div>
</div>
<div class="panel-body">
<div class="control-group">
<div id="fileList"></div>
</div>
<div class="control-group">
<p class="control-label">Download folder</p>
<div class="field-row">
<input type="text" id="destFolder" value="" placeholder="(workspace root)" readonly />
<button class="secondary" onclick="pickFolder()">Browse…</button>
</div>
</div>
<div class="action-row">
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
</main>
<script>
const vscode = acquireVsCodeApi();
const savedState = vscode.getState() || {};
document.getElementById('destFolder').value = savedState.destFolder || '';
let files = [];
let allSelected = true;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function refresh() {
setStatus('', '');
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest…</p>';
document.getElementById('initBtn').disabled = true;
document.getElementById('selectAllBtn').style.display = 'none';
vscode.postMessage({ type: 'fetchManifest' });
}
function openManifestSettings() { vscode.postMessage({ type: 'openSettings' }); }
function pickFolder() { vscode.postMessage({ type: 'pickFolder' }); }
function initProject() {
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
const destFolder = document.getElementById('destFolder').value.trim();
const el = document.getElementById('status');
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
el.className = 'status-panel is-busy';
vscode.postMessage({ type: 'init', files: checked, destFolder });
}
function toggleSelectAll() {
allSelected = !allSelected;
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
}
function renderFiles(list, manifestUrl) {
files = list;
allSelected = true;
document.getElementById('manifestSource').textContent =
manifestUrl ? '(' + manifestUrl.replace(/^https?:\\/\\//, '').split('/')[0] + ')' : '';
if (!list.length) {
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
document.getElementById('initBtn').disabled = true;
document.getElementById('selectAllBtn').style.display = 'none';
return;
}
const rows = list.map((f, i) =>
'<tr>' +
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" data-url="' + escapeHtml(f.url) + '" onclick="openUrl(this)">Open</button></td>' +
'</tr>'
).join('');
document.getElementById('fileList').innerHTML =
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
'<table class="data-table">' +
'<thead><tr>' +
'<th style="width:20px;"></th>' +
'<th style="width:20%;">Filename</th>' +
'<th style="width:70%;">Description</th>' +
'<th style="width:10%;"></th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>';
document.getElementById('initBtn').disabled = false;
document.getElementById('selectAllBtn').style.display = '';
document.getElementById('selectAllBtn').textContent = 'Deselect all';
}
function openUrl(btn) {
vscode.postMessage({ type: 'open', text: btn.dataset.url });
}
window.addEventListener('message', e => {
const msg = e.data;
if (msg.type === 'manifest') { renderFiles(msg.files, msg.manifestUrl); setStatus('', ''); }
if (msg.type === 'manifestError') {
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg.text) + '</p>';
document.getElementById('initBtn').disabled = true;
document.getElementById('selectAllBtn').style.display = 'none';
}
if (msg.type === 'folder') {
document.getElementById('destFolder').value = msg.path;
const s = vscode.getState() || {};
s.destFolder = msg.path;
vscode.setState(s);
}
if (msg.type === 'result') { setStatus(msg.text, msg.ok ? 'ok' : 'err'); }
});
function setStatus(text, cls) {
const el = document.getElementById('status');
el.textContent = text;
el.className = 'status-panel' + (cls ? ' ' + cls : '');
}
refresh();
</script>
</body>
</html>`;
}
export function openProjectInit(context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.projectInit', 'Initialize Project');
if (!isNew) { return; }
panel.webview.html = getHtml();
async function sendManifest() {
const manifestUrl = vscode.workspace.getConfiguration('hplToolbox.projectInit').get<string>('manifestUrl', '');
if (!manifestUrl) {
panel.webview.postMessage({
type: 'manifestError',
text: 'No manifest URL configured. Click ⚙ to set it in Settings.',
});
return;
}
try {
const files = await fetchManifest(manifestUrl);
panel.webview.postMessage({ type: 'manifest', files, manifestUrl });
} catch (err: any) {
panel.webview.postMessage({ type: 'manifestError', text: `Failed to load manifest: ${err.message}` });
}
}
panel.webview.onDidReceiveMessage(async (msg) => {
if (handleClipboardAndOpen(msg)) { return; }
if (msg.type === 'fetchManifest') {
await sendManifest();
}
if (msg.type === 'openSettings') {
vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.projectInit.manifestUrl');
}
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Destination Folder',
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
});
if (picked?.[0]) {
panel.webview.postMessage({ type: 'folder', path: picked[0].fsPath });
}
}
if (msg.type === 'init') {
const files: FileEntry[] = msg.files;
const dest: string = msg.destFolder || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
if (!dest) {
panel.webview.postMessage({ type: 'result', ok: false, text: 'No destination folder selected and no workspace is open.' });
return;
}
const errors: string[] = [];
for (const f of files) {
try {
await downloadFile(f.url, path.join(dest, f.filename));
} catch (err: any) {
errors.push(`${f.filename}: ${err.message}`);
}
}
if (errors.length) {
panel.webview.postMessage({ type: 'result', ok: false, text: `${files.length - errors.length}/${files.length} downloaded.\n\nErrors:\n${errors.join('\n')}` });
} else {
panel.webview.postMessage({ type: 'result', ok: true, text: `Done! ${files.length} file(s) downloaded to: ${dest}` });
}
}
}, undefined, context.subscriptions);
}

View File

@@ -234,7 +234,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
'Content-Type': 'text/html; charset=utf-8', 'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
}); });
res.end(file.buf); res.end(mobilePreviewPage(file));
return; return;
} }
@@ -296,6 +296,35 @@ function chooserPage(files: SharedFile[]): string {
</body></html>`; </body></html>`;
} }
function mobilePreviewPage(file: SharedFile): string {
const playableJson = JSON.stringify(file.buf.toString('utf8')).replace(/<\/script/gi, '<\\/script');
const title = file.filename.replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string)
);
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>${title}</title>
<style>
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
</style>
</head><body>
<div id="boot" class="boot">Loading preview...</div>
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
<script>
const playableHtml = ${playableJson};
setTimeout(() => {
const frame = document.getElementById('playable');
document.getElementById('boot').style.display = 'none';
frame.srcdoc = playableHtml;
}, 750);
</script>
</body></html>`;
}
interface LanIp { address: string; iface: string; } interface LanIp { address: string; iface: string; }
function getLanIps(): LanIp[] { function getLanIps(): LanIp[] {

80
src/updateChecker.ts Normal file
View File

@@ -0,0 +1,80 @@
import * as vscode from 'vscode';
const REMOTE_PACKAGE_JSON_URL = 'https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox/raw/branch/main/package.json';
interface RemotePackageJson {
version?: unknown;
}
export async function checkForUpdates(context: vscode.ExtensionContext): Promise<void> {
const currentVersion = String(context.extension.packageJSON.version ?? '').trim();
if (!currentVersion) {
vscode.window.showWarningMessage('HPL Toolbox could not read the installed version.');
return;
}
try {
const remoteVersion = await fetchRemoteVersion();
const comparison = compareVersions(remoteVersion, currentVersion);
if (comparison > 0) {
const action = await vscode.window.showInformationMessage(
`HPL Toolbox update available: ${remoteVersion} (installed: ${currentVersion}).`,
'Open Repository'
);
if (action === 'Open Repository') {
await vscode.env.openExternal(vscode.Uri.parse('https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox'));
}
return;
}
vscode.window.showInformationMessage(`HPL Toolbox is up to date (${currentVersion}).`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
vscode.window.showWarningMessage(`HPL Toolbox update check failed: ${message}`);
}
}
async function fetchRemoteVersion(): Promise<string> {
const response = await fetch(REMOTE_PACKAGE_JSON_URL, {
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`remote package.json returned HTTP ${response.status}`);
}
const packageJson = await response.json() as RemotePackageJson;
if (typeof packageJson.version !== 'string' || !packageJson.version.trim()) {
throw new Error('remote package.json is missing a version');
}
return packageJson.version.trim();
}
function compareVersions(left: string, right: string): number {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
}
function parseVersion(version: string): number[] {
return version
.split(/[.-]/)
.map(part => Number.parseInt(part, 10))
.filter(part => Number.isFinite(part));
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
if n.Beta { if n.Beta {
badge = ` <span class="beta-pill">Beta</span>` badge = ` <span class="beta-pill">Beta</span>`
} }
items.WriteString(`<button type="button" class="home-tool" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`) items.WriteString(`<button type="button" class="home-tool" draggable="true" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
} }
body := ` body := `
@@ -43,6 +43,7 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
font:inherit; font:inherit;
} }
.home-tool:hover { background:#33373a; border-color:#007fd4; } .home-tool:hover { background:#33373a; border-color:#007fd4; }
.home-tool.dragging { opacity:0.55; }
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; } .home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; } .home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
</style> </style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

View File

@@ -25,30 +25,111 @@ 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; }
.sidebar .nav-link.dragging { opacity: 0.55; }
.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 +159,77 @@ 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; }
.app-footer-version-button { justify-self: center; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-version-button:hover { background: transparent; opacity: 1; text-decoration: underline; }
.toast-box {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 50;
max-width: min(420px, calc(100vw - 36px));
padding: 10px 12px;
display: none;
align-items: center;
gap: 10px;
color: #ddd;
background: #252526;
border: 1px solid #444;
border-left: 3px solid #007fd4;
border-radius: 5px;
box-shadow: 0 10px 28px rgba(0,0,0,0.35);
font-size: 12px;
line-height: 1.35;
}
.toast-box.is-visible { display: flex; }
.toast-box.is-update { border-left-color: #ffd580; }
.toast-box.is-current { border-left-color: #89d185; }
.toast-box.is-error { border-left-color: #f48771; }
.toast-message { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.toast-action {
flex: 0 0 auto;
padding: 4px 8px;
background: #0e639c;
color: #fff;
border: 0;
border-radius: 2px;
font-size: 12px;
}
.toast-close {
flex: 0 0 auto;
width: 22px;
height: 22px;
padding: 0;
display: grid;
place-items: center;
background: transparent;
color: #a7a7a7;
border: 0;
font-size: 16px;
line-height: 1;
}
.toast-close:hover { background: rgba(255,255,255,0.08); color: #fff; }
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; }
@@ -324,12 +476,15 @@ type navItem struct {
var navItems = []navItem{ var navItems = []navItem{
{Path: "/", Label: "Home"}, {Path: "/", Label: "Home"},
{Path: "/project-init", Label: "Initialize Project", Description: "Download AGENTS.md and .gitignore templates from Google Drive"},
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"}, {Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"}, {Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, {Path: "/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},
{Path: "/device-simulator", Label: "Device Simulator", Description: "Preview HTML playables inside mobile device frames"},
} }
func visibleNavItems(betaToolsEnabled bool) []navItem { func visibleNavItems(betaToolsEnabled bool) []navItem {
@@ -365,6 +520,38 @@ func betaToolCount() int {
return count return count
} }
func navInitials(label string) string {
switch label {
case "Initialize Project":
return "IP"
case "Base64 Scanner":
return "B64"
case "AppLovin Playable Preview":
return "APP"
case "Mintegral Checker":
return "MiC"
case "Device Simulator":
return "DS"
}
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 +565,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 + `" draggable="` + boolAttr(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,20 +580,102 @@ 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="content">` + body + `</div> <div class="sidebar-header">
<div class="app-footer"> <span class="sidebar-title">HPL Toolbox</span>
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button> <button id="sidebarToggle" class="sidebar-toggle" title="Toggle sidebar" aria-label="Toggle sidebar">☰</button>
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span> </div>
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button> <nav class="sidebar-nav">` + tabs.String() + `</nav>
</aside>
<div class="app-shell">
<div class="content">` + body + `</div>
<div class="app-footer">
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
<button id="updateCheck" type="button" class="app-footer-version-button" title="Check for updates">` + AppVersion + ` - JJGC 00784</button>
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
</div>
</div>
<div id="toastBox" class="toast-box" role="status" aria-live="polite">
<span id="toastMessage" class="toast-message"></span>
<button id="toastAction" type="button" class="toast-action is-hidden">Open Repository</button>
<button id="toastClose" type="button" class="toast-close" aria-label="Close">x</button>
</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', () => {
if (element.dataset.dragging === 'true') {
element.dataset.dragging = 'false';
return;
}
const path = element.dataset.path; const path = element.dataset.path;
if (path) window.location.href = path; if (path) window.location.href = path;
}); });
}); });
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
function getToolOrder() {
try {
const parsed = JSON.parse(localStorage.getItem(TOOL_ORDER_KEY) || '[]');
return Array.isArray(parsed) ? parsed.filter(path => typeof path === 'string') : [];
} catch {
return [];
}
}
function setToolOrder(paths) {
localStorage.setItem(TOOL_ORDER_KEY, JSON.stringify(paths));
}
function applyStoredToolOrder(container, selector) {
if (!container) return;
const order = getToolOrder();
if (!order.length) return;
const elements = Array.from(container.querySelectorAll(selector));
const byPath = new Map(elements.map(el => [el.dataset.path, el]));
const home = byPath.get('/');
const ordered = order.map(path => byPath.get(path)).filter(Boolean);
const orderedSet = new Set(ordered);
const remaining = elements.filter(el => el !== home && !orderedSet.has(el));
if (home) container.appendChild(home);
ordered.concat(remaining).forEach(el => container.appendChild(el));
}
function setupToolReorder(container, selector) {
if (!container) return;
applyStoredToolOrder(container, selector);
let dragged = null;
container.querySelectorAll(selector).forEach(element => {
if (element.dataset.path === '/') return;
element.addEventListener('dragstart', event => {
dragged = element;
element.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', element.dataset.path || '');
});
element.addEventListener('dragend', () => {
element.classList.remove('dragging');
element.dataset.dragging = 'true';
dragged = null;
const paths = Array.from(container.querySelectorAll(selector))
.map(el => el.dataset.path)
.filter(path => path && path !== '/');
setToolOrder(paths);
setTimeout(() => { element.dataset.dragging = 'false'; }, 0);
});
element.addEventListener('dragover', event => {
event.preventDefault();
if (!dragged || dragged === element || element.dataset.path === '/') return;
const rect = element.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
container.insertBefore(dragged, after ? element.nextSibling : element);
});
});
}
setupToolReorder(document.querySelector('.sidebar-nav'), '.nav-link');
setupToolReorder(document.querySelector('.home-tools'), '.home-tool');
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => { document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
const enabled = event.currentTarget.dataset.enabled === 'true'; const enabled = event.currentTarget.dataset.enabled === 'true';
await fetch('/api/betaTools', { await fetch('/api/betaTools', {
@@ -416,6 +685,51 @@ document.getElementById('betaToolsToggle').addEventListener('click', async (even
}); });
window.location.reload(); window.location.reload();
}); });
const toastBox = document.getElementById('toastBox');
const toastMessage = document.getElementById('toastMessage');
const toastAction = document.getElementById('toastAction');
const toastClose = document.getElementById('toastClose');
let toastTimer = null;
let updateRepositoryUrl = '';
function showToast(message, status, repositoryUrl) {
window.clearTimeout(toastTimer);
updateRepositoryUrl = repositoryUrl || '';
toastMessage.textContent = message || '';
toastAction.classList.toggle('is-hidden', !updateRepositoryUrl);
toastBox.className = 'toast-box is-visible is-' + (status || 'current');
toastTimer = window.setTimeout(() => {
toastBox.classList.remove('is-visible');
}, updateRepositoryUrl ? 9000 : 5000);
}
async function checkForUpdates(showCurrent) {
try {
const response = await fetch('/api/update/check');
const result = await response.json();
if (result.status === 'current' && !showCurrent) return;
showToast(result.message || 'HPL Toolbox update check finished.', result.status, result.repositoryUrl);
} catch (error) {
showToast('HPL Toolbox update check failed: ' + (error.message || error), 'error');
}
}
document.getElementById('updateCheck').addEventListener('click', () => {
checkForUpdates(true);
});
toastClose.addEventListener('click', () => {
window.clearTimeout(toastTimer);
toastBox.classList.remove('is-visible');
});
toastAction.addEventListener('click', async () => {
if (!updateRepositoryUrl) return;
await fetch('/api/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: updateRepositoryUrl }),
});
});
if (sessionStorage.getItem('hplToolboxUpdateChecked') !== 'true') {
sessionStorage.setItem('hplToolboxUpdateChecked', 'true');
checkForUpdates(true);
}
</script> </script>
</body></html>` </body></html>`
} }

View File

@@ -107,20 +107,30 @@ func buildMux() *http.ServeMux {
} }
HomePage(w, r) HomePage(w, r)
}) })
mux.HandleFunc("GET /project-init", ProjectInitPage)
mux.HandleFunc("GET /plec", PlecPage) mux.HandleFunc("GET /plec", PlecPage)
mux.HandleFunc("GET /applovin", ApplovinPage) mux.HandleFunc("GET /applovin", ApplovinPage)
mux.HandleFunc("GET /base64", Base64Page) mux.HandleFunc("GET /base64", Base64Page)
mux.HandleFunc("GET /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 /device-simulator", DeviceSimulatorPage)
mux.HandleFunc("GET /changelog", ChangelogPage) mux.HandleFunc("GET /changelog", ChangelogPage)
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript) mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
// Initialize Project
mux.HandleFunc("GET /api/project-init/manifest", ProjectInitManifestEndpoint)
mux.HandleFunc("POST /api/project-init/pickFolder", ProjectInitPickFolderEndpoint)
mux.HandleFunc("POST /api/project-init/setManifestUrl", ProjectInitSetManifestURLEndpoint)
mux.HandleFunc("POST /api/project-init/download", ProjectInitDownloadEndpoint)
// Shared API // Shared API
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint) mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
mux.HandleFunc("POST /api/open", OpenEndpoint) mux.HandleFunc("POST /api/open", OpenEndpoint)
mux.HandleFunc("POST /api/focus", FocusEndpoint) mux.HandleFunc("POST /api/focus", FocusEndpoint)
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint) mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
mux.HandleFunc("GET /api/update/check", UpdateCheckEndpoint)
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint) mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
// PLEC // PLEC
@@ -145,6 +155,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)
@@ -156,6 +174,11 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput) mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert) mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
// Device Simulator
mux.HandleFunc("POST /api/device-simulator/pick", DeviceSimulatorPick)
mux.HandleFunc("POST /api/device-simulator/load", DeviceSimulatorLoad)
mux.HandleFunc("GET /api/device-simulator/preview/", DeviceSimulatorPreview)
return mux return mux
} }

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

@@ -457,7 +457,7 @@ func shareHandler(s *activeShare) http.Handler {
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.files[0].buf) _, _ = w.Write([]byte(mobilePreviewPage(s.files[0])))
case "/file": case "/file":
fallthrough fallthrough
case "/file/0": case "/file/0":
@@ -481,7 +481,7 @@ func shareHandler(s *activeShare) http.Handler {
if parts[0] == "view" { if parts[0] == "view" {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf) _, _ = w.Write([]byte(mobilePreviewPage(file)))
return return
} }
safeName := safeFilename(file.filename) safeName := safeFilename(file.filename)
@@ -499,6 +499,35 @@ func shareHandler(s *activeShare) http.Handler {
}) })
} }
func mobilePreviewPage(file sharedFile) string {
playableJSON, _ := json.Marshal(string(file.buf))
playableScriptValue := strings.ReplaceAll(string(playableJSON), "</script", "<\\/script")
playableScriptValue = strings.ReplaceAll(playableScriptValue, "</SCRIPT", "<\\/SCRIPT")
title := html.EscapeString(file.filename)
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>` + title + `</title>
<style>
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #000; }
iframe { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
.boot { position: fixed; inset: 0; display: grid; place-items: center; color: #777; font: 12px system-ui, sans-serif; }
</style>
</head><body>
<div id="boot" class="boot">Loading preview...</div>
<iframe id="playable" sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
<script>
const playableHtml = ` + playableScriptValue + `;
setTimeout(() => {
const frame = document.getElementById('playable');
document.getElementById('boot').style.display = 'none';
frame.srcdoc = playableHtml;
}, 750);
</script>
</body></html>`
}
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`) var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") } func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }

View File

@@ -29,11 +29,12 @@ type SendToMobileConfig struct {
} }
type AppConfig struct { type AppConfig struct {
Plec PlecConfig `json:"plec"` Plec PlecConfig `json:"plec"`
Applovin ApplovinConfig `json:"applovin"` Applovin ApplovinConfig `json:"applovin"`
SendToMobile SendToMobileConfig `json:"sendToMobile"` SendToMobile SendToMobileConfig `json:"sendToMobile"`
BetaToolsEnabled bool `json:"betaToolsEnabled"` BetaToolsEnabled bool `json:"betaToolsEnabled"`
LastPickDir string `json:"lastPickDir,omitempty"` LastPickDir string `json:"lastPickDir,omitempty"`
ProjectInitManifestURL string `json:"projectInitManifestUrl,omitempty"`
} }
var configMu sync.Mutex var configMu sync.Mutex

354
standalone/project_init.go Normal file
View File

@@ -0,0 +1,354 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
const defaultManifestURL = "https://drive.google.com/file/d/1lRmHIy_0nXEy_LTwn0NzIqoJ4s9_JmxO/view?usp=drive_link"
type ManifestEntry struct {
Filename string `json:"filename"`
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
var reGDriveFileID = regexp.MustCompile(`/file/d/([a-zA-Z0-9_-]+)`)
var reGDriveIDParam = regexp.MustCompile(`[?&]id=([a-zA-Z0-9_-]+)`)
func gdriveToDirectURL(rawURL string) string {
if m := reGDriveFileID.FindStringSubmatch(rawURL); m != nil {
return "https://drive.google.com/uc?export=download&id=" + m[1]
}
if m := reGDriveIDParam.FindStringSubmatch(rawURL); m != nil {
return "https://drive.google.com/uc?export=download&id=" + m[1]
}
return rawURL
}
func fetchManifest(manifestURL string) ([]ManifestEntry, error) {
req, err := http.NewRequest("GET", gdriveToDirectURL(manifestURL), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
}
var entries []ManifestEntry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
}
filtered := entries[:0]
for _, e := range entries {
if e.Filename != "" && e.URL != "" {
filtered = append(filtered, e)
}
}
return filtered, nil
}
func downloadManifestFile(rawURL, destPath string) error {
req, err := http.NewRequest("GET", gdriveToDirectURL(rawURL), nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ct := resp.Header.Get("Content-Type")
ext := strings.ToLower(filepath.Ext(destPath))
if strings.Contains(ct, "text/html") && ext != ".html" && ext != ".htm" {
return fmt.Errorf("got an HTML page instead of a file — make sure the Google Drive file is set to \"Anyone with the link can view\"")
}
return os.WriteFile(destPath, body, 0644)
}
func getManifestURL() string {
cfg := LoadConfig()
if cfg.ProjectInitManifestURL != "" {
return cfg.ProjectInitManifestURL
}
return defaultManifestURL
}
func ProjectInitPage(w http.ResponseWriter, r *http.Request) {
manifestURL := getManifestURL()
body := `
<header class="tool-header">
<h2 class="tool-title">Initialize Project</h2>
<p class="tool-description">Fetches the shared file list from your team manifest and downloads selected files into the chosen folder.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;">
<h3 class="panel-title">Files</h3>
<div style="display:flex;gap:4px;align-items:center;">
<span id="manifestSource" class="muted" style="font-size:11px;margin-right:4px;"></span>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Refresh manifest" onclick="refresh()">&#8635;</button>
<button class="secondary" style="min-height:22px;width:26px;padding:0;font-size:14px;" title="Configure manifest URL" onclick="toggleSettings()">&#9881;</button>
</div>
</div>
<div class="panel-body">
<div id="settingsGroup" class="control-group" style="display:none;">
<p class="control-label">Manifest URL</p>
<div class="field-row">
<input type="text" id="manifestUrlInput" value="` + htmlEscape(manifestURL) + `" style="flex:1;" placeholder="https://…" />
<button onclick="saveManifestUrl()">Save &amp; Refresh</button>
</div>
</div>
<div class="control-group">
<div id="fileList"></div>
</div>
<div class="control-group">
<p class="control-label">Download folder</p>
<div class="field-row">
<input type="text" id="destFolder" value="" placeholder="(no folder selected)" readonly style="flex:1;" />
<button class="secondary" onclick="pickFolder()">Browse&#8230;</button>
</div>
</div>
<div class="action-row">
<button id="initBtn" onclick="initProject()" disabled>Initialize Project</button>
<button id="selectAllBtn" class="secondary" onclick="toggleSelectAll()" style="display:none;">Deselect all</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<script>
let files = [];
let allSelected = true;
let settingsVisible = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function toggleSettings() {
settingsVisible = !settingsVisible;
document.getElementById('settingsGroup').style.display = settingsVisible ? '' : 'none';
}
async function saveManifestUrl() {
const url = document.getElementById('manifestUrlInput').value.trim();
await fetch('/api/project-init/setManifestUrl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
settingsVisible = false;
document.getElementById('settingsGroup').style.display = 'none';
refresh();
}
async function refresh() {
setStatus('', '');
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Loading manifest&#8230;</p>';
document.getElementById('initBtn').disabled = true;
document.getElementById('selectAllBtn').style.display = 'none';
try {
const r = await fetch('/api/project-init/manifest');
const j = await r.json();
if (j.error) { renderError(j.error); return; }
renderFiles(j.files || [], j.manifestUrl || '');
} catch (e) {
renderError('Failed to load manifest: ' + (e.message || e));
}
}
function renderError(msg) {
document.getElementById('fileList').innerHTML = '<p class="err" style="margin:0;">' + escapeHtml(msg) + '</p>';
document.getElementById('initBtn').disabled = true;
document.getElementById('selectAllBtn').style.display = 'none';
document.getElementById('manifestSource').textContent = '';
}
function renderFiles(list, manifestUrl) {
files = list;
allSelected = true;
document.getElementById('manifestSource').textContent =
manifestUrl ? '(' + manifestUrl.replace(/^https?:\/\//, '').split('/')[0] + ')' : '';
if (!list.length) {
document.getElementById('fileList').innerHTML = '<p class="muted" style="margin:0;">Manifest loaded but contains no files.</p>';
document.getElementById('initBtn').disabled = true;
document.getElementById('selectAllBtn').style.display = 'none';
return;
}
const rows = list.map((f, i) =>
'<tr>' +
'<td style="width:20px;text-align:center;"><input type="checkbox" id="cb_' + i + '" checked /></td>' +
'<td class="mono wrap" style="width:20%;">' + escapeHtml(f.filename) + '</td>' +
'<td class="muted wrap" style="width:70%;">' + escapeHtml(f.description || '') + '</td>' +
'<td style="width:10%;text-align:right;"><button style="min-height:22px;padding:2px 8px;font-size:11px;" onclick="openUrl(' + JSON.stringify(f.url) + ')">Open</button></td>' +
'</tr>'
).join('');
document.getElementById('fileList').innerHTML =
'<div class="results-panel" style="margin-top:0;overflow-x:hidden;">' +
'<table class="data-table">' +
'<thead><tr><th style="width:20px;"></th><th style="width:20%;">Filename</th><th style="width:70%;">Description</th><th style="width:10%;"></th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>';
document.getElementById('initBtn').disabled = false;
document.getElementById('selectAllBtn').style.display = '';
document.getElementById('selectAllBtn').textContent = 'Deselect all';
}
function toggleSelectAll() {
allSelected = !allSelected;
files.forEach((_, i) => { const cb = document.getElementById('cb_' + i); if (cb) cb.checked = allSelected; });
document.getElementById('selectAllBtn').textContent = allSelected ? 'Deselect all' : 'Select all';
}
async function pickFolder() {
const r = await fetch('/api/project-init/pickFolder', { method: 'POST' });
const j = await r.json();
if (j.path) document.getElementById('destFolder').value = j.path;
}
async function openUrl(url) {
await fetch('/api/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
}
async function initProject() {
const checked = files.filter((_, i) => document.getElementById('cb_' + i)?.checked);
if (!checked.length) { setStatus('No files selected.', 'err'); return; }
const destFolder = document.getElementById('destFolder').value.trim();
if (!destFolder) { setStatus('Please select a destination folder first.', 'err'); return; }
const el = document.getElementById('status');
el.textContent = 'Downloading ' + checked.length + ' file(s)…';
el.className = 'status-panel is-busy';
try {
const r = await fetch('/api/project-init/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: checked, destFolder }),
});
const j = await r.json();
setStatus(j.message, j.ok ? 'ok' : 'err');
} catch (e) {
setStatus('Error: ' + (e.message || e), 'err');
}
}
function setStatus(text, cls) {
const el = document.getElementById('status');
el.textContent = text;
el.className = 'status-panel' + (cls ? ' ' + cls : '');
}
refresh();
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/project-init", "Initialize Project", body)))
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
func ProjectInitManifestEndpoint(w http.ResponseWriter, r *http.Request) {
manifestURL := getManifestURL()
entries, err := fetchManifest(manifestURL)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, map[string]any{"files": entries, "manifestUrl": manifestURL})
}
func ProjectInitPickFolderEndpoint(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
folder := PickFolder("Select Destination Folder", cfg.LastPickDir)
if folder == "" {
writeJSON(w, map[string]any{"path": ""})
return
}
cfg.LastPickDir = folder
_ = SaveConfig(cfg)
writeJSON(w, map[string]any{"path": folder})
}
func ProjectInitSetManifestURLEndpoint(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
}
cfg := LoadConfig()
cfg.ProjectInitManifestURL = strings.TrimSpace(req.URL)
if err := SaveConfig(cfg); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true})
}
func ProjectInitDownloadEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
Files []ManifestEntry `json:"files"`
DestFolder string `json:"destFolder"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "message": "Invalid request: " + err.Error()})
return
}
if req.DestFolder == "" {
writeJSON(w, map[string]any{"ok": false, "message": "No destination folder specified."})
return
}
var errs []string
for _, f := range req.Files {
if err := downloadManifestFile(f.URL, filepath.Join(req.DestFolder, f.Filename)); err != nil {
errs = append(errs, f.Filename+": "+err.Error())
}
}
total := len(req.Files)
if len(errs) > 0 {
writeJSON(w, map[string]any{
"ok": false,
"message": fmt.Sprintf("%d/%d downloaded.\n\nErrors:\n%s", total-len(errs), total, strings.Join(errs, "\n")),
})
} else {
writeJSON(w, map[string]any{
"ok": true,
"message": fmt.Sprintf("Done! %d file%s downloaded to: %s", total, pluralS(total), req.DestFolder),
})
}
}

Binary file not shown.

View File

@@ -0,0 +1,127 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const remotePackageJSONURL = "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox/raw/branch/main/package.json"
const updateRepositoryURL = "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox"
type updateCheckResponse struct {
OK bool `json:"ok"`
Status string `json:"status"`
CurrentVersion string `json:"currentVersion"`
RemoteVersion string `json:"remoteVersion,omitempty"`
Message string `json:"message"`
RepositoryURL string `json:"repositoryUrl,omitempty"`
}
func UpdateCheckEndpoint(w http.ResponseWriter, r *http.Request) {
remoteVersion, err := fetchRemoteVersion()
if err != nil {
writeJSON(w, updateCheckResponse{
OK: false,
Status: "error",
CurrentVersion: AppVersion,
Message: "HPL Toolbox update check failed: " + err.Error(),
})
return
}
comparison := compareVersions(remoteVersion, AppVersion)
if comparison > 0 {
writeJSON(w, updateCheckResponse{
OK: true,
Status: "update",
CurrentVersion: AppVersion,
RemoteVersion: remoteVersion,
Message: fmt.Sprintf("HPL Toolbox update available: %s (installed: %s).", remoteVersion, AppVersion),
RepositoryURL: updateRepositoryURL,
})
return
}
writeJSON(w, updateCheckResponse{
OK: true,
Status: "current",
CurrentVersion: AppVersion,
RemoteVersion: remoteVersion,
Message: fmt.Sprintf("HPL Toolbox is up to date (%s).", AppVersion),
})
}
func fetchRemoteVersion() (string, error) {
client := http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, remotePackageJSONURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("remote package.json returned HTTP %d", resp.StatusCode)
}
var packageJSON struct {
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&packageJSON); err != nil {
return "", err
}
if strings.TrimSpace(packageJSON.Version) == "" {
return "", fmt.Errorf("remote package.json is missing a version")
}
return strings.TrimSpace(packageJSON.Version), nil
}
func compareVersions(left, right string) int {
leftParts := parseVersionParts(left)
rightParts := parseVersionParts(right)
length := len(leftParts)
if len(rightParts) > length {
length = len(rightParts)
}
for i := 0; i < length; i++ {
leftPart := 0
rightPart := 0
if i < len(leftParts) {
leftPart = leftParts[i]
}
if i < len(rightParts) {
rightPart = rightParts[i]
}
if leftPart > rightPart {
return 1
}
if leftPart < rightPart {
return -1
}
}
return 0
}
func parseVersionParts(version string) []int {
fields := strings.FieldsFunc(version, func(r rune) bool {
return r == '.' || r == '-'
})
parts := make([]int, 0, len(fields))
for _, field := range fields {
part, err := strconv.Atoi(field)
if err == nil {
parts = append(parts, part)
}
}
return parts
}

View File

@@ -1,4 +1,12 @@
[ [
{
"id": "project-init",
"title": "Initialize Project",
"description": "Download AGENTS.md and .gitignore templates from Google Drive",
"command": "hplToolbox.openProjectInit",
"path": "/project-init",
"beta": false
},
{ {
"id": "plec", "id": "plec",
"title": "PLEC Upload", "title": "PLEC Upload",
@@ -46,5 +54,21 @@
"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
},
{
"id": "device-simulator",
"title": "Device Simulator",
"description": "Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays",
"command": "hplToolbox.openDeviceSimulator",
"path": "/device-simulator",
"beta": false
},
] ]