diff --git a/dist/hpl-toolbox-0.1.6.exe b/dist/hpl-toolbox-0.1.6.exe index 95592fd..7576b82 100644 Binary files a/dist/hpl-toolbox-0.1.6.exe and b/dist/hpl-toolbox-0.1.6.exe differ diff --git a/dist/hpl-toolbox-0.1.6.vsix b/dist/hpl-toolbox-0.1.6.vsix index 72c896b..3763125 100644 Binary files a/dist/hpl-toolbox-0.1.6.vsix and b/dist/hpl-toolbox-0.1.6.vsix differ diff --git a/src/tools/playworksConverter.ts b/src/tools/playworksConverter.ts index 822477f..4be15ae 100644 --- a/src/tools/playworksConverter.ts +++ b/src/tools/playworksConverter.ts @@ -95,10 +95,11 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) { } const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']); +const SUPPORTED_NETWORKS = new Set(['al', 'is', 'fb', 'gg', 'mo', 'vu', 'mtg', 'tt']); +const MRAID_NETWORKS = new Set(['al', 'is']); const NETWORK_OUTPUT_FOLDERS: Record = { al: 'Applovin', is: 'Ironsource', - un: 'Unity', fb: 'Facebook', gg: 'GoogleAds', mo: 'Moloco', @@ -120,6 +121,9 @@ function outputBaseName(opts: ConvertOptions): string { } 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); @@ -150,34 +154,68 @@ function transformHtml(html: string, network: string): string { // 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, network); let headInject = buildLifecycleScript(); - if (network === 'al' || network === 'is') { + if (MRAID_NETWORKS.has(network)) { headInject += ''; } else if (network === 'gg') { headInject += ''; } - result = result.replace('', headInject + '\n'); + result = injectBeforeHeadClose(result, headInject); // Body-level flags if (network === 'vu') { - result = result.replace('', '\n'); + result = injectAfterBodyOpen(result, ''); } else if (network === 'mtg') { - result = result.replace('', ''); + result = addBodyOnload(result, 'gameReady()'); } else if (network === 'tt') { - result = result.replace('', '\n'); + result = injectAfterBodyOpen(result, ''); } // 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 removeNetworkSdkScripts(html: string, network: string): string { + let result = html; + if (MRAID_NETWORKS.has(network)) { + result = result.replace(/]*\bsrc\s*=\s*["']mraid\.js["'][^>]*>\s*<\/script>\s*/gi, ''); + } + if (network === 'gg') { + result = result.replace(/]*\bsrc\s*=\s*["']exitapi\.js["'][^>]*>\s*<\/script>\s*/gi, ''); + } + 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); @@ -245,11 +283,7 @@ function buildCTAScript(network: string): string { 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");}`; + 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");`; @@ -259,7 +293,7 @@ function buildCTAScript(network: string): string { 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");`; + 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 @@ -314,10 +348,9 @@ 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: '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' }, diff --git a/standalone/playworks.go b/standalone/playworks.go index 3a6bd88..5e78e60 100644 --- a/standalone/playworks.go +++ b/standalone/playworks.go @@ -77,10 +77,9 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
- - + @@ -230,6 +229,11 @@ func PlayworksConvert(w http.ResponseWriter, r *http.Request) { } var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`) +var playworksMraidScriptRx = regexp.MustCompile(`(?i)]*\bsrc\s*=\s*["']mraid\.js["'][^>]*>\s*\s*`) +var playworksExitapiScriptRx = regexp.MustCompile(`(?i)]*\bsrc\s*=\s*["']exitapi\.js["'][^>]*>\s*\s*`) +var playworksHeadCloseRx = regexp.MustCompile(`(?i)`) +var playworksBodyOpenRx = regexp.MustCompile(`(?i)]*>`) +var playworksBodyTagRx = regexp.MustCompile(`(?i)]*)>`) func playworksSourceBaseName(sourcePath string) string { base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath)) @@ -259,6 +263,9 @@ func playworksOutputBaseName(opts playworksConvertOptions) string { } func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) { + if !playworksIsSupportedNetwork(network) { + return "", fmt.Errorf("unsupported Playworks target network: %s", network) + } transformed := transformPlayworksHTML(htmlSrc, network) baseName := playworksOutputBaseName(opts) htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network) @@ -288,8 +295,6 @@ func playworksNetworkOutputFolder(network string) string { return "Applovin" case "is": return "Ironsource" - case "un": - return "Unity" case "fb": return "Facebook" case "gg": @@ -311,33 +316,130 @@ func playworksNeedsZip(network string) bool { return network == "gg" || network == "vu" || network == "mtg" } +func playworksIsMraidNetwork(network string) bool { + return network == "al" || network == "is" +} + +func playworksIsSupportedNetwork(network string) bool { + switch network { + case "al", "is", "fb", "gg", "mo", "vu", "mtg", "tt": + return true + default: + return false + } +} + func transformPlayworksHTML(htmlSrc, network string) string { result := htmlSrc + result = removePlayworksNetworkScripts(result, network) headInject := buildPlayworksLifecycleScript() - if network == "al" || network == "is" { + if playworksIsMraidNetwork(network) { headInject += `` } else if network == "gg" { headInject += `` } - result = strings.Replace(result, "", headInject+"\n", 1) + result = injectPlayworksBeforeHeadClose(result, headInject) if network == "vu" { - result = strings.Replace(result, "", ` -`, 1) + result = injectPlayworksAfterBodyOpen(result, ``) } else if network == "mtg" { - result = strings.Replace(result, "", ``, 1) + result = addPlayworksBodyOnload(result, "gameReady()") } else if network == "tt" { - result = strings.Replace(result, "", ` -`, 1) + result = injectPlayworksAfterBodyOpen(result, ``) } result = replacePlayworksCTAScript(result, network) - if network == "un" { - result = strings.ReplaceAll(result, "window.top", "window.self") + return result +} + +func removePlayworksNetworkScripts(htmlSrc, network string) string { + result := htmlSrc + if playworksIsMraidNetwork(network) { + result = playworksMraidScriptRx.ReplaceAllString(result, "") + } + if network == "gg" { + result = playworksExitapiScriptRx.ReplaceAllString(result, "") } return result } +func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string { + if playworksHeadCloseRx.MatchString(htmlSrc) { + return playworksHeadCloseRx.ReplaceAllString(htmlSrc, injection+"\n") + } + return injection + "\n" + htmlSrc +} + +func injectPlayworksAfterBodyOpen(htmlSrc, injection string) string { + loc := playworksBodyOpenRx.FindStringIndex(htmlSrc) + if loc == nil { + return injection + "\n" + htmlSrc + } + return htmlSrc[:loc[1]] + "\n" + injection + htmlSrc[loc[1]:] +} + +func addPlayworksBodyOnload(htmlSrc, handler string) string { + loc := playworksBodyTagRx.FindStringSubmatchIndex(htmlSrc) + if loc == nil { + return `` + "\n" + htmlSrc + } + attrs := htmlSrc[loc[2]:loc[3]] + onloadStart, onloadEnd, quote, valueStart, valueEnd := findPlayworksOnloadAttr(attrs) + if onloadStart == -1 { + updated := `` + return htmlSrc[:loc[0]] + updated + htmlSrc[loc[1]:] + } + value := attrs[valueStart:valueEnd] + if strings.Contains(value, handler) { + return htmlSrc + } + replacement := `onload=` + quote + value + `;` + handler + quote + updatedAttrs := attrs[:onloadStart] + replacement + attrs[onloadEnd:] + updatedTag := `` + return htmlSrc[:loc[0]] + updatedTag + htmlSrc[loc[1]:] +} + +func findPlayworksOnloadAttr(attrs string) (start int, end int, quote string, valueStart int, valueEnd int) { + lower := strings.ToLower(attrs) + idx := strings.Index(lower, "onload") + for idx != -1 { + if idx > 0 { + prev := lower[idx-1] + if (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '-' || prev == '_' { + next := strings.Index(lower[idx+len("onload"):], "onload") + if next == -1 { + return -1, -1, "", -1, -1 + } + idx += len("onload") + next + continue + } + } + pos := idx + len("onload") + for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') { + pos++ + } + if pos >= len(attrs) || attrs[pos] != '=' { + return -1, -1, "", -1, -1 + } + pos++ + for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') { + pos++ + } + if pos >= len(attrs) || (attrs[pos] != '"' && attrs[pos] != '\'') { + return -1, -1, "", -1, -1 + } + q := attrs[pos] + valueStart = pos + 1 + valueEnd = strings.IndexByte(attrs[valueStart:], q) + if valueEnd == -1 { + return -1, -1, "", -1, -1 + } + valueEnd += valueStart + return idx, valueEnd + 1, string(q), valueStart, valueEnd + } + return -1, -1, "", -1, -1 +} + func replacePlayworksCTAScript(htmlSrc, network string) string { marker := "Luna.Unity.Playable.InstallFullGame=function" markerIdx := strings.LastIndex(htmlSrc, marker) @@ -393,15 +495,13 @@ func buildPlayworksCTAScript(network string) string { ctaLogic := closeCall + `window.open(o,"_blank");` switch network { case "al", "is": - 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");}` - case "un": - ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}` + 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");` case "fb": ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");` case "gg": ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");` case "mo": - ctaLogic = closeCall + `if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");` + 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");` case "vu": ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}` case "mtg":