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 { 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 : 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 += ''; } else if (network === 'gg') { headInject += ''; } result = result.replace('', headInject + '\n'); // Body-level flags if (network === 'vu') { result = result.replace('', '\n'); } else if (network === 'mtg') { result = result.replace('', ''); } else if (network === 'tt') { result = result.replace('', '\n'); } // 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('', markerIdx); if (scriptCloseIdx === -1) return html; const before = html.slice(0, scriptOpenIdx); const after = html.slice(scriptCloseIdx + ''.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 [ '', ].join(''); } // Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events. // Registered in 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 [ '', ].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 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 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 ``; } function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise { 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 => ` `).join(''); return `

Playworks Converter


${checkboxRows}
`; }