/* The DevTools protocol, for the phone walk and the perf probe. Two things live here: a client small enough to read (a WebSocket, ids, a map of waits), and the phone attach — a debug build's WebView exposes DevTools on `localabstract:webview_devtools_remote_`, which `adb forward` puts on a local port. The attach refuses to run when another app has the phone's screen: the harness never takes the phone from whoever is holding it. */ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { MiniWS } from "./miniws.mjs"; /* Node 22 has a global WebSocket; Node 19 does not */ const WS = typeof WebSocket !== "undefined" ? WebSocket : MiniWS; export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export const ADB = [ path.join(process.env.LOCALAPPDATA || "", "Android/Sdk/platform-tools/adb.exe"), path.join(process.env.ANDROID_HOME || "", "platform-tools/adb.exe"), "adb", ].find((p) => p === "adb" || fs.existsSync(p)); export function adb(...args) { const r = spawnSync(ADB, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0) throw new Error("adb " + args.join(" ") + ": " + (r.stderr || "").toString()); return r.stdout; } export class CDP { constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.on = {}; } static async attach(port, { phone = false } = {}) { for (let i = 0; i < 200; i++) { try { /* a backgrounded WebView accepts the TCP connection and never answers — without a timeout this loop would hang for good */ const list = await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2500) })).json(); const page = list.find((t) => t.type === "page"); if (page) { const ws = new WS(page.webSocketDebuggerUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); const c = new CDP(ws); ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } else if (m.method && c.on[m.method]) c.on[m.method](m.params); }; return c; } } catch { /* not up yet */ } await sleep(200); } throw new Error("could not attach to the page on port " + port + (phone ? " (a WebView in the background does not answer; is SoulStream on screen?)" : "")); } /* a page that never answers (frozen, or replaced by a reload) must fail the call, not the whole walk */ send(method, params = {}, timeoutMs = 45000) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((res, rej) => { const t = setTimeout(() => { if (this.waits.delete(id)) rej(new Error("the page did not answer " + method + " in " + Math.round(timeoutMs / 1000) + "s — a frozen or replaced WebView?")); }, timeoutMs); this.waits.set(id, (m) => { clearTimeout(t); res(m); }); }); } async evaluate(expr) { const r = await this.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }); if (r.result && r.result.exceptionDetails) { throw new Error("page threw: " + (r.result.exceptionDetails.exception?.description || JSON.stringify(r.result.exceptionDetails))); } return r.result?.result?.value; } close() { try { this.ws.close(); } catch { /* fine */ } } } /** What is on the phone's screen, from the window manager. */ export function phoneFocus() { return (adb("shell", "dumpsys", "window").toString().match(/mCurrentFocus=[^\n]*/) || [""])[0]; } /** Attach to SoulStream's WebView on the connected phone. Launches the app * only from the launcher; refuses when another app is in front. */ export async function attachPhone(port) { const focus = phoneFocus(); if (!/com\.soulstream\.app/.test(focus)) { if (/launcher/i.test(focus)) { adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity"); await sleep(2500); } else { const who = (focus.match(/\s([\w.]+)\/[^}]*\}/) || [])[1] || "another app"; throw new Error("the phone is in use (" + who + " is on screen); not touching it"); } } /* The devtools socket is registered by the WebView, not by the process, so there is a window after a launch where the app is up, focused and has a pid, and the socket does not exist yet. On a loaded machine that window has been as long as ten seconds — a walk that attached once and gave up read it as "is SoulStream on screen?" when it plainly was. Try for a while, re-reading the pid each time in case the app restarted under us. */ let pid = ""; let last = null; for (let attempt = 0; attempt < 6; attempt++) { pid = adb("shell", "pidof", "com.soulstream.app").toString().trim(); if (pid) { adb("forward", "--remove-all"); adb("forward", `tcp:${port}`, `localabstract:webview_devtools_remote_${pid}`); try { const cdp = await CDP.attach(port, { phone: true }); return { cdp, pid }; } catch (e) { last = e; } } await sleep(2500); } if (!pid) throw new Error("SoulStream is not running on the phone (adb shell pidof found nothing)"); throw last ?? new Error("could not attach to the phone's WebView on port " + port); } /** The phone's own frame, status bar included. */ export function phoneStill(file) { fs.writeFileSync(file, adb("exec-out", "screencap", "-p")); }