@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.
- package/README.md +25 -14
- package/dist/mcp/index.js +565 -374
- package/dist/mcp/tools.js +135 -10
- package/dist/shared/android.d.ts +11 -2
- package/dist/shared/android.js +17 -6
- package/dist/shared/constants.d.ts +20 -0
- package/dist/shared/constants.js +20 -0
- package/dist/shared/db.d.ts +75 -0
- package/dist/shared/db.js +403 -0
- package/dist/shared/screen.d.ts +132 -0
- package/dist/shared/screen.js +576 -0
- package/dist/web/bin.js +64 -30
- package/dist/web/log-session.d.ts +89 -0
- package/dist/web/log-session.js +144 -0
- package/dist/web/public/index.html +1065 -712
- package/dist/web/server.d.ts +29 -0
- package/dist/web/server.js +900 -28
- package/dist/web/spec.d.ts +91 -0
- package/dist/web/spec.js +153 -0
- package/package.json +2 -1
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
throw
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
57
|
+
catch {
|
|
58
|
+
// Browser open is best-effort
|
|
23
59
|
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
process.on("SIGTERM", shutdown);
|
|
69
|
+
startWebServer();
|