Compare commits
9 Commits
859c7d9e7c
...
mraid-chec
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ad1e14ca | |||
| 6be55255b2 | |||
| 9d6ed4b0a5 | |||
| 1336695068 | |||
| 40f13989c2 | |||
| 716dac31ef | |||
| f367b66b60 | |||
| 1cfdcb03ab | |||
| f123cbfc4a |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go build *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -93,7 +93,6 @@ out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
|
||||
11
.vscodeignore
Normal file
11
.vscodeignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.git/**
|
||||
.vscode/**
|
||||
.gitignore
|
||||
standalone/**
|
||||
dist/**
|
||||
src/**
|
||||
out/**/*.map
|
||||
build.ps1
|
||||
tsconfig.json
|
||||
**/*.vsix
|
||||
node_modules/.cache/**
|
||||
4
LICENSE
Normal file
4
LICENSE
Normal file
@@ -0,0 +1,4 @@
|
||||
Copyright (c) 2026 HPL
|
||||
|
||||
All rights reserved. This software is proprietary and confidential.
|
||||
Unauthorized copying, distribution, or use is prohibited.
|
||||
132
build.ps1
Normal file
132
build.ps1
Normal file
@@ -0,0 +1,132 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds both the VSCode extension (.vsix) and the standalone Windows exe.
|
||||
Both artifacts land in dist/.
|
||||
|
||||
.EXAMPLE
|
||||
.\build.ps1
|
||||
.\build.ps1 -Standalone # Skip the extension; build only the exe
|
||||
.\build.ps1 -Extension # Skip the standalone; build only the .vsix
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Extension,
|
||||
[switch]$Standalone
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $repoRoot
|
||||
|
||||
# If neither was specified, build both.
|
||||
if (-not $Extension -and -not $Standalone) {
|
||||
$Extension = $true
|
||||
$Standalone = $true
|
||||
}
|
||||
|
||||
$distDir = Join-Path $repoRoot "dist"
|
||||
if (Test-Path $distDir) {
|
||||
Remove-Item -Path (Join-Path $distDir "*") -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
|
||||
|
||||
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
|
||||
$version = $pkg.version
|
||||
|
||||
function Write-Step($msg) {
|
||||
Write-Host ""
|
||||
Write-Host "==> $msg" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Get-FileSize($path) {
|
||||
if (-not (Test-Path $path)) { return $null }
|
||||
$bytes = (Get-Item $path).Length
|
||||
if ($bytes -ge 1MB) { return "{0:N2} MB" -f ($bytes / 1MB) }
|
||||
if ($bytes -ge 1KB) { return "{0:N1} KB" -f ($bytes / 1KB) }
|
||||
return "$bytes B"
|
||||
}
|
||||
|
||||
# Ensure Go is on PATH (winget install doesn't update current session)
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||
$goBin = "C:\Program Files\Go\bin"
|
||||
if (Test-Path "$goBin\go.exe") {
|
||||
$env:Path = "$goBin;$env:Path"
|
||||
} else {
|
||||
Write-Error "go not found on PATH. Install Go from https://go.dev/dl/"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$summary = @()
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# VSCode extension -> .vsix
|
||||
# ----------------------------------------------------------------------
|
||||
if ($Extension) {
|
||||
Write-Step "Building VSCode extension"
|
||||
if (-not (Test-Path "node_modules")) {
|
||||
Write-Host " node_modules missing - running npm install" -ForegroundColor Yellow
|
||||
npm install
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "npm install failed"; exit 1 }
|
||||
}
|
||||
npm run compile
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "tsc compile failed"; exit 1 }
|
||||
|
||||
Write-Step "Packaging .vsix -> dist/"
|
||||
$vsixTargetDir = $distDir
|
||||
if (Get-Command vsce -ErrorAction SilentlyContinue) {
|
||||
vsce package --out $vsixTargetDir
|
||||
} else {
|
||||
Write-Host " vsce not found - using npx @vscode/vsce" -ForegroundColor Yellow
|
||||
npx --yes @vscode/vsce package --out $vsixTargetDir
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "vsce package failed"; exit 1 }
|
||||
|
||||
$vsix = Get-ChildItem -Path $distDir -Filter "*.vsix" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($vsix) {
|
||||
$summary += [pscustomobject]@{
|
||||
Artifact = $vsix.Name
|
||||
Path = "dist\$($vsix.Name)"
|
||||
Size = Get-FileSize $vsix.FullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Standalone Windows exe -> dist/hpl-toolbox.exe
|
||||
# ----------------------------------------------------------------------
|
||||
if ($Standalone) {
|
||||
Write-Step "Building standalone exe -> dist/"
|
||||
Push-Location (Join-Path $repoRoot "standalone")
|
||||
try {
|
||||
# If rsrc.syso is missing (e.g. fresh clone before icon embedding), regenerate.
|
||||
if (-not (Test-Path "rsrc.syso") -and (Test-Path "hpl.ico")) {
|
||||
if (Get-Command rsrc -ErrorAction SilentlyContinue) {
|
||||
Write-Host " Regenerating rsrc.syso" -ForegroundColor Yellow
|
||||
rsrc -ico hpl.ico -o rsrc.syso
|
||||
} else {
|
||||
Write-Host " rsrc tool missing - exe will build without embedded icon" -ForegroundColor Yellow
|
||||
Write-Host " (install with: go install github.com/akavel/rsrc@latest)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
$exeName = "hpl-toolbox-$version.exe"
|
||||
$exeOut = Join-Path $distDir $exeName
|
||||
go build -ldflags="-s -w -H windowsgui -X main.AppVersion=$version" -trimpath -o $exeOut .
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "go build failed"; exit 1 }
|
||||
|
||||
$summary += [pscustomobject]@{
|
||||
Artifact = $exeName
|
||||
Path = "dist\$exeName"
|
||||
Size = Get-FileSize $exeOut
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Summary
|
||||
# ----------------------------------------------------------------------
|
||||
Write-Step "Build complete"
|
||||
$summary | Format-Table -AutoSize | Out-Host
|
||||
BIN
dist/hpl-toolbox-0.1.5.exe
vendored
Normal file
BIN
dist/hpl-toolbox-0.1.5.exe
vendored
Normal file
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
Normal file
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
Normal file
Binary file not shown.
Binary file not shown.
24
package.json
24
package.json
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"name": "hpl-toolbox",
|
||||
"displayName": "HPL Toolbox",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, Daily Update. Local HTML Host.",
|
||||
"version": "0.1.2",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker, 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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch] ?? ch));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
canSelectMany: true,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({
|
||||
@@ -34,15 +35,46 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
await handleUpload(panel, msg.paths);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'saveQr') {
|
||||
await handleSaveQr(panel, msg.url, msg.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'saveAllQrs') {
|
||||
await handleSaveAllQrs(panel, msg.items || []);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
panel.webview.postMessage({ type: 'error', message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
];
|
||||
|
||||
function pickUserAgent(randomize: boolean): string {
|
||||
if (!randomize) return USER_AGENTS[0];
|
||||
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.applovin');
|
||||
const cookie = (cfg.get<string>('cookie') || '').trim();
|
||||
const delayMs = Math.max(0, cfg.get<number>('delayMs') ?? 1500);
|
||||
const randomizeUA = cfg.get<boolean>('randomizeUserAgent') ?? true;
|
||||
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
if (!paths.length) {
|
||||
@@ -50,14 +82,17 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Origin': 'https://p.applov.in',
|
||||
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Origin': 'https://p.applov.in',
|
||||
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': pickUserAgent(randomizeUA),
|
||||
};
|
||||
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
|
||||
return headers;
|
||||
};
|
||||
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
|
||||
|
||||
const buildForm = (filePath: string): FormData => {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
@@ -80,12 +115,17 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
if (!filePath || !fs.existsSync(filePath)) { reportError('File missing'); continue; }
|
||||
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
|
||||
|
||||
if (i > 0 && delayMs > 0) {
|
||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` });
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` });
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch('https://p.applov.in/validateHTMLContent', {
|
||||
method: 'POST', body: buildForm(filePath), headers,
|
||||
method: 'POST', body: buildForm(filePath), headers: buildHeaders(),
|
||||
});
|
||||
} catch (err: any) { reportError('Network error (validate): ' + (err?.message || err)); continue; }
|
||||
const validateText = await res.text();
|
||||
@@ -100,7 +140,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
|
||||
try {
|
||||
res = await fetch('https://p.applov.in/getCachedAdURL', {
|
||||
method: 'POST', body: buildForm(filePath), headers,
|
||||
method: 'POST', body: buildForm(filePath), headers: buildHeaders(),
|
||||
});
|
||||
} catch (err: any) { reportError('Network error (cache): ' + (err?.message || err)); continue; }
|
||||
const cacheText = await res.text();
|
||||
@@ -124,6 +164,79 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
|
||||
panel.webview.postMessage({ type: 'done' });
|
||||
}
|
||||
|
||||
function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return undefined;
|
||||
const root = folders[0].uri.fsPath;
|
||||
const dist = path.join(root, 'dist');
|
||||
if (fs.existsSync(dist) && fs.statSync(dist).isDirectory()) {
|
||||
return vscode.Uri.file(dist);
|
||||
}
|
||||
return vscode.Uri.file(root);
|
||||
}
|
||||
|
||||
function qrBaseName(name: string): string {
|
||||
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
|
||||
}
|
||||
|
||||
async function downloadQr(url: string, destPath: string): Promise<void> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.promises.writeFile(destPath, buf);
|
||||
}
|
||||
|
||||
async function handleSaveQr(panel: vscode.WebviewPanel, url: string, name: string) {
|
||||
if (!url) return;
|
||||
const base = qrBaseName(name);
|
||||
const defaultUri = vscode.Uri.file(path.join(require('os').homedir(), 'Downloads', `${base}.png`));
|
||||
const target = await vscode.window.showSaveDialog({
|
||||
defaultUri,
|
||||
filters: { 'PNG Image': ['png'] },
|
||||
saveLabel: 'Save QR',
|
||||
});
|
||||
if (!target) return;
|
||||
try {
|
||||
await downloadQr(url, target.fsPath);
|
||||
panel.webview.postMessage({ type: 'status', message: `Saved QR: ${target.fsPath}` });
|
||||
} catch (err: any) {
|
||||
panel.webview.postMessage({ type: 'error', message: 'Save QR failed: ' + (err?.message || err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAllQrs(panel: vscode.WebviewPanel, items: Array<{ name: string; url: string }>) {
|
||||
if (!items.length) return;
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Save QRs Here',
|
||||
});
|
||||
if (!picked || !picked.length) return;
|
||||
const dir = picked[0].fsPath;
|
||||
let saved = 0;
|
||||
const errors: string[] = [];
|
||||
for (const it of items) {
|
||||
const base = qrBaseName(it.name);
|
||||
let dest = path.join(dir, `${base}.png`);
|
||||
let n = 1;
|
||||
while (fs.existsSync(dest)) {
|
||||
dest = path.join(dir, `${base} (${n++}).png`);
|
||||
}
|
||||
try {
|
||||
await downloadQr(it.url, dest);
|
||||
saved++;
|
||||
} catch (err: any) {
|
||||
errors.push(`${it.name}: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
panel.webview.postMessage({ type: 'error', message: `Saved ${saved}/${items.length}. Errors:\n` + errors.join('\n') });
|
||||
} else {
|
||||
panel.webview.postMessage({ type: 'status', message: `Saved ${saved} QR${saved === 1 ? '' : 's'} to ${dir}` });
|
||||
}
|
||||
}
|
||||
|
||||
function getHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -163,11 +276,15 @@ function getHtml(): string {
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app (iOS/Android).</p>
|
||||
|
||||
<div class="file-cell" style="margin-bottom:8px;">
|
||||
<button id="pick" class="secondary">Choose files...</button>
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span class="file-name" id="fileName">(no files)</span>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div class="actions">
|
||||
<button id="upload">Upload to AppLovin</button>
|
||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
@@ -175,13 +292,51 @@ function getHtml(): string {
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
let selectedPaths = [];
|
||||
let resultItems = [];
|
||||
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
}
|
||||
|
||||
saveAllBtn.addEventListener('click', () => {
|
||||
if (!resultItems.length) return;
|
||||
vscode.postMessage({ type: 'saveAllQrs', items: resultItems });
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', () => {
|
||||
const imgs = resultsEl.querySelectorAll('img[data-qr]');
|
||||
imgs.forEach(img => {
|
||||
const base = img.getAttribute('data-qr');
|
||||
img.src = base + '&_=' + Date.now();
|
||||
});
|
||||
});
|
||||
|
||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
statusEl.textContent = '';
|
||||
@@ -191,6 +346,9 @@ function getHtml(): string {
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
||||
});
|
||||
|
||||
@@ -201,13 +359,14 @@ function getHtml(): string {
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
selectedPaths = m.files.map(f => f.path);
|
||||
const names = m.files.map(f => f.name);
|
||||
fileNameEl.textContent = names.length === 1
|
||||
? names[0]
|
||||
: names.length + ' files: ' + names.slice(0, 3).join(', ') + (names.length > 3 ? '...' : '');
|
||||
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||
selectedPaths = selectedPaths.concat(added);
|
||||
renderSelection();
|
||||
} else if (m.type === 'start') {
|
||||
resultsEl.innerHTML = '';
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
@@ -230,27 +389,28 @@ function getHtml(): string {
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copyHash = document.createElement('button');
|
||||
copyHash.textContent = 'Copy Hash';
|
||||
copyHash.onclick = () => vscode.postMessage({ type: 'copy', text: m.hash });
|
||||
const copyPreview = document.createElement('button');
|
||||
copyPreview.textContent = 'Copy URL';
|
||||
copyPreview.className = 'secondary';
|
||||
copyPreview.onclick = () => vscode.postMessage({ type: 'copy', text: m.previewUrl });
|
||||
const openPreview = document.createElement('button');
|
||||
openPreview.textContent = 'Open';
|
||||
openPreview.className = 'secondary';
|
||||
openPreview.onclick = () => vscode.postMessage({ type: 'open', text: m.previewUrl });
|
||||
actions.appendChild(copyHash);
|
||||
actions.appendChild(copyPreview);
|
||||
const saveQr = document.createElement('button');
|
||||
saveQr.textContent = 'Save QR';
|
||||
saveQr.className = 'secondary';
|
||||
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
||||
actions.appendChild(openPreview);
|
||||
actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
496
src/tools/mraidChecker.ts
Normal file
496
src/tools/mraidChecker.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
type Severity = 'requirement' | 'best-practice';
|
||||
|
||||
interface MraidIssue {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
title: string;
|
||||
detail: string;
|
||||
line?: number;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
interface MraidResult {
|
||||
file: string;
|
||||
ok: boolean;
|
||||
issues: MraidIssue[];
|
||||
}
|
||||
|
||||
interface MraidRule {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
title: string;
|
||||
detail: string;
|
||||
reference: string;
|
||||
test: (ctx: ScanContext) => MraidIssue | null;
|
||||
}
|
||||
|
||||
interface ScanContext {
|
||||
html: string;
|
||||
scriptText: string;
|
||||
lowerHtml: string;
|
||||
lowerScript: string;
|
||||
mraidCallLines: Map<string, number>;
|
||||
}
|
||||
|
||||
export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.mraidChecker', 'MRAID Checker');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg.type === 'ready') {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0];
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||
}
|
||||
} else if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: false,
|
||||
canSelectFiles: true,
|
||||
canSelectMany: true,
|
||||
openLabel: 'Select Files',
|
||||
filters: { 'HTML': ['html', 'htm'] },
|
||||
});
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) });
|
||||
}
|
||||
} else if (msg.type === 'scan') {
|
||||
const folder: string | undefined = msg.folder;
|
||||
const files: string[] | undefined = msg.files;
|
||||
let targets: string[] = [];
|
||||
let baseDir = '';
|
||||
let skipped = 0;
|
||||
if (files && files.length) {
|
||||
const existing = files.filter(f => fs.existsSync(f));
|
||||
targets = existing.filter(isSupportedMraidBuild);
|
||||
skipped = existing.length - targets.length;
|
||||
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||
} else if (folder && fs.existsSync(folder)) {
|
||||
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||
targets = allHtmlFiles.filter(isSupportedMraidBuild);
|
||||
skipped = allHtmlFiles.length - targets.length;
|
||||
baseDir = folder;
|
||||
} else {
|
||||
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
panel.webview.postMessage({
|
||||
type: 'results',
|
||||
results: [],
|
||||
skipped,
|
||||
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const results: MraidResult[] = [];
|
||||
for (const file of targets) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const issues = checkMraid(content);
|
||||
const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
|
||||
results.push({ file: display, ok: issues.length === 0, issues });
|
||||
} catch {
|
||||
// Skip unreadable files, matching the scanner pattern used elsewhere.
|
||||
}
|
||||
}
|
||||
panel.webview.postMessage({ type: 'results', results, skipped });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function commonBaseDir(files: string[]): string {
|
||||
if (files.length === 0) return '';
|
||||
if (files.length === 1) return path.dirname(files[0]);
|
||||
const split = files.map(f => path.dirname(f).split(path.sep));
|
||||
const first = split[0];
|
||||
let i = 0;
|
||||
while (i < first.length && split.every(parts => parts[i] === first[i])) i++;
|
||||
return first.slice(0, i).join(path.sep);
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkMraid(html: string): MraidIssue[] {
|
||||
const scriptText = extractScriptText(html);
|
||||
const ctx: ScanContext = {
|
||||
html,
|
||||
scriptText,
|
||||
lowerHtml: html.toLowerCase(),
|
||||
lowerScript: scriptText.toLowerCase(),
|
||||
mraidCallLines: collectMraidCallLines(html),
|
||||
};
|
||||
|
||||
return MRAID_RULES
|
||||
.map(rule => rule.test(ctx))
|
||||
.filter((issue): issue is MraidIssue => issue !== null);
|
||||
}
|
||||
|
||||
function extractScriptText(html: string): string {
|
||||
const scripts: string[] = [];
|
||||
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = scriptRegex.exec(html)) !== null) {
|
||||
scripts.push(m[1]);
|
||||
}
|
||||
return scripts.join('\n');
|
||||
}
|
||||
|
||||
function collectMraidCallLines(html: string): Map<string, number> {
|
||||
const calls = new Map<string, number>();
|
||||
const lines = html.split(/\r?\n/);
|
||||
const callRegex = /\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(/g;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = callRegex.exec(lines[i])) !== null) {
|
||||
if (!calls.has(m[1])) calls.set(m[1], i + 1);
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isSupportedMraidBuild(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized);
|
||||
}
|
||||
|
||||
function hasMraidReference(ctx: ScanContext): boolean {
|
||||
return /\bmraid\b/i.test(ctx.html);
|
||||
}
|
||||
|
||||
function hasMraidCall(ctx: ScanContext, method: string): boolean {
|
||||
return ctx.mraidCallLines.has(method);
|
||||
}
|
||||
|
||||
function mraidLine(ctx: ScanContext, method: string): number | undefined {
|
||||
return ctx.mraidCallLines.get(method);
|
||||
}
|
||||
|
||||
function issue(rule: MraidRule, line?: number): MraidIssue {
|
||||
return {
|
||||
id: rule.id,
|
||||
severity: rule.severity,
|
||||
title: rule.title,
|
||||
detail: rule.detail,
|
||||
line,
|
||||
reference: rule.reference,
|
||||
};
|
||||
}
|
||||
|
||||
function hasReadyGate(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/mraid\s*\.\s*addEventListener\s*\(\s*['"]ready['"]/i.test(ctx.scriptText) ||
|
||||
/mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]/i.test(ctx.scriptText) ||
|
||||
/['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)/i.test(ctx.scriptText)
|
||||
);
|
||||
}
|
||||
|
||||
function hasEventListener(ctx: ScanContext, eventName: string): boolean {
|
||||
const pattern = new RegExp(`mraid\\s*\\.\\s*addEventListener\\s*\\(\\s*['"]${eventName}['"]`, 'i');
|
||||
return pattern.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function hasSupportCheck(ctx: ScanContext, feature: string): boolean {
|
||||
const pattern = new RegExp(`mraid\\s*\\.\\s*supports\\s*\\(\\s*['"]${feature}['"]`, 'i');
|
||||
return pattern.test(ctx.scriptText);
|
||||
}
|
||||
|
||||
function usesDirectBrowserNavigation(ctx: ScanContext): boolean {
|
||||
return (
|
||||
/\bwindow\s*\.\s*open\s*\(/i.test(ctx.scriptText) ||
|
||||
/\blocation\s*\.\s*(href|assign|replace)\b/i.test(ctx.scriptText)
|
||||
);
|
||||
}
|
||||
|
||||
const MRAID_REFERENCE = 'MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/';
|
||||
|
||||
const MRAID_RULES: MraidRule[] = [
|
||||
{
|
||||
id: 'mraid-reference',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID API reference not found',
|
||||
detail: 'Playable HTML should use the injected mraid object when it depends on an MRAID container.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidReference(ctx) ? null : issue(MRAID_RULES_BY_ID['mraid-reference']),
|
||||
},
|
||||
{
|
||||
id: 'ready-gate',
|
||||
severity: 'requirement',
|
||||
title: 'MRAID ready state is not guarded',
|
||||
detail: 'Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasReadyGate(ctx) ? null : issue(MRAID_RULES_BY_ID['ready-gate']),
|
||||
},
|
||||
{
|
||||
id: 'error-listener',
|
||||
severity: 'best-practice',
|
||||
title: 'No MRAID error listener found',
|
||||
detail: 'Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => !hasMraidReference(ctx) || hasEventListener(ctx, 'error') ? null : issue(MRAID_RULES_BY_ID['error-listener']),
|
||||
},
|
||||
{
|
||||
id: 'viewability',
|
||||
severity: 'best-practice',
|
||||
title: 'Viewability handling not found',
|
||||
detail: 'Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const hasVisibilitySignal = hasMraidCall(ctx, 'isViewable') || hasEventListener(ctx, 'viewableChange') || hasEventListener(ctx, 'exposureChange');
|
||||
return !hasMraidReference(ctx) || hasVisibilitySignal ? null : issue(MRAID_RULES_BY_ID['viewability']);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-api',
|
||||
severity: 'requirement',
|
||||
title: 'Browser navigation used instead of mraid.open',
|
||||
detail: 'Use mraid.open(url) for click-through navigation inside an MRAID ad container.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => usesDirectBrowserNavigation(ctx) && !hasMraidCall(ctx, 'open') ? issue(MRAID_RULES_BY_ID['open-api']) : null,
|
||||
},
|
||||
{
|
||||
id: 'close-api',
|
||||
severity: 'best-practice',
|
||||
title: 'No mraid.close call found',
|
||||
detail: 'If the creative renders its own close control, wire that control to mraid.close().',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const appearsToHaveCloseUi = /\b(close|dismiss|skip)\b/i.test(ctx.html);
|
||||
return appearsToHaveCloseUi && !hasMraidCall(ctx, 'close') ? issue(MRAID_RULES_BY_ID['close-api']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'expand-support',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.expand used without supports check',
|
||||
detail: 'Check mraid.supports("expand") before using expand behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'expand') && !hasSupportCheck(ctx, 'expand') ? issue(MRAID_RULES_BY_ID['expand-support'], mraidLine(ctx, 'expand')) : null,
|
||||
},
|
||||
{
|
||||
id: 'resize-support',
|
||||
severity: 'requirement',
|
||||
title: 'mraid.resize used without supports check',
|
||||
detail: 'Check mraid.supports("resize") before using resize behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'resize') && !hasSupportCheck(ctx, 'resize') ? issue(MRAID_RULES_BY_ID['resize-support'], mraidLine(ctx, 'resize')) : null,
|
||||
},
|
||||
{
|
||||
id: 'orientation-support',
|
||||
severity: 'best-practice',
|
||||
title: 'Orientation properties set without supports check',
|
||||
detail: 'Check mraid.supports("orientation") before requiring orientation behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => hasMraidCall(ctx, 'setOrientationProperties') && !hasSupportCheck(ctx, 'orientation') ? issue(MRAID_RULES_BY_ID['orientation-support'], mraidLine(ctx, 'setOrientationProperties')) : null,
|
||||
},
|
||||
{
|
||||
id: 'size-change',
|
||||
severity: 'best-practice',
|
||||
title: 'No sizeChange listener found',
|
||||
detail: 'Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const dependsOnSize = hasMraidCall(ctx, 'getMaxSize') || hasMraidCall(ctx, 'getScreenSize') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'setOrientationProperties');
|
||||
return dependsOnSize && !hasEventListener(ctx, 'sizeChange') ? issue(MRAID_RULES_BY_ID['size-change']) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'state-change',
|
||||
severity: 'best-practice',
|
||||
title: 'No stateChange listener found',
|
||||
detail: 'Listen for stateChange when using expand, resize, close, or other state-changing container behavior.',
|
||||
reference: MRAID_REFERENCE,
|
||||
test: (ctx) => {
|
||||
const changesState = hasMraidCall(ctx, 'expand') || hasMraidCall(ctx, 'resize') || hasMraidCall(ctx, 'close');
|
||||
return changesState && !hasEventListener(ctx, 'stateChange') ? issue(MRAID_RULES_BY_ID['state-change']) : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const MRAID_RULES_BY_ID = MRAID_RULES.reduce<Record<string, MraidRule>>((acc, rule) => {
|
||||
acc[rule.id] = rule;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
function getHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.summary { margin-top: 12px; opacity: 0.85; }
|
||||
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
|
||||
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
|
||||
.issues { margin: 6px 0 0 26px; padding: 0; }
|
||||
.issue { margin: 6px 0; list-style: none; }
|
||||
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
|
||||
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
|
||||
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
|
||||
.issue-title { font-weight: 600; }
|
||||
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MRAID Checker</h2>
|
||||
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
vscode.postMessage({ type: 'scan', folder });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
} else if (msg.type === 'filesPicked') {
|
||||
pickedFiles = msg.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
} else if (msg.type === 'results') {
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
const total = msg.results.length;
|
||||
const withIssues = msg.results.filter(r => !r.ok).length;
|
||||
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(msg.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of msg.results) {
|
||||
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML =
|
||||
'<div class="file-head">' +
|
||||
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
|
||||
'<span class="file-name">' + escapeText(r.file) + '</span>' +
|
||||
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
|
||||
'</div>';
|
||||
if (!r.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
for (const i of r.issues) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'issue';
|
||||
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||
item.innerHTML =
|
||||
'<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
|
||||
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
|
||||
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
list.appendChild(item);
|
||||
}
|
||||
row.appendChild(list);
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -17,18 +17,16 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
||||
|
||||
if (msg.type === 'pickFile') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
canSelectMany: true,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const fp = picked[0].fsPath;
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({
|
||||
type: 'fileSelected',
|
||||
type: 'filesSelected',
|
||||
rowId: msg.rowId,
|
||||
path: fp,
|
||||
name: path.basename(fp),
|
||||
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -233,6 +231,15 @@ function getHtml(): string {
|
||||
const vscode = acquireVsCodeApi();
|
||||
let nextId = 1;
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
|
||||
function parseIterationName(filename) {
|
||||
const base = filename.replace(/\\.html?$/i, '');
|
||||
const tokens = base.split('_');
|
||||
for (const t of tokens) {
|
||||
if (/^(full|\\d+clk|\\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
|
||||
}
|
||||
return base;
|
||||
}
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
@@ -286,13 +293,32 @@ function getHtml(): string {
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'fileSelected') {
|
||||
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
if (!tr) return;
|
||||
tr.querySelector('[data-path]').value = m.path;
|
||||
tr.querySelector('[data-name]').textContent = m.name;
|
||||
const iter = tr.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = m.name.replace(/\\.html?$/i, '');
|
||||
if (m.type === 'filesSelected') {
|
||||
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
const affected = [];
|
||||
m.files.forEach(function(f, i) {
|
||||
let tr;
|
||||
if (i === 0) {
|
||||
tr = firstTr;
|
||||
} else {
|
||||
addRow();
|
||||
tr = tbody.lastElementChild;
|
||||
}
|
||||
if (!tr) return;
|
||||
tr.querySelector('[data-path]').value = f.path;
|
||||
tr.querySelector('[data-name]').textContent = f.name;
|
||||
const iter = tr.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||
affected.push(tr);
|
||||
});
|
||||
if (affected.length > 1) {
|
||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||
affected.forEach((r, i) => {
|
||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
|
||||
4
standalone/.gitignore
vendored
Normal file
4
standalone/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
hpl-toolbox.exe
|
||||
config.json
|
||||
.hpltoolbox.lock
|
||||
debug.log
|
||||
525
standalone/applovin.go
Normal file
525
standalone/applovin.go
Normal file
@@ -0,0 +1,525 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
urlpkg "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var applovinUserAgents = []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
func pickApplovinUserAgent(randomize bool) string {
|
||||
if !randomize {
|
||||
return applovinUserAgents[0]
|
||||
}
|
||||
return applovinUserAgents[rand.Intn(len(applovinUserAgents))]
|
||||
}
|
||||
|
||||
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>AppLovin Playable Preview (QR)</h2>
|
||||
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span id="fileName" class="hint">(no files)</span>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="upload">Upload to AppLovin</button>
|
||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
|
||||
|
||||
<style>
|
||||
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
let selectedPaths = [];
|
||||
let resultItems = [];
|
||||
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
}
|
||||
|
||||
saveAllBtn.addEventListener('click', async () => {
|
||||
if (!resultItems.length) return;
|
||||
saveAllBtn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/applovin/saveAllQrs', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ items: resultItems }),
|
||||
});
|
||||
const j = await r.json();
|
||||
statusEl.innerHTML = j.ok
|
||||
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
|
||||
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
|
||||
} finally {
|
||||
saveAllBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
regenAllBtn.addEventListener('click', () => {
|
||||
resultsEl.querySelectorAll('img[data-qr]').forEach(img => {
|
||||
const base = img.getAttribute('data-qr');
|
||||
img.src = base + '&_=' + Date.now();
|
||||
});
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
pickBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.files && j.files.length) {
|
||||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||
selectedPaths = selectedPaths.concat(added);
|
||||
renderSelection();
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch('/api/applovin/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: selectedPaths }),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
|
||||
uploadBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
||||
if (!line.trim()) continue;
|
||||
handle(JSON.parse(line));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||
} finally {
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function handle(m) {
|
||||
if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
} else if (m.type === 'fileError') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
||||
resultsEl.appendChild(wrap);
|
||||
} else if (m.type === 'fileResult') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const openBtn = document.createElement('button');
|
||||
openBtn.textContent = 'Open';
|
||||
openBtn.className = 'secondary';
|
||||
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
|
||||
const saveQr = document.createElement('button');
|
||||
saveQr.textContent = 'Save QR';
|
||||
saveQr.className = 'secondary';
|
||||
saveQr.onclick = async () => {
|
||||
saveQr.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/applovin/saveQr', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ name: m.name, url: m.qrImg }),
|
||||
});
|
||||
const j = await r.json();
|
||||
statusEl.innerHTML = j.ok
|
||||
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
|
||||
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
|
||||
} finally {
|
||||
saveQr.disabled = false;
|
||||
}
|
||||
};
|
||||
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body)))
|
||||
}
|
||||
|
||||
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
picked := PickFiles(
|
||||
"Select HTML files",
|
||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||
true, cfg.LastPickDir,
|
||||
)
|
||||
if len(picked) > 0 {
|
||||
cfg.LastPickDir = filepath.Dir(picked[0])
|
||||
_ = SaveConfig(cfg)
|
||||
}
|
||||
files := make([]map[string]string, 0, len(picked))
|
||||
for _, p := range picked {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg := LoadConfig().Applovin
|
||||
cookie := cfg.Cookie
|
||||
delayMs := cfg.DelayMs
|
||||
if delayMs < 0 {
|
||||
delayMs = 0
|
||||
}
|
||||
randomizeUA := true
|
||||
if cfg.RandomizeUserAgent != nil {
|
||||
randomizeUA = *cfg.RandomizeUserAgent
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
send := func(obj any) {
|
||||
data, _ := json.Marshal(obj)
|
||||
_, _ = w.Write(append(data, '\n'))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
|
||||
|
||||
buildForm := func(filePath string) (*bytes.Buffer, string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := createFormFileWithType(mw, "file", filepath.Base(filePath), "text/html")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &buf, mw.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
doPost := func(url string, body *bytes.Buffer, contentType string) (int, string, error) {
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
req.Header.Set("Origin", "https://p.applov.in")
|
||||
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
req.Header.Set("User-Agent", pickApplovinUserAgent(randomizeUA))
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(out), nil
|
||||
}
|
||||
|
||||
for i, filePath := range req.Paths {
|
||||
fileName := filepath.Base(filePath)
|
||||
idx := i + 1
|
||||
fileErr := func(msg string) {
|
||||
send(map[string]any{"type": "fileError", "name": fileName, "message": msg})
|
||||
}
|
||||
|
||||
if filePath == "" || !fileExists(filePath) {
|
||||
fileErr("File missing")
|
||||
continue
|
||||
}
|
||||
if !htmlRx.MatchString(filePath) {
|
||||
fileErr("Must be .html")
|
||||
continue
|
||||
}
|
||||
|
||||
if i > 0 && delayMs > 0 {
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Waiting %dms before %s...", idx, len(req.Paths), delayMs, fileName)})
|
||||
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)})
|
||||
|
||||
body, ct, err := buildForm(filePath)
|
||||
if err != nil {
|
||||
fileErr("Read failed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
status, respText, err := doPost("https://p.applov.in/validateHTMLContent", body, ct)
|
||||
if err != nil {
|
||||
fileErr("Network (validate): " + err.Error())
|
||||
continue
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
fileErr(fmt.Sprintf("Validate HTTP %d: %s", status, truncate(respText, 300)))
|
||||
continue
|
||||
}
|
||||
var vJSON map[string]any
|
||||
_ = json.Unmarshal([]byte(respText), &vJSON)
|
||||
if code, ok := vJSON["code"]; ok {
|
||||
if codeNum, isNum := code.(float64); isNum && codeNum != 200 {
|
||||
fileErr("Validation failed: " + truncate(respText, 300))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)})
|
||||
|
||||
body, ct, err = buildForm(filePath)
|
||||
if err != nil {
|
||||
fileErr("Read failed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
status, respText, err = doPost("https://p.applov.in/getCachedAdURL", body, ct)
|
||||
if err != nil {
|
||||
fileErr("Network (cache): " + err.Error())
|
||||
continue
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
fileErr(fmt.Sprintf("Cache HTTP %d: %s", status, truncate(respText, 300)))
|
||||
continue
|
||||
}
|
||||
var cJSON map[string]any
|
||||
if err := json.Unmarshal([]byte(respText), &cJSON); err != nil {
|
||||
fileErr("Non-JSON: " + truncate(respText, 300))
|
||||
continue
|
||||
}
|
||||
hash, _ := cJSON["results"].(string)
|
||||
if hash == "" {
|
||||
fileErr("No hash in response: " + truncate(respText, 300))
|
||||
continue
|
||||
}
|
||||
|
||||
send(map[string]any{
|
||||
"type": "fileResult",
|
||||
"name": fileName,
|
||||
"hash": hash,
|
||||
"previewUrl": "https://p.applov.in/getPreviewHTML?preview=" + hash,
|
||||
"pageUrl": "https://p.applov.in/playablePreview?preview=" + hash + "&qr=1",
|
||||
"qrImg": "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=" + urlpkg.QueryEscape(hash),
|
||||
})
|
||||
}
|
||||
send(map[string]any{"type": "done"})
|
||||
}
|
||||
|
||||
func qrBaseName(name string) string {
|
||||
base := name
|
||||
if base == "" {
|
||||
base = "qr"
|
||||
}
|
||||
base = regexp.MustCompile(`(?i)\.html?$`).ReplaceAllString(base, "")
|
||||
base = regexp.MustCompile(`[\\/:*?"<>|]`).ReplaceAllString(base, "_")
|
||||
if base == "" {
|
||||
base = "qr"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func downloadQR(url, destPath string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(destPath, data, 0644)
|
||||
}
|
||||
|
||||
func ApplovinSaveQR(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.URL == "" {
|
||||
writeJSON(w, map[string]any{"ok": false, "message": "Missing URL"})
|
||||
return
|
||||
}
|
||||
base := qrBaseName(req.Name)
|
||||
dest := PickSaveFile("Save QR", base+".png", []FileFilter{{Name: "PNG Image", Extensions: []string{"png"}}}, "")
|
||||
if dest == "" {
|
||||
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
|
||||
return
|
||||
}
|
||||
if err := downloadQR(req.URL, dest); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "message": "Save failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "message": "Saved QR: " + dest})
|
||||
}
|
||||
|
||||
func ApplovinSaveAllQRs(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
writeJSON(w, map[string]any{"ok": false, "message": "No items"})
|
||||
return
|
||||
}
|
||||
dir := PickFolder("Choose folder to save QRs", "")
|
||||
if dir == "" {
|
||||
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
|
||||
return
|
||||
}
|
||||
saved := 0
|
||||
errs := []string{}
|
||||
for _, it := range req.Items {
|
||||
base := qrBaseName(it.Name)
|
||||
dest := filepath.Join(dir, base+".png")
|
||||
n := 1
|
||||
for fileExists(dest) {
|
||||
dest = filepath.Join(dir, fmt.Sprintf("%s (%d).png", base, n))
|
||||
n++
|
||||
}
|
||||
if err := downloadQR(it.URL, dest); err != nil {
|
||||
errs = append(errs, it.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
saved++
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeJSON(w, map[string]any{
|
||||
"ok": false,
|
||||
"message": fmt.Sprintf("Saved %d/%d. Errors: %s", saved, len(req.Items), strings.Join(errs, "; ")),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"ok": true,
|
||||
"message": fmt.Sprintf("Saved %d QR%s to %s", saved, plural(saved), dir),
|
||||
})
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
8
standalone/assets/qrcode.min.js
vendored
Normal file
8
standalone/assets/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
289
standalone/base64.go
Normal file
289
standalone/base64.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
|
||||
<style>
|
||||
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
|
||||
.mark { width:14px; flex-shrink:0; font-weight:bold; }
|
||||
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||
.file-name { word-break:break-all; }
|
||||
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
|
||||
.summary { opacity:0.8; margin-bottom:6px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
}
|
||||
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
const j = await r.json();
|
||||
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
const total = j.results.length;
|
||||
const flagged = j.results.filter(r => !r.ok).length;
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const rr of j.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
|
||||
mark.textContent = rr.ok ? '✓' : '✗';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
name.textContent = rr.file;
|
||||
row.appendChild(mark); row.appendChild(name);
|
||||
if (!rr.ok) {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'assets';
|
||||
a.textContent = '— ' + rr.assets.join(', ');
|
||||
row.appendChild(a);
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/base64", "Base64 Scanner", body)))
|
||||
}
|
||||
|
||||
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
p := PickFolder("Select folder to scan", "")
|
||||
writeJSON(w, map[string]any{"path": p})
|
||||
}
|
||||
|
||||
func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
|
||||
paths := PickFiles(
|
||||
"Select HTML files",
|
||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||
true, "",
|
||||
)
|
||||
writeJSON(w, map[string]any{"paths": paths})
|
||||
}
|
||||
|
||||
type scanResult struct {
|
||||
File string `json:"file"`
|
||||
OK bool `json:"ok"`
|
||||
Assets []string `json:"assets"`
|
||||
}
|
||||
|
||||
func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Folder string `json:"folder"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var targets []string
|
||||
var baseDir string
|
||||
|
||||
if len(req.Files) > 0 {
|
||||
for _, f := range req.Files {
|
||||
if fileExists(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
if len(targets) > 0 {
|
||||
baseDir = commonBaseDir(targets)
|
||||
}
|
||||
} else if req.Folder != "" && fileExists(req.Folder) {
|
||||
targets = collectHTMLFiles(req.Folder)
|
||||
baseDir = req.Folder
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"})
|
||||
return
|
||||
}
|
||||
|
||||
results := []scanResult{}
|
||||
for _, file := range targets {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
offenders := findNonBase64Assets(string(data))
|
||||
display := file
|
||||
if baseDir != "" {
|
||||
rel, err := filepath.Rel(baseDir, file)
|
||||
if err == nil && rel != "" {
|
||||
display = rel
|
||||
} else {
|
||||
display = filepath.Base(file)
|
||||
}
|
||||
}
|
||||
results = append(results, scanResult{
|
||||
File: display,
|
||||
OK: len(offenders) == 0,
|
||||
Assets: offenders,
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
func commonBaseDir(files []string) string {
|
||||
if len(files) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(files) == 1 {
|
||||
return filepath.Dir(files[0])
|
||||
}
|
||||
split := make([][]string, len(files))
|
||||
for i, f := range files {
|
||||
split[i] = strings.Split(filepath.Dir(f), string(filepath.Separator))
|
||||
}
|
||||
first := split[0]
|
||||
i := 0
|
||||
for i < len(first) {
|
||||
match := true
|
||||
for _, parts := range split {
|
||||
if i >= len(parts) || parts[i] != first[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
return strings.Join(first[:i], string(filepath.Separator))
|
||||
}
|
||||
|
||||
func collectHTMLFiles(root string) []string {
|
||||
var out []string
|
||||
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
|
||||
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
if name == "node_modules" || name == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if htmlRx.MatchString(d.Name()) {
|
||||
out = append(out, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
scriptBlockRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script>`)
|
||||
attrRx = regexp.MustCompile(`(?i)\b(?:src|href|poster|data-src)\s*=\s*("([^"]*)"|'([^']*)')`)
|
||||
cssUrlRx = regexp.MustCompile(`(?i)url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)`)
|
||||
base64DataRx = regexp.MustCompile(`(?i)^data:[^;,]*;base64,`)
|
||||
mailTelRx = regexp.MustCompile(`(?i)^(mailto:|tel:)`)
|
||||
)
|
||||
|
||||
func findNonBase64Assets(htmlSrc string) []string {
|
||||
markup := scriptBlockRx.ReplaceAllString(htmlSrc, "")
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(u string) {
|
||||
if u == "" || seen[u] {
|
||||
return
|
||||
}
|
||||
seen[u] = true
|
||||
out = append(out, u)
|
||||
}
|
||||
|
||||
for _, m := range attrRx.FindAllStringSubmatch(markup, -1) {
|
||||
url := m[2]
|
||||
if url == "" {
|
||||
url = m[3]
|
||||
}
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if isAssetReference(url) && !base64DataRx.MatchString(url) {
|
||||
add(url)
|
||||
}
|
||||
}
|
||||
for _, m := range cssUrlRx.FindAllStringSubmatch(markup, -1) {
|
||||
url := m[1]
|
||||
if url == "" {
|
||||
url = m[2]
|
||||
}
|
||||
if url == "" {
|
||||
url = m[3]
|
||||
}
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if !base64DataRx.MatchString(url) {
|
||||
add(url)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isAssetReference(url string) bool {
|
||||
if strings.HasPrefix(url, "#") || strings.HasPrefix(strings.ToLower(url), "javascript:") {
|
||||
return false
|
||||
}
|
||||
if mailTelRx.MatchString(url) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
131
standalone/daily.go
Normal file
131
standalone/daily.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func DailyPage(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
lastProject := html.EscapeString(cfg.DailyUpdate.LastProject)
|
||||
|
||||
body := `
|
||||
<h2>Daily Update</h2>
|
||||
<div class="hint" style="margin-bottom:12px;">Submit copies the formatted text to your clipboard.</div>
|
||||
|
||||
<div style="max-width:520px;">
|
||||
<label style="display:block;margin-top:8px;font-size:12px;opacity:0.85;">Date</label>
|
||||
<input type="date" id="date" value="` + today + `" style="width:100%;" />
|
||||
|
||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Status</label>
|
||||
<select id="status" style="width:100%;">
|
||||
<option>Started</option><option>Progress</option><option>Finished</option>
|
||||
<option>Client Feedback</option><option>For OT</option>
|
||||
</select>
|
||||
|
||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Project</label>
|
||||
<input type="text" id="project" placeholder="Project name" value="` + lastProject + `" style="width:100%;" />
|
||||
|
||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Remarks</label>
|
||||
<textarea id="remarks" placeholder="What did you do?" style="width:100%;min-height:96px;"></textarea>
|
||||
|
||||
<button id="submit" style="margin-top:16px;">Submit</button>
|
||||
<div id="status-msg" style="margin-top:12px;min-height:18px;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('submit').addEventListener('click', async () => {
|
||||
const payload = {
|
||||
date: document.getElementById('date').value,
|
||||
status: document.getElementById('status').value,
|
||||
project: document.getElementById('project').value.trim(),
|
||||
remarks: document.getElementById('remarks').value.trim(),
|
||||
};
|
||||
const statusEl = document.getElementById('status-msg');
|
||||
statusEl.textContent = 'Copying...';
|
||||
try {
|
||||
const res = await fetch('/api/daily/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.ok) {
|
||||
statusEl.innerHTML = '<span class="ok">Copied to clipboard.</span>';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="err">' + (j.error || 'Failed') + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/daily", "Daily Update", body)))
|
||||
}
|
||||
|
||||
type dailySubmitReq struct {
|
||||
Date string `json:"date"`
|
||||
Status string `json:"status"`
|
||||
Project string `json:"project"`
|
||||
Remarks string `json:"remarks"`
|
||||
}
|
||||
|
||||
func DailySubmit(w http.ResponseWriter, r *http.Request) {
|
||||
var req dailySubmitReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
text := formatDailyMessage(req)
|
||||
if err := CopyToClipboard(text); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := LoadConfig()
|
||||
cfg.DailyUpdate.LastProject = req.Project
|
||||
_ = SaveConfig(cfg)
|
||||
writeJSON(w, map[string]any{"ok": true, "text": text})
|
||||
}
|
||||
|
||||
func formatDailyMessage(p dailySubmitReq) string {
|
||||
bullets := []string{}
|
||||
for _, line := range strings.Split(p.Remarks, "\n") {
|
||||
t := strings.TrimRight(line, "\r")
|
||||
t = regexp.MustCompile(`^\s*[-*]\s*`).ReplaceAllString(t, "")
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
bullets = append(bullets, "- "+t)
|
||||
}
|
||||
}
|
||||
lines := []string{
|
||||
"Date: " + formatDailyDate(p.Date),
|
||||
"Status: " + p.Status,
|
||||
"Project: " + p.Project,
|
||||
"Remarks:",
|
||||
strings.Join(bullets, "\n"),
|
||||
}
|
||||
if p.Status == "For OT" {
|
||||
lines = append(lines, "@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatDailyDate(iso string) string {
|
||||
t, err := time.Parse("2006-01-02", iso)
|
||||
if err != nil {
|
||||
return iso
|
||||
}
|
||||
return t.Format("01/02/2006")
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
9
standalone/go.mod
Normal file
9
standalone/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module hpltoolbox
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e // indirect
|
||||
)
|
||||
7
standalone/go.sum
Normal file
7
standalone/go.sum
Normal file
@@ -0,0 +1,7 @@
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e h1:f5mksnk+hgXHnImpZoWj64ja99j9zV7YUgrVG95uFE4=
|
||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
19
standalone/home.go
Normal file
19
standalone/home.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func HomePage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>HPL Toolbox</h2>
|
||||
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
||||
<ul style="line-height:1.9;">
|
||||
<li><a href="/plec" style="color:#9cdcfe;">PLEC Upload</a> — Upload HTML to the internal PLEC server</li>
|
||||
<li><a href="/applovin" style="color:#9cdcfe;">AppLovin Demo Upload</a> — Upload to p.applov.in (QR preview)</li>
|
||||
<li><a href="/base64" style="color:#9cdcfe;">Base64 Scanner</a> — Find non-base64 assets in HTML</li>
|
||||
<li><a href="/daily" style="color:#9cdcfe;">Daily Update</a> — Compose & copy a daily status</li>
|
||||
<li><a href="/mobile" style="color:#9cdcfe;">Send To Mobile</a> — Share HTML to a phone via LAN + QR</li>
|
||||
</ul>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/", "Home", body)))
|
||||
}
|
||||
BIN
standalone/hpl.ico
Normal file
BIN
standalone/hpl.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
standalone/hpltoolbox.exe
Normal file
BIN
standalone/hpltoolbox.exe
Normal file
Binary file not shown.
74
standalone/layout.go
Normal file
74
standalone/layout.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
const SharedCSS = `
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1e1e1e; color: #ddd;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
|
||||
font-size: 13px;
|
||||
}
|
||||
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
||||
.topbar a:hover { background: #2a2d2e; }
|
||||
.topbar a.active { background: #094771; color: #fff; }
|
||||
.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 {
|
||||
box-sizing: border-box; padding: 6px 8px;
|
||||
background: #3c3c3c; color: #ddd;
|
||||
border: 1px solid #3c3c3c; border-radius: 2px;
|
||||
font-family: inherit; font-size: 13px;
|
||||
}
|
||||
input[type=text]:focus, input[type=date]:focus, select:focus, textarea:focus {
|
||||
outline: 1px solid #007fd4; border-color: #007fd4;
|
||||
}
|
||||
button {
|
||||
background: #0e639c; color: #fff;
|
||||
border: none; padding: 6px 14px; border-radius: 2px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { background: #1177bb; }
|
||||
button.secondary { background: #3a3d41; color: #ddd; }
|
||||
button.secondary:hover { background: #45494e; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.err { color: #f48771; white-space: pre-wrap; }
|
||||
.ok { color: #89d185; }
|
||||
.hint { font-size: 12px; opacity: 0.7; }
|
||||
`
|
||||
|
||||
type navItem struct{ Path, Label string }
|
||||
|
||||
var navItems = []navItem{
|
||||
{"/", "Home"},
|
||||
{"/plec", "PLEC Upload"},
|
||||
{"/applovin", "AppLovin Upload"},
|
||||
{"/base64", "Base64 Scanner"},
|
||||
{"/daily", "Daily Update"},
|
||||
{"/mobile", "Send To Mobile"},
|
||||
}
|
||||
|
||||
func Page(activePath, title, body string) string {
|
||||
var tabs strings.Builder
|
||||
for _, n := range navItems {
|
||||
cls := ""
|
||||
if n.Path == activePath {
|
||||
cls = " class=\"active\""
|
||||
}
|
||||
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + n.Label + `</a>`)
|
||||
}
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>` + title + ` — HPL Toolbox</title>
|
||||
<style>` + SharedCSS + `</style>
|
||||
</head><body>
|
||||
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
|
||||
<div class="content">` + body + `</div>
|
||||
</body></html>`
|
||||
}
|
||||
182
standalone/main.go
Normal file
182
standalone/main.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
webview "github.com/jchv/go-webview2"
|
||||
)
|
||||
|
||||
var AppVersion = "dev"
|
||||
|
||||
var currentHwnd atomic.Uintptr
|
||||
|
||||
func setupFileLogging() {
|
||||
logPath := filepath.Join(userDataDir(), "debug.log")
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.SetOutput(f)
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
func main() {
|
||||
setupFileLogging()
|
||||
log.Printf("HPL Toolbox starting (pid=%d)", os.Getpid())
|
||||
|
||||
if focusExistingInstance() {
|
||||
log.Printf("Another instance is running; focused it and exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
log.Printf("Server listening on 127.0.0.1:%d", port)
|
||||
|
||||
mux := buildMux()
|
||||
srv := &http.Server{Handler: mux}
|
||||
go func() {
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := writeLockFile(port); err != nil {
|
||||
log.Printf("Failed to write lock file: %v", err)
|
||||
}
|
||||
defer removeLockFile()
|
||||
|
||||
webviewTmp, err := os.MkdirTemp("", "hpltoolbox-wv2-*")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create webview temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(webviewTmp)
|
||||
|
||||
w := webview.NewWithOptions(webview.WebViewOptions{
|
||||
Debug: false,
|
||||
AutoFocus: true,
|
||||
DataPath: webviewTmp,
|
||||
WindowOptions: webview.WindowOptions{
|
||||
Title: "HPL Toolbox " + AppVersion,
|
||||
Width: 1100,
|
||||
Height: 720,
|
||||
IconId: 1,
|
||||
Center: true,
|
||||
},
|
||||
})
|
||||
if w == nil {
|
||||
log.Fatalln("Failed to create webview.")
|
||||
}
|
||||
defer w.Destroy()
|
||||
|
||||
// Stash HWND for the /api/focus handler.
|
||||
currentHwnd.Store(uintptr(w.Window()))
|
||||
|
||||
w.SetSize(1100, 720, webview.HintNone)
|
||||
w.Navigate(fmt.Sprintf("http://127.0.0.1:%d/", port))
|
||||
w.Run()
|
||||
|
||||
// Window closed — shut the server down cleanly.
|
||||
log.Printf("Window closed; shutting down server.")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func buildMux() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Pages
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/home" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
HomePage(w, r)
|
||||
})
|
||||
mux.HandleFunc("GET /plec", PlecPage)
|
||||
mux.HandleFunc("GET /applovin", ApplovinPage)
|
||||
mux.HandleFunc("GET /base64", Base64Page)
|
||||
mux.HandleFunc("GET /daily", DailyPage)
|
||||
mux.HandleFunc("GET /mobile", MobilePage)
|
||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||
|
||||
// Shared API
|
||||
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
||||
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
||||
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
||||
|
||||
// Daily Update
|
||||
mux.HandleFunc("POST /api/daily/submit", DailySubmit)
|
||||
|
||||
// PLEC
|
||||
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
||||
mux.HandleFunc("POST /api/plec/upload", PlecUpload)
|
||||
|
||||
// AppLovin
|
||||
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
|
||||
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
|
||||
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
|
||||
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
|
||||
|
||||
// Base64
|
||||
mux.HandleFunc("POST /api/base64/pickFolder", Base64PickFolder)
|
||||
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
||||
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
||||
|
||||
// Send To Mobile
|
||||
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
||||
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
||||
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// FocusEndpoint brings the webview window to the foreground. Used by a second
|
||||
// instance that wants to focus the running one before exiting.
|
||||
func FocusEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
hwnd := currentHwnd.Load()
|
||||
if hwnd != 0 {
|
||||
restoreAndFocus(hwnd)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
procShowWindow = user32.NewProc("ShowWindow")
|
||||
procSetForeground = user32.NewProc("SetForegroundWindow")
|
||||
procIsIconic = user32.NewProc("IsIconic")
|
||||
)
|
||||
|
||||
const (
|
||||
swRestore = 9
|
||||
swShow = 5
|
||||
)
|
||||
|
||||
func restoreAndFocus(hwnd uintptr) {
|
||||
// If minimized, restore. Otherwise just ensure it's shown.
|
||||
iconic, _, _ := procIsIconic.Call(hwnd)
|
||||
if iconic != 0 {
|
||||
_, _, _ = procShowWindow.Call(hwnd, swRestore)
|
||||
} else {
|
||||
_, _, _ = procShowWindow.Call(hwnd, swShow)
|
||||
}
|
||||
_, _, _ = procSetForeground.Call(hwnd)
|
||||
}
|
||||
|
||||
// Silence "imported and not used" complaints if any.
|
||||
var _ = unsafe.Sizeof(int(0))
|
||||
390
standalone/mobile.go
Normal file
390
standalone/mobile.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"html"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed assets/qrcode.min.js
|
||||
var qrcodeJS []byte
|
||||
|
||||
type activeShare struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
port int
|
||||
token string
|
||||
fileBuf []byte
|
||||
filename string
|
||||
}
|
||||
|
||||
var (
|
||||
activeMu sync.Mutex
|
||||
active *activeShare
|
||||
)
|
||||
|
||||
func stopActiveShare() {
|
||||
activeMu.Lock()
|
||||
defer activeMu.Unlock()
|
||||
if active != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = active.server.Shutdown(ctx)
|
||||
active = nil
|
||||
}
|
||||
}
|
||||
|
||||
func MobileQrScript(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
_, _ = w.Write(qrcodeJS)
|
||||
}
|
||||
|
||||
func MobilePage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Send To Mobile</h2>
|
||||
<p class="hint" style="margin-top:0;">
|
||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
||||
</p>
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button id="pick" class="secondary">Choose...</button>
|
||||
<span id="fileName" class="hint">(no file)</span>
|
||||
</div>
|
||||
<input type="hidden" id="filePath" />
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<button id="start">Start sharing</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
|
||||
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
||||
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" style="display:none;">
|
||||
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
||||
<button id="openUrl" class="secondary">Open in browser</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:8px;">
|
||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:18px;"></div>
|
||||
|
||||
<script src="/assets/qrcode.min.js"></script>
|
||||
<script>
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const startBtn = document.getElementById('start');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const statusEl = document.getElementById('status');
|
||||
const shareInfo = document.getElementById('shareInfo');
|
||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||
const ifaceList = document.getElementById('ifaceList');
|
||||
const qrEl = document.getElementById('qr');
|
||||
const urlTextEl = document.getElementById('urlText');
|
||||
|
||||
let currentUrls = [];
|
||||
let selectedIdx = 0;
|
||||
|
||||
pickBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) {
|
||||
filePathEl.value = j.path;
|
||||
fileNameEl.textContent = j.name;
|
||||
}
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
statusEl.innerHTML = '';
|
||||
if (!filePathEl.value) {
|
||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
statusEl.textContent = 'Starting server...';
|
||||
const r = await fetch('/api/mobile/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ path: filePathEl.value }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.error) {
|
||||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||||
startBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
currentUrls = j.urls;
|
||||
selectedIdx = 0;
|
||||
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
|
||||
shareInfo.style.display = '';
|
||||
renderIfaces(j.urls);
|
||||
renderQr(j.urls[0].url);
|
||||
stopBtn.disabled = false;
|
||||
});
|
||||
|
||||
stopBtn.addEventListener('click', async () => {
|
||||
await fetch('/api/mobile/stop', { method:'POST' });
|
||||
statusEl.textContent = 'Stopped.';
|
||||
shareInfo.style.display = 'none';
|
||||
currentUrls = [];
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
});
|
||||
|
||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
||||
const url = currentUrls[selectedIdx]?.url || '';
|
||||
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
|
||||
});
|
||||
document.getElementById('openUrl').addEventListener('click', () => {
|
||||
const url = currentUrls[selectedIdx]?.url || '';
|
||||
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
|
||||
});
|
||||
|
||||
function renderQr(url) {
|
||||
qrEl.innerHTML = '';
|
||||
urlTextEl.textContent = url;
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
qr.make();
|
||||
qrEl.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 2, scalable: true });
|
||||
const svg = qrEl.querySelector('svg');
|
||||
if (svg) { svg.setAttribute('width','240'); svg.setAttribute('height','240'); }
|
||||
} catch (e) {
|
||||
qrEl.textContent = 'QR render failed: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIfaces(urls) {
|
||||
ifaceList.innerHTML = '';
|
||||
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
||||
ifaceWrap.style.display = '';
|
||||
urls.forEach((u, i) => {
|
||||
const id = 'iface_' + i;
|
||||
const label = document.createElement('label');
|
||||
label.style.fontSize = '12px';
|
||||
label.style.cursor = 'pointer';
|
||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
||||
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
|
||||
label.querySelector('input').addEventListener('change', () => {
|
||||
selectedIdx = i;
|
||||
renderQr(urls[i].url);
|
||||
});
|
||||
ifaceList.appendChild(label);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/mobile", "Send To Mobile", body)))
|
||||
}
|
||||
|
||||
func MobilePick(w http.ResponseWriter, r *http.Request) {
|
||||
picked := PickFiles(
|
||||
"Select HTML",
|
||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||
false, "",
|
||||
)
|
||||
if len(picked) == 0 {
|
||||
writeJSON(w, map[string]any{"path": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
|
||||
}
|
||||
|
||||
func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
stopActiveShare()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Path == "" || !fileExists(req.Path) {
|
||||
writeJSON(w, map[string]any{"error": "File missing."})
|
||||
return
|
||||
}
|
||||
fileBuf, err := os.ReadFile(req.Path)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
filename := filepath.Base(req.Path)
|
||||
|
||||
// 6 random bytes -> base64url, no padding
|
||||
tokenBytes := make([]byte, 6)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
|
||||
|
||||
cfgPort := LoadConfig().SendToMobile.Port
|
||||
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()})
|
||||
return
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
share := &activeShare{
|
||||
listener: listener,
|
||||
port: port,
|
||||
token: token,
|
||||
fileBuf: fileBuf,
|
||||
filename: filename,
|
||||
}
|
||||
share.server = &http.Server{Handler: shareHandler(share)}
|
||||
|
||||
go func() { _ = share.server.Serve(listener) }()
|
||||
|
||||
ips := getLanIPs()
|
||||
if len(ips) == 0 {
|
||||
_ = share.server.Close()
|
||||
writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"})
|
||||
return
|
||||
}
|
||||
|
||||
activeMu.Lock()
|
||||
active = share
|
||||
activeMu.Unlock()
|
||||
|
||||
type urlInfo struct {
|
||||
IP string `json:"ip"`
|
||||
Iface string `json:"iface"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
urls := make([]urlInfo, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
urls = append(urls, urlInfo{
|
||||
IP: ip.address,
|
||||
Iface: ip.iface,
|
||||
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
|
||||
}
|
||||
|
||||
func MobileStop(w http.ResponseWriter, r *http.Request) {
|
||||
stopActiveShare()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func shareHandler(s *activeShare) http.Handler {
|
||||
prefix := "/s/" + s.token
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
switch rest {
|
||||
case "", "/":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(chooserPage(s.filename)))
|
||||
case "/view":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(s.fileBuf)
|
||||
case "/file":
|
||||
safeName := safeFilename(s.filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf)))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(s.fileBuf)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||
|
||||
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
||||
|
||||
func chooserPage(filename string) string {
|
||||
escName := html.EscapeString(filename)
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Send To Mobile</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
||||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||
a.view { background: #2d7dff; color: white; }
|
||||
a.dl { background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>HPL Toolbox</h1>
|
||||
<div class="file">` + escName + `</div>
|
||||
<a class="btn view" href="view">View in browser</a>
|
||||
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
type lanIP struct {
|
||||
address string
|
||||
iface string
|
||||
}
|
||||
|
||||
func getLanIPs() []lanIP {
|
||||
var out []lanIP
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipNet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip4 := ipNet.IP.To4()
|
||||
if ip4 == nil || ip4.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
out = append(out, lanIP{address: ip4.String(), iface: iface.Name})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
228
standalone/platform.go
Normal file
228
standalone/platform.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PlecConfig struct {
|
||||
UploadUrl string `json:"uploadUrl"`
|
||||
OriginUrl string `json:"originUrl"`
|
||||
PreviewBase string `json:"previewBase"`
|
||||
SkipExistsCheck bool `json:"skipExistsCheck"`
|
||||
}
|
||||
|
||||
type ApplovinConfig struct {
|
||||
Cookie string `json:"cookie"`
|
||||
DelayMs int `json:"delayMs"`
|
||||
RandomizeUserAgent *bool `json:"randomizeUserAgent,omitempty"`
|
||||
}
|
||||
|
||||
type SendToMobileConfig struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type DailyUpdateConfig struct {
|
||||
LastProject string `json:"lastProject"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Plec PlecConfig `json:"plec"`
|
||||
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{
|
||||
UploadUrl: "http://167.99.227.249/src/upload_HTML_Experimental.php",
|
||||
OriginUrl: "http://167.99.227.249",
|
||||
PreviewBase: "https://playable.applovindemo.com/phaser/",
|
||||
SkipExistsCheck: false,
|
||||
},
|
||||
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
||||
SendToMobile: SendToMobileConfig{Port: 0},
|
||||
DailyUpdate: DailyUpdateConfig{LastProject: ""},
|
||||
}
|
||||
}
|
||||
|
||||
func appDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
wd, _ := os.Getwd()
|
||||
return wd
|
||||
}
|
||||
// `go run` puts the exe in a temp dir; fall back to cwd in that case.
|
||||
resolved, err := filepath.EvalSymlinks(exe)
|
||||
if err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
if strings.Contains(strings.ToLower(dir), "go-build") {
|
||||
wd, _ := os.Getwd()
|
||||
return wd
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func configPath() string {
|
||||
return filepath.Join(userDataDir(), "config.json")
|
||||
}
|
||||
|
||||
func LoadConfig() AppConfig {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
cfg := defaultConfig()
|
||||
data, err := os.ReadFile(configPath())
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func SaveConfig(cfg AppConfig) error {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configPath(), data, 0644)
|
||||
}
|
||||
|
||||
func OpenInBrowser(url string) {
|
||||
// `start` is a cmd builtin. Quoted empty arg is the window title.
|
||||
cmd := exec.Command("cmd", "/c", "start", "", url)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
func CopyToClipboard(text string) error {
|
||||
cmd := exec.Command("clip")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
type FileFilter struct {
|
||||
Name string
|
||||
Extensions []string
|
||||
}
|
||||
|
||||
func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
|
||||
func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string {
|
||||
filterStr := "All files|*.*"
|
||||
if len(filters) > 0 {
|
||||
parts := make([]string, 0, len(filters))
|
||||
for _, f := range filters {
|
||||
exts := make([]string, 0, len(f.Extensions))
|
||||
for _, e := range f.Extensions {
|
||||
exts = append(exts, "*."+e)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s|%s", f.Name, strings.Join(exts, ";")))
|
||||
}
|
||||
filterStr = strings.Join(parts, "|")
|
||||
}
|
||||
multiStr := "false"
|
||||
if multi {
|
||||
multiStr = "true"
|
||||
}
|
||||
script := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dlg = New-Object System.Windows.Forms.OpenFileDialog
|
||||
$dlg.Title = '%s'
|
||||
$dlg.Filter = '%s'
|
||||
$dlg.Multiselect = $%s
|
||||
if ('%s'.Length -gt 0) { $dlg.InitialDirectory = '%s' }
|
||||
if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$dlg.FileNames | ForEach-Object { Write-Output $_ }
|
||||
}
|
||||
`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(out), "\n")
|
||||
result := make([]string, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
t := strings.TrimSpace(l)
|
||||
if t != "" {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func PickSaveFile(title, defaultName string, filters []FileFilter, initialDir string) string {
|
||||
filterStr := "All files|*.*"
|
||||
if len(filters) > 0 {
|
||||
parts := make([]string, 0, len(filters))
|
||||
for _, f := range filters {
|
||||
exts := make([]string, 0, len(f.Extensions))
|
||||
for _, e := range f.Extensions {
|
||||
exts = append(exts, "*."+e)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s|%s", f.Name, strings.Join(exts, ";")))
|
||||
}
|
||||
filterStr = strings.Join(parts, "|")
|
||||
}
|
||||
script := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dlg = New-Object System.Windows.Forms.SaveFileDialog
|
||||
$dlg.Title = '%s'
|
||||
$dlg.Filter = '%s'
|
||||
$dlg.FileName = '%s'
|
||||
if ('%s'.Length -gt 0) { $dlg.InitialDirectory = '%s' }
|
||||
if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.FileName
|
||||
}
|
||||
`, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func PickFolder(title, initialDir string) string {
|
||||
script := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$dlg.Description = '%s'
|
||||
if ('%s'.Length -gt 0) { $dlg.SelectedPath = '%s' }
|
||||
if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.SelectedPath
|
||||
}
|
||||
`, psEscape(title), psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
405
standalone/plec.go
Normal file
405
standalone/plec.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
urlpkg "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func PlecPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>PLEC Upload</h2>
|
||||
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
|
||||
|
||||
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;">
|
||||
<thead><tr>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="addRow" class="secondary">+ Add Row</button>
|
||||
<button id="upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||
<div id="results"></div>
|
||||
|
||||
<style>
|
||||
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
|
||||
.file-cell { display:flex; align-items:center; gap:8px; }
|
||||
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
|
||||
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
||||
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result a { color:#9cdcfe; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; }
|
||||
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let nextId = 1;
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
function parseIterationName(filename) {
|
||||
const base = filename.replace(/\.html?$/i, '');
|
||||
const tokens = base.split('_');
|
||||
for (const t of tokens) {
|
||||
if (/^(full|\d+clk|\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const id = 'r' + (nextId++);
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = id;
|
||||
tr.innerHTML = ` + "`" + `
|
||||
<td>
|
||||
<div class="file-cell">
|
||||
<button class="pick secondary">Choose...</button>
|
||||
<span class="file-name" data-name>(no file)</span>
|
||||
</div>
|
||||
<input type="hidden" data-path />
|
||||
<div class="row-error" data-error></div>
|
||||
</td>
|
||||
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
|
||||
<td><button class="remove-btn" title="Remove">×</button></td>
|
||||
` + "`" + `;
|
||||
tr.querySelector('.pick').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||||
const j = await res.json();
|
||||
if (j.files && j.files.length) {
|
||||
const affected = [];
|
||||
j.files.forEach(function(f, i) {
|
||||
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
|
||||
target.querySelector('[data-path]').value = f.path;
|
||||
target.querySelector('[data-name]').textContent = f.name;
|
||||
const iter = target.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||
affected.push(target);
|
||||
});
|
||||
if (affected.length > 1) {
|
||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||
affected.forEach((r, i) => {
|
||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
if (!tbody.children.length) addRow();
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
document.getElementById('addRow').addEventListener('click', addRow);
|
||||
|
||||
document.getElementById('upload').addEventListener('click', async () => {
|
||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
||||
id: tr.dataset.id,
|
||||
path: tr.querySelector('[data-path]').value,
|
||||
iteration: tr.querySelector('[data-iter]').value,
|
||||
})).filter(r => r.path || r.iteration);
|
||||
if (rows.length === 0) {
|
||||
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = 'Uploading...';
|
||||
const res = await fetch('/api/plec/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rows }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.rowErrors) {
|
||||
for (const re of j.rowErrors) {
|
||||
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
|
||||
if (tr) tr.querySelector('[data-error]').textContent = re.message;
|
||||
}
|
||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||
return;
|
||||
}
|
||||
if (j.error) {
|
||||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||||
return;
|
||||
}
|
||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
if (j.preview) {
|
||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copy = document.createElement('button');
|
||||
copy.textContent = 'Copy';
|
||||
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
|
||||
const open = document.createElement('button');
|
||||
open.textContent = 'Open';
|
||||
open.className = 'secondary';
|
||||
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
|
||||
const showRaw = document.createElement('button');
|
||||
showRaw.textContent = 'Show server response';
|
||||
showRaw.className = 'secondary';
|
||||
showRaw.onclick = () => {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.marginTop = '8px';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = j.rawText;
|
||||
wrap.appendChild(pre);
|
||||
showRaw.disabled = true;
|
||||
};
|
||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
||||
wrap.appendChild(actions);
|
||||
} else {
|
||||
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'<') + '</pre>';
|
||||
}
|
||||
resultsEl.innerHTML = '';
|
||||
resultsEl.appendChild(wrap);
|
||||
});
|
||||
|
||||
addRow();
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/plec", "PLEC Upload", body)))
|
||||
}
|
||||
|
||||
func PlecPick(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
picked := PickFiles(
|
||||
"Select HTML",
|
||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||
true, cfg.LastPickDir,
|
||||
)
|
||||
if len(picked) == 0 {
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
cfg.LastPickDir = filepath.Dir(picked[0])
|
||||
_ = SaveConfig(cfg)
|
||||
files := make([]map[string]string, 0, len(picked))
|
||||
for _, p := range picked {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
type plecRow struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Iteration string `json:"iteration"`
|
||||
}
|
||||
|
||||
func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Rows []plecRow `json:"rows"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := LoadConfig().Plec
|
||||
|
||||
type rowErr struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
var rowErrors []rowErr
|
||||
var valid []plecRow
|
||||
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
|
||||
|
||||
for _, row := range req.Rows {
|
||||
if row.Path == "" || !fileExists(row.Path) {
|
||||
rowErrors = append(rowErrors, rowErr{row.ID, "File missing"})
|
||||
} else if strings.TrimSpace(row.Iteration) == "" {
|
||||
rowErrors = append(rowErrors, rowErr{row.ID, "Iteration name required"})
|
||||
} else if !htmlRx.MatchString(row.Path) {
|
||||
rowErrors = append(rowErrors, rowErr{row.ID, "Must be .html"})
|
||||
} else {
|
||||
valid = append(valid, row)
|
||||
}
|
||||
}
|
||||
if len(rowErrors) > 0 {
|
||||
writeJSON(w, map[string]any{"rowErrors": rowErrors})
|
||||
return
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
writeJSON(w, map[string]any{"error": "Nothing to upload."})
|
||||
return
|
||||
}
|
||||
|
||||
stamp := time.Now().Format("20060102150405")
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for i, row := range valid {
|
||||
idx := i + 1
|
||||
data, err := os.ReadFile(row.Path)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
|
||||
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
|
||||
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
|
||||
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
|
||||
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
httpReq.Header.Set("Origin", cfg.OriginUrl)
|
||||
httpReq.Header.Set("Referer", cfg.OriginUrl+"/")
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
|
||||
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
httpReq.Header.Set("Accept", "*/*")
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
|
||||
return
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
|
||||
return
|
||||
}
|
||||
var preview any
|
||||
if v, ok := parsed["preview"]; ok && v != nil {
|
||||
preview = v
|
||||
} else if v, ok := parsed["previewURL"]; ok {
|
||||
preview = v
|
||||
}
|
||||
writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)})
|
||||
}
|
||||
|
||||
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := CopyToClipboard(req.Text); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func OpenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
OpenInBrowser(req.URL)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func appendTimestamp(filename, stamp string) string {
|
||||
ext := filepath.Ext(filename)
|
||||
base := filename
|
||||
if ext != "" {
|
||||
base = filename[:len(filename)-len(ext)]
|
||||
}
|
||||
return base + "_" + stamp + ext
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// createFormFileWithType is like multipart.Writer.CreateFormFile but lets us
|
||||
// override the Content-Type (CreateFormFile hardcodes "application/octet-stream").
|
||||
func createFormFileWithType(mw *multipart.Writer, field, filename, contentType string) (io.Writer, error) {
|
||||
h := make(map[string][]string)
|
||||
h["Content-Disposition"] = []string{
|
||||
fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename),
|
||||
}
|
||||
h["Content-Type"] = []string{contentType}
|
||||
return mw.CreatePart(h)
|
||||
}
|
||||
|
||||
// encodeURICompatible mimics JS encodeURI() — keeps A-Za-z0-9 and these
|
||||
// reserved/mark chars: ;,/?:@&=+$-_.!~*'()#
|
||||
func encodeURICompatible(s string) string {
|
||||
keep := func(c byte) bool {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
return true
|
||||
}
|
||||
switch c {
|
||||
case ';', ',', '/', '?', ':', '@', '&', '=', '+', '$',
|
||||
'-', '_', '.', '!', '~', '*', '\'', '(', ')', '#':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if keep(c) {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("%%%02X", c))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
BIN
standalone/rsrc.syso
Normal file
BIN
standalone/rsrc.syso
Normal file
Binary file not shown.
68
standalone/single_instance.go
Normal file
68
standalone/single_instance.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func lockFilePath() string {
|
||||
return filepath.Join(userDataDir(), ".hpltoolbox.lock")
|
||||
}
|
||||
|
||||
// focusExistingInstance returns true if another instance was found and
|
||||
// successfully asked to focus its window. In that case the caller should exit.
|
||||
// Returns false if no live instance exists (and clears any stale lock).
|
||||
func focusExistingInstance() bool {
|
||||
data, err := os.ReadFile(lockFilePath())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(parts) != 2 {
|
||||
_ = os.Remove(lockFilePath())
|
||||
return false
|
||||
}
|
||||
pid, errPid := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
port, errPort := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if errPid != nil || errPort != nil {
|
||||
_ = os.Remove(lockFilePath())
|
||||
return false
|
||||
}
|
||||
|
||||
// Quick liveness check: PID must exist.
|
||||
if proc, err := os.FindProcess(pid); err != nil || proc == nil {
|
||||
_ = os.Remove(lockFilePath())
|
||||
return false
|
||||
}
|
||||
|
||||
// Ask the running instance to focus its window.
|
||||
client := &http.Client{Timeout: 1500 * time.Millisecond}
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/api/focus", port)
|
||||
resp, err := client.Post(url, "application/json", bytes.NewReader(nil))
|
||||
if err != nil {
|
||||
// Lock file is stale (process died but didn't clean up).
|
||||
_ = os.Remove(lockFilePath())
|
||||
return false
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = os.Remove(lockFilePath())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeLockFile(port int) error {
|
||||
content := fmt.Sprintf("%d\n%d\n", os.Getpid(), port)
|
||||
return os.WriteFile(lockFilePath(), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func removeLockFile() {
|
||||
_ = os.Remove(lockFilePath())
|
||||
}
|
||||
Reference in New Issue
Block a user