@slack/radar-mcp 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,11 +5,37 @@ import path from "path";
5
5
  import { spawn } from "child_process";
6
6
  import { fileURLToPath } from "url";
7
7
  import { AndroidTransport, resolveAdbPath } from "../shared/android.js";
8
- import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js";
8
+ import { RADAR_HOST, RADAR_PORT, RADAR_VERSION, WEB_PORT_DEFAULT } from "../shared/constants.js";
9
+ import { cleanupPulledDatabases, initializeDatabasePath, listDatabases, pullDatabase, pulledSize, queryDatabase, resetDbState, } from "../shared/db.js";
10
+ import { LogSession } from "./log-session.js";
9
11
  import { BLANK_SPEC, coerceSpec, validateSpec } from "./spec.js";
12
+ import { screenCapabilities, refreshScreenCapabilities, grantScreenConsent, hasScreenConsent, detectDisplay, displayNote, captureScreenshot, startLive, addViewer, removeViewer, primeIfNeeded, startRecording, stopRecording, recordingPath, resetScreenState, registerScreenCleanup, resolveFfmpeg, ffmpegInstallHint, SCREEN_BOUNDARY, } from "../shared/screen.js";
10
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
14
  const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? WEB_PORT_DEFAULT);
12
15
  const INDEX_PATH = path.join(__dirname, "public", "index.html");
16
+ // Backpressure ceiling for an SSE connection's unwritten log frames. If a
17
+ // client's socket buffer is already this deep (slow/backgrounded tab), new log
18
+ // frames are dropped for that connection rather than queued without bound. ~4 MB
19
+ // is far above a healthy localhost client's transient buffer but well under a
20
+ // memory problem; a dropped frame is recovered on the client's next reconnect
21
+ // replay. Only the log fan-out is bounded this way — the device proxy stream is
22
+ // already paced by the upstream socket.
23
+ const MAX_SSE_WRITABLE_BYTES = 4 * 1024 * 1024;
24
+ /**
25
+ * Decide whether to drop a log frame under backpressure. LIVE frames are dropped
26
+ * when the connection's unwritten buffer is already past the cap (an unbounded
27
+ * queue to a slow/backgrounded tab). REPLAY frames are NEVER dropped: replay is a
28
+ * bounded one-time send of the whole ring and IS the connection's purpose; it runs
29
+ * synchronously so writableLength climbs monotonically, and dropping past the cap
30
+ * would silently cut the newest history and reintroduce the reconnect log-loss
31
+ * this feature fixes. Exported pure so the replay-exemption is unit-testable
32
+ * without a socket. `cap` defaults to the live ceiling.
33
+ */
34
+ export function shouldDropLogFrame(replay, writableLength, cap = MAX_SSE_WRITABLE_BYTES) {
35
+ if (replay)
36
+ return false;
37
+ return writableLength > cap;
38
+ }
13
39
  // The active dashboard spec is session-scoped runtime state, NOT source. It lives
14
40
  // under the user's home data dir (never inside the repo / published tarball) so it
15
41
  // cannot be accidentally committed. The path is scoped by port so two dashboards
@@ -19,6 +45,26 @@ const INDEX_PATH = path.join(__dirname, "public", "index.html");
19
45
  const SPEC_DIR = path.join(os.homedir(), ".slack-radar");
20
46
  const SPEC_PATH = path.join(SPEC_DIR, `web-spec-${WEB_PORT}.json`);
21
47
  const transport = new AndroidTransport();
