From 80f155dfe86ba5330dd35706aa8c0c24daee700f Mon Sep 17 00:00:00 2001 From: hesukastro Date: Fri, 29 May 2026 13:40:17 +0800 Subject: [PATCH] Add status progress for batch tool operations --- src/tools/applovinUpload.ts | 6 +-- src/tools/base64Scanner.ts | 7 ++- src/tools/mraidChecker.ts | 7 ++- src/tools/playworksConverter.ts | 9 +++- src/tools/plecUpload.ts | 2 + src/tools/sendToMobile.ts | 8 +++- standalone/applovin.go | 6 +-- standalone/base64.go | 50 ++++++++++++++++++--- standalone/json.go | 25 +++++++++++ standalone/mobile.go | 67 ++++++++++++++++++++++------ standalone/mraid.go | 52 +++++++++++++++++++--- standalone/platform.go | 3 +- standalone/playworks.go | 52 +++++++++++++++++++--- standalone/plec.go | 78 +++++++++++++++++++++++++-------- 14 files changed, 307 insertions(+), 65 deletions(-) diff --git a/src/tools/applovinUpload.ts b/src/tools/applovinUpload.ts index 460e856..297b79e 100644 --- a/src/tools/applovinUpload.ts +++ b/src/tools/applovinUpload.ts @@ -134,11 +134,11 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) { if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; } if (i > 0 && delayMs > 0) { - panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` }); + panel.webview.postMessage({ type: 'status', message: `Waiting ${idx}/${paths.length} ${fileName}` }); await sleep(delayMs); } - panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` }); + panel.webview.postMessage({ type: 'status', message: `Validating ${idx}/${paths.length} ${fileName}` }); let res: Response; try { @@ -154,7 +154,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) { reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue; } - panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Uploading ${fileName}...` }); + panel.webview.postMessage({ type: 'status', message: `Uploading ${idx}/${paths.length} ${fileName}` }); try { res = await fetch('https://p.applov.in/getCachedAdURL', { diff --git a/src/tools/base64Scanner.ts b/src/tools/base64Scanner.ts index aedbd2b..f14ac5f 100644 --- a/src/tools/base64Scanner.ts +++ b/src/tools/base64Scanner.ts @@ -58,7 +58,9 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) { return; } const results: { file: string; ok: boolean; assets: string[] }[] = []; - for (const file of targets) { + for (let i = 0; i < targets.length; i++) { + const file = targets[i]; + panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` }); try { const content = fs.readFileSync(file, 'utf8'); const offenders = findNonBase64Assets(content); @@ -312,6 +314,9 @@ ${getToolWebviewStyles()} pickedFolder = ''; selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); renderSelection(); + } else if (msg.type === 'status') { + statusEl.textContent = msg.message; + statusEl.className = 'status-panel is-busy'; } else if (msg.type === 'results') { if (msg.error) { statusEl.classList.remove('is-busy'); diff --git a/src/tools/mraidChecker.ts b/src/tools/mraidChecker.ts index a3e5fdb..3321b52 100644 --- a/src/tools/mraidChecker.ts +++ b/src/tools/mraidChecker.ts @@ -116,7 +116,9 @@ export function openMraidChecker(_context: vscode.ExtensionContext) { } const results: MraidResult[] = []; - for (const file of targets) { + for (let i = 0; i < targets.length; i++) { + const file = targets[i]; + panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` }); try { const content = fs.readFileSync(file, 'utf8'); const issues = checkMraid(content, file); @@ -829,6 +831,9 @@ ${getToolWebviewStyles()} pickedFolder = ''; selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); renderSelection(); + } else if (msg.type === 'status') { + statusEl.textContent = msg.message; + statusEl.className = 'status-panel is-busy'; } else if (msg.type === 'results') { if (msg.error) { statusEl.classList.remove('is-busy'); diff --git a/src/tools/playworksConverter.ts b/src/tools/playworksConverter.ts index b2bed4b..c766e82 100644 --- a/src/tools/playworksConverter.ts +++ b/src/tools/playworksConverter.ts @@ -68,6 +68,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) { let html: string; try { + panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' }); html = fs.readFileSync(opts.sourcePath, 'utf8'); } catch (e: any) { panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` }); @@ -76,11 +77,14 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) { const results: NetworkResult[] = []; try { + panel.webview.postMessage({ type: 'status', message: 'Converting Unity...' }); results.push({ network: 'un', file: copyUnityInput(opts), ok: true }); } catch (e: any) { results.push({ network: 'un', file: '', ok: false, error: e.message }); } - for (const network of opts.networks) { + for (let i = 0; i < opts.networks.length; i++) { + const network = opts.networks[i]; + panel.webview.postMessage({ type: 'status', message: `Converting ${i + 1}/${opts.networks.length} ${network}` }); try { const filePath = await convertNetwork(html, opts, network); results.push({ network, file: filePath, ok: true }); @@ -600,6 +604,9 @@ ${getToolWebviewStyles()} outEl.value = msg.path; outputDir = msg.path; updateConvertBtn(); + } else if (msg.type === 'status') { + statusEl.textContent = msg.message; + statusEl.className = 'status-panel is-busy'; } else if (msg.type === 'convertDone') { convertBtn.disabled = false; updateConvertBtn(); diff --git a/src/tools/plecUpload.ts b/src/tools/plecUpload.ts index 054c2d9..3d93cca 100644 --- a/src/tools/plecUpload.ts +++ b/src/tools/plecUpload.ts @@ -101,11 +101,13 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) { for (let i = 0; i < valid.length; i++) { const r = valid[i]; const idx = i + 1; + panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${path.basename(r.path)}` }); const buf = fs.readFileSync(r.path); const file = new File([buf], stampedNames[i], { type: 'text/html' }); form.append(`fileUpload_${idx}`, file as any); form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, ''))); } + panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` }); let res: Response; try { diff --git a/src/tools/sendToMobile.ts b/src/tools/sendToMobile.ts index cf089ae..a50b0a2 100644 --- a/src/tools/sendToMobile.ts +++ b/src/tools/sendToMobile.ts @@ -103,13 +103,16 @@ export function openSendToMobile(context: vscode.ExtensionContext) { return; } const files: SharedFile[] = []; - for (const filePath of filePaths) { + for (let i = 0; i < filePaths.length; i++) { + const filePath = filePaths[i]; if (!filePath || !fs.existsSync(filePath)) { panel.webview.postMessage({ type: 'error', message: 'File missing.' }); return; } + panel.webview.postMessage({ type: 'status', message: `Loading ${i + 1}/${filePaths.length} ${path.basename(filePath)}` }); files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) }); } + panel.webview.postMessage({ type: 'status', message: 'Starting server...' }); const token = crypto.randomBytes(6).toString('base64url'); const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile'); @@ -549,6 +552,9 @@ ${getToolWebviewStyles()} selectedPaths = selectedFiles.map(f => f.path); selectedPage = 0; renderSelection(); + } else if (m.type === 'status') { + statusEl.textContent = m.message; + statusEl.className = 'status-panel is-busy'; } else if (m.type === 'sharing') { statusEl.classList.remove('is-busy'); currentUrls = m.urls; diff --git a/standalone/applovin.go b/standalone/applovin.go index 94a3063..83d9224 100644 --- a/standalone/applovin.go +++ b/standalone/applovin.go @@ -451,11 +451,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) { } if i > 0 && delayMs > 0 { - send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Waiting %dms before %s...", idx, len(req.Paths), delayMs, fileName)}) + send(map[string]any{"type": "status", "message": fmt.Sprintf("Waiting %d/%d %s", idx, len(req.Paths), fileName)}) time.Sleep(time.Duration(delayMs) * time.Millisecond) } - send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)}) + send(map[string]any{"type": "status", "message": fmt.Sprintf("Validating %d/%d %s", idx, len(req.Paths), fileName)}) body, ct, err := buildForm(filePath) if err != nil { @@ -480,7 +480,7 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) { } } - send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)}) + send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d/%d %s", idx, len(req.Paths), fileName)}) body, ct, err = buildForm(filePath) if err != nil { diff --git a/standalone/base64.go b/standalone/base64.go index 46c9a8a..bf6c558 100644 --- a/standalone/base64.go +++ b/standalone/base64.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "io/fs" "net/http" "os" @@ -89,6 +90,24 @@ function addPaths(paths) { selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); renderSelection(); } +async function readJSONStream(response, handle) { + if (!response.ok || !response.body) throw new Error('Request failed.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.trim()) handle(JSON.parse(line)); + } + } + if (buffer.trim()) handle(JSON.parse(buffer)); +} renderSelection(); setupDropZone('dropZone', statusEl, (j) => { addPaths(j.paths || []); @@ -119,8 +138,25 @@ document.getElementById('scan').addEventListener('click', async () => { resultsEl.innerHTML = ''; if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; } const payload = { files: pickedFiles }; - const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); - const j = await r.json(); + let j = null; + try { + const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + await readJSONStream(r, (msg) => { + if (msg.type === 'status') { + statusEl.textContent = msg.message; + statusEl.classList.add('is-busy'); + } else if (msg.type === 'done') { + j = msg; + } else if (msg.type === 'error') { + j = { error: msg.message }; + } + }); + } catch (e) { + statusEl.classList.remove('is-busy'); + statusEl.textContent = 'Error: ' + (e.message || e); + return; + } + if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; } if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; } const total = j.results.length; const flagged = j.results.filter(r => !r.ok).length; @@ -168,12 +204,13 @@ type scanResult struct { } func Base64Scan(w http.ResponseWriter, r *http.Request) { + send := newJSONStream(w) var req struct { Folder string `json:"folder"` Files []string `json:"files"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } @@ -193,12 +230,13 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) { targets = collectHTMLFiles(req.Folder) baseDir = req.Folder } else { - writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"}) + send(map[string]any{"type": "error", "message": "Pick a folder or files first"}) return } results := []scanResult{} - for _, file := range targets { + for i, file := range targets { + send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))}) data, err := os.ReadFile(file) if err != nil { continue @@ -219,7 +257,7 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) { Assets: offenders, }) } - writeJSON(w, map[string]any{"results": results}) + send(map[string]any{"type": "done", "results": results}) } func commonBaseDir(files []string) string { diff --git a/standalone/json.go b/standalone/json.go index c82b62d..035a10e 100644 --- a/standalone/json.go +++ b/standalone/json.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" ) @@ -9,3 +10,27 @@ func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } + +func newJSONStream(w http.ResponseWriter) func(any) { + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + flusher, _ := w.(http.Flusher) + return func(v any) { + data, err := json.Marshal(v) + if err != nil { + data = []byte(fmt.Sprintf(`{"type":"error","message":%q}`, err.Error())) + } + _, _ = w.Write(data) + _, _ = w.Write([]byte("\n")) + if flusher != nil { + flusher.Flush() + } + } +} + +func pluralS(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/standalone/mobile.go b/standalone/mobile.go index 25c2edc..6107f3f 100644 --- a/standalone/mobile.go +++ b/standalone/mobile.go @@ -167,6 +167,24 @@ function addFiles(files) { selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1); renderSelection(); } +async function readJSONStream(response, handle) { + if (!response.ok || !response.body) throw new Error('Request failed.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.trim()) handle(JSON.parse(line)); + } + } + if (buffer.trim()) handle(JSON.parse(buffer)); +} renderSelection(); setupDropZone('dropZone', statusEl, (j) => { addFiles(j.files || []); @@ -201,11 +219,29 @@ startBtn.addEventListener('click', async () => { startBtn.disabled = true; statusEl.textContent = 'Starting server...'; statusEl.classList.add('is-busy'); - const r = await fetch('/api/mobile/start', { - method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({ paths: selectedPaths }), - }); - const j = await r.json(); + let j = null; + try { + const r = await fetch('/api/mobile/start', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ paths: selectedPaths }), + }); + await readJSONStream(r, (msg) => { + if (msg.type === 'status') { + statusEl.textContent = msg.message; + statusEl.classList.add('is-busy'); + } else if (msg.type === 'done') { + j = msg; + } else if (msg.type === 'error') { + j = { error: msg.message }; + } + }); + } catch (e) { + statusEl.classList.remove('is-busy'); + statusEl.innerHTML = '' + (e.message || e) + ''; + startBtn.disabled = false; + return; + } + if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = 'No response.'; startBtn.disabled = false; return; } if (j.error) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '' + j.error + ''; @@ -303,13 +339,14 @@ func MobilePickFolder(w http.ResponseWriter, r *http.Request) { func MobileStart(w http.ResponseWriter, r *http.Request) { stopActiveShare() + send := newJSONStream(w) var req struct { Path string `json:"path"` Paths []string `json:"paths"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } paths := req.Paths @@ -317,18 +354,19 @@ func MobileStart(w http.ResponseWriter, r *http.Request) { paths = []string{req.Path} } if len(paths) == 0 { - writeJSON(w, map[string]any{"error": "File missing."}) + send(map[string]any{"type": "error", "message": "File missing."}) return } files := make([]sharedFile, 0, len(paths)) - for _, p := range paths { + for i, p := range paths { if p == "" || !fileExists(p) { - writeJSON(w, map[string]any{"error": "File missing."}) + send(map[string]any{"type": "error", "message": "File missing."}) return } + send(map[string]any{"type": "status", "message": fmt.Sprintf("Loading %d/%d %s", i+1, len(paths), filepath.Base(p))}) fileBuf, err := os.ReadFile(p) if err != nil { - writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) + send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()}) return } files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)}) @@ -337,15 +375,16 @@ func MobileStart(w http.ResponseWriter, r *http.Request) { // 6 random bytes -> base64url, no padding tokenBytes := make([]byte, 6) if _, err := rand.Read(tokenBytes); err != nil { - writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()}) + send(map[string]any{"type": "error", "message": "Token gen failed: " + err.Error()}) return } token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=") cfgPort := LoadConfig().SendToMobile.Port + send(map[string]any{"type": "status", "message": "Starting server..."}) listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort)) if err != nil { - writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()}) + send(map[string]any{"type": "error", "message": "Failed to bind: " + err.Error()}) return } port := listener.Addr().(*net.TCPAddr).Port @@ -363,7 +402,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) { ips := getLanIPs() if len(ips) == 0 { _ = share.server.Close() - writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"}) + send(map[string]any{"type": "error", "message": "No non-loopback IPv4 interface found. Are you on a network?"}) return } @@ -388,7 +427,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) { if len(files) == 1 { filename = files[0].filename } - writeJSON(w, map[string]any{"urls": urls, "filename": filename}) + send(map[string]any{"type": "done", "urls": urls, "filename": filename}) } func MobileStop(w http.ResponseWriter, r *http.Request) { diff --git a/standalone/mraid.go b/standalone/mraid.go index da26968..24f4a71 100644 --- a/standalone/mraid.go +++ b/standalone/mraid.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "os" "path/filepath" @@ -131,6 +132,24 @@ function addPaths(paths) { selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); renderSelection(); } +async function readJSONStream(response, handle) { + if (!response.ok || !response.body) throw new Error('Request failed.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.trim()) handle(JSON.parse(line)); + } + } + if (buffer.trim()) handle(JSON.parse(buffer)); +} renderSelection(); setupDropZone('dropZone', statusEl, (j) => { addPaths(j.paths || []); @@ -161,8 +180,25 @@ document.getElementById('scan').addEventListener('click', async () => { resultsEl.innerHTML = ''; if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; } const payload = { files: pickedFiles }; - const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); - const j = await r.json(); + let j = null; + try { + const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + await readJSONStream(r, (msg) => { + if (msg.type === 'status') { + statusEl.textContent = msg.message; + statusEl.classList.add('is-busy'); + } else if (msg.type === 'done') { + j = msg; + } else if (msg.type === 'error') { + j = { error: msg.message, results: msg.results || [], skipped: msg.skipped, skippedReason: msg.skippedReason }; + } + }); + } catch (e) { + statusEl.classList.remove('is-busy'); + statusEl.textContent = 'Error: ' + (e.message || e); + return; + } + if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; } if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; } const total = j.results.length; const withIssues = j.results.filter(r => !r.ok).length; @@ -228,12 +264,13 @@ func MraidPickFiles(w http.ResponseWriter, r *http.Request) { } func MraidScan(w http.ResponseWriter, r *http.Request) { + send := newJSONStream(w) var req struct { Folder string `json:"folder"` Files []string `json:"files"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error(), "results": []mraidResult{}}) return } @@ -268,16 +305,17 @@ func MraidScan(w http.ResponseWriter, r *http.Request) { noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found." baseDir = req.Folder } else { - writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"}) + send(map[string]any{"type": "error", "message": "Pick a folder or files first", "results": []mraidResult{}}) return } if len(targets) == 0 { - writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason, "error": noTargetsError}) + send(map[string]any{"type": "error", "message": noTargetsError, "results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason}) return } results := []mraidResult{} - for _, file := range targets { + for i, file := range targets { + send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))}) data, err := os.ReadFile(file) if err != nil { continue @@ -293,7 +331,7 @@ func MraidScan(w http.ResponseWriter, r *http.Request) { } results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues}) } - writeJSON(w, map[string]any{"results": results, "skipped": skipped, "skippedReason": skippedReason}) + send(map[string]any{"type": "done", "results": results, "skipped": skipped, "skippedReason": skippedReason}) } var ( diff --git a/standalone/platform.go b/standalone/platform.go index 96b6f8a..efdfb86 100644 --- a/standalone/platform.go +++ b/standalone/platform.go @@ -114,8 +114,7 @@ func SaveConfig(cfg AppConfig) error { } func OpenInBrowser(url string) { - // `start` is a cmd builtin. Quoted empty arg is the window title. - cmd := hiddenCommand("cmd", "/c", "start", "", url) + cmd := hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url) _ = cmd.Start() } diff --git a/standalone/playworks.go b/standalone/playworks.go index 48fa054..2155d85 100644 --- a/standalone/playworks.go +++ b/standalone/playworks.go @@ -113,6 +113,25 @@ const statusEl = document.getElementById('status'); const resultsEl = document.getElementById('results'); const sourceSelectionEl = document.getElementById('sourceSelection'); +async function readJSONStream(response, handle) { + if (!response.ok || !response.body) throw new Error('Request failed.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.trim()) handle(JSON.parse(line)); + } + } + if (buffer.trim()) handle(JSON.parse(buffer)); +} + function updateConvertBtn() { const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked); convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet; @@ -215,9 +234,24 @@ document.getElementById('convert').addEventListener('click', async () => { headers:{'Content-Type':'application/json'}, body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, baseName: baseNameEl.value, networks }) }); - const msg = await res.json(); + let msg = null; + try { + await readJSONStream(res, (event) => { + if (event.type === 'status') { + statusEl.textContent = event.message; + statusEl.classList.add('is-busy'); + } else if (event.type === 'done') { + msg = event; + } else if (event.type === 'error') { + msg = { error: event.message, results: event.results || [] }; + } + }); + } catch (e) { + msg = { error: e.message || e, results: [] }; + } convertBtn.disabled = false; updateConvertBtn(); + if (!msg) { msg = { error: 'No response.', results: [] }; } 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; @@ -256,33 +290,37 @@ func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) { } func PlayworksConvert(w http.ResponseWriter, r *http.Request) { + send := newJSONStream(w) var opts playworksConvertOptions if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { - writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error(), "results": []playworksNetworkResult{}}) return } if !fileExists(opts.SourcePath) { - writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."}) + send(map[string]any{"type": "error", "message": "Source file not found.", "results": []playworksNetworkResult{}}) return } if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { - writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."}) + send(map[string]any{"type": "error", "message": "Could not create output folder.", "results": []playworksNetworkResult{}}) return } + send(map[string]any{"type": "status", "message": "Reading source HTML..."}) data, err := os.ReadFile(opts.SourcePath) if err != nil { - writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()}) + send(map[string]any{"type": "error", "message": "Could not read source: " + err.Error(), "results": []playworksNetworkResult{}}) return } results := []playworksNetworkResult{} + send(map[string]any{"type": "status", "message": "Converting Unity..."}) 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 { + for i, network := range opts.Networks { + send(map[string]any{"type": "status", "message": fmt.Sprintf("Converting %d/%d %s", i+1, len(opts.Networks), network)}) filePath, err := convertPlayworksNetwork(string(data), opts, network) if err != nil { results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()}) @@ -290,7 +328,7 @@ func PlayworksConvert(w http.ResponseWriter, r *http.Request) { } results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true}) } - writeJSON(w, map[string]any{"results": results}) + send(map[string]any{"type": "done", "results": results}) } var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$`) diff --git a/standalone/plec.go b/standalone/plec.go index d6c6c04..5ee6526 100644 --- a/standalone/plec.go +++ b/standalone/plec.go @@ -100,6 +100,24 @@ function addFiles(files) { page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1); renderRows(); } +async function readJSONStream(response, handle) { + if (!response.ok || !response.body) throw new Error('Request failed.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.trim()) handle(JSON.parse(line)); + } + } + if (buffer.trim()) handle(JSON.parse(buffer)); +} function renderRows() { const oldPager = document.getElementById('selectedPager'); if (oldPager) oldPager.remove(); @@ -183,12 +201,31 @@ document.getElementById('upload').addEventListener('click', async () => { } statusEl.textContent = 'Uploading...'; statusEl.classList.add('is-busy'); - const res = await fetch('/api/plec/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ rows }), - }); - const j = await res.json(); + let j = null; + try { + const res = await fetch('/api/plec/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rows }), + }); + await readJSONStream(res, (msg) => { + if (msg.type === 'status') { + statusEl.textContent = msg.message; + statusEl.classList.add('is-busy'); + } else if (msg.type === 'done') { + j = msg; + } else if (msg.type === 'error') { + j = { error: msg.message }; + } else if (msg.type === 'rowErrors') { + j = { rowErrors: msg.rowErrors }; + } + }); + } catch (e) { + statusEl.classList.remove('is-busy'); + statusEl.innerHTML = '' + (e.message || e) + ''; + return; + } + if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = 'No response.'; return; } if (j.rowErrors) { statusEl.classList.remove('is-busy'); for (const re of j.rowErrors) { @@ -260,11 +297,12 @@ type plecRow struct { } func PlecUpload(w http.ResponseWriter, r *http.Request) { + send := newJSONStream(w) var req struct { Rows []plecRow `json:"rows"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } cfg := LoadConfig().Plec @@ -289,11 +327,11 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) { } } if len(rowErrors) > 0 { - writeJSON(w, map[string]any{"rowErrors": rowErrors}) + send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors}) return } if len(valid) == 0 { - writeJSON(w, map[string]any{"error": "Nothing to upload."}) + send(map[string]any{"type": "error", "message": "Nothing to upload."}) return } @@ -303,37 +341,39 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) { mw := multipart.NewWriter(&buf) for i, row := range valid { idx := i + 1 + send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filepath.Base(row.Path))}) data, err := os.ReadFile(row.Path) if err != nil { - writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) + send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()}) return } stampedName := appendTimestamp(filepath.Base(row.Path), stamp) fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html") if err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } if _, err := fw.Write(data); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", "")) // Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars. iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", "")) if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } } if err := mw.Close(); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } + send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))}) httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf) if err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) + send(map[string]any{"type": "error", "message": err.Error()}) return } httpReq.Header.Set("Content-Type", mw.FormDataContentType()) @@ -345,18 +385,18 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) { resp, err := http.DefaultClient.Do(httpReq) if err != nil { - writeJSON(w, map[string]any{"error": "Network error: " + err.Error()}) + send(map[string]any{"type": "error", "message": "Network error: " + err.Error()}) return } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))}) + send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))}) return } var parsed map[string]any if err := json.Unmarshal(respBody, &parsed); err != nil { - writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)}) + send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)}) return } var preview any @@ -365,7 +405,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) { } else if v, ok := parsed["previewURL"]; ok { preview = v } - writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)}) + send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)}) } func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {