@slack/radar-mcp 1.3.0 → 1.5.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,576 @@
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 { resolveAdbPath } from "./android.js";
44
+ const SC_TMP = path.join(os.homedir(), ".slack-radar", "screen");
45
+ /**
46
+ * Remove all recorded mp4s from the screen tmp dir. Recordings capture sensitive
47
+ * live screen content and must not linger across sessions on disk. Called on
48
+ * resetScreenState() (device re-enable) and on process exit — the same posture the
49
+ * DB-browser uses for its pulled copies. Scoped to the radar_screen_*.mp4 names this
50
+ * module writes so it never deletes an unrelated file that landed in the dir.
51
+ */
52
+ export function cleanupRecordings() {
53
+ try {
54
+ for (const f of fs.readdirSync(SC_TMP)) {
55
+ if (/^radar_screen_[\w-]+\.mp4$/.test(f)) {
56
+ try {
57
+ fs.unlinkSync(path.join(SC_TMP, f));
58
+ }
59
+ catch {
60
+ // best-effort per file
61
+ }
62
+ }
63
+ }
64
+ }
65
+ catch {
66
+ // dir may not exist yet; nothing to clean
67
+ }
68
+ }
69
+ const SC_SIZE = "720x1612";
70
+ const SC_BITRATE = "8000000";
71
+ const SC_BOUNDARY = "radarframe";
72
+ // PNG magic header: screencap output begins here; anything before it is a prefix
73
+ // warning line that must be stripped.
74
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
75
+ function adb() {
76
+ return resolveAdbPath() ?? "adb";
77
+ }
78
+ // ---- host-binary detection (for the degrade tiers) --------------------------
79
+ let ffmpegPath; // undefined = not probed, null = absent
80
+ let scrcpyPresent;
81
+ /** Resolve an ffmpeg binary, or null if none is installed. Cached. */
82
+ export function resolveFfmpeg() {
83
+ // Test hook: simulate a machine with no ffmpeg so the degrade path can be exercised
84
+ // on CI/dev machines that do have it. Not for production use.
85
+ if (process.env.SLACK_RADAR_NO_FFMPEG === "1")
86
+ return null;
87
+ if (ffmpegPath !== undefined)
88
+ return ffmpegPath;
89
+ for (const c of ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"]) {
90
+ try {
91
+ execFileSync(c, ["-version"], { timeout: 3000, stdio: "ignore" });
92
+ ffmpegPath = c;
93
+ return c;
94
+ }
95
+ catch {
96
+ // try next candidate
97
+ }
98
+ }
99
+ ffmpegPath = null;
100
+ return null;
101
+ }
102
+ /** Whether scrcpy is installed (the no-ffmpeg fallback hint). Cached. */
103
+ export function hasScrcpy() {
104
+ if (scrcpyPresent !== undefined)
105
+ return scrcpyPresent;
106
+ try {
107
+ execFileSync("scrcpy", ["--version"], { timeout: 3000, stdio: "ignore" });
108
+ scrcpyPresent = true;
109
+ }
110
+ catch {
111
+ scrcpyPresent = false;
112
+ }
113
+ return scrcpyPresent;
114
+ }
115
+ /** The platform-specific copy-paste command to install ffmpeg. Never run automatically. */
116
+ export function ffmpegInstallHint() {
117
+ switch (process.platform) {
118
+ case "darwin":
119
+ return "brew install ffmpeg";
120
+ case "win32":
121
+ return "winget install ffmpeg (or: choco install ffmpeg)";
122
+ default:
123
+ return "sudo apt install ffmpeg (or your distro's package manager)";
124
+ }
125
+ }
126
+ /** Capability summary for /health: what the screen overlay can do on this machine. */
127
+ export function screenCapabilities() {
128
+ const ffmpeg = resolveFfmpeg() !== null;
129
+ return {
130
+ // screencap path is pure adb, always available when a device is attached.
131
+ screenshot: true,
132
+ liveCast: ffmpeg,
133
+ ffmpeg,
134
+ scrcpy: hasScrcpy(),
135
+ installHint: ffmpeg ? null : ffmpegInstallHint(),
136
+ };
137
+ }
138
+ // ---- consent (server-enforced, per process) ---------------------------------
139
+ let consentGranted = false;
140
+ /** Grant consent to mirror the live screen. Per server process. */
141
+ export function grantScreenConsent() {
142
+ consentGranted = true;
143
+ }
144
+ export function hasScreenConsent() {
145
+ return consentGranted;
146
+ }
147
+ /**
148
+ * Parse `adb shell cmd display get-displays` output into per-display
149
+ * {on, phys}, dropping any block without a physical id. The active panel is the
150
+ * one whose block reports state ON.
151
+ */
152
+ export function parseDisplays(out) {
153
+ return out
154
+ .split(/Display id \d+:/)
155
+ .slice(1)
156
+ .map((b) => ({
157
+ on: /committedState ON\b/.test(b) || /\bstate ON\b/.test(b),
158
+ phys: (b.match(/uniqueId "local:(\d+)"/) ?? [])[1] ?? null,
159
+ }))
160
+ .filter((d) => d.phys !== null);
161
+ }
162
+ /** Choose the physical display id to record: the powered-ON panel, else a lone panel, else null. */
163
+ export function pickDisplay(displays) {
164
+ const on = displays.find((d) => d.on);
165
+ if (on)
166
+ return { phys: on.phys, note: "ON panel " + on.phys };
167
+ if (displays.length === 1)
168
+ return { phys: displays[0].phys, note: "single panel" };
169
+ return { phys: null, note: "no ON panel (screen asleep?) — using default" };
170
+ }
171
+ /** Strip any text prefix (e.g. a multi-display warning) before the PNG magic header. */
172
+ export function stripPngPrefix(raw) {
173
+ const off = raw.indexOf(PNG_MAGIC);
174
+ return off > 0 ? raw.subarray(off) : raw;
175
+ }
176
+ // ---- active-display detection ------------------------------------------------
177
+ let scDisplay = null;
178
+ let scDetected = false;
179
+ let scNote = "";
180
+ function shAdb(args) {
181
+ return new Promise((resolve, reject) => {
182
+ execFile(adb(), args, { timeout: 15000, maxBuffer: 64 * 1024 * 1024 }, (e, so, se) => e ? reject(new Error(se?.toString() || e.message)) : resolve(so.toString()));
183
+ });
184
+ }
185
+ /** Detect (and cache) the active display id. resetScreenState() forces a re-detect. */
186
+ export async function detectDisplay() {
187
+ if (scDetected)
188
+ return scDisplay;
189
+ try {
190
+ const out = await shAdb(["shell", "cmd", "display", "get-displays"]);
191
+ const picked = pickDisplay(parseDisplays(out));
192
+ scDisplay = picked.phys;
193
+ scNote = picked.note;
194
+ }
195
+ catch (e) {
196
+ scDisplay = null;
197
+ scNote = "get-displays failed: " + e.message;
198
+ }
199
+ scDetected = true;
200
+ return scDisplay;
201
+ }
202
+ export function displayNote() {
203
+ return scNote;
204
+ }
205
+ // ---- screenshot (pure screencap, no ffmpeg) ----------------------------------
206
+ /** Capture a full-resolution PNG of the active display. Works without ffmpeg. */
207
+ export function captureScreenshot() {
208
+ return new Promise((resolve) => {
209
+ const args = ["exec-out", "screencap", "-p"];
210
+ if (scDisplay)
211
+ args.push("-d", scDisplay);
212
+ const cap = execFile(adb(), args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, timeout: 8000 }, (e, raw) => {
213
+ if (e || !raw || !raw.length)
214
+ return resolve(null);
215
+ resolve(stripPngPrefix(raw));
216
+ });
217
+ cap.on("error", () => resolve(null));
218
+ });
219
+ }
220
+ let scLive = null;
221
+ function screenrecordArgs() {
222
+ const a = ["exec-out", "screenrecord", "--output-format=h264", "--size", SC_SIZE, "--bit-rate", SC_BITRATE];
223
+ if (scDisplay)
224
+ a.push("--display-id", scDisplay);
225
+ a.push("-");
226
+ return a;
227
+ }
228
+ /** Prime an instant first frame (screencap -> single mjpeg) so a static screen is not blank. */
229
+ async function primeFrame(ffmpeg) {
230
+ const png = await captureScreenshot();
231
+ if (!png)
232
+ return null;
233
+ return new Promise((resolve) => {
234
+ 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"] });
235
+ let out = Buffer.alloc(0);
236
+ ff.stdout.on("data", (c) => (out = Buffer.concat([out, c])));
237
+ ff.on("exit", () => resolve(out.length ? out : null));
238
+ ff.on("error", () => resolve(null));
239
+ ff.stdin.write(png);
240
+ ff.stdin.end();
241
+ });
242
+ }
243
+ function pushFrame(L, frame) {
244
+ const head = Buffer.from(`--${SC_BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
245
+ const tail = Buffer.from("\r\n");
246
+ for (const r of L.viewers) {
247
+ try {
248
+ r.write(head);
249
+ r.write(frame);
250
+ r.write(tail);
251
+ }
252
+ catch {
253
+ // viewer disconnected; harmless
254
+ }
255
+ }
256
+ if (L.recording) {
257
+ try {
258
+ L.recording.proc.stdin?.write(frame);
259
+ }
260
+ catch {
261
+ // recording ended; harmless
262
+ }
263
+ }
264
+ }
265
+ function cleanupLive(L) {
266
+ if (L.recording)
267
+ void stopRecording(L);
268
+ reapCastProcesses(L);
269
+ }
270
+ /**
271
+ * screenrecord self-exits at its ~180s cap, so this fires routinely for any open
272
+ * viewer, not just on error. Migrate the viewers and the in-progress recording to a
273
+ * fresh cast, then FULLY reap the old one: clear its keepalive interval, drop its
274
+ * retained frame, and kill its (now orphaned) screenrecord + ffmpeg children. Without
275
+ * this, each ~3-minute cycle leaks a 400ms interval pinning a multi-KB JPEG and leaves
276
+ * the old ffmpeg to rely on pipe-EOF — the same orphan-on-the-single-transport class
277
+ * the module header warns about.
278
+ */
279
+ function relaunch(old) {
280
+ const migratedRecording = old.recording;
281
+ old.recording = null; // detach so reaping the old cast does not stop the recording
282
+ const next = startLive(old.fps);
283
+ reapCastProcesses(old);
284
+ if (!next) {
285
+ // ffmpeg vanished mid-session: nothing to migrate to. Stop the orphaned recording.
286
+ if (migratedRecording)
287
+ old.recording = migratedRecording;
288
+ if (old.recording)
289
+ void stopRecording(old);
290
+ return;
291
+ }
292
+ for (const v of old.viewers)
293
+ next.viewers.add(v);
294
+ old.viewers.clear();
295
+ if (migratedRecording)
296
+ next.recording = migratedRecording;
297
+ }
298
+ /** Clear the keepalive + retained frame and SIGKILL the cast's child processes. Does NOT touch recording. */
299
+ function reapCastProcesses(L) {
300
+ if (L.keepalive) {
301
+ clearInterval(L.keepalive);
302
+ L.keepalive = null;
303
+ }
304
+ L.lastFrame = null;
305
+ try {
306
+ L.sr.kill("SIGKILL");
307
+ }
308
+ catch {
309
+ /* already gone */
310
+ }
311
+ try {
312
+ L.ff.kill("SIGKILL");
313
+ }
314
+ catch {
315
+ /* already gone */
316
+ }
317
+ }
318
+ /**
319
+ * Start (or return the existing) live cast. Returns null if ffmpeg is unavailable
320
+ * — callers must degrade rather than assume a stream. The boundary string is
321
+ * SC_BOUNDARY; the route serves multipart/x-mixed-replace with it.
322
+ */
323
+ export function startLive(fps) {
324
+ if (scLive)
325
+ return scLive;
326
+ const ffmpeg = resolveFfmpeg();
327
+ if (!ffmpeg)
328
+ return null;
329
+ const sr = spawn(adb(), screenrecordArgs(), { stdio: ["ignore", "pipe", "ignore"] });
330
+ const ff = spawn(ffmpeg, [
331
+ "-hide_banner", "-loglevel", "error", "-f", "h264", "-probesize", "64k", "-i", "pipe:0",
332
+ "-vf", `format=yuvj420p,fps=${fps || 20}`, "-q:v", "6", "-flush_packets", "1",
333
+ "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1",
334
+ ], { stdio: ["pipe", "pipe", "ignore"] });
335
+ sr.stdout?.pipe(ff.stdin);
336
+ const L = {
337
+ sr,
338
+ ff,
339
+ viewers: new Set(),
340
+ lastFrame: null,
341
+ lastFrameTs: 0,
342
+ recording: null,
343
+ fps: fps || 20,
344
+ keepalive: null,
345
+ };
346
+ let acc = Buffer.alloc(0);
347
+ ff.stdout?.on("data", (chunk) => {
348
+ acc = Buffer.concat([acc, chunk]);
349
+ for (;;) {
350
+ const soi = acc.indexOf(Buffer.from([0xff, 0xd8]));
351
+ if (soi < 0) {
352
+ if (acc.length > 4 << 20)
353
+ acc = Buffer.alloc(0);
354
+ break;
355
+ }
356
+ const eoi = acc.indexOf(Buffer.from([0xff, 0xd9]), soi + 2);
357
+ if (eoi < 0) {
358
+ if (soi > 0)
359
+ acc = acc.subarray(soi);
360
+ break;
361
+ }
362
+ const frame = acc.subarray(soi, eoi + 2);
363
+ acc = acc.subarray(eoi + 2);
364
+ L.lastFrame = frame;
365
+ L.lastFrameTs = Date.now();
366
+ pushFrame(L, frame);
367
+ }
368
+ });
369
+ const onExit = () => {
370
+ if (scLive === L) {
371
+ scLive = null;
372
+ if (L.viewers.size || L.recording)
373
+ relaunch(L);
374
+ else
375
+ cleanupLive(L);
376
+ }
377
+ };
378
+ sr.on("exit", onExit);
379
+ ff.on("exit", onExit);
380
+ // Re-push the last frame while idle so a static screen's final frame paints
381
+ // (multipart/x-mixed-replace renders part N only when part N+1's boundary arrives).
382
+ //
383
+ // KNOWN LIMITATION: screenrecord does not flush its encoder when the screen goes
384
+ // static, so the last 1-2 motion frames stay buffered on the device and the live
385
+ // view can sit a beat behind after you stop, catching up on the next motion. This
386
+ // is cosmetic (no data is lost; the screenshot button always reflects the true
387
+ // current screen) and self-corrects the instant the screen changes. The only full
388
+ // fix is scrcpy's MediaCodec KEY_REPEAT_PREVIOUS_FRAME_AFTER, which re-emits frames
389
+ // encoder-side on a static screen; that is a separate larger change. An idle
390
+ // screencap-refresh was tried and rejected: on a single adb transport it contends
391
+ // with the live screenrecord and the radar socket and destabilizes the connection.
392
+ L.keepalive = setInterval(() => {
393
+ if (L.lastFrame && Date.now() - L.lastFrameTs > 350)
394
+ pushFrame(L, L.lastFrame);
395
+ }, 400);
396
+ scLive = L;
397
+ return L;
398
+ }
399
+ /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
400
+ export function addViewer(L, res) {
401
+ L.viewers.add(res);
402
+ }
403
+ export function removeViewer(res) {
404
+ if (!scLive)
405
+ return;
406
+ scLive.viewers.delete(res);
407
+ if (!scLive.viewers.size && !scLive.recording) {
408
+ cleanupLive(scLive);
409
+ scLive = null;
410
+ }
411
+ }
412
+ export function getLive() {
413
+ return scLive;
414
+ }
415
+ export function lastFrame() {
416
+ return scLive?.lastFrame ?? null;
417
+ }
418
+ export async function primeIfNeeded() {
419
+ const ffmpeg = resolveFfmpeg();
420
+ if (!ffmpeg || !scLive || scLive.lastFrame)
421
+ return;
422
+ const f = await primeFrame(ffmpeg);
423
+ if (f && scLive && !scLive.lastFrame) {
424
+ scLive.lastFrame = f;
425
+ scLive.lastFrameTs = Date.now();
426
+ pushFrame(scLive, f);
427
+ }
428
+ }
429
+ export const SCREEN_BOUNDARY = SC_BOUNDARY;
430
+ // ---- record to mp4 -----------------------------------------------------------
431
+ export function startRecording() {
432
+ const ffmpeg = resolveFfmpeg();
433
+ if (!ffmpeg || !scLive)
434
+ return null;
435
+ if (scLive.recording)
436
+ return scLive.recording.path;
437
+ // Recordings capture live screen content (open DMs, message text, notifications),
438
+ // so the dir is owner-only (0700) and each mp4 owner-read/write only (0600) — the
439
+ // same posture the DB-browser uses for its equally-sensitive pulled copies. Without
440
+ // this the dir is world-traversable (~0755) and ffmpeg writes the file under the
441
+ // default umask (~0644, world-readable), exposing captures to other local users.
442
+ fs.mkdirSync(SC_TMP, { recursive: true, mode: 0o700 });
443
+ // mkdirSync's `mode` only applies when CREATING the dir; a dir left over from an
444
+ // earlier session (or created before this posture existed) keeps its old perms
445
+ // (~0755, world-traversable). chmod unconditionally so the owner-only guarantee
446
+ // holds regardless of when the dir first appeared.
447
+ try {
448
+ fs.chmodSync(SC_TMP, 0o700);
449
+ }
450
+ catch {
451
+ // best-effort; file is still written 0600 below
452
+ }
453
+ const safe = new Date().toISOString().replace(/[:.]/g, "-");
454
+ const out = path.join(SC_TMP, `radar_screen_${safe}.mp4`);
455
+ 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"] });
456
+ scLive.recording = { proc: rec, path: out };
457
+ return out;
458
+ }
459
+ export function stopRecording(L = scLive) {
460
+ if (!L?.recording)
461
+ return Promise.resolve(null);
462
+ const r = L.recording;
463
+ L.recording = null;
464
+ return new Promise((resolve) => {
465
+ let done = false;
466
+ const fin = () => {
467
+ if (!done) {
468
+ done = true;
469
+ // The finished mp4 holds captured screen content; restrict to owner before
470
+ // it is downloadable. The 0700 dir already blocks other-user traversal; this
471
+ // is defense-in-depth on the file itself (ffmpeg created it under the umask).
472
+ try {
473
+ fs.chmodSync(r.path, 0o600);
474
+ }
475
+ catch {
476
+ // best-effort; dir perms are the primary control
477
+ }
478
+ resolve(r.path);
479
+ }
480
+ };
481
+ r.proc.on("exit", fin);
482
+ try {
483
+ r.proc.stdin?.end();
484
+ }
485
+ catch {
486
+ fin();
487
+ }
488
+ setTimeout(fin, 8000);
489
+ });
490
+ }
491
+ /**
492
+ * Resolve a recording for download. Restricted to the screen tmp dir AND to the
493
+ * radar_screen_*.mp4 name this module writes: path.basename strips any directory
494
+ * (no traversal), and the prefix/extension check means a download request can only
495
+ * ever name a radar recording, never an arbitrary file that happened to land in the dir.
496
+ */
497
+ export function recordingPath(file) {
498
+ const base = path.basename(file);
499
+ if (!/^radar_screen_[\w-]+\.mp4$/.test(base))
500
+ return null;
501
+ const p = path.join(SC_TMP, base);
502
+ return fs.existsSync(p) ? p : null;
503
+ }
504
+ // ---- lifecycle ---------------------------------------------------------------
505
+ /** Reset detection + tear down any live cast. Called on device re-enable and shutdown. */
506
+ export function resetScreenState() {
507
+ scDetected = false;
508
+ scDisplay = null;
509
+ scNote = "";
510
+ if (scLive) {
511
+ cleanupLive(scLive);
512
+ scLive = null;
513
+ }
514
+ // Recorded mp4s hold sensitive screen content; do not let them linger across the
515
+ // session boundary on disk.
516
+ cleanupRecordings();
517
+ // Best-effort: clean up orphaned device-side screenrecord procs that the host pipe
518
+ // close did not kill (they congest the single adb transport). NOTE: `-f screenrecord`
519
+ // is device-WIDE — it kills every screenrecord on the device, not just radar's. That
520
+ // is intentional for a debug tool (radar should not leave the transport congested),
521
+ // but a user running an unrelated manual screenrecord would have it killed too.
522
+ try {
523
+ execFile(adb(), ["shell", "pkill", "-9", "-f", "screenrecord"], { timeout: 5000 }, () => { });
524
+ }
525
+ catch {
526
+ // best-effort
527
+ }
528
+ }
529
+ let cleanupRegistered = false;
530
+ /**
531
+ * Kill any live cast (and orphaned device-side screenrecord) on process exit. Mirrors
532
+ * logcat-capture.registerCleanup(); without it, Ctrl-C on an open mirror orphans the
533
+ * screenrecord + ffmpeg children and leaves the device transport congested for the
534
+ * next session. Called from createServer().
535
+ */
536
+ export function registerScreenCleanup() {
537
+ if (cleanupRegistered)
538
+ return;
539
+ cleanupRegistered = true;
540
+ process.on("exit", () => {
541
+ if (scLive)
542
+ reapCastProcesses(scLive);
543
+ });
544
+ process.on("SIGINT", () => {
545
+ signalCleanup();
546
+ process.exit(130);
547
+ });
548
+ process.on("SIGTERM", () => {
549
+ signalCleanup();
550
+ process.exit(143);
551
+ });
552
+ }
553
+ /**
554
+ * Synchronous teardown for the signal path. resetScreenState()'s device-side
555
+ * orphan-kill uses async execFile, which does NOT complete before process.exit on
556
+ * Ctrl-C — so the orphaned device screenrecord the module header warns about would
557
+ * survive. Reap host children, then kill the device-side screenrecord SYNCHRONOUSLY
558
+ * (execFileSync, short timeout) so it actually runs before we exit, and clean the
559
+ * recordings. Best-effort throughout; exit must not be blocked by a cleanup failure.
560
+ */
561
+ function signalCleanup() {
562
+ if (scLive) {
563
+ cleanupLive(scLive);
564
+ scLive = null;
565
+ }
566
+ try {
567
+ execFileSync(adb(), ["shell", "pkill", "-9", "-f", "screenrecord"], {
568
+ timeout: 3000,
569
+ stdio: "ignore",
570
+ });
571
+ }
572
+ catch {
573
+ // best-effort: no adb, no device, or nothing to kill
574
+ }
575
+ cleanupRecordings();
576
+ }
package/dist/web/bin.js CHANGED
@@ -1,35 +1,69 @@
1
1
  #!/usr/bin/env node
2
- import { createServer, WEB_PORT } from "./server.js";
3
2
  import { execSync } from "child_process";
4
- const server = createServer();
5
- server.on("error", (err) => {
6
- if (err.code === "EADDRINUSE") {
7
- console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
8
- `Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
9
- `Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar-web`);
10
- process.exit(1);
11
- }
12
- throw err;
13
- });
14
- server.listen(WEB_PORT, () => {
15
- const url = `http://localhost:${WEB_PORT}`;
16
- console.log(`Slack Radar Web running at ${url}`);
17
- try {
18
- if (process.platform === "darwin") {
19
- execSync(`open ${url}`, { stdio: "ignore" });
3
+ import { createServer, WEB_PORT } from "./server.js";
4
+ /**
5
+ * Start the web dashboard server, open the browser, and wire shutdown signals.
6
+ * Extracted so both the dedicated `slack-radar-web` bin and the default bin's
7
+ * `web` subcommand dispatcher launch the dashboard the same way.
8
+ */
9
+ function startWebServer() {
10
+ // Safety net: a single route handler must never take the whole dashboard down. An async
11
+ // callback (e.g. a device-probe that both ends and errors) can otherwise throw
12
+ // ERR_HTTP_HEADERS_SENT outside any try/catch and kill the process. Log and keep serving;
13
+ // handlers still guard their own writes, this is the backstop for anything they miss.
14
+ process.on("uncaughtException", (err) => {
15
+ console.error("uncaughtException (kept alive):", err);
16
+ });
17
+ process.on("unhandledRejection", (err) => {
18
+ console.error("unhandledRejection (kept alive):", err);
19
+ });
20
+ const server = createServer();
21
+ server.on("error", (err) => {
22
+ if (err.code === "EADDRINUSE") {
23
+ console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
24
+ `Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
25
+ `Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar web`);
26
+ process.exit(1);
27
+ }
28
+ throw err;
29
+ });
30
+ // Bind loopback-only. The dashboard serves sensitive device data with no auth:
31
+ // live ring buffers, the on-device message store via the DB browser, and the live
32
+ // screen mirror (open DMs, notifications). Omitting the host arg binds all
33
+ // interfaces (0.0.0.0/::), which would let any same-network peer hit /dbquery or
34
+ // grant screen consent (a single unauthenticated process boolean) and watch the
35
+ // device. The consent gate only raises the bar for the local user; 127.0.0.1 is
36
+ // what actually closes the network. The device-side connection is already
37
+ // loopback-scoped (constants.ts); the dashboard's own listener must match. Remote
38
+ // access, if ever needed, belongs in a separate token-gated feature.
39
+ server.listen(WEB_PORT, "127.0.0.1", () => {
40
+ const url = `http://localhost:${WEB_PORT}`;
41
+ console.log(`Slack Radar Web running at ${url}`);
42
+ // Auto-open the browser for the HUMAN launcher (`slack-radar web` / `slack-radar-web`),
43
+ // but NOT when spawned as a bridge child by the open_radar_dashboard MCP tool
44
+ // (SLACK_RADAR_NO_OPEN=1). The Claude Code session must not pop a browser window the user
45
+ // did not ask for — the same no-auto-open-browser rule the SERVER_INSTRUCTIONS state for
46
+ // /radar-ui. The tool returns the URL instead, for the Claude Code session to surface.
47
+ if (process.env.SLACK_RADAR_NO_OPEN)
48
+ return;
49
+ try {
50
+ if (process.platform === "darwin") {
51
+ execSync(`open ${url}`, { stdio: "ignore" });
52
+ }
53
+ else if (process.platform === "linux") {
54
+ execSync(`xdg-open ${url}`, { stdio: "ignore" });
55
+ }
20
56
  }
21
- else if (process.platform === "linux") {
22
- execSync(`xdg-open ${url}`, { stdio: "ignore" });
57
+ catch {
58
+ // Browser open is best-effort
23
59
  }
24
- }
25
- catch {
26
- // Browser open is best-effort
27
- }
28
- });
29
- function shutdown() {
30
- console.log("\nShutting down…");
31
- server.close(() => process.exit(0));
32
- setTimeout(() => process.exit(0), 2000);
60
+ });
61
+ const shutdown = () => {
62
+ console.log("\nShutting down…");
63
+ server.close(() => process.exit(0));
64
+ setTimeout(() => process.exit(0), 2000);
65
+ };
66
+ process.on("SIGINT", shutdown);
67
+ process.on("SIGTERM", shutdown);
33
68
  }
34
- process.on("SIGINT", shutdown);
35
- process.on("SIGTERM", shutdown);
69
+ startWebServer();