453 lines
18 KiB
Go
453 lines
18 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"`
|
|
BaseName string `json:"baseName"`
|
|
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">Base Filename</div>
|
|
<div class="row">
|
|
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
|
|
</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 baseNameEl = document.getElementById('baseName');
|
|
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 || !baseNameEl.value.trim() || !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;
|
|
baseNameEl.value = j.baseName || '';
|
|
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);
|
|
baseNameEl.addEventListener('input', updateConvertBtn);
|
|
openOutBtn.addEventListener('click', () => {
|
|
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
|
});
|
|
|
|
function outputDisplayName(file) {
|
|
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
|
return parts.slice(-2).join('/');
|
|
}
|
|
|
|
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, baseName: baseNameEl.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 = outputDisplayName(r.file);
|
|
} 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]), "baseName": playworksSourceBaseName(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 playworksOutputBaseName(opts playworksConvertOptions) string {
|
|
baseName := strings.TrimSpace(opts.BaseName)
|
|
if baseName == "" {
|
|
baseName = playworksSourceBaseName(opts.SourcePath)
|
|
}
|
|
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
|
baseName = filepath.Base(baseName)
|
|
baseName = strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
|
|
return '_'
|
|
default:
|
|
return r
|
|
}
|
|
}, baseName)
|
|
baseName = strings.TrimSpace(baseName)
|
|
if baseName == "" {
|
|
return playworksSourceBaseName(opts.SourcePath)
|
|
}
|
|
return baseName
|
|
}
|
|
|
|
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
|
transformed := transformPlayworksHTML(htmlSrc, network)
|
|
baseName := playworksOutputBaseName(opts)
|
|
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
|
outputDir := filepath.Join(opts.OutputDir, playworksNetworkOutputFolder(network))
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if playworksNeedsZip(network) {
|
|
zipPath := filepath.Join(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(outputDir, htmlFileName)
|
|
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
|
return "", err
|
|
}
|
|
return outPath, nil
|
|
}
|
|
|
|
func playworksNetworkOutputFolder(network string) string {
|
|
switch network {
|
|
case "al":
|
|
return "Applovin"
|
|
case "is":
|
|
return "Ironsource"
|
|
case "un":
|
|
return "Unity"
|
|
case "fb":
|
|
return "Facebook"
|
|
case "gg":
|
|
return "GoogleAds"
|
|
case "mo":
|
|
return "Moloco"
|
|
case "vu":
|
|
return "Vungle"
|
|
case "mtg":
|
|
return "Mintegral"
|
|
case "tt":
|
|
return "TikTok"
|
|
default:
|
|
return network
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|