@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,403 @@
1
+ /**
2
+ * On-device SQLite browser (host-side, read-only).
3
+ *
4
+ * Slack's on-device databases are plaintext SQLite (not SQLCipher) on a debug build,
5
+ * which is what makes host-side reading possible at all: `adb exec-out "run-as <pkg>
6
+ * cat databases/<db>"` streams the file to the host, where the platform-tools `sqlite3`
7
+ * binary runs read-only SELECT/PRAGMA against a local copy. Nothing is written on the
8
+ * device; no app rebuild is needed. If a future build encrypts these DBs, the magic-header
9
+ * check in `pullDatabase` fails fast with a clear message rather than producing garbage.
10
+ *
11
+ * SECURITY: a pulled DB is the full message store in plaintext (request bodies, message
12
+ * text, user/channel IDs). Pulled copies are written 0600 under a dedicated tmp dir and
13
+ * cleaned on server start/stop (see `cleanupPulledDatabases`). The DB names are learned
14
+ * live from the device (`listDatabases`); none are baked into this file.
15
+ */
16
+ import { execFile, execFileSync } from "child_process";
17
+ import { promisify } from "util";
18
+ import fs from "fs";
19
+ import os from "os";
20
+ import path from "path";
21
+ import { resolveAdbPath } from "./android.js";
22
+ /** The debug build whose sandbox we can `run-as` into. */
23
+ const DB_PKG = "com.Slack.internal.debug";
24
+ /** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600. Port-scoped so concurrent dashboards do not collide. */
25
+ let DB_TMP = path.join(os.tmpdir(), "slack-radar-db");
26
+ /** Initialize DB_TMP with a port-scoped path. Call from the web server on startup. */
27
+ export function initializeDatabasePath(port) {
28
+ DB_TMP = path.join(os.tmpdir(), `slack-radar-db-${port}`);
29
+ }
30
+ /** Every valid SQLite file begins with these 16 bytes. */
31
+ const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "binary");
32
+ const ADB_MAX_BUFFER = 256 * 1024 * 1024; // an org message DB can be several MB
33
+ const SQLITE_MAX_BUFFER = 64 * 1024 * 1024;
34
+ // No cached user preference: the target user is passed explicitly to every call (sourced
35
+ // from the dashboard's bound profile / the operator's profile switcher), never guessed.
36
+ // Caching the sqlite binary path is safe because the binary does not change during a session.
37
+ let cachedSqlite = null;
38
+ function adbBin() {
39
+ const p = resolveAdbPath();
40
+ if (!p)
41
+ throw new Error("adb not found. Ensure platform-tools is installed and on PATH.");
42
+ return p;
43
+ }
44
+ function runAdb(args, opts = {}) {
45
+ return execFileSync(adbBin(), args, {
46
+ timeout: 15000,
47
+ maxBuffer: ADB_MAX_BUFFER,
48
+ ...opts,
49
+ });
50
+ }
51
+ const execFileP = promisify(execFile);
52
+ // Async adb/exec — used for the HEAVY operations (the multi-MB device pull and the sqlite
53
+ // query over it). These must NOT use execFileSync: Node is single-threaded, so a blocking
54
+ // 62MB exec-out or a sqlite read freezes the whole event loop (every tab, the stream, and
55
+ // /health hang until it returns). The light, cached probes (ls, pm list users) stay sync.
56
+ function runAdbAsync(args) {
57
+ // buffer encoding: the pull streams raw SQLite bytes, not text.
58
+ return execFileP(adbBin(), args, {
59
+ timeout: 30000,
60
+ maxBuffer: ADB_MAX_BUFFER,
61
+ encoding: "buffer",
62
+ }).then((r) => r.stdout);
63
+ }
64
+ /**
65
+ * Locate a host `sqlite3`. The Slack on-device DBs use FTS5 virtual tables (search index),
66
+ * so a binary WITHOUT the fts5 module fails with "no such module: fts5" on those tables —
67
+ * including the per-table COUNT the browser runs. Android platform-tools' bundled sqlite3 is
68
+ * built without fts5; the macOS system /usr/bin/sqlite3 has it. So prefer an fts5-capable
69
+ * binary, and only fall back to a working-but-fts5-less one if none has it (non-FTS DBs still
70
+ * work there). Cached after first success.
71
+ */
72
+ function hasSqlite(bin) {
73
+ try {
74
+ execFileSync(bin, ["--version"], { timeout: 3000 });
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function hasFts5(bin) {
82
+ try {
83
+ // throws "no such module: fts5" on a binary built without it
84
+ execFileSync(bin, [":memory:", "CREATE VIRTUAL TABLE t USING fts5(x);"], { timeout: 3000 });
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ function sqliteBin() {
92
+ if (cachedSqlite)
93
+ return cachedSqlite;
94
+ const candidates = [
95
+ "/usr/bin/sqlite3", // macOS system build: has fts5
96
+ "/opt/homebrew/bin/sqlite3",
97
+ "/usr/local/bin/sqlite3",
98
+ path.join(process.env.HOME ?? "", "Library/Android/sdk/platform-tools/sqlite3"),
99
+ "sqlite3",
100
+ ];
101
+ const working = candidates.filter(hasSqlite);
102
+ // Prefer one with fts5; else any working sqlite3 (non-FTS DBs still query fine).
103
+ cachedSqlite = working.find(hasFts5) ?? working[0] ?? "sqlite3";
104
+ return cachedSqlite;
105
+ }
106
+ /**
107
+ * Enumerate the Android user IDs present on the device from `pm list users`, whose lines
108
+ * read `UserInfo{<id>:<name>:<flags>} ...`. Returns the numeric ids as strings, in device
109
+ * order (personal 0 first, then profiles 10/11/12/... — NOT a hardcoded guess, so a second
110
+ * work profile or a cloned profile is covered). Empty on parse/adb failure.
111
+ */
112
+ function deviceUserIds() {
113
+ try {
114
+ const out = runAdb(["shell", "pm list users"]).toString();
115
+ const ids = [];
116
+ for (const m of out.matchAll(/UserInfo\{(\d+):/g))
117
+ ids.push(m[1]);
118
+ return ids;
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ /**
125
+ * Probe whether the debug build is runnable for an Android user. Used to filter the
126
+ * device's users down to the profiles that actually have the build (a multi-profile device
127
+ * may have it on personal=0 and a work profile=10/11/..., each holding a DIFFERENT org's store).
128
+ */
129
+ function userHasBuild(user) {
130
+ try {
131
+ runAdb([
132
+ "shell",
133
+ `run-as ${DB_PKG} ${user ? "--user " + user : ""} ls databases >/dev/null 2>&1 && echo ok`,
134
+ ]);
135
+ return true;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ /**
142
+ * The Android users that actually have the debug build, enumerated from the device (not a
143
+ * fixed list). "" means a single-user device (no --user flag). Empty array => build not
144
+ * installed/debuggable anywhere.
145
+ *
146
+ * CACHED: this runs `pm list users` + a `run-as` probe per user — several blocking adb
147
+ * round-trips. The set of profiles does not change during a session, so probing it on every
148
+ * /dbs request froze the (single-threaded) event loop and hung the whole dashboard. Resolve
149
+ * it once and reuse; resetDbState() clears it for a device/profile change.
150
+ */
151
+ let cachedUsers = null;
152
+ export function availableUsers() {
153
+ if (cachedUsers)
154
+ return cachedUsers;
155
+ const ids = deviceUserIds();
156
+ const found = ids.filter(userHasBuild);
157
+ // Fallback for an older adb / odd `pm` output: try the no-flag form.
158
+ const resolved = found.length ? found : userHasBuild("") ? [""] : [];
159
+ if (resolved.length)
160
+ cachedUsers = resolved; // only cache a successful probe
161
+ return resolved;
162
+ }
163
+ /** `--user N` flag for a resolved user, or "" for the single-user case. */
164
+ function userFlag(user) {
165
+ return user ? `--user ${user}` : "";
166
+ }
167
+ /**
168
+ * Reset cached device state. Clears the sqlite-binary path so a device swap
169
+ * re-detects it. The targeted Android user is passed explicitly to every call
170
+ * (sourced from the dashboard's bound profile) rather than guessed, so list / pull /
171
+ * query can never silently disagree on which profile's store they touch.
172
+ */
173
+ export function resetDbState() {
174
+ cachedSqlite = null;
175
+ cachedUsers = null;
176
+ }
177
+ /** A DB name from the device. Reject anything that is not a bare filename. */
178
+ function assertSafeName(name) {
179
+ if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) {
180
+ throw new Error("invalid database name");
181
+ }
182
+ }
183
+ /**
184
+ * Local copy path, scoped by Android user. A work + personal profile can each hold a
185
+ * databases/<same-name> with DIFFERENT contents; without the user in the path, pulling
186
+ * one would overwrite the other's copy and queries would read the wrong profile's store.
187
+ */
188
+ function localPath(name, user) {
189
+ const safeName = name.replace(/[^A-Za-z0-9._-]/g, "_");
190
+ const safeUser = (user || "single").replace(/[^A-Za-z0-9._-]/g, "_");
191
+ return path.join(DB_TMP, `u${safeUser}-${safeName}.sqlite`);
192
+ }
193
+ /**
194
+ * Resolve the Android user to target. The dashboard passes the profile it is bound to
195
+ * (transport.getActiveUserId()); if that user does not have the build (or none was
196
+ * passed), fall back to the first available profile. Throws if none is available.
197
+ */
198
+ function resolveUser(user) {
199
+ const available = availableUsers();
200
+ if (!available.length) {
201
+ throw new Error(`cannot run-as ${DB_PKG}. Is the debug build installed and is a device connected?`);
202
+ }
203
+ if (user != null && available.includes(user))
204
+ return user;
205
+ return available[0];
206
+ }
207
+ /**
208
+ * List the on-device databases for the given Android user (the dashboard's bound
209
+ * profile). Filters out WAL/SHM/journal sidecar files. Returns the resolved user and
210
+ * the full set of profiles that have the build, so the UI can show which profile this
211
+ * store belongs to and offer a switch when more than one exists.
212
+ */
213
+ export function listDatabases(user) {
214
+ // NOTE: do not resetDbState() here — that would re-run the multi-call user probe on every
215
+ // /dbs and freeze the event loop. The profile set is cached for the session; a device or
216
+ // profile change clears it via resetDbState() (wired to /enable / reconnect).
217
+ const available = availableUsers();
218
+ if (!available.length) {
219
+ throw new Error(`cannot run-as ${DB_PKG}. Is the debug build installed and is a device connected?`);
220
+ }
221
+ const u = user != null && available.includes(user) ? user : available[0];
222
+ const out = runAdb([
223
+ "shell",
224
+ `run-as ${DB_PKG} ${userFlag(u)} ls -1 databases`,
225
+ ]).toString();
226
+ const names = out
227
+ .split("\n")
228
+ .map((s) => s.trim())
229
+ .filter(Boolean)
230
+ .filter((n) => !/-(wal|shm|journal)$/.test(n));
231
+ return { user: u, availableUsers: available, names };
232
+ }
233
+ /**
234
+ * Stream a database off the device to a local 0600 copy (scoped to the target user) and
235
+ * return its path. `run-as ... cp` into shared storage fails silently (sandbox), so the
236
+ * working path is `exec-out cat` piped to a host file. Validates the SQLite magic header
237
+ * so a missing DB (whose `cat` emits an error string, not file bytes) fails with a clear
238
+ * message instead of being written as a junk file that later trips an opaque parse error.
239
+ *
240
+ * WAL: Slack's on-device DBs are WAL-mode and the live app holds write connections open,
241
+ * so recently-committed rows live in the `-wal` sidecar, NOT yet in the main file. Pulling
242
+ * the main file alone (and reading it `immutable`) silently drops those recent rows — the
243
+ * exact data a debugger wants. So we also pull the `-wal` and `-shm` sidecars next to the
244
+ * main copy (best-effort; absent if the DB was checkpointed), and queryDatabase opens the
245
+ * copy NORMALLY (not immutable) so SQLite replays the WAL into the view.
246
+ */
247
+ export async function pullDatabase(name, user, force = false) {
248
+ assertSafeName(name);
249
+ const u = resolveUser(user);
250
+ // DB_TMP lives under os.tmpdir(), which the OS (or a manual cleanup) can wipe
251
+ // mid-session; recreate it every pull so writeFileSync cannot ENOENT on a gone dir.
252
+ // mode 0700: the pulled copies are the plaintext message store; even though each file is
253
+ // 0600, a world-traversable dir would leak the DB filenames to other local users.
254
+ fs.mkdirSync(DB_TMP, { recursive: true, mode: 0o700 });
255
+ const local = localPath(name, u);
256
+ // Pull-once: an on-device DB can be several MB and writes a plaintext copy to disk, so
257
+ // reuse the existing local copy across db-switches. The UI's Refresh control passes
258
+ // force=true to re-stream when the user wants the current device state.
259
+ if (!force && fs.existsSync(local))
260
+ return local;
261
+ let bytes;
262
+ try {
263
+ // Async exec-out so a multi-MB pull does not block the event loop (which would hang
264
+ // every other tab, the stream, and /health for the duration of the pull).
265
+ bytes = await runAdbAsync([
266
+ "exec-out",
267
+ `run-as ${DB_PKG} ${userFlag(u)} cat databases/${name}`,
268
+ ]);
269
+ }
270
+ catch {
271
+ throw new Error(`"${name}" is not readable on the device (not present for this profile, or run-as failed). List databases to see what is there.`);
272
+ }
273
+ if (!bytes ||
274
+ bytes.length < SQLITE_MAGIC.length ||
275
+ !bytes.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC)) {
276
+ throw new Error(`"${name}" did not come back as a valid SQLite file (${bytes ? bytes.length : 0} bytes). It may not exist on the device, the org may not be loaded on this profile, or the database may be encrypted.`);
277
+ }
278
+ fs.writeFileSync(local, bytes, { mode: 0o600 });
279
+ // Pull the WAL sidecars alongside the main file so the normal (non-immutable) open below
280
+ // replays recently-committed rows. Best-effort: a checkpointed DB has none; a `cat` of a
281
+ // missing sidecar returns empty, which we simply skip. Stale local sidecars from a prior
282
+ // pull are removed first so they cannot be mis-applied to a freshly-pulled main file.
283
+ // The main file and the sidecars are separate `cat` round-trips against a DB the app may
284
+ // be writing, so the snapshot is best-effort, not atomic: if the app checkpoints between
285
+ // the cats, SQLite finds a mismatched -wal salt and ignores it (a partial, not corrupt,
286
+ // view). Unavoidable without a device-side read lock; the normal-open choice still stands.
287
+ for (const ext of ["-wal", "-shm"]) {
288
+ const sidecar = local + ext;
289
+ try {
290
+ fs.rmSync(sidecar, { force: true });
291
+ const sb = await runAdbAsync([
292
+ "exec-out",
293
+ `run-as ${DB_PKG} ${userFlag(u)} cat databases/${name}${ext}`,
294
+ ]);
295
+ if (sb && sb.length > 0)
296
+ fs.writeFileSync(sidecar, sb, { mode: 0o600 });
297
+ }
298
+ catch {
299
+ // sidecar absent or unreadable — fine, the main file stands alone
300
+ }
301
+ }
302
+ return local;
303
+ }
304
+ /**
305
+ * Run a read-only query against a (lazily pulled) local copy.
306
+ *
307
+ * Read-only is NOT just about blocking writes. `sqlite3 -readonly` forbids writes to the
308
+ * opened DB, but a single SELECT can still READ any host file the process can open, via
309
+ * `ATTACH '/path' AS x; SELECT * FROM x.t` or `SELECT readfile('/path')` — a confidentiality
310
+ * bypass (the other plaintext Slack DBs, any file on disk), reachable through the query box
311
+ * or a pushed db-spec's `sql`. Three layers close this:
312
+ * 1. `sqlite3 -safe` — engine-level: forbids ATTACH, readfile/writefile/edit, and
313
+ * load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
314
+ * control; a keyword denylist alone is whack-a-mole.
315
+ * 2. the guard below: only a SINGLE SELECT/PRAGMA statement (no `;`-chaining), and an
316
+ * explicit reject of attach/readfile/writefile names as defense in depth.
317
+ * 3. `-readonly` still blocks writes to the opened DB.
318
+ * Surfaces sqlite's own error message on failure.
319
+ */
320
+ export async function queryDatabase(name, sql, user) {
321
+ assertSafeName(name);
322
+ // Leading-keyword gate over the common read-query entry forms: SELECT, WITH
323
+ // (CTEs, common against the message store), EXPLAIN, VALUES, PRAGMA. This shapes
324
+ // UX; it is NOT the security boundary. A `WITH ... DELETE` would slip the gate,
325
+ // but the device-safety property does not rest here — it rests on the copy-based
326
+ // design (the device file is `cat`'d to a throwaway and never written back), plus
327
+ // `-safe` (blocks file access), the single-statement gate, the write-PRAGMA
328
+ // reject, and the file-primitive denylist below. Worst case a smuggled write
329
+ // mutates the disposable copy; the device store is untouched.
330
+ if (!/^\s*(select|pragma|with|explain|values)\b/i.test(sql)) {
331
+ throw new Error("only read-only statements are allowed (SELECT, WITH, EXPLAIN, VALUES, PRAGMA)");
332
+ }
333
+ // Single statement only: a trailing `;` is fine, but no statement chaining (which is how
334
+ // `select 1; ATTACH ...; select * from s.t` smuggles a read past the leading-keyword check).
335
+ if (/;\s*\S/.test(sql)) {
336
+ throw new Error("only a single statement is allowed (no ';'-separated statements)");
337
+ }
338
+ // Reject the common write PRAGMAs: an assigning form (`PRAGMA user_version=5`,
339
+ // `PRAGMA journal_mode=DELETE`) or a checkpoint (`PRAGMA wal_checkpoint(...)`).
340
+ // This is not exhaustive — a few argless mutating PRAGMAs (`incremental_vacuum`,
341
+ // `optimize`, `shrink_memory`) still pass. That is acceptable: the device-safety
342
+ // guarantee comes from the copy-based design (we only ever read the device file
343
+ // and write a throwaway local copy), not from this gate. Worst case such a PRAGMA
344
+ // mutates the disposable copy; the device store is never written back.
345
+ if (/^\s*pragma\b/i.test(sql) && (/=/.test(sql) || /\bwal_checkpoint\b/i.test(sql))) {
346
+ throw new Error("that PRAGMA writes; only read-only PRAGMAs are allowed");
347
+ }
348
+ // Belt-and-suspenders over -safe: reject the file-access primitives by name too.
349
+ if (/\b(attach|readfile|writefile|edit|load_extension|fts3_tokenizer)\b/i.test(sql)) {
350
+ throw new Error("that function/statement is not allowed (read-only, no file access)");
351
+ }
352
+ const u = resolveUser(user);
353
+ const local = localPath(name, u);
354
+ if (!fs.existsSync(local))
355
+ await pullDatabase(name, u);
356
+ let out;
357
+ try {
358
+ // Open the pulled copy NORMALLY (not `immutable`, not `-readonly`): pullDatabase brought
359
+ // the -wal/-shm sidecars along, and only a normal open replays the WAL so recently-
360
+ // committed rows (which live in -wal until the app checkpoints) are visible. immutable=1
361
+ // / -readonly would read the main file alone and silently drop those rows. The copy is in
362
+ // our own writable temp dir, so SQLite can do its WAL replay there; nothing on the device
363
+ // is touched. `-safe` is the load-bearing read-only/exfil control (blocks ATTACH/readfile/
364
+ // writefile/load_extension at the engine level) and holds in normal mode too. The path is
365
+ // URI-encoded so spaces/specials are safe. Async exec so a large query does not block the
366
+ // event loop.
367
+ const uri = `file:${encodeURI(local)}`;
368
+ const r = await execFileP(sqliteBin(), ["-safe", "-json", uri, sql], {
369
+ timeout: 15000,
370
+ maxBuffer: SQLITE_MAX_BUFFER,
371
+ encoding: "utf8",
372
+ });
373
+ out = String(r.stdout) || "[]";
374
+ }
375
+ catch (e) {
376
+ const err = e;
377
+ const stderr = (err.stderr && err.stderr.toString().trim()) || "";
378
+ const m = stderr.match(/(no such table:.*|no such column:.*|near ".*|syntax error.*|.*error.*)/i);
379
+ throw new Error(m ? m[0].trim() : stderr.split("\n")[0] || "query failed");
380
+ }
381
+ return JSON.parse(out);
382
+ }
383
+ /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
384
+ export function pulledSize(name, user) {
385
+ try {
386
+ return fs.statSync(localPath(name, resolveUser(user))).size;
387
+ }
388
+ catch {
389
+ return 0;
390
+ }
391
+ }
392
+ /**
393
+ * Remove all pulled database copies. Pulled DBs are plaintext message stores, so they
394
+ * must not linger; call on server start and stop. Best-effort.
395
+ */
396
+ export function cleanupPulledDatabases() {
397
+ try {
398
+ fs.rmSync(DB_TMP, { recursive: true, force: true });
399
+ }
400
+ catch {
401
+ // best-effort
402
+ }
403
+ }
@@ -0,0 +1,132 @@
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 { type ChildProcess } from "child_process";
40
+ /**
41
+ * Remove all recorded mp4s from the screen tmp dir. Recordings capture sensitive
42
+ * live screen content and must not linger across sessions on disk. Called on
43
+ * resetScreenState() (device re-enable) and on process exit — the same posture the
44
+ * DB-browser uses for its pulled copies. Scoped to the radar_screen_*.mp4 names this
45
+ * module writes so it never deletes an unrelated file that landed in the dir.
46
+ */
47
+ export declare function cleanupRecordings(): void;
48
+ /** Resolve an ffmpeg binary, or null if none is installed. Cached. */
49
+ export declare function resolveFfmpeg(): string | null;
50
+ /** Whether scrcpy is installed (the no-ffmpeg fallback hint). Cached. */
51
+ export declare function hasScrcpy(): boolean;
52
+ /** The platform-specific copy-paste command to install ffmpeg. Never run automatically. */
53
+ export declare function ffmpegInstallHint(): string;
54
+ /** Capability summary for /health: what the screen overlay can do on this machine. */
55
+ export declare function screenCapabilities(): {
56
+ screenshot: boolean;
57
+ liveCast: boolean;
58
+ ffmpeg: boolean;
59
+ scrcpy: boolean;
60
+ installHint: string | null;
61
+ };
62
+ /** Grant consent to mirror the live screen. Per server process. */
63
+ export declare function grantScreenConsent(): void;
64
+ export declare function hasScreenConsent(): boolean;
65
+ export interface DisplayInfo {
66
+ on: boolean;
67
+ phys: string;
68
+ }
69
+ /**
70
+ * Parse `adb shell cmd display get-displays` output into per-display
71
+ * {on, phys}, dropping any block without a physical id. The active panel is the
72
+ * one whose block reports state ON.
73
+ */
74
+ export declare function parseDisplays(out: string): DisplayInfo[];
75
+ /** Choose the physical display id to record: the powered-ON panel, else a lone panel, else null. */
76
+ export declare function pickDisplay(displays: DisplayInfo[]): {
77
+ phys: string | null;
78
+ note: string;
79
+ };
80
+ /** Strip any text prefix (e.g. a multi-display warning) before the PNG magic header. */
81
+ export declare function stripPngPrefix(raw: Buffer): Buffer;
82
+ /** Detect (and cache) the active display id. resetScreenState() forces a re-detect. */
83
+ export declare function detectDisplay(): Promise<string | null>;
84
+ export declare function displayNote(): string;
85
+ /** Capture a full-resolution PNG of the active display. Works without ffmpeg. */
86
+ export declare function captureScreenshot(): Promise<Buffer | null>;
87
+ interface Recording {
88
+ proc: ChildProcess;
89
+ path: string;
90
+ }
91
+ interface LiveCast {
92
+ sr: ChildProcess;
93
+ ff: ChildProcess;
94
+ viewers: Set<NodeJS.WritableStream>;
95
+ lastFrame: Buffer | null;
96
+ lastFrameTs: number;
97
+ recording: Recording | null;
98
+ fps: number;
99
+ keepalive: NodeJS.Timeout | null;
100
+ }
101
+ /**
102
+ * Start (or return the existing) live cast. Returns null if ffmpeg is unavailable
103
+ * — callers must degrade rather than assume a stream. The boundary string is
104
+ * SC_BOUNDARY; the route serves multipart/x-mixed-replace with it.
105
+ */
106
+ export declare function startLive(fps: number): LiveCast | null;
107
+ /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
108
+ export declare function addViewer(L: LiveCast, res: NodeJS.WritableStream): void;
109
+ export declare function removeViewer(res: NodeJS.WritableStream): void;
110
+ export declare function getLive(): LiveCast | null;
111
+ export declare function lastFrame(): Buffer | null;
112
+ export declare function primeIfNeeded(): Promise<void>;
113
+ export declare const SCREEN_BOUNDARY = "radarframe";
114
+ export declare function startRecording(): string | null;
115
+ export declare function stopRecording(L?: LiveCast): Promise<string | null>;
116
+ /**
117
+ * Resolve a recording for download. Restricted to the screen tmp dir AND to the
118
+ * radar_screen_*.mp4 name this module writes: path.basename strips any directory
119
+ * (no traversal), and the prefix/extension check means a download request can only
120
+ * ever name a radar recording, never an arbitrary file that happened to land in the dir.
121
+ */
122
+ export declare function recordingPath(file: string): string | null;
123
+ /** Reset detection + tear down any live cast. Called on device re-enable and shutdown. */
124
+ export declare function resetScreenState(): void;
125
+ /**
126
+ * Kill any live cast (and orphaned device-side screenrecord) on process exit. Mirrors
127
+ * logcat-capture.registerCleanup(); without it, Ctrl-C on an open mirror orphans the
128
+ * screenrecord + ffmpeg children and leaves the device transport congested for the
129
+ * next session. Called from createServer().
130
+ */
131
+ export declare function registerScreenCleanup(): void;
132
+ export {};