687 lines
29 KiB
Go
687 lines
29 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 := `
|
|
<header class="tool-header">
|
|
<h2 class="tool-title">Playworks Converter</h2>
|
|
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
|
|
</header>
|
|
|
|
<style>
|
|
.net-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:4px 12px; }
|
|
.net-row { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; user-select:none; border:1px solid #333; border-radius:4px; background:rgba(128,128,128,0.06); }
|
|
.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; }
|
|
</style>
|
|
|
|
<section class="tool-panel">
|
|
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
|
<div class="panel-body">
|
|
<div class="control-group">
|
|
<div class="control-label">Source HTML (Playworks export)</div>
|
|
<div id="dropZone" class="drop-zone">
|
|
<div class="file-row">
|
|
<button id="pickSrc" class="secondary">Select File</button>
|
|
<button id="clearSrc" class="secondary">Clear</button>
|
|
</div>
|
|
<div class="drop-zone-text">or drop a Playworks HTML file here</div>
|
|
</div>
|
|
<input id="src" type="hidden" />
|
|
<div id="sourceSelection" class="selected-list"></div>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<div class="control-label">Base Filename</div>
|
|
<div class="field-row">
|
|
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<div class="control-label">Output Folder</div>
|
|
<div class="field-row">
|
|
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
|
<button id="pickOut" class="secondary">Select Folder</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<div class="control-label">Networks to generate</div>
|
|
<div class="toggle-row">
|
|
<button id="selectAll" class="secondary">All</button>
|
|
<button id="selectNone" class="secondary">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="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="ap" checked /><span class="net-label">Appreciate <span class="net-tag">ap</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 + FbPlayableAd CTA</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" /><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="action-row">
|
|
<button id="convert" disabled>Convert</button>
|
|
<button id="openOut" class="secondary" disabled>Open Output Folder</button>
|
|
</div>
|
|
|
|
<div id="status" class="status-panel"></div>
|
|
</div>
|
|
</section>
|
|
<div id="results" class="results-panel"></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');
|
|
const sourceSelectionEl = document.getElementById('sourceSelection');
|
|
|
|
function updateConvertBtn() {
|
|
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
|
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
|
}
|
|
|
|
function basename(p) {
|
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
|
return i >= 0 ? p.slice(i + 1) : p;
|
|
}
|
|
|
|
function escapeHtml(s){
|
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
function renderSourceSelection() {
|
|
if (!srcEl.value) {
|
|
sourceSelectionEl.innerHTML = '<span class="file-name">(no file selected)</span>';
|
|
return;
|
|
}
|
|
sourceSelectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
|
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
|
'<tr><td class="mono wrap">' + escapeHtml(basename(srcEl.value)) + '</td><td><button id="removeSource" class="remove-selected danger" title="Remove">×</button></td></tr>' +
|
|
'</tbody></table></div>';
|
|
document.getElementById('removeSource').addEventListener('click', clearSource);
|
|
}
|
|
|
|
function setSource(path, baseName, dir) {
|
|
srcEl.value = path || '';
|
|
baseNameEl.value = baseName || '';
|
|
if (!outEl.value && dir) { outEl.value = dir.replace(/[\\\/]+$/, '') + '\\\\Output'; outputDir = outEl.value; }
|
|
renderSourceSelection();
|
|
updateConvertBtn();
|
|
}
|
|
|
|
function clearSource() {
|
|
srcEl.value = '';
|
|
baseNameEl.value = '';
|
|
statusEl.textContent = '';
|
|
statusEl.classList.remove('is-busy');
|
|
renderSourceSelection();
|
|
updateConvertBtn();
|
|
}
|
|
|
|
document.getElementById('pickSrc').addEventListener('click', async () => {
|
|
const r = await fetch('/api/playworks/pickSource', { method:'POST' });
|
|
const j = await r.json();
|
|
if (j.path) {
|
|
setSource(j.path, j.baseName || '', j.dir || '');
|
|
}
|
|
});
|
|
document.getElementById('clearSrc').addEventListener('click', clearSource);
|
|
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 = c.dataset.tag !== 'tt');
|
|
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('/');
|
|
}
|
|
setupDropZone('dropZone', statusEl, (j) => {
|
|
const paths = j.paths || [];
|
|
if (!paths.length) {
|
|
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
|
return;
|
|
}
|
|
const sourcePath = paths[0];
|
|
const droppedBase = basename(sourcePath).replace(/\\.html?$/i, '').replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/i, '');
|
|
const i = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\\\'));
|
|
setSource(sourcePath, droppedBase, i >= 0 ? sourcePath.slice(0, i) : '');
|
|
const skipped = Number(j.skipped || 0) + Math.max(0, paths.length - 1);
|
|
if (skipped) statusEl.textContent = 'Using first dropped HTML file. Skipped ' + skipped + ' other item(s).';
|
|
});
|
|
renderSourceSelection();
|
|
|
|
document.getElementById('convert').addEventListener('click', async () => {
|
|
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
|
statusEl.textContent = 'Converting...';
|
|
statusEl.classList.add('is-busy');
|
|
resultsEl.innerHTML = '';
|
|
openOutBtn.disabled = true;
|
|
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.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; return; }
|
|
const results = msg.results || [];
|
|
const ok = results.filter(r => r.ok).length;
|
|
statusEl.classList.remove('is-busy');
|
|
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
|
if (!results.length) { resultsEl.innerHTML = ''; return; }
|
|
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th>Output Path</th><th>Error</th></tr></thead><tbody></tbody></table>';
|
|
const tbody = resultsEl.querySelector('tbody');
|
|
for (const r of results) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td class="mono">' + r.network + '</td>' +
|
|
'<td>' + (r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Error</span>') + '</td>' +
|
|
'<td class="mono wrap">' + (r.ok ? outputDisplayName(r.file) : '<span class="muted">-</span>') + '</td>' +
|
|
'<td class="err wrap">' + (r.ok ? '' : (r.error || 'Unknown error')) + '</td>';
|
|
tbody.appendChild(row);
|
|
}
|
|
if (ok > 0) { outputDir = outEl.value; openOutBtn.disabled = false; }
|
|
});
|
|
</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{}
|
|
unityPath, err := copyPlayworksUnityInput(data, opts)
|
|
if err != nil {
|
|
results = append(results, playworksNetworkResult{Network: "un", OK: false, Error: err.Error()})
|
|
} else {
|
|
results = append(results, playworksNetworkResult{Network: "un", File: unityPath, OK: true})
|
|
}
|
|
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|ap|mo|vu|mtg|tt)$`)
|
|
var playworksMraidScriptRx = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']mraid\.js["'][^>]*>\s*</script>\s*`)
|
|
var playworksExitapiScriptRx = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']exitapi\.js["'][^>]*>\s*</script>\s*`)
|
|
var playworksHeadCloseRx = regexp.MustCompile(`(?i)</head>`)
|
|
var playworksBodyOpenRx = regexp.MustCompile(`(?i)<body\b[^>]*>`)
|
|
var playworksBodyTagRx = regexp.MustCompile(`(?i)<body\b([^>]*)>`)
|
|
var playworksHtmlCloseRx = regexp.MustCompile(`(?i)</html\s*>`)
|
|
var playworksRemoteDebugScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?insertYourRemoteDebuggingTokenHere.*?</script>\s*`)
|
|
var playworksStatsURLRx = regexp.MustCompile(`(?i)https://mrdoob\.github\.io/stats\.js/build/stats\.min\.js`)
|
|
var playworksCrossoriginRx = regexp.MustCompile(`(?i)\s+crossorigin(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?`)
|
|
var playworksTypeModuleRx = regexp.MustCompile(`(?i)\s+type\s*=\s*["']module["']`)
|
|
var playworksConsoleErrorRx = regexp.MustCompile(`\bconsole\.error\s*\(`)
|
|
|
|
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) {
|
|
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)
|
|
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 copyPlayworksUnityInput(data []byte, opts playworksConvertOptions) (string, error) {
|
|
baseName := playworksOutputBaseName(opts)
|
|
outputDir := filepath.Join(opts.OutputDir, "Unity")
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
outPath := filepath.Join(outputDir, fmt.Sprintf("%s_un.html", baseName))
|
|
if err := os.WriteFile(outPath, data, 0644); err != nil {
|
|
return "", err
|
|
}
|
|
return outPath, nil
|
|
}
|
|
|
|
func playworksNetworkOutputFolder(network string) string {
|
|
switch network {
|
|
case "al":
|
|
return "Applovin"
|
|
case "is":
|
|
return "Ironsource"
|
|
case "fb":
|
|
return "Facebook"
|
|
case "gg":
|
|
return "GoogleAds"
|
|
case "ap":
|
|
return "Appreciate"
|
|
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 == "ap" || network == "vu" || network == "mtg"
|
|
}
|
|
|
|
func playworksIsMraidNetwork(network string) bool {
|
|
return network == "al" || network == "is"
|
|
}
|
|
|
|
func playworksIsExitapiNetwork(network string) bool {
|
|
return network == "gg" || network == "ap"
|
|
}
|
|
|
|
func playworksIsSupportedNetwork(network string) bool {
|
|
switch network {
|
|
case "al", "is", "fb", "gg", "ap", "mo", "vu", "mtg", "tt":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func transformPlayworksHTML(htmlSrc, network string) string {
|
|
result := sanitizePlayworksHTML(htmlSrc)
|
|
headInject := buildPlayworksLifecycleScript()
|
|
if playworksIsMraidNetwork(network) {
|
|
headInject += `<script src="mraid.js"></script>` + buildPlayworksMraidComplianceScript()
|
|
} else if playworksIsExitapiNetwork(network) {
|
|
headInject += `<script src="exitapi.js"></script>`
|
|
}
|
|
result = injectPlayworksBeforeHeadClose(result, headInject)
|
|
|
|
if network == "vu" {
|
|
result = injectPlayworksAfterBodyOpen(result, `<script>window.__VUNGLE__=true;</script>`)
|
|
} else if network == "mtg" {
|
|
result = addPlayworksBodyOnload(result, "gameReady()")
|
|
} else if network == "tt" {
|
|
result = injectPlayworksAfterBodyOpen(result, `<script>window.__TIKTOK__=true;</script>`)
|
|
}
|
|
|
|
result = replacePlayworksCTAScript(result, network)
|
|
return finalizePlayworksNetworkHTML(result, network)
|
|
}
|
|
|
|
func sanitizePlayworksHTML(htmlSrc string) string {
|
|
return removePlayworksNetworkScripts(cleanupPlayworksHTML(htmlSrc))
|
|
}
|
|
|
|
func cleanupPlayworksHTML(htmlSrc string) string {
|
|
result := trimPlayworksAfterFirstHTMLClose(htmlSrc)
|
|
result = playworksRemoteDebugScriptRx.ReplaceAllString(result, "")
|
|
result = playworksStatsURLRx.ReplaceAllString(result, "data:text/javascript,")
|
|
result = playworksCrossoriginRx.ReplaceAllString(result, "")
|
|
result = playworksTypeModuleRx.ReplaceAllString(result, "")
|
|
result = playworksConsoleErrorRx.ReplaceAllString(result, "console.warn(")
|
|
return result
|
|
}
|
|
|
|
func trimPlayworksAfterFirstHTMLClose(htmlSrc string) string {
|
|
loc := playworksHtmlCloseRx.FindStringIndex(htmlSrc)
|
|
if loc == nil {
|
|
return htmlSrc
|
|
}
|
|
return htmlSrc[:loc[1]]
|
|
}
|
|
|
|
func finalizePlayworksNetworkHTML(htmlSrc, network string) string {
|
|
result := cleanupPlayworksHTML(htmlSrc)
|
|
result = dedupePlayworksScriptSrc(result, "mraid.js", playworksIsMraidNetwork(network))
|
|
result = dedupePlayworksScriptSrc(result, "exitapi.js", playworksIsExitapiNetwork(network))
|
|
return result
|
|
}
|
|
|
|
func removePlayworksNetworkScripts(htmlSrc string) string {
|
|
result := playworksMraidScriptRx.ReplaceAllString(htmlSrc, "")
|
|
result = playworksExitapiScriptRx.ReplaceAllString(result, "")
|
|
return result
|
|
}
|
|
|
|
func dedupePlayworksScriptSrc(htmlSrc, src string, shouldExist bool) string {
|
|
rx := regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']` + regexp.QuoteMeta(src) + `["'][^>]*>\s*</script>\s*`)
|
|
seen := false
|
|
result := rx.ReplaceAllStringFunc(htmlSrc, func(match string) string {
|
|
if shouldExist && !seen {
|
|
seen = true
|
|
return `<script src="` + src + `"></script>`
|
|
}
|
|
return ""
|
|
})
|
|
if shouldExist && !seen {
|
|
result = injectPlayworksBeforeHeadClose(result, `<script src="`+src+`"></script>`)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string {
|
|
loc := playworksHeadCloseRx.FindStringIndex(htmlSrc)
|
|
if loc != nil {
|
|
return htmlSrc[:loc[0]] + injection + "\n" + htmlSrc[loc[0]:]
|
|
}
|
|
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 `<body onload="` + handler + `">` + "\n" + htmlSrc
|
|
}
|
|
attrs := htmlSrc[loc[2]:loc[3]]
|
|
onloadStart, onloadEnd, quote, valueStart, valueEnd := findPlayworksOnloadAttr(attrs)
|
|
if onloadStart == -1 {
|
|
updated := `<body` + attrs + ` onload="` + handler + `">`
|
|
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 := `<body` + updatedAttrs + `>`
|
|
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)
|
|
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 buildPlayworksMraidComplianceScript() string {
|
|
return strings.Join([]string{
|
|
`<script>(function(){`,
|
|
`var m=null,scene=null,viewable=true,exposed=true,volume=null;`,
|
|
`function get(){return window.mraid||m;}`,
|
|
`function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}`,
|
|
`function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}`,
|
|
`function onViewable(v){viewable=!!v;apply();}`,
|
|
`function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}`,
|
|
`function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}`,
|
|
`function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}`,
|
|
`function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}`,
|
|
`window.__hplMraidUnload=unload;`,
|
|
`function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}`,
|
|
`function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}`,
|
|
`window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};`,
|
|
`if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}`,
|
|
`setTimeout(setup,2000);`,
|
|
`})();</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"){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", "ap":
|
|
ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`
|
|
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");`
|
|
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()
|
|
}
|