The playlist repair (Find a local match: the library searched by the row's name, a pick relinks it through playlist_relink_item; an unknown row takes the track's identity). A peer's sheet on the raw results (play or save the folder, browse their files, message, buddy) and the Browse screen — the hub's Browse tab: a user's whole share with sizes and totals, a folder played or saved whole; a share can hold thousands of folders, so it filters and renders 80 at a time. "Look up a user" on Community → Buddies. The sheets the phone lacked: a queue row's (Play now, Move to next, Remove from queue), a catalog song's, a catalog card's and the record page's own, a raw file's (in the results and in Browse), a room name's, a buddy's, the now-playing Save (the desktop's Save menu for a streamed track or record). The artwork viewer with pinch zoom, one layer the back gesture peels. Search's recent searches and its spelling suggestion from the names this phone knows. You → Streaming, Downloads, Sharing, Lyrics, Album artwork, and Clear play history; a record's Re-fetch the cover. The lyrics tools on the sheet's face: the offset nudged and kept per track, another LRCLIB candidate chosen, the lookup forgotten and done again (lyrics.svelte.js, shared by the face and the lyrics sheet). The harness: every CDP call times out and the reload step re-attaches (a WebView that stopped answering hung a walk for ten minutes); the audio check counts SoulStream's own tracks' underruns from the mixer dump instead of the thread-wide counters that any app's notification moves — the afternoon's "dropouts" were other apps' sounds, and the browse of a 49,544-file share measured at zero; __settle resets every tab to its root through the nav; an adb step kind; the walks cover all of the above on the mock and on the phone.
109 lines
5.1 KiB
JavaScript
109 lines
5.1 KiB
JavaScript
/* 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_<pid>`, 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");
|
|
}
|
|
}
|
|
const pid = adb("shell", "pidof", "com.soulstream.app").toString().trim();
|
|
if (!pid) throw new Error("SoulStream is not running on the phone (adb shell pidof found nothing)");
|
|
adb("forward", "--remove-all");
|
|
adb("forward", `tcp:${port}`, `localabstract:webview_devtools_remote_${pid}`);
|
|
const cdp = await CDP.attach(port, { phone: true });
|
|
return { cdp, pid };
|
|
}
|
|
|
|
/** The phone's own frame, status bar included. */
|
|
export function phoneStill(file) {
|
|
fs.writeFileSync(file, adb("exec-out", "screencap", "-p"));
|
|
}
|