updates
This commit is contained in:
385
standalone/playworks.go
Normal file
385
standalone/playworks.go
Normal file
@@ -0,0 +1,385 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user