6 Commits

Author SHA1 Message Date
c7ad1e14ca mraidChecker 2026-05-27 18:13:51 +08:00
6be55255b2 update 2026-05-23 16:57:24 +08:00
9d6ed4b0a5 updated 2026-05-23 16:45:23 +08:00
1336695068 fix 2026-05-22 14:36:34 +08:00
40f13989c2 build script update 2026-05-14 14:38:22 +08:00
716dac31ef update applovin demo upload 2026-05-14 14:33:44 +08:00
18 changed files with 1239 additions and 103 deletions

View File

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

4
LICENSE Normal file
View File

@@ -0,0 +1,4 @@
Copyright (c) 2026 HPL
All rights reserved. This software is proprietary and confidential.
Unauthorized copying, distribution, or use is prohibited.

View File

@@ -25,8 +25,14 @@ if (-not $Extension -and -not $Standalone) {
}
$distDir = Join-Path $repoRoot "dist"
if (Test-Path $distDir) {
Remove-Item -Path (Join-Path $distDir "*") -Recurse -Force
}
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
$version = $pkg.version
function Write-Step($msg) {
Write-Host ""
Write-Host "==> $msg" -ForegroundColor Cyan
@@ -104,13 +110,14 @@ if ($Standalone) {
}
}
$exeOut = Join-Path $distDir "hpl-toolbox.exe"
go build -ldflags="-s -w -H windowsgui" -trimpath -o $exeOut .
$exeName = "hpl-toolbox-$version.exe"
$exeOut = Join-Path $distDir $exeName
go build -ldflags="-s -w -H windowsgui -X main.AppVersion=$version" -trimpath -o $exeOut .
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
$summary += [pscustomobject]@{
Artifact = "hpl-toolbox.exe"
Path = "dist\hpl-toolbox.exe"
Artifact = $exeName
Path = "dist\$exeName"
Size = Get-FileSize $exeOut
}
} finally {

View File

@@ -1,9 +1,14 @@
{
"name": "hpl-toolbox",
"displayName": "HPL Toolbox",
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, Daily Update. Local HTML Host.",
"version": "0.1.2",
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker, Daily Update. Local HTML Host.",
"version": "0.1.5",
"publisher": "hesukastro",
"license": "UNLICENSED",
"repository": {
"type": "git",
"url": "https://gitea.hesukastro.com/hesukastro/vsix-hpl-toolbox.git"
},
"engines": {
"vscode": "^1.85.0"
},
@@ -25,6 +30,10 @@
"command": "hplToolbox.openBase64Scanner",
"title": "HPL Toolbox: Open Base64 Scanner"
},
{
"command": "hplToolbox.openMraidChecker",
"title": "HPL Toolbox: Open MRAID Checker"
},
{
"command": "hplToolbox.openDailyUpdate",
"title": "HPL Toolbox: Open Daily Update"
@@ -85,6 +94,17 @@
"type": "string",
"default": "",
"description": "Value of the __Host-APPLOVINID cookie for p.applov.in (log in at https://p.applov.in/playablePreview?create=1&qr=1 and copy the cookie from DevTools)."
},
"hplToolbox.applovin.delayMs": {
"type": "number",
"default": 5000,
"minimum": 0,
"description": "Delay (in milliseconds) between consecutive file uploads, to avoid AppLovin rate limiting / 403 responses."
},
"hplToolbox.applovin.randomizeUserAgent": {
"type": "boolean",
"default": true,
"description": "Randomize the User-Agent header on every request to p.applov.in to reduce bot detection."
}
}
},

View File

@@ -3,6 +3,7 @@ import { LauncherViewProvider } from './launcherView';
import { openPlecUpload } from './tools/plecUpload';
import { openApplovinUpload } from './tools/applovinUpload';
import { openBase64Scanner } from './tools/base64Scanner';
import { openMraidChecker } from './tools/mraidChecker';
import { openDailyUpdate } from './tools/dailyUpdate';
import { openSendToMobile } from './tools/sendToMobile';
@@ -11,9 +12,10 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)),
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider())
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
);
}

View File

