import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import * as cp from 'child_process'; import { getToolWebviewStyles, singletonPanel } from './shared'; const store: { panel: vscode.WebviewPanel | null } = { panel: null }; interface ConvertOptions { sourcePath: string; outputDir: string; baseName?: 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, baseName: sourceBaseName(srcPath) }); 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 { panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' }); 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[] = []; try { panel.webview.postMessage({ type: 'status', message: 'Converting Unity...' }); results.push({ network: 'un', file: copyUnityInput(opts), ok: true }); } catch (e: any) { results.push({ network: 'un', file: '', ok: false, error: e.message }); } for (let i = 0; i < opts.networks.length; i++) { const network = opts.networks[i]; panel.webview.postMessage({ type: 'status', message: `Converting ${i + 1}/${opts.networks.length} ${network}` }); 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', 'ap', 'vu', 'mtg']); const SUPPORTED_NETWORKS = new Set(['al', 'is', 'fb', 'gg', 'ap', 'mo', 'vu', 'mtg', 'tt']); const MRAID_NETWORKS = new Set(['al', 'is']); const EXITAPI_NETWORKS = new Set(['gg', 'ap']); const NETWORK_OUTPUT_FOLDERS: Record = { al: 'Applovin', is: 'Ironsource', fb: 'Facebook', gg: 'GoogleAds', ap: 'Appreciate', mo: 'Moloco', vu: 'Vungle', mtg: 'Mintegral', tt: 'TikTok', }; function sourceBaseName(sourcePath: string): string { return path.basename(sourcePath, path.extname(sourcePath)) .replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/, ''); } function outputBaseName(opts: ConvertOptions): string { const rawBaseName = opts.baseName?.trim() || sourceBaseName(opts.sourcePath); const withoutExtension = rawBaseName.replace(/\.html?$/i, ''); const safeBaseName = path.basename(withoutExtension).replace(/[<>:"/\\|?*]/g, '_').trim(); return safeBaseName || sourceBaseName(opts.sourcePath); } async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise { if (!SUPPORTED_NETWORKS.has(network)) { throw new Error(`Unsupported Playworks target network: ${network}`); } const transformed = transformHtml(html, network); const needsZip = ZIPPED_NETWORKS.has(network); const baseName = outputBaseName(opts); const htmlFileName = `${baseName}_${network}.html`; const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network); fs.mkdirSync(outputDir, { recursive: true }); if (needsZip) { const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`); const zipPath = path.join(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(outputDir, htmlFileName); fs.writeFileSync(outPath, transformed, 'utf8'); return outPath; } function copyUnityInput(opts: ConvertOptions): string { const baseName = outputBaseName(opts); const outputDir = path.join(opts.outputDir, 'Unity'); const outPath = path.join(outputDir, `${baseName}_un.html`); fs.mkdirSync(outputDir, { recursive: true }); if (path.resolve(opts.sourcePath) === path.resolve(outPath)) { return outPath; } fs.copyFileSync(opts.sourcePath, outPath); return outPath; } function transformHtml(html: string, network: string): string { let result = sanitizePlayworksHtml(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. result = removeNetworkSdkScripts(result); let headInject = buildLifecycleScript(); if (MRAID_NETWORKS.has(network)) { headInject += '' + buildMraidComplianceScript(); } else if (EXITAPI_NETWORKS.has(network)) { headInject += ''; } result = injectBeforeHeadClose(result, headInject); // Body-level flags if (network === 'vu') { result = injectAfterBodyOpen(result, ''); } else if (network === 'mtg') { result = addBodyOnload(result, 'gameReady()'); } else if (network === 'tt') { result = injectAfterBodyOpen(result, ''); } // Replace the CTA script result = replaceCTAScript(result, network); return finalizeNetworkHtml(result, network); } function sanitizePlayworksHtml(html: string): string { let result = cleanupPlayworksHtml(html); return removeNetworkSdkScripts(result); } function cleanupPlayworksHtml(html: string): string { let result = html; result = trimAfterFirstHtmlClose(result); result = result.replace(/]*>[\s\S]*?insertYourRemoteDebuggingTokenHere[\s\S]*?<\/script>\s*/gi, ''); result = result.replace(/https:\/\/mrdoob\.github\.io\/stats\.js\/build\/stats\.min\.js/gi, 'data:text/javascript,'); result = result.replace(/\s+crossorigin(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, ''); result = result.replace(/\s+type\s*=\s*["']module["']/gi, ''); result = result.replace(/\bconsole\.error\s*\(/g, 'console.warn('); return result; } function trimAfterFirstHtmlClose(html: string): string { const idx = html.search(/<\/html\s*>/i); if (idx === -1) return html; const close = html.match(/<\/html\s*>/i); return close ? html.slice(0, idx + close[0].length) : html; } function finalizeNetworkHtml(html: string, network: string): string { let result = cleanupPlayworksHtml(html); result = dedupeScriptSrc(result, 'mraid.js', MRAID_NETWORKS.has(network)); result = dedupeScriptSrc(result, 'exitapi.js', EXITAPI_NETWORKS.has(network)); return result; } function removeNetworkSdkScripts(html: string): string { let result = html; for (const src of ['mraid.js', 'exitapi.js']) { const escaped = src.replace('.', '\\.'); result = result.replace(new RegExp(`]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), ''); } return result; } function dedupeScriptSrc(html: string, src: string, shouldExist: boolean): string { const escaped = src.replace('.', '\\.'); let seen = false; let result = html.replace(new RegExp(`]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), (match) => { if (shouldExist && !seen) { seen = true; return ``; } return ''; }); if (shouldExist && !seen) { result = injectBeforeHeadClose(result, ``); } return result; } function injectBeforeHeadClose(html: string, injection: string): string { if (/<\/head>/i.test(html)) { return html.replace(/<\/head>/i, `${injection}\n`); } return `${injection}\n${html}`; } function injectAfterBodyOpen(html: string, injection: string): string { if (/]*>/i.test(html)) { return html.replace(/]*>/i, match => `${match}\n${injection}`); } return `${injection}\n${html}`; } function addBodyOnload(html: string, handler: string): string { if (!/]*>/i.test(html)) { return `\n${html}`; } return html.replace(/]*)>/i, (match, attrs: string) => { const onloadMatch = attrs.match(/\bonload\s*=\s*(["'])(.*?)\1/i); if (!onloadMatch) return ``; if (onloadMatch[2].includes(handler)) return match; const updated = `${onloadMatch[2]};${handler}`; return match.replace(onloadMatch[0], `onload=${onloadMatch[1]}${updated}${onloadMatch[1]}`); }); } 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 buildMraidComplianceScript(): 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"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}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': case 'ap': // 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 FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}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: 'fb', label: 'Facebook', note: 'HTML' }, { tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' }, { tag: 'ap', label: 'Appreciate', note: 'ZIP + exitapi.js in head' }, { tag: 'mo', label: 'Moloco', note: 'HTML + FbPlayableAd CTA' }, { 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 => ` ${n.label} ${n.tag} ${n.note} `).join(''); return `

Playworks Converter

Convert a Playworks HTML export into network-specific playable outputs.

Inputs

Source HTML (Playworks export)
(no file)
Base Filename
Output Folder
Networks to Generate
${checkboxRows}
UseNetworkOutput behavior
`; }