Files
vsix-hpl-toolbox/standalone/playworks.go
2026-05-27 18:37:52 +08:00

386 lines
16 KiB
Go

package main
import (
"archive/zip"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
type playworksConvertOptions struct {
SourcePath string `json:"sourcePath"`
OutputDir string `json:"outputDir"`
Networks []string `json:"networks"`
}
type playworksNetworkResult struct {
Network string `json:"network"`
File string `json:"file"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
body := `
<h2>Playworks Converter</h2>
<style>
.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; }
.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:Consolas, monospace; }
.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 #333; margin:14px 0; }
</style>
<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 style="flex:1;" />
<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..." style="flex:1;" />
<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">
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="is" checked /><span class="net-label">Ironsource <span class="net-tag">is</span></span><span class="net-note">HTML + mraid.js in head</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="un" checked /><span class="net-label">Unity <span class="net-tag">un</span></span><span class="net-note">HTML (window.top to self)</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="fb" checked /><span class="net-label">Facebook <span class="net-tag">fb</span></span><span class="net-note">HTML</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="gg" checked /><span class="net-label">Google Ads <span class="net-tag">gg</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" checked /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
</div>
</div>
<div class="actions">
<button id="convert" disabled>Convert</button>
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
</div>
<div id="status"></div>
<div class="results" id="results"></div>
<script>
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', async () => {
const r = await fetch('/api/playworks/pickSource', { method:'POST' });
const j = await r.json();
if (j.path) {
srcEl.value = j.path;
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
updateConvertBtn();
}
});
document.getElementById('pickOut').addEventListener('click', async () => {
const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
const j = await r.json();
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
});
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) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
});
document.getElementById('convert').addEventListener('click', async () => {
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;
const res = await fetch('/api/playworks/convert', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, networks })
});
const msg = await res.json();
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>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/playworks", "Playworks Converter", body)))
}
func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
picked := PickFiles("Select Playworks HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
if len(picked) == 0 {
writeJSON(w, map[string]any{})
return
}
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0])})
}
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"path": PickFolder("Select Output Folder", "")})
}
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
var opts playworksConvertOptions
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()})
return
}
if !fileExists(opts.SourcePath) {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."})
return
}
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."})
return
}
data, err := os.ReadFile(opts.SourcePath)
if err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()})
return
}
results := []playworksNetworkResult{}
for _, network := range opts.Networks {
filePath, err := convertPlayworksNetwork(string(data), opts, network)
if err != nil {
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
continue
}
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
}
writeJSON(w, map[string]any{"results": results})
}
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`)
func playworksSourceBaseName(sourcePath string) string {
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
return playworksSuffixRx.ReplaceAllString(base, "")
}
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
transformed := transformPlayworksHTML(htmlSrc, network)
baseName := playworksSourceBaseName(opts.SourcePath)
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
if playworksNeedsZip(network) {
zipPath := filepath.Join(opts.OutputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil {
return "", err
}
return zipPath, nil
}
outPath := filepath.Join(opts.OutputDir, htmlFileName)
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
return "", err
}
return outPath, nil
}
func playworksNeedsZip(network string) bool {
return network == "gg" || network == "vu" || network == "mtg"
}
func transformPlayworksHTML(htmlSrc, network string) string {
result := htmlSrc
headInject := buildPlayworksLifecycleScript()
if network == "al" || network == "is" {
headInject += `<script src="mraid.js"></script>`
} else if network == "gg" {
headInject += `<script src="exitapi.js"></script>`
}
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1)
if network == "vu" {
result = strings.Replace(result, "<body>", `<body>
<script>window.__VUNGLE__=true;</script>`, 1)
} else if network == "mtg" {
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1)
} else if network == "tt" {
result = strings.Replace(result, "<body>", `<body>
<script>window.__TIKTOK__=true;</script>`, 1)
}
result = replacePlayworksCTAScript(result, network)
if network == "un" {
result = strings.ReplaceAll(result, "window.top", "window.self")
}
return result
}
func replacePlayworksCTAScript(htmlSrc, network string) string {
marker := "Luna.Unity.Playable.InstallFullGame=function"
markerIdx := strings.LastIndex(htmlSrc, marker)
if markerIdx == -1 {
return htmlSrc
}
scriptOpenIdx := strings.LastIndex(htmlSrc[:markerIdx], "<script>")
if scriptOpenIdx == -1 {
return htmlSrc
}
scriptCloseRel := strings.Index(htmlSrc[markerIdx:], "</script>")
if scriptCloseRel == -1 {
return htmlSrc
}
scriptCloseIdx := markerIdx + scriptCloseRel
return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len("</script>"):]
}
func buildPlayworksConsoleRestoreScript() string {
return strings.Join([]string{
`<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>`,
}, "")
}
func buildPlayworksLifecycleScript() string {
return strings.Join([]string{
`<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>`,
}, "")
}
func buildPlayworksCTAScript(network string) string {
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;`
closeCall := `try{window.gameClose();}catch(e){}`
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");}`
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");`
case "vu":
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
case "mtg":
ctaLogic = closeCall + `if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`
case "tt":
ctaLogic = closeCall + `if(typeof window.openAppStore==="function"){window.openAppStore();return;}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>`
}
func createSingleFileZip(data []byte, nameInZip, destZipPath string) error {
_ = os.Remove(destZipPath)
out, err := os.Create(destZipPath)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
entry, err := zw.Create(nameInZip)
if err != nil {
_ = zw.Close()
return err
}
if _, err := entry.Write(data); err != nil {
_ = zw.Close()
return err
}
return zw.Close()
}