@@ -1,25 +1,86 @@
import * as vscode from 'vscode';
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
interface ToolDefinition {
command: string;
title: string;
description: string;
beta?: boolean;
}
const TOOLS: ToolDefinition[] = [
{
command: 'hplToolbox.openPlecUpload',
title: 'PLEC Upload',
description: 'Upload HTML to internal PLEC server',
},
{
command: 'hplToolbox.openApplovinUpload',
title: 'AppLovin Demo Upload',
description: 'Upload to p.applov.in (QR preview)',
},
{
command: 'hplToolbox.openBase64Scanner',
title: 'Base64 Scanner',
description: 'Find non-base64 assets in HTML',
},
{
command: 'hplToolbox.openMraidChecker',
title: 'MRAID Checker',
description: 'Check MRAID requirements and best practices',
beta: true,
},
{
command: 'hplToolbox.openDailyUpdate',
title: 'Daily Update',
description: 'Compose & copy a daily status',
},
{
command: 'hplToolbox.openSendToMobile',
title: 'Send To Mobile',
description: 'Share HTML to a phone via LAN + QR',
},
];
export class LauncherViewProvider implements vscode.WebviewViewProvider {
constructor(private readonly context: vscode.ExtensionContext) {}
resolveWebviewView(view: vscode.WebviewView) {
view.webview.options = { enableScripts: true };
view.webview.html = getHtml();
view.webview.onDidReceiveMessage((msg) => {
const version = this.context.extension.packageJSON.version as string;
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
view.webview.html = getHtml(version, betaToolsEnabled);
view.webview.onDidReceiveMessage(async (msg) => {
if (msg?.type === 'open' && typeof msg.command === 'string') {
vscode.commands.executeCommand(msg.command);
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
view.webview.html = getHtml(version, msg.enabled);
}
});
}
}
function getHtml(): string {
function getHtml(version: string, betaToolsEnabled: boolean): string {
const visibleTools = TOOLS.filter(tool => betaToolsEnabled || !tool.beta);
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
const toolButtons = visibleTools.map(renderToolButton).join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 8px; }
body {
min-height: calc(100vh - 24px);
display: flex;
flex-direction: column;
font-family: var(--vscode-font-family);
color: var(--vscode-foreground);
padding: 12px 8px;
}
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
.tools { flex: 1; }
.tool-btn {
display: block; width: 100%; text-align: left;
padding: 8px 10px; margin-bottom: 6px;
@@ -31,31 +92,48 @@ function getHtml(): string {
.tool-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.tool-title { display: flex; align-items: center; gap: 6px; }
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
.beta-badge {
padding: 1px 5px;
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
color: var(--vscode-descriptionForeground);
font-size: 10px;
text-transform: uppercase;
}
.beta-toggle {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--vscode-panel-border);
}
.beta-toggle button {
width: 100%;
padding: 7px 10px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
cursor: pointer;
text-align: left;
}
.beta-toggle button:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.beta-note { display: block; margin-top: 3px; color: var(--vscode-descriptionForeground); font-size: 11px; }
</style>
</head>
<body>
<h3>HPL Toolbox</h3>
<button class="tool-btn" data-cmd="hplToolbox.openPlecUpload">
PLEC Upload
<span class="tool-desc">Upload HTML to internal PLEC server</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openApplovinUpload">
AppLovin Demo Upload
<span class="tool-desc">Upload to p.applov.in (QR preview)</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openBase64Scanner">
Base64 Scanner
<span class="tool-desc">Find non-base64 assets in HTML</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openDailyUpdate">
Daily Update
<span class="tool-desc">Compose &amp; copy a daily status</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openSendToMobile">
Send To Mobile
<span class="tool-desc">Share HTML to a phone via LAN + QR</span>
</button>
<h3>HPL Toolbox ${version}</h3>
<div class="tools">
${toolButtons}
</div>
<div class="beta-toggle">
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'}
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
</button>
</div>
<script>
const vscode = acquireVsCodeApi();
document.querySelectorAll('.tool-btn').forEach((btn) => {
@@ -63,7 +141,28 @@ function getHtml(): string {
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
});
});
document.getElementById('betaToggle').addEventListener('click', (event) => {
const enabled = event.currentTarget.dataset.enabled === 'true';
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
});
</script>
</body>
</html>`;
}
function renderToolButton(tool: ToolDefinition): string {
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
<span class="tool-desc">${escapeHtml(tool.description)}</span>
</button>`;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, ch => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ch] ?? ch));
}

View File

