Files
vsix-hpl-toolbox/src/tools/playworksConverter.ts
2026-05-27 15:13:58 +08:00

505 lines
20 KiB
TypeScript

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>`;
}