48
+ // One session-long logcat capture shared across every dashboard stream
49
+ // connection. The device cannot replay logcat history to us, so the dashboard
50
+ // keeps its own bounded session ring here and replays it to each newly opened
51
+ // stream — this is what makes a tab switch (which reopens /stream) show the full
52
+ // log history instead of only lines that arrive after the reconnect, matching
53
+ // how Android Studio retains logcat for the whole session. Spawn + parse are
54
+ // injected so the capture logic is unit-testable without a real `adb logcat`.
55
+ const logSession = new LogSession(() => spawn(resolveAdbPath() ?? "adb", ["logcat", "-v", "threadtime", "-T", "1"], {
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ }), (line) => parseLogcat(line));
58
+ // Kill the persistent logcat child on process exit so it does not orphan when the
59
+ // dashboard is spawned and later terminated by the open_radar_dashboard MCP tool.
60
+ logSession.registerCleanup();
61
+ // By design the capture is NOT stopped when the last log stream disconnects (e.g.
62
+ // switching to the Network tab): it keeps filling the session ring so returning to
63
+ // a log tab — even much later — still shows the full history, the way Android
64
+ // Studio retains logcat for the whole session. The cost is a logcat reader running
65
+ // while no log tab is open; memory stays bounded by the ring cap and by the
66
+ // per-connection backpressure guard in handleStream (a stalled client drops frames
67
+ // rather than buffering without limit).
22
68
  function writeSpec(spec) {
23
69
  try {
24
70
  fs.mkdirSync(SPEC_DIR, { recursive: true });
@@ -58,15 +104,56 @@ function initialSpec() {
58
104
  return BLANK_SPEC;
59
105
  }
60
106
  }
61
- writeSpec(initialSpec());
107
+ /**
108
+ * Write the starting spec for a server that is taking ownership of this port. MUST be
109
+ * called only AFTER a successful listen() bind, never at module load: importing this
110
+ * module (which bin.ts does before it knows whether the port is free) must not touch
111
+ * the shared spec file, or a second launcher would wipe the spec of the dashboard that
112
+ * already owns the port. The post-bind caller is the sole legitimate writer.
113
+ */
114
+ export function seedInitialSpec() {
115
+ writeSpec(initialSpec());
116
+ }
117
+ // Initialize the database path with the web server port, so concurrent dashboards on
118
+ // different ports do not collide in tmpdir. This only COMPUTES a path string (no filesystem
119
+ // write), so it is safe to run at module load even for a launcher that loses the port bind.
120
+ initializeDatabasePath(WEB_PORT);
121
+ /**
122
+ * Wipe stale pulled-DB copies and arm the cleanup-on-exit handlers for THIS process. MUST be
123
+ * called only AFTER a successful listen() bind, never at module load: the pulled-DB tmp dir is
124
+ * scoped by WEB_PORT, so it is SHARED by every launcher that imports this module on the same
125
+ * port. A second launcher that loses the bind must not rmSync that dir, or it wipes the
126
+ * running dashboard's pulled-DB cache and breaks its open DB-browser tab until it re-pulls.
127
+ * Same post-bind discipline as seedInitialSpec: the port owner is the sole legitimate writer.
128
+ */
129
+ export function cleanupOnBind() {
130
+ // Pulled DB copies are plaintext message stores; never let them linger across runs.
131
+ cleanupPulledDatabases();
132
+ for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
133
+ process.once(sig, cleanupPulledDatabases);
134
+ }
135
+ }
62
136
  export function createServer() {
137
+ registerScreenCleanup(); // tear down any live screen cast on process exit/signals
63
138
  const server = http.createServer((req, res) => {
64
139
  const url = req.url ?? "";
65
- if (url === "/" || url === "/index.html") {
140
+ const urlPath = url.split("?")[0];
141
+ if (urlPath === "/" || urlPath === "/index.html") {
66
142
  serveIndex(res);
67
143
  return;
68
144
  }
145
+ // Dashboard client modules (vendor/, next/). GET serves the body; HEAD serves headers
146
+ // only (a proxy/cache validator hitting a real asset should not get a 404).
147
+ const inm = req.headers["if-none-match"];
148
+ if ((req.method === "GET" || req.method === "HEAD") && serveStatic(urlPath, res, req.method === "HEAD", Array.isArray(inm) ? inm[0] : inm)) {
149
+ return;
150
+ }
69
151
  if (url === "/spec" && req.method === "GET") {
152
+ // Version marker for a second launcher's adopt path: it can compare this against its own
153
+ // RADAR_VERSION and warn on a mismatch (it still adopts; it never kills the holder).
154
+ // A header, not a body field, so the /spec fingerprint and the client's change-detection
155
+ // key (JSON.stringify of the body) stay byte-identical.
156
+ res.setHeader("X-Radar-Version", RADAR_VERSION);
70
157
  sendJson(res, 200, loadSpec());
71
158
  return;
72
159
  }
@@ -94,6 +181,18 @@ export function createServer() {
94
181
  handleDetail(req, res);
95
182
  return;
96
183
  }
184
+ if (url.startsWith("/dbs") && req.method === "GET") {
185
+ handleDbList(req, res);
186
+ return;
187
+ }
188
+ if (url.startsWith("/dbtables") && req.method === "GET") {
189
+ handleDbTables(req, res);
190
+ return;
191
+ }
192
+ if (url.startsWith("/dbquery") && req.method === "GET") {
193
+ handleDbQuery(req, res);
194
+ return;
195
+ }
97
196
  if (url === "/_control/profiles" && req.method === "GET") {
98
197
  listProfiles(res);
99
198
  return;
@@ -104,6 +203,10 @@ export function createServer() {
104
203
  activateRadar(res, userId);
105
204
  return;
106
205
  }
206
+ if (url.startsWith("/screen/")) {
207
+ handleScreen(req, res);
208
+ return;
209
+ }
107
210
  if (url.startsWith("/api/")) {
108
211
  proxyToRadar(req, res);
109
212
  return;
@@ -124,6 +227,88 @@ function serveIndex(res) {
124
227
  res.end(`Failed to read ${INDEX_PATH}: ${e.message}`);
125
228
  }
126
229
  }
230
+ // Static assets for the dashboard client. The dashboard was historically one
231
+ // self-contained index.html; the UI is being migrated to Preact components (container by
232
+ // container), which the browser fetches as ES modules over HTTP. This serves only the
233
+ // vendored runtime (vendor/) and the UI modules (next/): locked to those two dirs, .js
234
+ // only, no path traversal, from the known public root. The listener is already
235
+ // loopback-only (bin.ts), so this adds no network exposure.
236
+ const PUBLIC_DIR = path.join(__dirname, "public");
237
+ // vendor/ exists today; next/ is populated by the in-progress Preact UI migration (the very
238
+ // next PRs). Keeping it here now means those module fetches resolve the moment they land; until
239
+ // then a /next/* request is a harmless 404 (the dir does not exist, readFileSync misses).
240
+ const STATIC_DIRS = ["vendor", "next"];
241
+ // Canonicalized public root, resolved once at module load. The realpath containment check
242
+ // below compares a resolved file path against this; if we compared against the raw PUBLIC_DIR
243
+ // while the install prefix itself is a symlink (a global npm prefix, or macOS /tmp ->
244
+ // /private/tmp), realpathSync(full) would resolve the prefix too and never start with the
245
+ // un-resolved PUBLIC_DIR, so EVERY legitimate module would 403 and the UI would silently fail
246
+ // to load. Resolve the root the same way so the comparison is apples-to-apples. Falls back to
247
+ // PUBLIC_DIR if the dir does not exist yet (e.g. a build without vendored assets).
248
+ const PUBLIC_REAL = (() => {
249
+ try {
250
+ return fs.realpathSync(PUBLIC_DIR);
251
+ }
252
+ catch {
253
+ return PUBLIC_DIR;
254
+ }
255
+ })();
256
+ // Cache policy for vendored modules: revalidate every time (no-cache), but serve a cheap 304
257
+ // when the file is unchanged via an ETag derived from its size+mtime. This cuts redundant
258
+ // transfers WITHOUT the stale-after-upgrade footgun a plain max-age would create: the module
259
+ // URLs are version-less and have no content hash, so an npm upgrade that swaps vendor/ would
260
+ // otherwise serve stale JS from a long-lived cache for up to the max-age window.
261
+ // `clean` is the already-query-stripped request path. `isHead` sends headers with no body.
262
+ function serveStatic(clean, res, isHead, ifNoneMatch) {
263
+ const top = clean.split("/")[1];
264
+ if (!STATIC_DIRS.includes(top))
265
+ return false;
266
+ if (!clean.endsWith(".js"))
267
+ return false; // only JS modules; no arbitrary file types
268
+ const full = path.normalize(path.join(PUBLIC_DIR, clean));
269
+ // Lexical containment: the normalized path must stay under PUBLIC_DIR (defeats ../).
270
+ if (full !== PUBLIC_DIR && !full.startsWith(PUBLIC_DIR + path.sep)) {
271
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
272
+ res.end("forbidden");
273
+ return true;
274
+ }
275
+ try {
276
+ // path.normalize is lexical only; a symlink inside vendor/ could still point outside the
277
+ // root. Resolve the real path and re-check containment against the canonicalized root
278
+ // (PUBLIC_REAL, not the raw PUBLIC_DIR) before reading.
279
+ const real = fs.realpathSync(full);
280
+ if (real !== PUBLIC_REAL && !real.startsWith(PUBLIC_REAL + path.sep)) {
281
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
282
+ res.end("forbidden");
283
+ return true;
284
+ }
285
+ // Weak ETag from size + mtime: changes whenever the file is rebuilt/upgraded, so a stale
286
+ // browser copy revalidates to a fresh body instead of being served from cache.
287
+ const st = fs.statSync(real);
288
+ const etag = `W/"${st.size.toString(16)}-${Math.round(st.mtimeMs).toString(16)}"`;
289
+ if (ifNoneMatch === etag) {
290
+ res.writeHead(304, { ETag: etag, "Cache-Control": "no-cache" });
291
+ res.end();
292
+ return true;
293
+ }
294
+ const body = fs.readFileSync(real);
295
+ // Set Content-Length explicitly so a HEAD mirrors the GET's framing (RFC 7230): a
296
+ // cache validator doing HEAD gets the size, and GET returns a real length not chunked.
297
+ // no-cache = always revalidate (cheap 304 above); never serve a stale module after upgrade.
298
+ res.writeHead(200, {
299
+ "Content-Type": "text/javascript; charset=utf-8",
300
+ "Content-Length": body.length,
301
+ "Cache-Control": "no-cache",
302
+ ETag: etag,
303
+ });
304
+ res.end(isHead ? undefined : body);
305
+ }
306
+ catch {
307
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
308
+ res.end("not found");
309
+ }
310
+ return true;
311
+ }
127
312
  function sendJson(res, status, payload) {
128
313
  // A device probe can fire both "end" and "error"/"timeout" for one request, so its
129
314
  // callbacks may call sendJson twice. The second writeHead would throw
@@ -177,6 +362,25 @@ async function handleSetSpec(req, res) {
177
362
  * forward/proxy base as the rest of the server.
178
363
  */
179
364
  function handleHealth(res) {
365
+ // A probe can fire BOTH an "end" and an "error"/"timeout" (partial response then socket
366
+ // drop), so guard the response to exactly one write. Without this the second sendJson
367
+ // does a second writeHead -> ERR_HTTP_HEADERS_SENT -> uncaught -> the whole server dies.
368
+ let replied = false;
369
+ // The bound profile, read from the device ping body. This is the TRUTH about which
370
+ // profile owns the socket (the app that bound first), unlike the host-side activate
371
+ // guess in getActiveUserId() which can drift. The dashboard shows this so it never
372
+ // claims a profile you are not actually seeing.
373
+ const reply = (device, boundUserId, boundProfile) => {
374
+ if (replied)
375
+ return;
376
+ replied = true;
377
+ sendJson(res, 200, {
378
+ device,
379
+ screen: screenCapabilities(),
380
+ boundUserId: boundUserId ?? null,
381
+ boundProfile: boundProfile ?? null,
382
+ });
383
+ };
180
384
  const probe = http.request({
181
385
  host: RADAR_HOST,
182
386
  port: RADAR_PORT,
@@ -184,13 +388,44 @@ function handleHealth(res) {
184
388
  method: "GET",
185
389
  timeout: 2500,
186
390
  }, (up) => {
187
- up.resume();
188
- up.on("end", () => sendJson(res, 200, { device: up.statusCode === 200 }));
391
+ // The ping body is the device's own small JSON status. Bound the accumulation anyway so
392
+ // a misbehaving/compromised local endpoint cannot grow it without limit (matches the
393
+ // bounded-read posture elsewhere in this file). 64KB is far above any real ping body;
394
+ // once tripped we stop appending and parse what we have (a truncated body simply fails
395
+ // the JSON.parse below and falls back to no bound profile, which is the safe default).
396
+ const MAX_PING_BODY = 64 * 1024;
397
+ let body = "";
398
+ up.on("data", (c) => {
399
+ if (body.length < MAX_PING_BODY)
400
+ body += c;
401
+ });
402
+ up.on("end", () => {
403
+ const ok = up.statusCode === 200;
404
+ let uid = null;
405
+ let profile = null;
406
+ // Only trust the body on a 200. A device 5xx-ing with a stale ping body must NOT
407
+ // surface a bound profile while device:false — that would let the dashboard show a
408
+ // confident profile label for an unhealthy device.
409
+ if (ok) {
410
+ try {
411
+ const p = JSON.parse(body);
412
+ if (p.android_user_id != null)
413
+ uid = String(p.android_user_id);
414
+ if (typeof p.profile === "string")
415
+ profile = p.profile;
416
+ }
417
+ catch {
418
+ // non-JSON / partial body: device is up but we cannot name the profile
419
+ }
420
+ }
421
+ reply(ok, uid, profile);
422
+ });
423
+ up.on("error", () => reply(false));
189
424
  });
190
- probe.on("error", () => sendJson(res, 200, { device: false }));
425
+ probe.on("error", () => reply(false));
191
426
  probe.on("timeout", () => {
192
427
  probe.destroy();
193
- sendJson(res, 200, { device: false });
428
+ reply(false);
194
429
  });
195
430
  probe.end();
196
431
  }
@@ -202,6 +437,10 @@ function handleHealth(res) {
202
437
  */
203
438
  function handleEnable(res) {
204
439
  transport.resetState();
440
+ // A reconnect may be a different device / re-armed profile, so drop the cached DB user
441
+ // set + sqlite path; the next /dbs re-detects the profiles fresh.
442
+ resetDbState();
443
+ resetScreenState(); // re-detect the active display + clear any stale cast on re-arm
205
444
  const forwarded = transport.ensureForward();
206
445
  if (!forwarded) {
207
446
  const reason = transport.getLastForwardError?.() ?? null;
@@ -221,7 +460,15 @@ function handleEnable(res) {
221
460
  });
222
461
  return;
223
462
  }
224
- // Verify by pinging the device the same way /health does.
463
+ // Verify by pinging the device the same way /health does. Reply exactly once (an "end"
464
+ // and an "error"/"timeout" can both fire) to avoid a double writeHead crashing the server.
465
+ let replied = false;
466
+ const reply = (enabled) => {
467
+ if (replied)
468
+ return;
469
+ replied = true;
470
+ sendJson(res, 200, { enabled });
471
+ };
225
472
  const probe = http.request({
226
473
  host: RADAR_HOST,
227
474
  port: RADAR_PORT,
@@ -230,12 +477,13 @@ function handleEnable(res) {
230
477
  timeout: 2500,
231
478
  }, (up) => {
232
479
  up.resume();
233
- up.on("end", () => sendJson(res, 200, { enabled: up.statusCode === 200 }));
480
+ up.on("end", () => reply(up.statusCode === 200));
481
+ up.on("error", () => reply(false));
234
482
  });
235
- probe.on("error", () => sendJson(res, 200, { enabled: false }));
483
+ probe.on("error", () => reply(false));
236
484
  probe.on("timeout", () => {
237
485
  probe.destroy();
238
- sendJson(res, 200, { enabled: false });
486
+ reply(false);
239
487
  });
240
488
  probe.end();
241
489
  }
@@ -354,6 +602,249 @@ function handleDetail(req, res) {
354
602
  });
355
603
  upstream.end();
356
604
  }
605
+ // --- On-device SQLite browser (source: "db") ---
606
+ //
607
+ // These delegate to shared/db.ts (run-as + host sqlite3, read-only). Each computes its
608
+ // payload+status BEFORE writing headers so a throw still yields a clean JSON error rather
609
+ // than a half-written response. Errors come back as 200 {error} for the list/tables views
610
+ // (so the UI renders the message in-place) and 400 {error} for a rejected query.
611
+ // The Android user whose store the DB browser targets. Prefer an explicit ?user= (the UI's
612
+ // profile switcher, when a dual-profile device exists), else the profile the dashboard is
613
+ // bound to. Threaded through every db call so list / pull / query never disagree on profile.
614
+ function dbUser(req) {
615
+ const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
616
+ const explicit = u.searchParams.get("user");
617
+ if (explicit !== null)
618
+ return explicit;
619
+ // No explicit pick: default to the profile the debug build is actually running as
620
+ // (resolveActiveUser prefers the non-zero/secondary profile Radar binds to), not user 0.
621
+ try {
622
+ return transport.resolveActiveUser();
623
+ }
624
+ catch {
625
+ return undefined; // let db.ts fall back to its first available user
626
+ }
627
+ }
628
+ function handleDbList(req, res) {
629
+ try {
630
+ sendJson(res, 200, listDatabases(dbUser(req)));
631
+ }
632
+ catch (e) {
633
+ sendJson(res, 200, { error: e.message, names: [], availableUsers: [] });
634
+ }
635
+ }
636
+ async function handleDbTables(req, res) {
637
+ const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
638
+ const db = u.searchParams.get("db") ?? "";
639
+ const user = dbUser(req);
640
+ const force = u.searchParams.get("refresh") === "1";
641
+ let payload;
642
+ let status;
643
+ try {
644
+ await pullDatabase(db, user, force);
645
+ const tableRows = (await queryDatabase(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", user));
646
+ // Row counts: awaited serially against the already-pulled LOCAL copy (each is fast and
647
+ // keeps the event loop free between calls; 91 parallel sqlite procs would spike).
648
+ const tables = [];
649
+ for (const t of tableRows) {
650
+ let rows = "?";
651
+ try {
652
+ rows = (await queryDatabase(db, `SELECT COUNT(*) c FROM "${t.name}"`, user))[0].c;
653
+ }
654
+ catch {
655
+ // a view or virtual table may not count; leave "?"
656
+ }
657
+ tables.push({ name: t.name, rows });
658
+ }
659
+ payload = { db, sizeBytes: pulledSize(db, user), tables };
660
+ status = 200;
661
+ }
662
+ catch (e) {
663
+ payload = { error: e.message };
664
+ status = 200; // surfaced in the tables pane, not a transport error
665
+ }
666
+ sendJson(res, status, payload);
667
+ }
668
+ async function handleDbQuery(req, res) {
669
+ const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
670
+ const db = u.searchParams.get("db") ?? "";
671
+ const sql = u.searchParams.get("sql") ?? "";
672
+ let payload;
673
+ let status;
674
+ try {
675
+ payload = { rows: await queryDatabase(db, sql, dbUser(req)) };
676
+ status = 200;
677
+ }
678
+ catch (e) {
679
+ payload = { error: String(e.message).split("\n")[0] };
680
+ status = 400;
681
+ }
682
+ sendJson(res, status, payload);
683
+ }
684
+ /**
685
+ * Screen overlay routes. The live screen shows real DMs and message content, so the
686
+ * stream and record routes are gated behind an explicit per-process consent (granted
687
+ * by POST /screen/consent from the in-UI consent panel). The gate is enforced HERE,
688
+ * server-side, not just in the browser, so a direct request cannot bypass it.
689
+ *
690
+ * /screen/caps capabilities (ffmpeg/scrcpy present, install hint, consent state)
691
+ * /screen/consent POST: grant consent for this server process
692
+ * /screen/shot full-resolution PNG screenshot (pure screencap; works without ffmpeg)
693
+ * /screen/stream multipart/x-mixed-replace MJPEG live cast (needs ffmpeg + consent)
694
+ * /screen/record/start|stop record the live cast to mp4 (needs ffmpeg + consent)
695
+ * /screen/download download a finished recording
696
+ * /screen/redetect re-detect the active display
697
+ */
698
+ function handleScreen(req, res) {
699
+ const url = req.url ?? "";
700
+ // Capabilities + consent state. Always available; drives the overlay's degrade UI.
701
+ // ?refresh=1 re-probes ffmpeg/scrcpy (asynchronously, so it never blocks the event
702
+ // loop / live stream) before reading, so a user who installs ffmpeg then reopens the
703
+ // overlay is detected without a server restart. Without refresh it reads the cache.
704
+ if (url.startsWith("/screen/caps")) {
705
+ const refresh = new URL(url, "http://localhost").searchParams.get("refresh") === "1";
706
+ if (refresh) {
707
+ void (async () => {
708
+ // refreshScreenCapabilities cannot throw today, but a future edit that lets a rejection
709
+ // escape would leave this request hanging until the client's 3s timeout. Guard it: on a
710
+ // failed re-probe, still answer with the (cached) capabilities rather than nothing.
711
+ try {
712
+ await refreshScreenCapabilities();
713
+ }
714
+ catch {
715
+ // fall through to the cached capabilities below
716
+ }
717
+ sendJson(res, 200, { ...screenCapabilities(), consent: hasScreenConsent() });
718
+ })();
719
+ }
720
+ else {
721
+ sendJson(res, 200, { ...screenCapabilities(), consent: hasScreenConsent() });
722
+ }
723
+ return;
724
+ }
725
+ // Grant consent for this process. Required before any pixels are mirrored.
726
+ if (url === "/screen/consent" && req.method === "POST") {
727
+ grantScreenConsent();
728
+ sendJson(res, 200, { consent: true });
729
+ return;
730
+ }
731
+ // Screenshot: pure screencap, no ffmpeg. Still consent-gated — it is the live screen.
732
+ if (url.startsWith("/screen/shot")) {
733
+ if (!hasScreenConsent()) {
734
+ sendJson(res, 403, { error: "screen consent required" });
735
+ return;
736
+ }
737
+ void (async () => {
738
+ await detectDisplay();
739
+ const png = await captureScreenshot();
740
+ if (!png) {
741
+ sendJson(res, 502, { error: "screencap failed; is the device awake and connected?" });
742
+ return;
743
+ }
744
+ res.writeHead(200, {
745
+ "Content-Type": "image/png",
746
+ "Content-Disposition": `attachment; filename="radar-screen-${Date.now()}.png"`,
747
+ });
748
+ res.end(png);
749
+ })();
750
+ return;
751
+ }
752
+ // Live MJPEG stream. Needs consent AND ffmpeg.
753
+ if (url.startsWith("/screen/stream")) {
754
+ if (!hasScreenConsent()) {
755
+ sendJson(res, 403, { error: "screen consent required" });
756
+ return;
757
+ }
758
+ if (!resolveFfmpeg()) {
759
+ sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
760
+ return;
761
+ }
762
+ const fps = Number(new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("fps")) || 20;
763
+ void (async () => {
764
+ await detectDisplay();
765
+ const L = startLive(fps);
766
+ if (!L) {
767
+ sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
768
+ return;
769
+ }
770
+ res.writeHead(200, {
771
+ "Content-Type": `multipart/x-mixed-replace; boundary=${SCREEN_BOUNDARY}`,
772
+ "Cache-Control": "no-cache",
773
+ Connection: "keep-alive",
774
+ });
775
+ addViewer(L, res);
776
+ req.on("close", () => removeViewer(res));
777
+ void primeIfNeeded();
778
+ })();
779
+ return;
780
+ }
781
+ if (url === "/screen/record/start" && req.method === "POST") {
782
+ if (!hasScreenConsent()) {
783
+ sendJson(res, 403, { error: "screen consent required" });
784
+ return;
785
+ }
786
+ void (async () => {
787
+ await detectDisplay();
788
+ if (!startLive(20)) {
789
+ sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
790
+ return;
791
+ }
792
+ const p = startRecording();
793
+ sendJson(res, 200, { ok: p !== null, file: p ? path.basename(p) : null });
794
+ })();
795
+ return;
796
+ }
797
+ if (url === "/screen/record/stop" && req.method === "POST") {
798
+ void (async () => {
799
+ const p = await stopRecording();
800
+ const file = p ? path.basename(p) : null;
801
+ let sizeBytes = 0;
802
+ if (p) {
803
+ try {
804
+ sizeBytes = fs.statSync(p).size;
805
+ }
806
+ catch {
807
+ sizeBytes = 0;
808
+ }
809
+ }
810
+ sendJson(res, 200, {
811
+ ok: true,
812
+ file,
813
+ sizeBytes,
814
+ download: file ? "/screen/download?f=" + encodeURIComponent(file) : null,
815
+ });
816
+ })();
817
+ return;
818
+ }
819
+ if (url.startsWith("/screen/download")) {
820
+ // A recording is device-screen content, so gate download on consent too — a fresh
821
+ // (unconsented) process must not serve a prior session's screen recording.
822
+ if (!hasScreenConsent()) {
823
+ sendJson(res, 403, { error: "screen consent required" });
824
+ return;
825
+ }
826
+ const f = new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("f") ?? "";
827
+ const p = recordingPath(f);
828
+ if (!p) {
829
+ res.writeHead(404);
830
+ res.end("Not found");
831
+ return;
832
+ }
833
+ res.writeHead(200, {
834
+ "Content-Type": "video/mp4",
835
+ "Content-Disposition": `attachment; filename="${path.basename(p)}"`,
836
+ });
837
+ fs.createReadStream(p).pipe(res);
838
+ return;
839
+ }
840
+ if (url === "/screen/redetect") {
841
+ resetScreenState();
842
+ void detectDisplay().then(() => sendJson(res, 200, { display: displayNote() }));
843
+ return;
844
+ }
845
+ res.writeHead(404);
846
+ res.end("Not found");
847
+ }
357
848
  // Logcat line parsing for the host-side log merge. Format:
358
849
  // "MM-DD HH:MM:SS.mmm PID TID L Tag: message"
359
850
  const LOG_LINE_RE = /^(\d\d-\d\d \d\d:\d\d:\d\d\.\d+)\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+([^:]*?):\s?(.*)$/;
@@ -437,10 +928,13 @@ export function parseLogcat(line, resolvePkg = packageForPid) {
437
928
  * Every other retry it rebuilds the adb forward (the real cause of recurring
438
929
  * "unreachable"), reusing AndroidTransport rather than shelling adb directly.
439
930
  *
440
- * When the active spec wants logs (source "log" or "all"), it also spawns a
441
- * host-side `adb logcat -v threadtime -T 1` and merges parsed lines as
442
- * `type: "log"` frames. The adb binary is resolved via resolveAdbPath() so GUI
443
- * launchers without the user's PATH still work.
931
+ * When the active spec wants logs (source "log" or "all"), the handler subscribes
932
+ * to the shared session-long logSession: it is first replayed the entire retained
933
+ * ring buffer (so a tab switch shows full history), then receives live lines as
934
+ * they arrive. The logcat process itself is persistent and shared across all
935
+ * /stream clients, so tab switches never drop history AND the active tab keeps
936
+ * streaming live. The adb binary is resolved via resolveAdbPath() so GUI launchers
937
+ * without the user's PATH still work.
444
938
  */
445
939
  function handleStream(req, res) {
446
940
  res.writeHead(200, {
@@ -457,6 +951,24 @@ function handleStream(req, res) {
457
951
  lastState = state;
458
952
  res.write(`data: ${JSON.stringify({ type: "_status", event: { state, msg } })}\n\n`);
459
953
  };
954
+ // Keep the persistent logcat capture armed for the life of this connection.
955
+ // start() is idempotent, so it is safe to call both on initial setup and from
956
+ // the device-proxy reconnect path: when the device sleeps/unplugs, `adb logcat`
957
+ // exits and LogSession clears its child, and without a re-arm the live log feed
958
+ // would stay dead (until a tab switch) while network/rtm/clog self-heal via the
959
+ // proxy retry loop. Calling this on reconnect heals logs on the same path.
960
+ const spec = loadSpec();
961
+ const wantsLogs = spec.source === "log" || spec.source === "all";
962
+ const armLogcat = () => {
963
+ if (!wantsLogs || closed)
964
+ return;
965
+ const ok = logSession.start();
966
+ if (!ok) {
967
+ // Spawn failed (e.g. no adb on PATH). The capture is a named session-scoped
968
+ // thing now, so surface the reason rather than showing silent emptiness.
969
+ announce("waiting", "device logs unavailable (adb not found?)");
970
+ }
971
+ };
460
972
  let retries = 0;
461
973
  // One scheduled reconnect per attempt. up.on("end"), up.on("error"), and
462
974
  // upstream.on("error") can all fire for the same attempt; without this guard each
@@ -493,6 +1005,10 @@ function handleStream(req, res) {
493
1005
  method: "GET",
494
1006
  }, (up) => {
495
1007
  announce("connected", "device stream connected");
1008
+ // Re-arm logs on the same path the device proxy heals on: if the device
1009
+ // dropped and came back, `adb logcat` had exited; restart it (idempotent)
1010
+ // so the live log feed resumes without needing a browser reconnect.
1011
+ armLogcat();
496
1012
  up.on("data", (c) => res.write(c));
497
1013
  up.on("end", () => {
498
1014
  announce("waiting", "device stream ended, retrying…");
@@ -514,53 +1030,60 @@ function handleStream(req, res) {
514
1030
  if (!closed)
515
1031
  res.write(": hb\n\n");
516
1032
  }, 15000);
517
- // Host-side logcat merge for log/all sources. -T 1 streams only NEW lines;
518
- // without it logcat replays the entire existing buffer on connect and floods
519
- // the browser.
520
- let logcat = null;
521
- const spec = loadSpec();
522
- const wantsLogs = spec.source === "log" || spec.source === "all";
523
- const adb = resolveAdbPath();
524
- if (wantsLogs && adb) {
525
- try {
526
- refreshPidMap(); // warm PID->package so the first log rows resolve their app
527
- logcat = spawn(adb, ["logcat", "-v", "threadtime", "-T", "1"], {
528
- stdio: ["ignore", "pipe", "ignore"],
529
- });
530
- let lineBuf = "";
531
- logcat.stdout?.on("data", (c) => {
532
- lineBuf += c.toString();
533
- let nl;
534
- while ((nl = lineBuf.indexOf("\n")) >= 0) {
535
- const line = lineBuf.slice(0, nl);
536
- lineBuf = lineBuf.slice(nl + 1);
537
- const ev = parseLogcat(line);
538
- if (ev && !closed) {
539
- res.write(`data: ${JSON.stringify({ type: "log", event: ev })}\n\n`);
540
- }
541
- }
542
- });
543
- logcat.on("error", () => { });
544
- }
545
- catch {
546
- logcat = null;
547
- }
1033
+ // Host-side logcat merge for log/all sources. Subscribe to the shared,
1034
+ // session-long logSession: it replays the full retained ring buffer to this
1035
+ // connection first (so a tab switch shows complete history), then delivers
1036
+ // live lines. Both replayed and live frames are written to THIS response, so
1037
+ // the tab you are watching keeps streaming in real time. The logcat process
1038
+ // is persistent and shared, so a tab switch only adds/removes a listener — it
1039
+ // never tears down the capture or loses lines in a reconnect gap.
1040
+ let unsubscribeLogs = null;
1041
+ if (wantsLogs) {
1042
+ refreshPidMap(); // warm PID->package so the first log rows resolve their app
1043
+ armLogcat(); // idempotent start; surfaces a status frame if spawn fails
1044
+ unsubscribeLogs = logSession.subscribe((ev, replay) => {
1045
+ if (closed)
1046
+ return;
1047
+ // Backpressure guard: if this client is slow or its tab is backgrounded
1048
+ // (an EventSource stays subscribed when backgrounded), res.write() queues
1049
+ // unwritten bytes in Node's writable buffer without bound. The ring is
1050
+ // bounded but that per-connection buffer is not, so a stalled consumer
1051
+ // would grow host memory for the whole (now persistent) session. When the
1052
+ // socket buffer is already deep, DROP this frame rather than buffer it —
1053
+ // the client replays the full ring on its next reconnect and catches up.
1054
+ //
1055
+ // EXEMPT replay: the replay burst is a bounded one-time send of the whole
1056
+ // ring (~10 MB worst case at the 20000-frame cap) and IS the connection's
1057
+ // purpose. It runs synchronously oldest->newest, so the I/O phase cannot
1058
+ // drain between writes and writableLength climbs monotonically; gating it
1059
+ // on the 4 MB cap would silently drop the NEWEST history past ~4 MB and
1060
+ // reintroduce the very "log count shrinks on reconnect" loss this feature
1061
+ // fixes. The guard is only meant for the unbounded LIVE queue.
1062
+ if (shouldDropLogFrame(replay, res.writableLength))
1063
+ return;
1064
+ // Tag replayed history with replay:true so the client counts it as history
1065
+ // (live=false) and does NOT fire alerts on it. The client's own cache is
1066
+ // capped (count + TTL) while the server ring is larger, so on a long
1067
+ // session the client can evict frames the ring still holds; without this
1068
+ // flag those replayed-on-reconnect frames would look new and re-fire
1069
+ // `alert {at:N}` and re-count. The flag makes replay alert-safe regardless.
1070
+ const frame = replay
1071
+ ? { type: "log", event: ev, replay: true }
1072
+ : { type: "log", event: ev };
1073
+ res.write(`data: ${JSON.stringify(frame)}\n\n`);
1074
+ });
548
1075
  }
549
1076
  req.on("close", () => {
550
1077
  closed = true;
551
1078
  clearInterval(heartbeat);
1079
+ if (unsubscribeLogs)
1080
+ unsubscribeLogs(); // detach listener; capture stays up
552
1081
  try {
553
1082
  upstream?.destroy();
554
1083
  }
555
1084
  catch {
556
1085
  // already torn down
557
1086
  }
558
- try {
559
- logcat?.kill();
560
- }
561
- catch {
562
- // already dead
563
- }
564
1087
  });
565
1088
  }
566
1089
  function listProfiles(res) {