@@ -20,6 +20,7 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
canSelectMany: true,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked.length) {
panel.webview.postMessage({
@@ -34,15 +35,46 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
await handleUpload(panel, msg.paths);
return;
}
if (msg.type === 'saveQr') {
await handleSaveQr(panel, msg.url, msg.name);
return;
}
if (msg.type === 'saveAllQrs') {
await handleSaveAllQrs(panel, msg.items || []);
return;
}
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: err?.message || String(err) });
}
});
}
const USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
];
function pickUserAgent(randomize: boolean): string {
if (!randomize) return USER_AGENTS[0];
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
const cfg = vscode.workspace.getConfiguration('hplToolbox.applovin');
const cookie = (cfg.get<string>('cookie') || '').trim();
const delayMs = Math.max(0, cfg.get<number>('delayMs') ?? 1500);
const randomizeUA = cfg.get<boolean>('randomizeUserAgent') ?? true;
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
if (!paths.length) {
@@ -50,14 +82,17 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
return;
}
const headers: Record<string, string> = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Origin': 'https://p.applov.in',
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
const buildHeaders = (): Record<string, string> => {
const headers: Record<string, string> = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Origin': 'https://p.applov.in',
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': pickUserAgent(randomizeUA),
};
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
return headers;
};
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
const buildForm = (filePath: string): FormData => {
const buf = fs.readFileSync(filePath);
@@ -80,12 +115,17 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
if (!filePath || !fs.existsSync(filePath)) { reportError('File missing'); continue; }
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
if (i > 0 && delayMs > 0) {
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` });
await sleep(delayMs);
}
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` });
let res: Response;
try {
res = await fetch('https://p.applov.in/validateHTMLContent', {
method: 'POST', body: buildForm(filePath), headers,
method: 'POST', body: buildForm(filePath), headers: buildHeaders(),
});
} catch (err: any) { reportError('Network error (validate): ' + (err?.message || err)); continue; }
const validateText = await res.text();
@@ -100,7 +140,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
try {
res = await fetch('https://p.applov.in/getCachedAdURL', {
method: 'POST', body: buildForm(filePath), headers,
method: 'POST', body: buildForm(filePath), headers: buildHeaders(),
});
} catch (err: any) { reportError('Network error (cache): ' + (err?.message || err)); continue; }
const cacheText = await res.text();
@@ -124,6 +164,79 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
panel.webview.postMessage({ type: 'done' });
}
function getPickerDefaultUri(): vscode.Uri | undefined {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return undefined;
const root = folders[0].uri.fsPath;
const dist = path.join(root, 'dist');
if (fs.existsSync(dist) && fs.statSync(dist).isDirectory()) {
return vscode.Uri.file(dist);
}
return vscode.Uri.file(root);
}
function qrBaseName(name: string): string {
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
}
async function downloadQr(url: string, destPath: string): Promise<void> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
await fs.promises.writeFile(destPath, buf);
}
async function handleSaveQr(panel: vscode.WebviewPanel, url: string, name: string) {
if (!url) return;
const base = qrBaseName(name);
const defaultUri = vscode.Uri.file(path.join(require('os').homedir(), 'Downloads', `${base}.png`));
const target = await vscode.window.showSaveDialog({
defaultUri,
filters: { 'PNG Image': ['png'] },
saveLabel: 'Save QR',
});
if (!target) return;
try {
await downloadQr(url, target.fsPath);
panel.webview.postMessage({ type: 'status', message: `Saved QR: ${target.fsPath}` });
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: 'Save QR failed: ' + (err?.message || err) });
}
}
async function handleSaveAllQrs(panel: vscode.WebviewPanel, items: Array<{ name: string; url: string }>) {
if (!items.length) return;
const picked = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: 'Save QRs Here',
});
if (!picked || !picked.length) return;
const dir = picked[0].fsPath;
let saved = 0;
const errors: string[] = [];
for (const it of items) {
const base = qrBaseName(it.name);
let dest = path.join(dir, `${base}.png`);
let n = 1;
while (fs.existsSync(dest)) {
dest = path.join(dir, `${base} (${n++}).png`);
}
try {
await downloadQr(it.url, dest);
saved++;
} catch (err: any) {
errors.push(`${it.name}: ${err?.message || err}`);
}
}
if (errors.length) {
panel.webview.postMessage({ type: 'error', message: `Saved ${saved}/${items.length}. Errors:\n` + errors.join('\n') });
} else {
panel.webview.postMessage({ type: 'status', message: `Saved ${saved} QR${saved === 1 ? '' : 's'} to ${dir}` });
}
}
function getHtml(): string {
return `<!DOCTYPE html>
<html>
@@ -163,11 +276,15 @@ function getHtml(): string {
<p style="opacity:0.8;font-size:12px;margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app (iOS/Android).</p>
<div class="file-cell" style="margin-bottom:8px;">
<button id="pick" class="secondary">Choose files...</button>
<button id="pick" class="secondary">Add files...</button>
<button id="clear" class="secondary" style="display:none;">Clear</button>
<span class="file-name" id="fileName">(no files)</span>
</div>
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
<div class="actions">
<button id="upload">Upload to AppLovin</button>
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
</div>
<div id="status"></div>
<div id="results"></div>
@@ -175,13 +292,51 @@ function getHtml(): string {
<script>
const vscode = acquireVsCodeApi();
const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const uploadBtn = document.getElementById('upload');
const fileNameEl = document.getElementById('fileName');
const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const saveAllBtn = document.getElementById('saveAll');
const regenAllBtn = document.getElementById('regenAll');
let selectedPaths = [];
let resultItems = [];
function basename(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
return i >= 0 ? p.slice(i + 1) : p;
}
function renderSelection() {
if (!selectedPaths.length) {
fileNameEl.textContent = '(no files)';
fileListEl.innerHTML = '';
clearBtn.style.display = 'none';
return;
}
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
clearBtn.style.display = '';
}
saveAllBtn.addEventListener('click', () => {
if (!resultItems.length) return;
vscode.postMessage({ type: 'saveAllQrs', items: resultItems });
});
regenAllBtn.addEventListener('click', () => {
const imgs = resultsEl.querySelectorAll('img[data-qr]');
imgs.forEach(img => {
const base = img.getAttribute('data-qr');
img.src = base + '&_=' + Date.now();
});
});
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
clearBtn.addEventListener('click', () => {
selectedPaths = [];
renderSelection();
});
uploadBtn.addEventListener('click', () => {
statusEl.textContent = '';
@@ -191,6 +346,9 @@ function getHtml(): string {
return;
}
uploadBtn.disabled = true;
resultItems = [];
saveAllBtn.style.display = 'none';
regenAllBtn.style.display = 'none';
vscode.postMessage({ type: 'upload', paths: selectedPaths });
});
@@ -201,13 +359,14 @@ function getHtml(): string {
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'filesSelected') {
selectedPaths = m.files.map(f => f.path);
const names = m.files.map(f => f.name);
fileNameEl.textContent = names.length === 1
? names[0]
: names.length + ' files: ' + names.slice(0, 3).join(', ') + (names.length > 3 ? '...' : '');
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
renderSelection();
} else if (m.type === 'start') {
resultsEl.innerHTML = '';
resultItems = [];
saveAllBtn.style.display = 'none';
regenAllBtn.style.display = 'none';
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = '';
@@ -230,27 +389,28 @@ function getHtml(): string {
wrap.innerHTML =
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
'<div style="text-align:center;margin-bottom:8px;">' +
'<img src="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
'</div>' +
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copyHash = document.createElement('button');
copyHash.textContent = 'Copy Hash';
copyHash.onclick = () => vscode.postMessage({ type: 'copy', text: m.hash });
const copyPreview = document.createElement('button');
copyPreview.textContent = 'Copy URL';
copyPreview.className = 'secondary';
copyPreview.onclick = () => vscode.postMessage({ type: 'copy', text: m.previewUrl });
const openPreview = document.createElement('button');
openPreview.textContent = 'Open';
openPreview.className = 'secondary';
openPreview.onclick = () => vscode.postMessage({ type: 'open', text: m.previewUrl });
actions.appendChild(copyHash);
actions.appendChild(copyPreview);
const saveQr = document.createElement('button');
saveQr.textContent = 'Save QR';
saveQr.className = 'secondary';
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
actions.appendChild(openPreview);
actions.appendChild(saveQr);
wrap.appendChild(actions);
resultsEl.appendChild(wrap);
resultItems.push({ name: m.name, url: m.qrImg });
if (resultItems.length >= 2) {
saveAllBtn.style.display = '';
regenAllBtn.style.display = '';
}
} else if (m.type === 'done') {
statusEl.innerHTML = '<span class="ok">Done.</span>';
uploadBtn.disabled = false;

496
src/tools/mraidChecker.ts Normal file
View File

@@ -0,0 +1,496 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
type Severity = 'requirement' | 'best-practice';
interface MraidIssue {
id: string;
severity: Severity;
title: string;
detail: string;
line?: number;
reference: string;
}
interface MraidResult {
file: string;
ok: boolean;
issues: MraidIssue[];
}
interface MraidRule {
id: string;
severity: Severity;
title: string;
detail: string;
reference: string;
test: (ctx: ScanContext) => MraidIssue | null;
}
interface ScanContext {
html: string;
scriptText: string;
lowerHtml: string;
lowerScript: string;
mraidCallLines: Map<string, number>;
}
export function openMraidChecker(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.mraidChecker', 'MRAID Checker');
if (!isNew) return;
panel.webview.html = getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'ready') {
const ws = vscode.workspace.workspaceFolders?.[0];
if (ws) {
const distPath = path.join(ws.uri.fsPath, 'dist');
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
}
}
} else if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
});
if (picked && picked[0]) {
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
}
} else if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: false,
canSelectFiles: true,
canSelectMany: true,
openLabel: 'Select Files',
filters: { 'HTML': ['html', 'htm'] },
});
if (picked && picked.length) {
panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) });
}
} else if (msg.type === 'scan') {
const folder: string | undefined = msg.folder;
const files: string[] | undefined = msg.files;
let targets: string[] = [];
let baseDir = '';
let skipped = 0;
if (files && files.length) {
const existing = files.filter(f => fs.existsSync(f));
targets = existing.filter(isSupportedMraidBuild);
skipped = existing.length - targets.length;
baseDir = targets.length ? commonBaseDir(targets) : '';
} else if (folder && fs.existsSync(folder)) {
const allHtmlFiles = await collectHtmlFiles(folder);
targets = allHtmlFiles.filter(isSupportedMraidBuild);
skipped = allHtmlFiles.length - targets.length;
baseDir = folder;
} else {
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
return;
}
if (targets.length === 0) {
panel.webview.postMessage({
type: 'results',
results: [],
skipped,
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
});
return;
}
const results: MraidResult[] = [];
for (const file of targets) {
try {
const content = fs.readFileSync(file, 'utf8');
const issues = checkMraid(content);
const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
results.push({ file: display, ok: issues.length === 0, issues });
} catch {
// Skip unreadable files, matching the scanner pattern used elsewhere.
}
}
panel.webview.postMessage({ type: 'results', results, skipped });
}
});
}
function commonBaseDir(files: string[]): string {
if (files.length === 0) return '';
if (files.length === 1) return path.dirname(files[0]);
const split = files.map(f => path.dirname(f).split(path.sep));
const first = split[0];
let i = 0;
while (i < first.length && split.every(parts => parts[i] === first[i])) i++;
return first.slice(0, i).join(path.sep);
}
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function checkMraid(html: string): MraidIssue[] {
const scriptText = extractScriptText(html);
const ctx: ScanContext = {
html,
scriptText,
lowerHtml: html.toLowerCase(),
lowerScript: scriptText.toLowerCase(),
mraidCallLines: collectMraidCallLines(html),
};
return MRAID_RULES
.map(rule => rule.test(ctx))
.filter((issue): issue is MraidIssue => issue !== null);
}
function extractScriptText(html: string): string {
const scripts: string[] = [];
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
let m: RegExpExecArray | null;
while ((m = scriptRegex.exec(html)) !== null) {
scripts.push(m[1]);
}
return scripts.join('\n');
}
function collectMraidCallLines(html: string): Map<string, number> {
const calls = new Map<string, number>();
const lines = html.split(/\r?\n/);
const callRegex = /\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(/g;
for (let i = 0; i < lines.length; i++) {
let m: RegExpExecArray | null;
while ((m = callRegex.exec(lines[i])) !== null) {
if (!calls.has(m[1])) calls.set(m[1], i + 1);
}
}
return calls;
}
function isSupportedMraidBuild(filePath: string): boolean {
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized);
}
function hasMraidReference(ctx: ScanContext): boolean {
return /\bmraid\b/i.test(ctx.html);
}
function hasMraidCall(ctx: ScanContext, method: string): boolean {
return ctx.mraidCallLines.has(method);
}
function mraidLine(ctx: ScanContext, method: string): number | undefined {
return ctx.mraidCallLines.get(method);
}
function issue(rule: MraidRule, line?: number): MraidIssue {
return {
id: rule.id,
severity: rule.severity,
title: rule.title,
detail: rule.detail,
line,
reference: rule.reference,
};
}
function hasReadyGate(ctx: ScanContext): boolean {
return (
/mraid\s*\.\s*addEventListener\s*\(\s*['"]ready['"]/i.test(ctx.scriptText) ||
/mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]/i.test(ctx.scriptText) ||
/['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText)
);
}
function hasEventListener(ctx: ScanContext, eventName: string): boolean {
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
return pattern.test(ctx.scriptText);
}
function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
const pattern = new RegExp(`mraid\\s*\\.\\s*supports\\s*\\(\\s*['"]${feature}['"]`, 'i');
return pattern.test(ctx.scriptText);
}
function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
return (
/\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) ||
/\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText)
);
}
const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/';
const MRAID_RULES: MraidRule[] = [
{
id: 'mraid-reference',
severity: 'requirement',
title: 'MRAID API reference not found',
detail: 'Playable HTML should use the injected mraid object when it depends on an MRAID container.',
reference: MRAID_REFERENCE,
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']),
},
{
id: 'ready-gate',
severity: 'requirement',
title: 'MRAID ready state is not guarded',
detail: 'Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.',
reference: MRAID_REFERENCE,
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']),
},
{
id: 'error-listener',
severity: 'best-practice',
title: 'No MRAID error listener found',
detail: 'Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.',
reference: MRAID_REFERENCE,
test: (ctx) => !hasMraidReference(ctx) || hasEventListener(ctx, 'error') ? null : issue(MRAID_RULES_BY_ID['error-listener']),
},
{
id: 'viewability',
severity: 'best-practice',
title: 'Viewability handling not found',
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.',
reference: MRAID_REFERENCE,
test: (ctx) => {
const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange');
return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']);
},
},
{
id: 'open-api',
severity: 'requirement',
title: 'Browser navigation used instead of mraid.open',
detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container.',
reference: MRAID_REFERENCE,
test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null,
},
{
id: 'close-api',
severity: 'best-practice',
title: 'No mraid.close call found',
detail: 'If the creative renders its own close control, wire that control to mraid.close().',
reference: MRAID_REFERENCE,
test: (ctx) => {
const appearsToHaveCloseUi = /\b(close|dismiss|skip)\b/i.test(ctx.html);
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null;
},
},
{
id: 'expand-support',
severity: 'requirement',
title: 'mraid.expand used without supports check',
detail: 'Check mraid.supports("expand") before using expand behavior.',
reference: MRAID_REFERENCE,
test: (ctx) => hasMraidCall(ctx, 'expand') && !hasSupportCheck(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null,
},
{
id: 'resize-support',
severity: 'requirement',
title: 'mraid.resize used without supports check',
detail: 'Check mraid.supports("resize") before using resize behavior.',
reference: MRAID_REFERENCE,
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasSupportCheck(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
},
{
id: 'orientation-support',
severity: 'best-practice',
title: 'Orientation properties set without supports check',
detail: 'Check mraid.supports("orientation") before requiring orientation behavior.',
reference: MRAID_REFERENCE,
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasSupportCheck(ctx, 'orientation') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
},
{
id: 'size-change',
severity: 'best-practice',
title: 'No sizeChange listener found',
detail: 'Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.',
reference: MRAID_REFERENCE,
test: (ctx) => {
const dependsOnSize = hasMraidCall(ctx, 'getMaxSize') || hasMraidCall(ctx, 'getScreenSize') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'setOrientationProperties');
return dependsOnSize && !hasEventListener(ctx, 'sizeChange') ? issue(MRAID_RULES_BY_ID['size-change']) : null;
},
},
{
id: 'state-change',
severity: 'best-practice',
title: 'No stateChange listener found',
detail: 'Listen for stateChange when using expand, resize, close, or other state-changing container behavior.',
reference: MRAID_REFERENCE,
test: (ctx) => {
const changesState = hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'close');
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null;
},
},
];
const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
acc[rule.id] = rule;
return acc;
}, {});
function getHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
button:hover { background: var(--vscode-button-hoverBackground); }
.summary { margin-top: 12px; opacity: 0.85; }
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
.ok { color: #3fb950; }
.bad { color: #f85149; }
.file-name { word-break: break-all; }
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
.issues { margin: 6px 0 0 26px; padding: 0; }
.issue { margin: 6px 0; list-style: none; }
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
.issue-title { font-weight: 600; }
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
</style>
</head>
<body>
<h2>MRAID Checker</h2>
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
<div class="row">
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
<button id="pick">Browse Folder...</button>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button>
</div>
<div id="selection" class="summary"></div>
<div id="status"></div>
<div id="results"></div>
<script>
const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection');
let pickedFiles = [];
function clearFiles() {
pickedFiles = [];
selectionEl.textContent = '';
}
function escapeText(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
folderEl.addEventListener('input', clearFiles);
document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
});
document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFiles' });
});
document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
if (pickedFiles.length) {
vscode.postMessage({ type: 'scan', files: pickedFiles });
} else {
const folder = folderEl.value.trim();
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
vscode.postMessage({ type: 'scan', folder });
}
});
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.type === 'folderPicked') {
folderEl.value = msg.path;
clearFiles();
} else if (msg.type === 'filesPicked') {
pickedFiles = msg.paths;
folderEl.value = '';
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
} else if (msg.type === 'results') {
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
const total = msg.results.length;
const withIssues = msg.results.filter(r => !r.ok).length;
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
const skipped = Number(msg.skipped || 0);
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
resultsEl.innerHTML = '';
for (const r of msg.results) {
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
const row = document.createElement('div');
row.className = 'row-result';
row.innerHTML =
'<div class="file-head">' +
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
'<span class="file-name">' + escapeText(r.file) + '</span>' +
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
'</div>';
if (!r.ok) {
const list = document.createElement('ul');
list.className = 'issues';
for (const i of r.issues) {
const item = document.createElement('li');
item.className = 'issue';
const line = i.line ? ' line ' + i.line + ':' : '';
item.innerHTML =
'<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
list.appendChild(item);
}
row.appendChild(list);
}
resultsEl.appendChild(row);
}
}
});
</script>
</body>
</html>`;
}

View File

@@ -17,18 +17,16 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({
canSelectMany: false,
canSelectMany: true,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const fp = picked[0].fsPath;
if (picked && picked.length) {
panel.webview.postMessage({
type: 'fileSelected',
type: 'filesSelected',
rowId: msg.rowId,
path: fp,
name: path.basename(fp),
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
});
}
return;
@@ -233,6 +231,15 @@ function getHtml(): string {
const vscode = acquireVsCodeApi();
let nextId = 1;
const tbody = document.querySelector('#rows tbody');
function parseIterationName(filename) {
const base = filename.replace(/\\.html?$/i, '');
const tokens = base.split('_');
for (const t of tokens) {
if (/^(full|\\d+clk|\\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
}
return base;
}
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
@@ -286,13 +293,32 @@ function getHtml(): string {
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'fileSelected') {
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
if (!tr) return;
tr.querySelector('[data-path]').value = m.path;
tr.querySelector('[data-name]').textContent = m.name;
const iter = tr.querySelector('[data-iter]');
if (!iter.value) iter.value = m.name.replace(/\\.html?$/i, '');
if (m.type === 'filesSelected') {
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
const affected = [];
m.files.forEach(function(f, i) {
let tr;
if (i === 0) {
tr = firstTr;
} else {
addRow();
tr = tbody.lastElementChild;
}
if (!tr) return;
tr.querySelector('[data-path]').value = f.path;
tr.querySelector('[data-name]').textContent = f.name;
const iter = tr.querySelector('[data-iter]');
if (!iter.value) iter.value = parseIterationName(f.name);
affected.push(tr);
});
if (affected.length > 1) {
const iters = affected.map(r => r.querySelector('[data-iter]').value);
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
affected.forEach((r, i) => {
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
});
}
}
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = '';

View File

@@ -5,25 +5,49 @@ import (
"encoding/json"
"fmt"
"io"
"math/rand"
"mime/multipart"
"net/http"
urlpkg "net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var applovinUserAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
}
func pickApplovinUserAgent(randomize bool) string {
if !randomize {
return applovinUserAgents[0]
}
return applovinUserAgents[rand.Intn(len(applovinUserAgents))]
}
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
body := `
<h2>AppLovin Playable Preview (QR)</h2>
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<button id="pick" class="secondary">Choose files...</button>
<button id="pick" class="secondary">Add files...</button>
<button id="clear" class="secondary" style="display:none;">Clear</button>
<span id="fileName" class="hint">(no files)</span>
</div>
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
<div style="display:flex;gap:8px;">
<button id="upload">Upload to AppLovin</button>
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
</div>
<div id="status" style="margin-top:12px;min-height:20px;"></div>
@@ -36,21 +60,70 @@ func ApplovinPage(w http.ResponseWriter, r *http.Request) {
<script>
const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const uploadBtn = document.getElementById('upload');
const fileNameEl = document.getElementById('fileName');
const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const saveAllBtn = document.getElementById('saveAll');
const regenAllBtn = document.getElementById('regenAll');
let selectedPaths = [];
let resultItems = [];
function basename(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
return i >= 0 ? p.slice(i + 1) : p;
}
function renderSelection() {
if (!selectedPaths.length) {
fileNameEl.textContent = '(no files)';
fileListEl.innerHTML = '';
clearBtn.style.display = 'none';
return;
}
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
clearBtn.style.display = '';
}
saveAllBtn.addEventListener('click', async () => {
if (!resultItems.length) return;
saveAllBtn.disabled = true;
try {
const r = await fetch('/api/applovin/saveAllQrs', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ items: resultItems }),
});
const j = await r.json();
statusEl.innerHTML = j.ok
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
} finally {
saveAllBtn.disabled = false;
}
});
regenAllBtn.addEventListener('click', () => {
resultsEl.querySelectorAll('img[data-qr]').forEach(img => {
const base = img.getAttribute('data-qr');
img.src = base + '&_=' + Date.now();
});
});
clearBtn.addEventListener('click', () => {
selectedPaths = [];
renderSelection();
});
pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/applovin/pick', { method: 'POST' });
const j = await r.json();
if (j.files && j.files.length) {
selectedPaths = j.files.map(f => f.path);
const names = j.files.map(f => f.name);
fileNameEl.textContent = names.length === 1
? names[0]
: names.length + ' files: ' + names.slice(0,3).join(', ') + (names.length > 3 ? '...' : '');
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
renderSelection();
}
});
@@ -62,6 +135,9 @@ uploadBtn.addEventListener('click', async () => {
return;
}
uploadBtn.disabled = true;
resultItems = [];
saveAllBtn.style.display = 'none';
regenAllBtn.style.display = 'none';
try {
const res = await fetch('/api/applovin/upload', {
method: 'POST',
@@ -113,25 +189,42 @@ function handle(m) {
wrap.innerHTML =
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
'<div style="text-align:center;margin-bottom:8px;">' +
'<img src="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
'</div>' +
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copyHash = document.createElement('button');
copyHash.textContent = 'Copy Hash';
copyHash.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.hash})});
const copyUrl = document.createElement('button');
copyUrl.textContent = 'Copy URL';
copyUrl.className = 'secondary';
copyUrl.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.previewUrl})});
const openBtn = document.createElement('button');
openBtn.textContent = 'Open';
openBtn.className = 'secondary';
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
actions.appendChild(copyHash); actions.appendChild(copyUrl); actions.appendChild(openBtn);
const saveQr = document.createElement('button');
saveQr.textContent = 'Save QR';
saveQr.className = 'secondary';
saveQr.onclick = async () => {
saveQr.disabled = true;
try {
const r = await fetch('/api/applovin/saveQr', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: m.name, url: m.qrImg }),
});
const j = await r.json();
statusEl.innerHTML = j.ok
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
} finally {
saveQr.disabled = false;
}
};
actions.appendChild(openBtn); actions.appendChild(saveQr);
wrap.appendChild(actions);
resultsEl.appendChild(wrap);
resultItems.push({ name: m.name, url: m.qrImg });
if (resultItems.length >= 2) {
saveAllBtn.style.display = '';
regenAllBtn.style.display = '';
}
} else if (m.type === 'done') {
statusEl.innerHTML = '<span class="ok">Done.</span>';
}
@@ -143,11 +236,16 @@ function handle(m) {
}
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
picked := PickFiles(
"Select HTML files",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
true, "",
true, cfg.LastPickDir,
)
if len(picked) > 0 {
cfg.LastPickDir = filepath.Dir(picked[0])
_ = SaveConfig(cfg)
}
files := make([]map[string]string, 0, len(picked))
for _, p := range picked {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
@@ -163,7 +261,16 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cookie := LoadConfig().Applovin.Cookie
cfg := LoadConfig().Applovin
cookie := cfg.Cookie
delayMs := cfg.DelayMs
if delayMs < 0 {
delayMs = 0
}
randomizeUA := true
if cfg.RandomizeUserAgent != nil {
randomizeUA = *cfg.RandomizeUserAgent
}
w.Header().Set("Content-Type", "application/x-ndjson")
flusher, _ := w.(http.Flusher)
@@ -208,7 +315,7 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
req.Header.Set("Origin", "https://p.applov.in")
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
req.Header.Set("User-Agent", pickApplovinUserAgent(randomizeUA))
if cookie != "" {
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
}
@@ -237,6 +344,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
continue
}
if i > 0 && delayMs > 0 {
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Waiting %dms before %s...", idx, len(req.Paths), delayMs, fileName)})
time.Sleep(time.Duration(delayMs) * time.Millisecond)
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)})
body, ct, err := buildForm(filePath)
@@ -300,3 +412,114 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
}
send(map[string]any{"type": "done"})
}
func qrBaseName(name string) string {
base := name
if base == "" {
base = "qr"
}
base = regexp.MustCompile(`(?i)\.html?$`).ReplaceAllString(base, "")
base = regexp.MustCompile(`[\\/:*?"<>|]`).ReplaceAllString(base, "_")
if base == "" {
base = "qr"
}
return base
}
func downloadQR(url, destPath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return os.WriteFile(destPath, data, 0644)
}
func ApplovinSaveQR(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
return
}
if req.URL == "" {
writeJSON(w, map[string]any{"ok": false, "message": "Missing URL"})
return
}
base := qrBaseName(req.Name)
dest := PickSaveFile("Save QR", base+".png", []FileFilter{{Name: "PNG Image", Extensions: []string{"png"}}}, "")
if dest == "" {
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
return
}
if err := downloadQR(req.URL, dest); err != nil {
writeJSON(w, map[string]any{"ok": false, "message": "Save failed: " + err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true, "message": "Saved QR: " + dest})
}
func ApplovinSaveAllQRs(w http.ResponseWriter, r *http.Request) {
var req struct {
Items []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
return
}
if len(req.Items) == 0 {
writeJSON(w, map[string]any{"ok": false, "message": "No items"})
return
}
dir := PickFolder("Choose folder to save QRs", "")
if dir == "" {
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
return
}
saved := 0
errs := []string{}
for _, it := range req.Items {
base := qrBaseName(it.Name)
dest := filepath.Join(dir, base+".png")
n := 1
for fileExists(dest) {
dest = filepath.Join(dir, fmt.Sprintf("%s (%d).png", base, n))
n++
}
if err := downloadQR(it.URL, dest); err != nil {
errs = append(errs, it.Name+": "+err.Error())
continue
}
saved++
}
if len(errs) > 0 {
writeJSON(w, map[string]any{
"ok": false,
"message": fmt.Sprintf("Saved %d/%d. Errors: %s", saved, len(req.Items), strings.Join(errs, "; ")),
})
return
}
writeJSON(w, map[string]any{
"ok": true,
"message": fmt.Sprintf("Saved %d QR%s to %s", saved, plural(saved), dir),
})
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}

