playworks converter
This commit is contained in:
504
src/tools/playworksConverter.ts
Normal file
504
src/tools/playworksConverter.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cp from 'child_process';
|
||||
import { singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
interface ConvertOptions {
|
||||
sourcePath: string;
|
||||
outputDir: string;
|
||||
networks: string[];
|
||||
}
|
||||
|
||||
interface NetworkResult {
|
||||
network: string;
|
||||
file: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'pickSource': {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: false,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select Playworks HTML',
|
||||
});
|
||||
if (!picked?.[0]) return;
|
||||
const srcPath = picked[0].fsPath;
|
||||
const dir = path.dirname(srcPath);
|
||||
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir });
|
||||
break;
|
||||
}
|
||||
case 'pickOutput': {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Output Folder',
|
||||
});
|
||||
if (!picked?.[0]) return;
|
||||
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
|
||||
break;
|
||||
}
|
||||
case 'convert': {
|
||||
const opts: ConvertOptions = msg.opts;
|
||||
if (!fs.existsSync(opts.sourcePath)) {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(opts.outputDir)) {
|
||||
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||
} catch (e: any) {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const results: NetworkResult[] = [];
|
||||
for (const network of opts.networks) {
|
||||
try {
|
||||
const filePath = await convertNetwork(html, opts, network);
|
||||
results.push({ network, file: filePath, ok: true });
|
||||
} catch (e: any) {
|
||||
results.push({ network, file: '', ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
panel.webview.postMessage({ type: 'convertDone', results });
|
||||
break;
|
||||
}
|
||||
case 'openOutput': {
|
||||
vscode.env.openExternal(vscode.Uri.file(msg.dir));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
|
||||
|
||||
function sourceBaseName(sourcePath: string): string {
|
||||
return path.basename(sourcePath, path.extname(sourcePath))
|
||||
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
|
||||
}
|
||||
|
||||
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
|
||||
const transformed = transformHtml(html, network);
|
||||
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||
const baseName = sourceBaseName(opts.sourcePath);
|
||||
const htmlFileName = `${baseName}_${network}.html`;
|
||||
|
||||
if (needsZip) {
|
||||
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
|
||||
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||
try {
|
||||
await createZip(tmpPath, 'index.html', zipPath);
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
const outPath = path.join(opts.outputDir, htmlFileName);
|
||||
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function transformHtml(html: string, network: string): string {
|
||||
let result = html;
|
||||
|
||||
// Inject into </head>: lifecycle stubs only (must register luna:build listener
|
||||
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
|
||||
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
|
||||
let headInject = buildLifecycleScript();
|
||||
if (network === 'al' || network === 'is') {
|
||||
headInject += '<script src="mraid.js"></script>';
|
||||
} else if (network === 'gg') {
|
||||
headInject += '<script src="exitapi.js"></script>';
|
||||
}
|
||||
result = result.replace('</head>', headInject + '\n</head>');
|
||||
|
||||
// Body-level flags
|
||||
if (network === 'vu') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
|
||||
} else if (network === 'mtg') {
|
||||
result = result.replace('<body>', '<body onload="gameReady()">');
|
||||
} else if (network === 'tt') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>');
|
||||
}
|
||||
|
||||
// Replace the CTA script
|
||||
result = replaceCTAScript(result, network);
|
||||
|
||||
// Unity: rewrite window.top → window.self (Luna static scan requirement)
|
||||
if (network === 'un') {
|
||||
result = result.split('window.top').join('window.self');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceCTAScript(html: string, network: string): string {
|
||||
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||
const markerIdx = html.lastIndexOf(marker);
|
||||
if (markerIdx === -1) return html;
|
||||
|
||||
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
|
||||
if (scriptOpenIdx === -1) return html;
|
||||
|
||||
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
|
||||
if (scriptCloseIdx === -1) return html;
|
||||
|
||||
const before = html.slice(0, scriptOpenIdx);
|
||||
const after = html.slice(scriptCloseIdx + '</script>'.length);
|
||||
|
||||
// Console restore runs after the bundle (which overrides console), before CTA
|
||||
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
|
||||
}
|
||||
|
||||
// Restores native console methods overridden by the Playworks bundle,
|
||||
// using a temporary iframe to get a fresh un-patched console reference.
|
||||
function buildConsoleRestoreScript(): string {
|
||||
return [
|
||||
'<script>(function(){',
|
||||
'try{',
|
||||
'var f=document.createElement("iframe");',
|
||||
'f.style.display="none";',
|
||||
'document.documentElement.appendChild(f);',
|
||||
'var nc=f.contentWindow.console;',
|
||||
'document.documentElement.removeChild(f);',
|
||||
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
|
||||
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
|
||||
'}catch(e){}',
|
||||
'})();</script>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
|
||||
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
|
||||
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
|
||||
// One-time guards prevent double-calls:
|
||||
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
|
||||
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
|
||||
function buildLifecycleScript(): string {
|
||||
return [
|
||||
'<script>(function(){',
|
||||
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
|
||||
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
|
||||
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
|
||||
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
|
||||
'var _grOnce=false,_gsOnce=false;',
|
||||
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
|
||||
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
|
||||
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
|
||||
'})();</script>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildCTAScript(network: string): string {
|
||||
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
|
||||
// gameClose must fire before every redirect
|
||||
const closeCall = `try{window.gameClose();}catch(e){}`;
|
||||
|
||||
let ctaLogic: string;
|
||||
switch (network) {
|
||||
case 'al':
|
||||
case 'is':
|
||||
// MRAID — mraid.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`;
|
||||
break;
|
||||
case 'un':
|
||||
// Unity Ads — MRAID-based; window.top already rewritten separately
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
|
||||
break;
|
||||
case 'fb':
|
||||
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'gg':
|
||||
// Google — exitapi.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'mo':
|
||||
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'vu':
|
||||
// Vungle — window.__VUNGLE__ flag set in body
|
||||
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
|
||||
break;
|
||||
case 'mtg':
|
||||
// Mintegral — onload="gameReady()" set on body
|
||||
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'tt':
|
||||
// TikTok — window.__TIKTOK__ flag set in body
|
||||
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
default:
|
||||
ctaLogic = `${closeCall}window.open(o,"_blank");`;
|
||||
}
|
||||
|
||||
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
|
||||
}
|
||||
|
||||
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Remove existing zip if present (Compress-Archive would fail otherwise)
|
||||
if (fs.existsSync(destZipPath)) {
|
||||
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const psScript = [
|
||||
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
|
||||
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
|
||||
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
|
||||
'$zip.Dispose()',
|
||||
].join(' ');
|
||||
|
||||
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Webview UI ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getHtml(): string {
|
||||
const networks = [
|
||||
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
|
||||
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
||||
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
||||
{ tag: 'mo', label: 'Moloco', note: 'HTML' },
|
||||
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
|
||||
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
||||
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
||||
];
|
||||
|
||||
const checkboxRows = networks.map(n => `
|
||||
<label class="net-row">
|
||||
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
|
||||
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
|
||||
<span class="net-note">${n.note}</span>
|
||||
</label>`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; }
|
||||
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
|
||||
.section { margin-bottom: 14px; }
|
||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
|
||||
.row { display: flex; gap: 6px; align-items: center; }
|
||||
input[type=text] {
|
||||
flex: 1; padding: 4px 7px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, #555);
|
||||
border-radius: 2px; font-size: 12px;
|
||||
}
|
||||
button {
|
||||
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border, #444);
|
||||
}
|
||||
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
button.primary {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 18px; font-size: 13px;
|
||||
}
|
||||
button.primary:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
.net-grid { display: flex; flex-direction: column; gap: 4px; }
|
||||
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
|
||||
.net-label { min-width: 100px; font-weight: 500; }
|
||||
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||
.net-note { font-size: 11px; opacity: 0.6; }
|
||||
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||
.toggle-row button { font-size: 11px; padding: 2px 8px; }
|
||||
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
|
||||
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
|
||||
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
|
||||
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.res-net { min-width: 70px; font-weight: 500; }
|
||||
.res-file { opacity: 0.65; word-break: break-all; }
|
||||
.res-err { color: #f85149; opacity: 0.9; }
|
||||
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Playworks Converter</h2>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Source HTML (Playworks export)</div>
|
||||
<div class="row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
|
||||
<button id="pickSrc">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Output Folder</div>
|
||||
<div class="row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||
<button id="pickOut">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Networks to generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll">All</button>
|
||||
<button id="selectNone">None</button>
|
||||
</div>
|
||||
<div class="net-grid">${checkboxRows}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" id="convert" disabled>Convert</button>
|
||||
<button id="openOut" style="display:none">Open Output Folder</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div class="results" id="results"></div>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
let outputDir = '';
|
||||
|
||||
const srcEl = document.getElementById('src');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||
});
|
||||
|
||||
document.getElementById('convert').addEventListener('click', () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')]
|
||||
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
convertBtn.disabled = true;
|
||||
vscode.postMessage({
|
||||
type: 'convert',
|
||||
opts: {
|
||||
sourcePath: srcEl.value,
|
||||
outputDir: outEl.value,
|
||||
networks,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'sourcePicked') {
|
||||
srcEl.value = msg.path;
|
||||
if (!outEl.value && msg.dir) {
|
||||
outEl.value = msg.dir;
|
||||
outputDir = msg.dir;
|
||||
}
|
||||
updateConvertBtn();
|
||||
} else if (msg.type === 'outputPicked') {
|
||||
outEl.value = msg.path;
|
||||
outputDir = msg.path;
|
||||
updateConvertBtn();
|
||||
} else if (msg.type === 'convertDone') {
|
||||
convertBtn.disabled = false;
|
||||
updateConvertBtn();
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
return;
|
||||
}
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'res-row';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const net = document.createElement('span');
|
||||
net.className = 'res-net';
|
||||
net.textContent = r.network;
|
||||
const detail = document.createElement('span');
|
||||
if (r.ok) {
|
||||
detail.className = 'res-file';
|
||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||
} else {
|
||||
detail.className = 'res-err';
|
||||
detail.textContent = r.error || 'Unknown error';
|
||||
}
|
||||
row.append(mark, net, detail);
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
if (ok > 0) {
|
||||
outputDir = outEl.value;
|
||||
openOutBtn.style.display = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user