@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.
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Live device-screen mirror + record, for the dashboard's Screen overlay.
3
+ *
4
+ * Pipeline (scrcpy-lite, no custom decoder): `adb exec-out screenrecord
5
+ * --output-format=h264` on the active display -> `ffmpeg` transcodes h264 to
6
+ * MJPEG -> the server pushes JPEG frames to a browser <img> over
7
+ * multipart/x-mixed-replace. Record tees the same JPEG frames to a second
8
+ * ffmpeg (mjpeg in -> libx264 mp4), so the live view and the recording share one
9
+ * screenrecord (the way scrcpy shares one capture).
10
+ *
11
+ * SENSITIVE: this mirrors the real device screen, which shows live DMs, message
12
+ * content, and anything else on screen. It is OFF by default and gated behind an
13
+ * explicit per-process consent (see consent state below); the server refuses the
14
+ * stream and record routes until consent is granted.
15
+ *
16
+ * DEPENDENCY POLICY: ffmpeg is NOT bundled and NOT a hard dependency (it is large
17
+ * and license-encumbered; the published package keeps a single runtime dep). The
18
+ * stream/record paths detect-and-degrade: present -> embedded MJPEG; absent ->
19
+ * a clear message plus the platform install command, and a scrcpy fallback hint.
20
+ * The screenshot path uses pure `screencap` (PNG, no transcode) so it MUST keep
21
+ * working with no ffmpeg.
22
+ *
23
+ * Hard-won device facts baked in (Pixel 10 Pro Fold and similar):
24
+ * - Active-display detect must be deterministic: pick the powered-ON physical
25
+ * panel from `cmd display get-displays`. Byte-probing cannot tell a dozing
26
+ * black panel from a live one (a DOZE panel still emits ~1 keyframe).
27
+ * - `screencap -p` prepends a "[Warning] Multiple displays were found" text line
28
+ * on multi-display devices, corrupting the PNG. Strip everything before the
29
+ * PNG magic bytes.
30
+ * - screenrecord emits full-range YUV that the mjpeg encoder rejects; `-vf
31
+ * format=yuvj420p` fixes it.
32
+ * - H.264 emits nothing on a static screen (correct, not a hang). A
33
+ * multipart/x-mixed-replace viewer only paints part N when part N+1 arrives,
34
+ * so a keepalive re-pushes the last frame while idle to flush it.
35
+ * - adb does not reliably SIGKILL the device-side screenrecord on host pipe
36
+ * close; orphaned screenrecord procs congest the single adb transport. Kill
37
+ * explicitly on cleanup.
38
+ */
39
+ import { spawn, execFile, execFileSync } from "child_process";
40
+ import fs from "fs";
41
+ import os from "os";
42
+ import path from "path";
43
+ import { promisify } from "util";
44
+ import { resolveAdbPath } from "./android.js";
45
+ const SC_TMP = path.join(os.homedir(), ".slack-radar", "screen");
46
+ /**
47
+ * Remove all recorded mp4s from the screen tmp dir. Recordings capture sensitive
48
+ * live screen content and must not linger across sessions on disk. Called on
49
+ * resetScreenState() (device re-enable) and on process exit — the same posture the
50
+ * DB-browser uses for its pulled copies. Scoped to the radar_screen_*.mp4 names this
51
+ * module writes so it never deletes an unrelated file that landed in the dir.
52
+ */
53
+ export function cleanupRecordings() {
54
+ try {
55
+ for (const f of fs.readdirSync(SC_TMP)) {
56
+ if (/^radar_screen_[\w-]+\.mp4$/.test(f)) {
57
+ try {
58
+ fs.unlinkSync(path.join(SC_TMP, f));
59
+ }
60
+ catch {
61
+ // best-effort per file
62
+ }
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ // dir may not exist yet; nothing to clean
68
+ }
69
+ }
70
+ const SC_SIZE = "720x1612";
71
+ const SC_BITRATE = "8000000";
72
+ const SC_BOUNDARY = "radarframe";
73
+ // PNG magic header: screencap output begins here; anything before it is a prefix
74
+ // warning line that must be stripped.
75
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
76
+ function adb() {
77
+ return resolveAdbPath() ?? "adb";
78
+ }
79
+ // ---- host-binary detection (for the degrade tiers) --------------------------
80
+ const execFileP = promisify(execFile);
81
+ let ffmpegPath; // undefined = not probed, null = absent
82
+ let scrcpyPresent;
83
+ const FFMPEG_CANDIDATES = ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"];
84
+ /** Resolve an ffmpeg binary, or null if none is installed. Cached. */
85
+ export function resolveFfmpeg() {
86
+ // Test hook: simulate a machine with no ffmpeg so the degrade path can be exercised
87
+ // on CI/dev machines that do have it. Not for production use.
88
+ if (process.env.SLACK_RADAR_NO_FFMPEG === "1")
89
+ return null;
90
+ if (ffmpegPath !== undefined)
91
+ return ffmpegPath;
92
+ for (const c of FFMPEG_CANDIDATES) {
93
+ try {
94
+ execFileSync(c, ["-version"], { timeout: 3000, stdio: "ignore" });
95
+ ffmpegPath = c;
96
+ return c;
97
+ }
98
+ catch {
99
+ // try next candidate
100
+ }
101
+ }
102
+ ffmpegPath = null;
103
+ return null;
104
+ }
105
+ /** Whether scrcpy is installed (the no-ffmpeg fallback hint). Cached. */
106
+ export function hasScrcpy() {
107
+ if (scrcpyPresent !== undefined)
108
+ return scrcpyPresent;
109
+ try {
110
+ execFileSync("scrcpy", ["--version"], { timeout: 3000, stdio: "ignore" });
111
+ scrcpyPresent = true;
112
+ }
113
+ catch {
114
+ scrcpyPresent = false;
115
+ }
116
+ return scrcpyPresent;
117
+ }
118
+ /**
119
+ * Re-probe ffmpeg/scrcpy ASYNCHRONOUSLY and update the cache, so the overlay can
120
+ * recover after a user installs ffmpeg mid-session without restarting the server.
121
+ *
122
+ * Why async (not a force flag on the sync probes): the sync `execFileSync` probes are
123
+ * safe only BECAUSE they are cached and run at most once (see the same rule in db.ts).
124
+ * Re-running them synchronously on every panel reopen would block the single event loop
125
+ * (the live MJPEG stream, /health, every tab) for the spawn duration. This path uses
126
+ * execFile (non-blocking) instead. It also ONLY re-probes a binary that is currently
127
+ * cached as absent: if ffmpeg was already found there is nothing to recover, so the
128
+ * common case does no work.
129
+ */
130
+ export async function refreshScreenCapabilities() {
131
+ if (process.env.SLACK_RADAR_NO_FFMPEG === "1") {
132
+ ffmpegPath = null;
133
+ }
134
+ else if (ffmpegPath === undefined || ffmpegPath === null) {
135
+ // Not yet probed, or last probe found nothing: (re-)check now. Probe into a local
136
+ // and assign the cache only once, at the end, so a concurrent reader (a second tab,
137
+ // /health) keeps seeing the prior value during the probe instead of a transient
138
+ // null. Recovery only moves absent -> present, never the reverse.
139
+ let found = null;
140
+ for (const c of FFMPEG_CANDIDATES) {
141
+ try {
142
+ await execFileP(c, ["-version"], { timeout: 3000 });
143
+ found = c;
144
+ break;
145
+ }
146
+ catch {
147
+ // try next candidate
148
+ }
149
+ }
150
+ ffmpegPath = found;
151
+ }
152
+ if (scrcpyPresent === undefined || scrcpyPresent === false) {
153
+ try {
154
+ await execFileP("scrcpy", ["--version"], { timeout: 3000 });
155
+ scrcpyPresent = true;
156
+ }
157
+ catch {
158
+ scrcpyPresent = false;
159
+ }
160
+ }
161
+ }
162
+ /**
163
+ * A single runnable command to install ffmpeg on this platform. Never run
164
+ * automatically. This is presented as a copy-paste command, so it must be ONE
165
+ * runnable line: no parentheticals or alternatives, which would error if pasted
166
+ * into a shell. Alternative package managers belong in surrounding prose, not here.
167
+ */
168
+ export function ffmpegInstallHint() {
169
+ switch (process.platform) {
170
+ case "darwin":
171
+ return "brew install ffmpeg";
172
+ case "win32":
173
+ return "winget install ffmpeg";
174
+ default:
175
+ return "sudo apt install ffmpeg";
176
+ }
177
+ }
178
+ /**
179
+ * Capability summary for /health and /screen/caps: what the screen overlay can do
180
+ * on this machine. Reads the cached probe result. To recover after a mid-session
181
+ * ffmpeg install, call refreshScreenCapabilities() first (the /screen/caps?refresh=1
182
+ * path does this), then read here.
183
+ */
184
+ export function screenCapabilities() {
185
+ const ffmpeg = resolveFfmpeg() !== null;
186
+ return {
187
+ // screencap path is pure adb, always available when a device is attached.
188
+ screenshot: true,
189
+ liveCast: ffmpeg,
190
+ ffmpeg,
191
+ scrcpy: hasScrcpy(),
192
+ installHint: ffmpeg ? null : ffmpegInstallHint(),
193
+ };
194
+ }
195
+ // ---- consent (server-enforced, per process) ---------------------------------
196
+ let consentGranted = false;
197
+ /** Grant consent to mirror the live screen. Per server process. */
198
+ export function grantScreenConsent() {
199
+ consentGranted = true;
200
+ }
201
+ export function hasScreenConsent() {
202
+ return consentGranted;
203
+ }
204
+ /**
205
+ * Parse `adb shell cmd display get-displays` output into per-display
206
+ * {on, phys}, dropping any block without a physical id. The active panel is the
207
+ * one whose block reports state ON.
208
+ */
209
+ export function parseDisplays(out) {
210
+ return out
211
+ .split(/Display id \d+:/)
212
+ .slice(1)
213
+ .map((b) => ({
214
+ on: /committedState ON\b/.test(b) || /\bstate ON\b/.test(b),
215
+ phys: (b.match(/uniqueId "local:(\d+)"/) ?? [])[1] ?? null,
216
+ }))
217
+ .filter((d) => d.phys !== null);
218
+ }
219
+ /** Choose the physical display id to record: the powered-ON panel, else a lone panel, else null. */
220
+ export function pickDisplay(displays) {
221
+ const on = displays.find((d) => d.on);
222
+ if (on)
223
+ return { phys: on.phys, note: "ON panel " + on.phys };
224
+ if (displays.length === 1)
225
+ return { phys: displays[0].phys, note: "single panel" };
226
+ return { phys: null, note: "no ON panel (screen asleep?) — using default" };
227
+ }
228
+ /** Strip any text prefix (e.g. a multi-display warning) before the PNG magic header. */
229
+ export function stripPngPrefix(raw) {
230
+ const off = raw.indexOf(PNG_MAGIC);
231
+ return off > 0 ? raw.subarray(off) : raw;
232
+ }
233
+ // ---- active-display detection ------------------------------------------------
234
+ let scDisplay = null;
235
+ let scDetected = false;
236
+ let scNote = "";
237
+ function shAdb(args) {
238
+ return new Promise((resolve, reject) => {
239
+ execFile(adb(), args, { timeout: 15000, maxBuffer: 64 * 1024 * 1024 }, (e, so, se) => e ? reject(new Error(se?.toString() || e.message)) : resolve(so.toString()));
240
+ });
241
+ }
242
+ /** Detect (and cache) the active display id. resetScreenState() forces a re-detect. */
243
+ export async function detectDisplay() {
244
+ if (scDetected)
245
+ return scDisplay;
246
+ try {
247
+ const out = await shAdb(["shell", "cmd", "display", "get-displays"]);
248
+ const picked = pickDisplay(parseDisplays(out));
249
+ scDisplay = picked.phys;
250
+ scNote = picked.note;
251
+ }
252
+ catch (e) {
253
+ scDisplay = null;
254
+ scNote = "get-displays failed: " + e.message;
255
+ }
256
+ scDetected = true;
257
+ return scDisplay;
258
+ }
259
+ export function displayNote() {
260
+ return scNote;
261
+ }
262
+ // ---- screenshot (pure screencap, no ffmpeg) ----------------------------------
263
+ /** Capture a full-resolution PNG of the active display. Works without ffmpeg. */
264
+ export function captureScreenshot() {
265
+ return new Promise((resolve) => {
266
+ const args = ["exec-out", "screencap", "-p"];
267
+ if (scDisplay)
268
+ args.push("-d", scDisplay);
269
+ const cap = execFile(adb(), args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, timeout: 8000 }, (e, raw) => {
270
+ if (e || !raw || !raw.length)
271
+ return resolve(null);
272
+ resolve(stripPngPrefix(raw));
273
+ });
274
+ cap.on("error", () => resolve(null));
275
+ });
276
+ }
277
+ let scLive = null;
278
+ function screenrecordArgs() {
279
+ const a = ["exec-out", "screenrecord", "--output-format=h264", "--size", SC_SIZE, "--bit-rate", SC_BITRATE];
280
+ if (scDisplay)
281
+ a.push("--display-id", scDisplay);
282
+ a.push("-");
283
+ return a;
284
+ }
285
+ /** Prime an instant first frame (screencap -> single mjpeg) so a static screen is not blank. */
286
+ async function primeFrame(ffmpeg) {
287
+ const png = await captureScreenshot();
288
+ if (!png)
289
+ return null;
290
+ return new Promise((resolve) => {
291
+ const ff = spawn(ffmpeg, ["-hide_banner", "-loglevel", "error", "-i", "pipe:0", "-vf", "format=yuvj420p", "-q:v", "6", "-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1"], { stdio: ["pipe", "pipe", "ignore"] });
292
+ let out = Buffer.alloc(0);
293
+ ff.stdout.on("data", (c) => (out = Buffer.concat([out, c])));
294
+ ff.on("exit", () => resolve(out.length ? out : null));
295
+ ff.on("error", () => resolve(null));
296
+ ff.stdin.write(png);
297
+ ff.stdin.end();
298
+ });
299
+ }
300
+ function pushFrame(L, frame) {
301
+ const head = Buffer.from(`--${SC_BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
302
+ const tail = Buffer.from("\r\n");
303
+ for (const r of L.viewers) {
304
+ try {
305
+ r.write(head);
306
+ r.write(frame);
307
+ r.write(tail);
308
+ }
309
+ catch {
310
+ // viewer disconnected; harmless
311
+ }
312
+ }
313
+ if (L.recording) {
314
+ try {
315
+ L.recording.proc.stdin?.write(frame);
316
+ }
317
+ catch {
318
+ // recording ended; harmless
319
+ }
320
+ }
321
+ }
322
+ function cleanupLive(L) {
323
+ if (L.recording)
324
+ void stopRecording(L);
325
+ reapCastProcesses(L);
326
+ }
327
+ /**
328
+ * screenrecord self-exits at its ~180s cap, so this fires routinely for any open
329
+ * viewer, not just on error. Migrate the viewers and the in-progress recording to a
330
+ * fresh cast, then FULLY reap the old one: clear its keepalive interval, drop its
331
+ * retained frame, and kill its (now orphaned) screenrecord + ffmpeg children. Without
332
+ * this, each ~3-minute cycle leaks a 400ms interval pinning a multi-KB JPEG and leaves
333
+ * the old ffmpeg to rely on pipe-EOF — the same orphan-on-the-single-transport class
334
+ * the module header warns about.
335
+ */
336
+ function relaunch(old) {
337
+ const migratedRecording = old.recording;
338
+ old.recording = null; // detach so reaping the old cast does not stop the recording
339
+ const next = startLive(old.fps);
340
+ reapCastProcesses(old);
341
+ if (!next) {
342
+ // ffmpeg vanished mid-session: nothing to migrate to. Stop the orphaned recording.
343
+ if (migratedRecording)
344
+ old.recording = migratedRecording;
345
+ if (old.recording)
346
+ void stopRecording(old);
347
+ return;
348
+ }
349
+ for (const v of old.viewers)
350
+ next.viewers.add(v);
351
+ old.viewers.clear();
352
+ if (migratedRecording)
353
+ next.recording = migratedRecording;
354
+ }
355
+ /** Clear the keepalive + retained frame and SIGKILL the cast's child processes. Does NOT touch recording. */
356
+ function reapCastProcesses(L) {
357
+ if (L.keepalive) {
358
+ clearInterval(L.keepalive);
359
+ L.keepalive = null;
360
+ }
361
+ L.lastFrame = null;
362
+ try {
363
+ L.sr.kill("SIGKILL");
364
+ }
365
+ catch {
366
+ /* already gone */
367
+ }
368
+ try {
369
+ L.ff.kill("SIGKILL");
370
+ }
371
+ catch {
372
+ /* already gone */
373
+ }
374
+ }
375
+ /**
376
+ * Start (or return the existing) live cast. Returns null if ffmpeg is unavailable
377
+ * — callers must degrade rather than assume a stream. The boundary string is
378
+ * SC_BOUNDARY; the route serves multipart/x-mixed-replace with it.
379
+ */
380
+ export function startLive(fps) {
381
+ if (scLive)
382
+ return scLive;
383
+ const ffmpeg = resolveFfmpeg();
384
+ if (!ffmpeg)
385
+ return null;
386
+ const sr = spawn(adb(), screenrecordArgs(), { stdio: ["ignore", "pipe", "ignore"] });
387
+ const ff = spawn(ffmpeg, [
388
+ "-hide_banner", "-loglevel", "error", "-f", "h264", "-probesize", "64k", "-i", "pipe:0",
389
+ "-vf", `format=yuvj420p,fps=${fps || 20}`, "-q:v", "6", "-flush_packets", "1",
390
+ "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1",
391
+ ], { stdio: ["pipe", "pipe", "ignore"] });
392
+ sr.stdout?.pipe(ff.stdin);
393
+ const L = {
394
+ sr,
395
+ ff,
396
+ viewers: new Set(),
397
+ lastFrame: null,
398
+ lastFrameTs: 0,
399
+ recording: null,
400
+ fps: fps || 20,
401
+ keepalive: null,
402
+ };
403
+ let acc = Buffer.alloc(0);
404
+ ff.stdout?.on("data", (chunk) => {
405
+ acc = Buffer.concat([acc, chunk]);
406
+ for (;;) {
407
+ const soi = acc.indexOf(Buffer.from([0xff, 0xd8]));
408
+ if (soi < 0) {
409
+ if (acc.length > 4 << 20)
410
+ acc = Buffer.alloc(0);
411
+ break;
412
+ }
413
+ const eoi = acc.indexOf(Buffer.from([0xff, 0xd9]), soi + 2);
414
+ if (eoi < 0) {
415
+ if (soi > 0)
416
+ acc = acc.subarray(soi);
417
+ break;
418
+ }
419
+ const frame = acc.subarray(soi, eoi + 2);
420
+ acc = acc.subarray(eoi + 2);
421
+ L.lastFrame = frame;
422
+ L.lastFrameTs = Date.now();
423
+ pushFrame(L, frame);
424
+ }
425
+ });
426
+ const onExit = () => {
427
+ if (scLive === L) {
428
+ scLive = null;
429
+ if (L.viewers.size || L.recording)
430
+ relaunch(L);
431
+ else
432
+ cleanupLive(L);
433
+ }
434
+ };
435
+ sr.on("exit", onExit);
436
+ ff.on("exit", onExit);
437
+ // Re-push the last frame while idle so a static screen's final frame paints
438
+ // (multipart/x-mixed-replace renders part N only when part N+1's boundary arrives).
439
+ //
440
+ // KNOWN LIMITATION: screenrecord does not flush its encoder when the screen goes
441
+ // static, so the last 1-2 motion frames stay buffered on the device and the live
442
+ // view can sit a beat behind after you stop, catching up on the next motion. This
443
+ // is cosmetic (no data is lost; the screenshot button always reflects the true
444
+ // current screen) and self-corrects the instant the screen changes. The only full
445
+ // fix is scrcpy's MediaCodec KEY_REPEAT_PREVIOUS_FRAME_AFTER, which re-emits frames
446
+ // encoder-side on a static screen; that is a separate larger change. An idle
447
+ // screencap-refresh was tried and rejected: on a single adb transport it contends
448
+ // with the live screenrecord and the radar socket and destabilizes the connection.
449
+ L.keepalive = setInterval(() => {
450
+ if (L.lastFrame && Date.now() - L.lastFrameTs > 350)
451
+ pushFrame(L, L.lastFrame);
452
+ }, 400);
453
+ scLive = L;
454
+ return L;
455
+ }
456
+ /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
457
+ export function addViewer(L, res) {
458
+ L.viewers.add(res);
459
+ }
460
+ export function removeViewer(res) {
461
+ if (!scLive)
462
+ return;
463
+ scLive.viewers.delete(res);
464
+ if (!scLive.viewers.size && !scLive.recording) {
465
+ cleanupLive(scLive);
466
+ scLive = null;
467
+ }
468
+ }
469
+ export function getLive() {
470
+ return scLive;
471
+ }
472
+ export function lastFrame() {
473
+ return scLive?.lastFrame ?? null;
474
+ }
475
+ export async function primeIfNeeded() {
476
+ const ffmpeg = resolveFfmpeg();
477
+ if (!ffmpeg || !scLive || scLive.lastFrame)
478
+ return;
479
+ const f = await primeFrame(ffmpeg);
480
+ if (f && scLive && !scLive.lastFrame) {
481
+ scLive.lastFrame = f;
482
+ scLive.lastFrameTs = Date.now();
483
+ pushFrame(scLive, f);
484
+ }
485
+ }
486
+ export const SCREEN_BOUNDARY = SC_BOUNDARY;
487
+ // ---- record to mp4 -----------------------------------------------------------
488
+ export function startRecording() {
489
+ const ffmpeg = resolveFfmpeg();
490
+ if (!ffmpeg || !scLive)
491
+ return null;
492
+ if (scLive.recording)
493
+ return scLive.recording.path;
494
+ // Recordings capture live screen content (open DMs, message text, notifications),
495
+ // so the dir is owner-only (0700) and each mp4 owner-read/write only (0600) — the
496
+ // same posture the DB-browser uses for its equally-sensitive pulled copies. Without
497
+ // this the dir is world-traversable (~0755) and ffmpeg writes the file under the
498
+ // default umask (~0644, world-readable), exposing captures to other local users.
499
+ fs.mkdirSync(SC_TMP, { recursive: true, mode: 0o700 });
500
+ // mkdirSync's `mode` only applies when CREATING the dir; a dir left over from an
501
+ // earlier session (or created before this posture existed) keeps its old perms
502
+ // (~0755, world-traversable). chmod unconditionally so the owner-only guarantee
503
+ // holds regardless of when the dir first appeared.
504
+ try {
505
+ fs.chmodSync(SC_TMP, 0o700);
506
+ }
507
+ catch {
508
+ // best-effort; file is still written 0600 below
509
+ }
510
+ const safe = new Date().toISOString().replace(/[:.]/g, "-");
511
+ const out = path.join(SC_TMP, `radar_screen_${safe}.mp4`);
512
+ const rec = spawn(ffmpeg, ["-hide_banner", "-loglevel", "error", "-y", "-f", "image2pipe", "-r", String(scLive.fps), "-c:v", "mjpeg", "-i", "pipe:0", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", out], { stdio: ["pipe", "ignore", "ignore"] });
513
+ scLive.recording = { proc: rec, path: out };
514
+ return out;
515
+ }
516
+ export function stopRecording(L = scLive) {
517
+ if (!L?.recording)
518
+ return Promise.resolve(null);
519
+ const r = L.recording;
520
+ L.recording = null;
521
+ return new Promise((resolve) => {
522
+ let done = false;
523
+ const fin = () => {
524
+ if (!done) {
525
+ done = true;
526
+ // The finished mp4 holds captured screen content; restrict to owner before
527
+ // it is downloadable. The 0700 dir already blocks other-user traversal; this
528
+ // is defense-in-depth on the file itself (ffmpeg created it under the umask).
529
+ try {
530
+ fs.chmodSync(r.path, 0o600);
531
+ }
532
+ catch {
533
+ // best-effort; dir perms are the primary control
534
+ }
535
+ resolve(r.path);
536
+ }
537
+ };
538
+ r.proc.on("exit", fin);
539
+ try {
540
+ r.proc.stdin?.end();
541
+ }
542
+ catch {
543
+ fin();
544
+ }
545
+ setTimeout(fin, 8000);
546
+ });
547
+ }
548
+ /**
549
+ * Resolve a recording for download. Restricted to the screen tmp dir AND to the
550
+ * radar_screen_*.mp4 name this module writes: path.basename strips any directory
551
+ * (no traversal), and the prefix/extension check means a download request can only
552
+ * ever name a radar recording, never an arbitrary file that happened to land in the dir.
553
+ */
554
+ export function recordingPath(file) {
555
+ const base = path.basename(file);
556
+ if (!/^radar_screen_[\w-]+\.mp4$/.test(base))
557
+ return null;
558
+ const p = path.join(SC_TMP, base);
559
+ return fs.existsSync(p) ? p : null;
560
+ }
561
+ // ---- lifecycle ---------------------------------------------------------------
562
+ /** Reset detection + tear down any live cast. Called on device re-enable and shutdown. */
563
+ export function resetScreenState() {
564
+ scDetected = false;
565
+ scDisplay = null;
566
+ scNote = "";
567
+ if (scLive) {
568
+ cleanupLive(scLive);
569
+ scLive = null;
570
+ }
571
+ // Recorded mp4s hold sensitive screen content; do not let them linger across the
572
+ // session boundary on disk.
573
+ cleanupRecordings();
574
+ // Best-effort: clean up orphaned device-side screenrecord procs that the host pipe
575
+ // close did not kill (they congest the single adb transport). NOTE: `-f screenrecord`
576
+ // is device-WIDE — it kills every screenrecord on the device, not just radar's. That
577
+ // is intentional for a debug tool (radar should not leave the transport congested),
578
+ // but a user running an unrelated manual screenrecord would have it killed too.
579
+ try {
580
+ execFile(adb(), ["shell", "pkill", "-9", "-f", "screenrecord"], { timeout: 5000 }, () => { });
581
+ }
582
+ catch {
583
+ // best-effort
584
+ }
585
+ }
586
+ let cleanupRegistered = false;
587
+ /**
588
+ * Kill any live cast (and orphaned device-side screenrecord) on process exit. Mirrors
589
+ * logcat-capture.registerCleanup(); without it, Ctrl-C on an open mirror orphans the
590
+ * screenrecord + ffmpeg children and leaves the device transport congested for the
591
+ * next session. Called from createServer().
592
+ */
593
+ export function registerScreenCleanup() {
594
+ if (cleanupRegistered)
595
+ return;
596
+ cleanupRegistered = true;
597
+ process.on("exit", () => {
598
+ if (scLive)
599
+ reapCastProcesses(scLive);
600
+ });
601
+ process.on("SIGINT", () => {
602
+ signalCleanup();
603
+ process.exit(130);
604
+ });
605
+ process.on("SIGTERM", () => {
606
+ signalCleanup();
607
+ process.exit(143);
608
+ });
609
+ }
610
+ /**
611
+ * Synchronous teardown for the signal path. resetScreenState()'s device-side
612
+ * orphan-kill uses async execFile, which does NOT complete before process.exit on
613
+ * Ctrl-C — so the orphaned device screenrecord the module header warns about would
614
+ * survive. Reap host children, then kill the device-side screenrecord SYNCHRONOUSLY
615
+ * (execFileSync, short timeout) so it actually runs before we exit, and clean the
616
+ * recordings. Best-effort throughout; exit must not be blocked by a cleanup failure.
617
+ */
618
+ function signalCleanup() {
619
+ if (scLive) {
620
+ cleanupLive(scLive);
621
+ scLive = null;
622
+ }
623
+ try {
624
+ execFileSync(adb(), ["shell", "pkill", "-9", "-f", "screenrecord"], {
625
+ timeout: 3000,
626
+ stdio: "ignore",
627
+ });
628
+ }
629
+ catch {
630
+ // best-effort: no adb, no device, or nothing to kill
631
+ }
632
+ cleanupRecordings();
633
+ }
@@ -64,6 +64,16 @@ export interface DeviceTransport {
64
64
  * unchecked casts when asking "which profile did this capture?".
65
65
  */
66
66
  getActiveUserId(): string | null;
67
+ /**
68
+ * Resolve the user the debug build is actually running as, never null. Unlike
69
+ * getActiveUserId() (which returns null when nothing has been detected), this always
70
+ * resolves to a concrete profile, preferring the non-zero/secondary profile the work
71
+ * build runs under rather than blindly defaulting to user 0. Callers that need a definite
72
+ * target (the on-device DB browser) use this so list/pull/query never silently disagree
73
+ * on which profile's store they touch. On a single-user platform (iOS) this returns the
74
+ * sole user or an empty string.
75
+ */
76
+ resolveActiveUser(): string;
67
77
  /**
68
78
  * Returns the last error message from an `ensureForward()` failure, or null
69
79
  * if the last attempt succeeded or has not happened. Lets callers surface
package/dist/web/bin.d.ts CHANGED
@@ -1,2 +1,17 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /**
3
+ * Decide whether a /spec response body identifies a Radar dashboard. A Radar /spec
4
+ * is always a plain object carrying a `viz` array (even a blank spec is
5
+ * { title, blank, viz: [] }). Requiring both excludes a foreign service that happens
6
+ * to 200 with arbitrary JSON (a bare array, a string, an unrelated object). Exported
7
+ * for unit testing. statusCode must be 200.
8
+ */
9
+ export declare function isRadarSpecBody(statusCode: number | undefined, body: string): boolean;
10
+ /**
11
+ * Start the web dashboard server, open the browser, and wire shutdown signals.
12
+ * Extracted so both the dedicated `slack-radar-web` bin and the default bin's
13
+ * `web` subcommand dispatcher launch the dashboard the same way. Exported so the
14
+ * dispatcher (mcp/index.ts) calls it explicitly rather than relying on an import
15
+ * side-effect, which keeps importing this module (e.g. in a test) side-effect-free.
16
+ */
17
+ export declare function startWebServer(): void;