BIN
standalone/hpltoolbox.exe Normal file

Binary file not shown.

View File

@@ -17,6 +17,7 @@ const SharedCSS = `
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
.topbar a:hover { background: #2a2d2e; }
.topbar a.active { background: #094771; color: #fff; }
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; }
.content { padding: 16px; max-width: 980px; margin: 0 auto; }
h2 { margin-top: 0; font-weight: 500; }
input[type=text], input[type=date], select, textarea {
@@ -67,7 +68,7 @@ func Page(activePath, title, body string) string {
<title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style>
</head><body>
<div class="topbar">` + tabs.String() + `</div>
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
<div class="content">` + body + `</div>
</body></html>`
}

View File

@@ -16,10 +16,12 @@ import (
webview "github.com/jchv/go-webview2"
)
var AppVersion = "dev"
var currentHwnd atomic.Uintptr
func setupFileLogging() {
logPath := filepath.Join(appDir(), "debug.log")
logPath := filepath.Join(userDataDir(), "debug.log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return
@@ -57,11 +59,18 @@ func main() {
}
defer removeLockFile()
webviewTmp, err := os.MkdirTemp("", "hpltoolbox-wv2-*")
if err != nil {
log.Fatalf("Failed to create webview temp dir: %v", err)
}
defer os.RemoveAll(webviewTmp)
w := webview.NewWithOptions(webview.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: webviewTmp,
WindowOptions: webview.WindowOptions{
Title: "HPL Toolbox",
Title: "HPL Toolbox " + AppVersion,
Width: 1100,
Height: 720,
IconId: 1,
@@ -120,6 +129,8 @@ func buildMux() *http.ServeMux {
// AppLovin
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
// Base64
mux.HandleFunc("POST /api/base64/pickFolder", Base64PickFolder)

View File

@@ -18,7 +18,9 @@ type PlecConfig struct {
}
type ApplovinConfig struct {
Cookie string `json:"cookie"`
Cookie string `json:"cookie"`
DelayMs int `json:"delayMs"`
RandomizeUserAgent *bool `json:"randomizeUserAgent,omitempty"`
}
type SendToMobileConfig struct {
@@ -34,10 +36,28 @@ type AppConfig struct {
Applovin ApplovinConfig `json:"applovin"`
SendToMobile SendToMobileConfig `json:"sendToMobile"`
DailyUpdate DailyUpdateConfig `json:"dailyUpdate"`
LastPickDir string `json:"lastPickDir,omitempty"`
}
var configMu sync.Mutex
// userDataDir returns %LOCALAPPDATA%\HPLToolbox, creating it if needed.
// All mutable app files (config, lock, logs, WebView2 cache) live here so
// they are never placed next to the executable, which may be on a synced or
// antivirus-watched drive and would cause message-loop stalls.
func userDataDir() string {
local := os.Getenv("LOCALAPPDATA")
if local == "" {
local = os.Getenv("APPDATA")
}
if local == "" {
return appDir()
}
dir := filepath.Join(local, "HPLToolbox")
_ = os.MkdirAll(dir, 0755)
return dir
}
func defaultConfig() AppConfig {
return AppConfig{
Plec: PlecConfig{
@@ -46,7 +66,7 @@ func defaultConfig() AppConfig {
PreviewBase: "https://playable.applovindemo.com/phaser/",
SkipExistsCheck: false,
},
Applovin: ApplovinConfig{Cookie: ""},
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
SendToMobile: SendToMobileConfig{Port: 0},
DailyUpdate: DailyUpdateConfig{LastProject: ""},
}
@@ -72,7 +92,7 @@ func appDir() string {
}
func configPath() string {
return filepath.Join(appDir(), "config.json")
return filepath.Join(userDataDir(), "config.json")
}
func LoadConfig() AppConfig {
@@ -159,6 +179,37 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
return result
}
func PickSaveFile(title, defaultName string, filters []FileFilter, initialDir string) string {
filterStr := "All files|*.*"
if len(filters) > 0 {
parts := make([]string, 0, len(filters))
for _, f := range filters {
exts := make([]string, 0, len(f.Extensions))
for _, e := range f.Extensions {
exts = append(exts, "*."+e)
}
parts = append(parts, fmt.Sprintf("%s|%s", f.Name, strings.Join(exts, ";")))
}
filterStr = strings.Join(parts, "|")
}
script := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$dlg = New-Object System.Windows.Forms.SaveFileDialog
$dlg.Title = '%s'
$dlg.Filter = '%s'
$dlg.FileName = '%s'
if ('%s'.Length -gt 0) { $dlg.InitialDirectory = '%s' }
if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dlg.FileName
}
`, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir))
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func PickFolder(title, initialDir string) string {
script := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms

View File

@@ -54,6 +54,15 @@ const tbody = document.querySelector('#rows tbody');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
function parseIterationName(filename) {
const base = filename.replace(/\.html?$/i, '');
const tokens = base.split('_');
for (const t of tokens) {
if (/^(full|\d+clk|\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
}
return base;
}
function addRow() {
const id = 'r' + (nextId++);
const tr = document.createElement('tr');
@@ -73,11 +82,24 @@ function addRow() {
tr.querySelector('.pick').addEventListener('click', async () => {
const res = await fetch('/api/plec/pick', { method: 'POST' });
const j = await res.json();
if (j.path) {
tr.querySelector('[data-path]').value = j.path;
tr.querySelector('[data-name]').textContent = j.name;
const iter = tr.querySelector('[data-iter]');
if (!iter.value) iter.value = j.name.replace(/\.html?$/i, '');
if (j.files && j.files.length) {
const affected = [];
j.files.forEach(function(f, i) {
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
target.querySelector('[data-path]').value = f.path;
target.querySelector('[data-name]').textContent = f.name;
const iter = target.querySelector('[data-iter]');
if (!iter.value) iter.value = parseIterationName(f.name);
affected.push(target);
});
if (affected.length > 1) {
const iters = affected.map(r => r.querySelector('[data-iter]').value);
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
affected.forEach((r, i) => {
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
});
}
}
}
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
@@ -163,16 +185,23 @@ addRow();
}
func PlecPick(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
picked := PickFiles(
"Select HTML",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
false, "",
true, cfg.LastPickDir,
)
if len(picked) == 0 {
writeJSON(w, map[string]any{"path": nil})
writeJSON(w, map[string]any{"files": []any{}})
return
}
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
cfg.LastPickDir = filepath.Dir(picked[0])
_ = SaveConfig(cfg)
files := make([]map[string]string, 0, len(picked))
for _, p := range picked {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
type plecRow struct {

View File

@@ -12,7 +12,7 @@ import (
)
func lockFilePath() string {
return filepath.Join(appDir(), ".hpltoolbox.lock")
return filepath.Join(userDataDir(), ".hpltoolbox.lock")
}
// focusExistingInstance returns true if another instance was found and