Ports all five tools to a single 7.5 MB Windows exe with an embedded WebView2 window, file-based config, single-instance focus, and clean shutdown. Adds build.ps1 to build both targets from one command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
9.9 KiB
Go
303 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
urlpkg "net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
)
|
|
|
|
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
|
body := `
|
|
<h2>AppLovin Playable Preview (QR)</h2>
|
|
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
|
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
|
<button id="pick" class="secondary">Choose files...</button>
|
|
<span id="fileName" class="hint">(no files)</span>
|
|
</div>
|
|
<div style="display:flex;gap:8px;">
|
|
<button id="upload">Upload to AppLovin</button>
|
|
</div>
|
|
|
|
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
|
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
|
|
|
|
<style>
|
|
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
|
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
|
|
</style>
|
|
|
|
<script>
|
|
const pickBtn = document.getElementById('pick');
|
|
const uploadBtn = document.getElementById('upload');
|
|
const fileNameEl = document.getElementById('fileName');
|
|
const statusEl = document.getElementById('status');
|
|
const resultsEl = document.getElementById('results');
|
|
let selectedPaths = [];
|
|
|
|
pickBtn.addEventListener('click', async () => {
|
|
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
|
const j = await r.json();
|
|
if (j.files && j.files.length) {
|
|
selectedPaths = j.files.map(f => f.path);
|
|
const names = j.files.map(f => f.name);
|
|
fileNameEl.textContent = names.length === 1
|
|
? names[0]
|
|
: names.length + ' files: ' + names.slice(0,3).join(', ') + (names.length > 3 ? '...' : '');
|
|
}
|
|
});
|
|
|
|
uploadBtn.addEventListener('click', async () => {
|
|
statusEl.textContent = '';
|
|
resultsEl.innerHTML = '';
|
|
if (!selectedPaths.length) {
|
|
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
|
return;
|
|
}
|
|
uploadBtn.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/applovin/upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paths: selectedPaths }),
|
|
});
|
|
if (!res.ok || !res.body) {
|
|
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
|
|
uploadBtn.disabled = false;
|
|
return;
|
|
}
|
|
const reader = res.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = '';
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
let nl;
|
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
if (!line.trim()) continue;
|
|
handle(JSON.parse(line));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
|
} finally {
|
|
uploadBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
function escapeHtml(s){
|
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
function handle(m) {
|
|
if (m.type === 'status') {
|
|
statusEl.textContent = m.message;
|
|
} else if (m.type === 'fileError') {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'result';
|
|
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
|
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
|
resultsEl.appendChild(wrap);
|
|
} else if (m.type === 'fileResult') {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'result';
|
|
wrap.innerHTML =
|
|
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
|
'<div style="text-align:center;margin-bottom:8px;">' +
|
|
'<img src="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
|
'</div>' +
|
|
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
|
const actions = document.createElement('div');
|
|
actions.className = 'result-actions';
|
|
const copyHash = document.createElement('button');
|
|
copyHash.textContent = 'Copy Hash';
|
|
copyHash.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.hash})});
|
|
const copyUrl = document.createElement('button');
|
|
copyUrl.textContent = 'Copy URL';
|
|
copyUrl.className = 'secondary';
|
|
copyUrl.onclick = () => fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:m.previewUrl})});
|
|
const openBtn = document.createElement('button');
|
|
openBtn.textContent = 'Open';
|
|
openBtn.className = 'secondary';
|
|
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
|
|
actions.appendChild(copyHash); actions.appendChild(copyUrl); actions.appendChild(openBtn);
|
|
wrap.appendChild(actions);
|
|
resultsEl.appendChild(wrap);
|
|
} else if (m.type === 'done') {
|
|
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
|
}
|
|
}
|
|
</script>
|
|
`
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body)))
|
|
}
|
|
|
|
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
|
picked := PickFiles(
|
|
"Select HTML files",
|
|
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
|
true, "",
|
|
)
|
|
files := make([]map[string]string, 0, len(picked))
|
|
for _, p := range picked {
|
|
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
|
}
|
|
writeJSON(w, map[string]any{"files": files})
|
|
}
|
|
|
|
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Paths []string `json:"paths"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
cookie := LoadConfig().Applovin.Cookie
|
|
|
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
flusher, _ := w.(http.Flusher)
|
|
|
|
send := func(obj any) {
|
|
data, _ := json.Marshal(obj)
|
|
_, _ = w.Write(append(data, '\n'))
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
|
|
|
|
buildForm := func(filePath string) (*bytes.Buffer, string, error) {
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, err := createFormFileWithType(mw, "file", filepath.Base(filePath), "text/html")
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if _, err := fw.Write(data); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return &buf, mw.FormDataContentType(), nil
|
|
}
|
|
|
|
doPost := func(url string, body *bytes.Buffer, contentType string) (int, string, error) {
|
|
req, err := http.NewRequest("POST", url, body)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
req.Header.Set("Content-Type", contentType)
|
|
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
|
|
req.Header.Set("Origin", "https://p.applov.in")
|
|
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
|
|
if cookie != "" {
|
|
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
out, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(out), nil
|
|
}
|
|
|
|
for i, filePath := range req.Paths {
|
|
fileName := filepath.Base(filePath)
|
|
idx := i + 1
|
|
fileErr := func(msg string) {
|
|
send(map[string]any{"type": "fileError", "name": fileName, "message": msg})
|
|
}
|
|
|
|
if filePath == "" || !fileExists(filePath) {
|
|
fileErr("File missing")
|
|
continue
|
|
}
|
|
if !htmlRx.MatchString(filePath) {
|
|
fileErr("Must be .html")
|
|
continue
|
|
}
|
|
|
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)})
|
|
|
|
body, ct, err := buildForm(filePath)
|
|
if err != nil {
|
|
fileErr("Read failed: " + err.Error())
|
|
continue
|
|
}
|
|
status, respText, err := doPost("https://p.applov.in/validateHTMLContent", body, ct)
|
|
if err != nil {
|
|
fileErr("Network (validate): " + err.Error())
|
|
continue
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
fileErr(fmt.Sprintf("Validate HTTP %d: %s", status, truncate(respText, 300)))
|
|
continue
|
|
}
|
|
var vJSON map[string]any
|
|
_ = json.Unmarshal([]byte(respText), &vJSON)
|
|
if code, ok := vJSON["code"]; ok {
|
|
if codeNum, isNum := code.(float64); isNum && codeNum != 200 {
|
|
fileErr("Validation failed: " + truncate(respText, 300))
|
|
continue
|
|
}
|
|
}
|
|
|
|
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)})
|
|
|
|
body, ct, err = buildForm(filePath)
|
|
if err != nil {
|
|
fileErr("Read failed: " + err.Error())
|
|
continue
|
|
}
|
|
status, respText, err = doPost("https://p.applov.in/getCachedAdURL", body, ct)
|
|
if err != nil {
|
|
fileErr("Network (cache): " + err.Error())
|
|
continue
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
fileErr(fmt.Sprintf("Cache HTTP %d: %s", status, truncate(respText, 300)))
|
|
continue
|
|
}
|
|
var cJSON map[string]any
|
|
if err := json.Unmarshal([]byte(respText), &cJSON); err != nil {
|
|
fileErr("Non-JSON: " + truncate(respText, 300))
|
|
continue
|
|
}
|
|
hash, _ := cJSON["results"].(string)
|
|
if hash == "" {
|
|
fileErr("No hash in response: " + truncate(respText, 300))
|
|
continue
|
|
}
|
|
|
|
send(map[string]any{
|
|
"type": "fileResult",
|
|
"name": fileName,
|
|
"hash": hash,
|
|
"previewUrl": "https://p.applov.in/getPreviewHTML?preview=" + hash,
|
|
"pageUrl": "https://p.applov.in/playablePreview?preview=" + hash + "&qr=1",
|
|
"qrImg": "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=" + urlpkg.QueryEscape(hash),
|
|
})
|
|
}
|
|
send(map[string]any{"type": "done"})
|
|
}
|