@slack/radar-mcp 1.5.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,50 @@
1
+ /**
2
+ * On-device app log buffer reader (host-side, read-only).
3
+ *
4
+ * The Slack app plants a Timber tree (`DebugMenuLoggingTree`) on debug builds that
5
+ * captures every DEBUG-and-above log line into a rolling on-disk buffer
6
+ * (`slack.commons.logger.DebugLogger`): six files `debug.log0`..`debug.log5` in the
7
+ * app sandbox `files/` dir, ~256KB each. `debug.log0` is the newest file; on rotation
8
+ * files shift up and the oldest is dropped. Within a file, the oldest line is at the top.
9
+ * Each line is formatted `[timestamp] TAG : message` with NO logcat column padding.
10
+ *
11
+ * This is the app's own structured log buffer, which is strictly better than scraping
12
+ * `adb logcat`: it survives the radar enable/disable cycle, is not subject to the device
13
+ * logcat ring being evicted, and carries the exact tag the app logged with. We read it the
14
+ * same way the DB browser reads the message store: `adb exec-out "run-as <pkg> cat ..."`,
15
+ * which works because debug builds are `run-as`-able. Nothing is written on the device.
16
+ *
17
+ * SECURITY: app logs can contain user/channel IDs and message fragments. This reader only
18
+ * returns lines to the caller (no host-side file is written), and the tag/grep filter is
19
+ * applied before return.
20
+ */
21
+ /**
22
+ * Build the oldest-to-newest list of sandbox-relative log file paths. DebugLogger keeps
23
+ * `debug.log0` newest, so chronological order is log5, log4, ..., log0.
24
+ */
25
+ export declare function debugLogFileOrder(count?: number): string[];
26
+ /**
27
+ * Concatenate raw file contents (passed oldest-file-first) into a single chronological
28
+ * line array. Each file is already oldest-line-first, so a flat concat preserves order.
29
+ * Blank lines are dropped. Pure: no device access, unit-testable against fixtures.
30
+ */
31
+ export declare function flattenDebugLogFiles(contentsOldestFirst: string[]): string[];
32
+ /**
33
+ * A `run-as` denial does NOT fail the adb command: `adb exec-out` exits 0 and the helper's
34
+ * error ("run-as: unknown package: ...", "run-as: ... not debuggable", "run-as: couldn't
35
+ * stat ...") is written to stdout, where it would otherwise be mistaken for a log line.
36
+ * So failure must be detected by inspecting the output, not by catching an exception. This
37
+ * returns the run-as error message when the output is a denial, else null. Pure and testable.
38
+ */
39
+ export declare function detectRunAsError(output: string): string | null;
40
+ /**
41
+ * Read the app's DebugLogger buffer off the device via `run-as` and return its lines in
42
+ * chronological order (oldest first). Returns null when the buffer cannot be read so the
43
+ * caller can fall back to a logcat snapshot. Two distinct null cases, deliberately kept
44
+ * apart: a `run-as` failure (no device, the debug build is not installed, or it is not
45
+ * debuggable on this profile) is a condition the user likely wants to know about, so it is
46
+ * logged to stderr before falling back; an empty buffer (debug build present, nothing logged
47
+ * yet) is benign and silent. A specific `user` (Android multi-user id) is forwarded to
48
+ * run-as when provided.
49
+ */
50
+ export declare function readDebugLogBuffer(adbPath: string, user?: string): string[] | null;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * On-device app log buffer reader (host-side, read-only).
3
+ *
4
+ * The Slack app plants a Timber tree (`DebugMenuLoggingTree`) on debug builds that
5
+ * captures every DEBUG-and-above log line into a rolling on-disk buffer
6
+ * (`slack.commons.logger.DebugLogger`): six files `debug.log0`..`debug.log5` in the
7
+ * app sandbox `files/` dir, ~256KB each. `debug.log0` is the newest file; on rotation
8
+ * files shift up and the oldest is dropped. Within a file, the oldest line is at the top.
9
+ * Each line is formatted `[timestamp] TAG : message` with NO logcat column padding.
10
+ *
11
+ * This is the app's own structured log buffer, which is strictly better than scraping
12
+ * `adb logcat`: it survives the radar enable/disable cycle, is not subject to the device
13
+ * logcat ring being evicted, and carries the exact tag the app logged with. We read it the
14
+ * same way the DB browser reads the message store: `adb exec-out "run-as <pkg> cat ..."`,
15
+ * which works because debug builds are `run-as`-able. Nothing is written on the device.
16
+ *
17
+ * SECURITY: app logs can contain user/channel IDs and message fragments. This reader only
18
+ * returns lines to the caller (no host-side file is written), and the tag/grep filter is
19
+ * applied before return.
20
+ */
21
+ import { execFileSync } from "child_process";
22
+ import { SLACK_DEBUG_PKG } from "./constants.js";
23
+ /** The debug build whose sandbox we can `run-as` into. */
24
+ const LOG_PKG = SLACK_DEBUG_PKG;
25
+ /** Number of rolling log files DebugLogger maintains (debug.log0..debug.log5). */
26
+ const LOG_FILE_COUNT = 6;
27
+ const ADB_MAX_BUFFER = 16 * 1024 * 1024; // total buffer is ~1.5MB; headroom for safety.
28
+ /**
29
+ * Build the oldest-to-newest list of sandbox-relative log file paths. DebugLogger keeps
30
+ * `debug.log0` newest, so chronological order is log5, log4, ..., log0.
31
+ */
32
+ export function debugLogFileOrder(count = LOG_FILE_COUNT) {
33
+ const files = [];
34
+ for (let i = count - 1; i >= 0; i--)
35
+ files.push(`files/debug.log${i}`);
36
+ return files;
37
+ }
38
+ /**
39
+ * Concatenate raw file contents (passed oldest-file-first) into a single chronological
40
+ * line array. Each file is already oldest-line-first, so a flat concat preserves order.
41
+ * Blank lines are dropped. Pure: no device access, unit-testable against fixtures.
42
+ */
43
+ export function flattenDebugLogFiles(contentsOldestFirst) {
44
+ return contentsOldestFirst
45
+ .join("\n")
46
+ .split("\n")
47
+ .filter((line) => line.length > 0);
48
+ }
49
+ /**
50
+ * A `run-as` denial does NOT fail the adb command: `adb exec-out` exits 0 and the helper's
51
+ * error ("run-as: unknown package: ...", "run-as: ... not debuggable", "run-as: couldn't
52
+ * stat ...") is written to stdout, where it would otherwise be mistaken for a log line.
53
+ * So failure must be detected by inspecting the output, not by catching an exception. This
54
+ * returns the run-as error message when the output is a denial, else null. Pure and testable.
55
+ */
56
+ export function detectRunAsError(output) {
57
+ const first = output.trimStart().split("\n")[0] ?? "";
58
+ return first.startsWith("run-as:") ? first.trim() : null;
59
+ }
60
+ /**
61
+ * Read the app's DebugLogger buffer off the device via `run-as` and return its lines in
62
+ * chronological order (oldest first). Returns null when the buffer cannot be read so the
63
+ * caller can fall back to a logcat snapshot. Two distinct null cases, deliberately kept
64
+ * apart: a `run-as` failure (no device, the debug build is not installed, or it is not
65
+ * debuggable on this profile) is a condition the user likely wants to know about, so it is
66
+ * logged to stderr before falling back; an empty buffer (debug build present, nothing logged
67
+ * yet) is benign and silent. A specific `user` (Android multi-user id) is forwarded to
68
+ * run-as when provided.
69
+ */
70
+ export function readDebugLogBuffer(adbPath, user) {
71
+ const catList = debugLogFileOrder().join(" ");
72
+ // One run-as invocation with the given user flag; cats every file oldest-first. Missing files
73
+ // are tolerated (2>/dev/null) so a not-yet-rotated buffer with only debug.log0 reads cleanly.
74
+ // Returns the raw stdout, or an Error if adb itself failed (distinct from a run-as denial,
75
+ // which exits 0 with the helper error in stdout).
76
+ const runAs = (userFlag) => {
77
+ try {
78
+ return execFileSync(adbPath, ["exec-out", `run-as ${LOG_PKG} ${userFlag}sh -c 'cat ${catList} 2>/dev/null'`], { timeout: 10000, maxBuffer: ADB_MAX_BUFFER, encoding: "utf-8" });
79
+ }
80
+ catch (err) {
81
+ return err instanceof Error ? err : new Error(String(err));
82
+ }
83
+ };
84
+ let output = runAs(user ? `--user ${user} ` : "");
85
+ if (output instanceof Error) {
86
+ // adb itself failed (no device, adb not running). Surface and fall back.
87
+ console.error(`[slack-radar] app log buffer unavailable (adb error: ${output.message.split("\n")[0]}); falling back to logcat`);
88
+ return null;
89
+ }
90
+ // run-as denial: adb exited 0 but the helper error is in stdout. On old adb the `--user N`
91
+ // form can be the denial while the no-flag form works (db.ts resolves users with the same
92
+ // no-flag fallback). So before giving up to logcat, retry once with no user flag — but only
93
+ // when a flag was actually used, else we would just repeat the identical call.
94
+ let runAsError = detectRunAsError(output);
95
+ if (runAsError && user) {
96
+ const retry = runAs("");
97
+ if (!(retry instanceof Error) && !detectRunAsError(retry)) {
98
+ output = retry;
99
+ runAsError = null;
100
+ }
101
+ }
102
+ if (runAsError) {
103
+ console.error(`[slack-radar] app log buffer unavailable (${runAsError}); falling back to logcat`);
104
+ return null;
105
+ }
106
+ const lines = flattenDebugLogFiles([output]);
107
+ return lines.length > 0 ? lines : null;
108
+ }
@@ -49,9 +49,32 @@ export declare function cleanupRecordings(): void;
49
49
  export declare function resolveFfmpeg(): string | null;
50
50
  /** Whether scrcpy is installed (the no-ffmpeg fallback hint). Cached. */
51
51
  export declare function hasScrcpy(): boolean;
52
- /** The platform-specific copy-paste command to install ffmpeg. Never run automatically. */
52
+ /**
53
+ * Re-probe ffmpeg/scrcpy ASYNCHRONOUSLY and update the cache, so the overlay can
54
+ * recover after a user installs ffmpeg mid-session without restarting the server.
55
+ *
56
+ * Why async (not a force flag on the sync probes): the sync `execFileSync` probes are
57
+ * safe only BECAUSE they are cached and run at most once (see the same rule in db.ts).
58
+ * Re-running them synchronously on every panel reopen would block the single event loop
59
+ * (the live MJPEG stream, /health, every tab) for the spawn duration. This path uses
60
+ * execFile (non-blocking) instead. It also ONLY re-probes a binary that is currently
61
+ * cached as absent: if ffmpeg was already found there is nothing to recover, so the
62
+ * common case does no work.
63
+ */
64
+ export declare function refreshScreenCapabilities(): Promise<void>;
65
+ /**
66
+ * A single runnable command to install ffmpeg on this platform. Never run
67
+ * automatically. This is presented as a copy-paste command, so it must be ONE
68
+ * runnable line: no parentheticals or alternatives, which would error if pasted
69
+ * into a shell. Alternative package managers belong in surrounding prose, not here.
70
+ */
53
71
  export declare function ffmpegInstallHint(): string;
54
- /** Capability summary for /health: what the screen overlay can do on this machine. */
72
+ /**
73
+ * Capability summary for /health and /screen/caps: what the screen overlay can do
74
+ * on this machine. Reads the cached probe result. To recover after a mid-session
75
+ * ffmpeg install, call refreshScreenCapabilities() first (the /screen/caps?refresh=1
76
+ * path does this), then read here.
77
+ */
55
78
  export declare function screenCapabilities(): {
56
79
  screenshot: boolean;
57
80
  liveCast: boolean;
@@ -40,6 +40,7 @@ import { spawn, execFile, execFileSync } from "child_process";
40
40
  import fs from "fs";
41
41
  import os from "os";
42
42
  import path from "path";
43
+ import { promisify } from "util";
43
44
  import { resolveAdbPath } from "./android.js";
44
45
  const SC_TMP = path.join(os.homedir(), ".slack-radar", "screen");
45
46
  /**
@@ -76,8 +77,10 @@ function adb() {
76
77
  return resolveAdbPath() ?? "adb";
77
78
  }
78
79
  // ---- host-binary detection (for the degrade tiers) --------------------------
80
+ const execFileP = promisify(execFile);
79
81
  let ffmpegPath; // undefined = not probed, null = absent
80
82
  let scrcpyPresent;
83
+ const FFMPEG_CANDIDATES = ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"];
81
84
  /** Resolve an ffmpeg binary, or null if none is installed. Cached. */
82
85
  export function resolveFfmpeg() {
83
86
  // Test hook: simulate a machine with no ffmpeg so the degrade path can be exercised
@@ -86,7 +89,7 @@ export function resolveFfmpeg() {
86
89
  return null;
87
90
  if (ffmpegPath !== undefined)
88
91
  return ffmpegPath;
89
- for (const c of ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"]) {
92
+ for (const c of FFMPEG_CANDIDATES) {
90
93
  try {
91
94
  execFileSync(c, ["-version"], { timeout: 3000, stdio: "ignore" });
92
95
  ffmpegPath = c;
@@ -112,18 +115,72 @@ export function hasScrcpy() {
112
115
  }
113
116
  return scrcpyPresent;
114
117
  }
115
- /** The platform-specific copy-paste command to install ffmpeg. Never run automatically. */
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
+ */
116
168
  export function ffmpegInstallHint() {
117
169
  switch (process.platform) {
118
170
  case "darwin":
119
171
  return "brew install ffmpeg";
120
172
  case "win32":
121
- return "winget install ffmpeg (or: choco install ffmpeg)";
173
+ return "winget install ffmpeg";
122
174
  default:
123
- return "sudo apt install ffmpeg (or your distro's package manager)";
175
+ return "sudo apt install ffmpeg";
124
176
  }
125
177
  }
126
- /** Capability summary for /health: what the screen overlay can do on this machine. */
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
+ */
127
184
  export function screenCapabilities() {
128
185
  const ffmpeg = resolveFfmpeg() !== null;
129
186
  return {
@@ -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;
package/dist/web/bin.js CHANGED
@@ -1,12 +1,113 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "child_process";
3
- import { createServer, WEB_PORT } from "./server.js";
3
+ import { realpathSync } from "fs";
4
+ import http from "http";
5
+ import { fileURLToPath } from "url";
6
+ import { cleanupOnBind, createServer, seedInitialSpec, WEB_PORT } from "./server.js";
7
+ import { RADAR_VERSION } from "../shared/constants.js";
8
+ /**
9
+ * One loopback GET against the dashboard's own port, used by the adopt path. Resolves with the
10
+ * status, body, and headers, or null on error/timeout. Centralizes the timeout + 127.0.0.1
11
+ * binding the probes share (the MCP side has its own copy in mcp/index.ts; this is the web
12
+ * bin's). Never throws.
13
+ */
14
+ function loopbackGet(path, timeoutMs) {
15
+ return new Promise((resolve) => {
16
+ const req = http.request({ host: "127.0.0.1", port: WEB_PORT, path, method: "GET", timeout: timeoutMs }, (res) => {
17
+ let body = "";
18
+ res.on("data", (c) => (body += c));
19
+ res.on("end", () => resolve({ status: res.statusCode, body, headers: res.headers }));
20
+ });
21
+ req.on("error", () => resolve(null));
22
+ req.on("timeout", () => {
23
+ req.destroy();
24
+ resolve(null);
25
+ });
26
+ req.end();
27
+ });
28
+ }
29
+ /**
30
+ * Decide whether a /spec response body identifies a Radar dashboard. A Radar /spec
31
+ * is always a plain object carrying a `viz` array (even a blank spec is
32
+ * { title, blank, viz: [] }). Requiring both excludes a foreign service that happens
33
+ * to 200 with arbitrary JSON (a bare array, a string, an unrelated object). Exported
34
+ * for unit testing. statusCode must be 200.
35
+ */
36
+ export function isRadarSpecBody(statusCode, body) {
37
+ if (statusCode !== 200)
38
+ return false;
39
+ let spec;
40
+ try {
41
+ spec = JSON.parse(body);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ // typeof [] is "object" in JS, hence the explicit Array.isArray exclusion.
47
+ return (spec !== null &&
48
+ typeof spec === "object" &&
49
+ !Array.isArray(spec) &&
50
+ Array.isArray(spec.viz));
51
+ }
52
+ /** Open the dashboard in the default browser. Best-effort; never throws. */
53
+ function openBrowser(url) {
54
+ try {
55
+ if (process.platform === "darwin") {
56
+ execSync(`open ${url}`, { stdio: "ignore" });
57
+ }
58
+ else if (process.platform === "linux") {
59
+ execSync(`xdg-open ${url}`, { stdio: "ignore" });
60
+ }
61
+ }
62
+ catch {
63
+ // Browser open is best-effort
64
+ }
65
+ }
66
+ /**
67
+ * The port is busy. Decide whether the thing holding it is an existing Radar
68
+ * dashboard (adopt it: open the browser and exit cleanly) or something else
69
+ * (print the manual options and exit non-zero). We do NOT kill the holder:
70
+ * `lsof -ti :PORT` could be a browser, a teammate's server, or any unrelated
71
+ * process, and killing by port from a published tool is a footgun.
72
+ *
73
+ * Detection probes GET /spec on the loopback port. /spec is instant (it returns
74
+ * the current dashboard spec, no device round-trip) and is unique to a Radar
75
+ * dashboard, so a 200 with a JSON body is a reliable fingerprint. The response's
76
+ * X-Radar-Version header lets us notice when the running dashboard is a DIFFERENT
77
+ * Radar version than this launcher and say so — we still adopt it (never kill the
78
+ * holder), the note just explains why a feature you expect might be missing.
79
+ */
80
+ function handlePortInUse() {
81
+ const url = `http://localhost:${WEB_PORT}`;
82
+ void loopbackGet("/spec", 2000).then((r) => {
83
+ if (r && isRadarSpecBody(r.status, r.body)) {
84
+ const running = r.headers["x-radar-version"];
85
+ if (typeof running === "string" && running !== RADAR_VERSION) {
86
+ console.log(`Note: the running dashboard is Radar ${running}, this launcher is ${RADAR_VERSION}. ` +
87
+ `Adopting the running one as-is (it owns the port). Restart it to pick up ${RADAR_VERSION}.`);
88
+ }
89
+ console.log(`Slack Radar is already running at ${url}. Opening it.`);
90
+ if (!process.env.SLACK_RADAR_NO_OPEN)
91
+ openBrowser(url);
92
+ process.exit(0);
93
+ }
94
+ reportForeignPort();
95
+ });
96
+ }
97
+ /** Port is held by something that is not a Radar dashboard. Never kill it for the user. */
98
+ function reportForeignPort() {
99
+ console.error(`Port ${WEB_PORT} is already in use by another program (not a Radar dashboard).\n` +
100
+ `Free it, or run Radar on another port: SLACK_RADAR_WEB_PORT=8101 slack-radar web`);
101
+ process.exit(1);
102
+ }
4
103
  /**
5
104
  * Start the web dashboard server, open the browser, and wire shutdown signals.
6
105
  * Extracted so both the dedicated `slack-radar-web` bin and the default bin's
7
- * `web` subcommand dispatcher launch the dashboard the same way.
106
+ * `web` subcommand dispatcher launch the dashboard the same way. Exported so the
107
+ * dispatcher (mcp/index.ts) calls it explicitly rather than relying on an import
108
+ * side-effect, which keeps importing this module (e.g. in a test) side-effect-free.
8
109
  */
9
- function startWebServer() {
110
+ export function startWebServer() {
10
111
  // Safety net: a single route handler must never take the whole dashboard down. An async
11
112
  // callback (e.g. a device-probe that both ends and errors) can otherwise throw
12
113
  // ERR_HTTP_HEADERS_SENT outside any try/catch and kill the process. Log and keep serving;
@@ -20,10 +121,10 @@ function startWebServer() {
20
121
  const server = createServer();
21
122
  server.on("error", (err) => {
22
123
  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);
124
+ // Likely our own dashboard from an earlier launch. Adopt it if so; only error
125
+ // if the port is held by something else. Never kill the holder for the user.
126
+ handlePortInUse();
127
+ return;
27
128
  }
28
129
  throw err;
29
130
  });
@@ -37,6 +138,12 @@ function startWebServer() {
37
138
  // loopback-scoped (constants.ts); the dashboard's own listener must match. Remote
38
139
  // access, if ever needed, belongs in a separate token-gated feature.
39
140
  server.listen(WEB_PORT, "127.0.0.1", () => {
141
+ // Seed the starting spec AND wipe stale pulled DBs only now that THIS process owns the
142
+ // port. Doing both here (not at server.js import) means a second launcher that loses the
143
+ // bind never touches the running dashboard's spec file or its port-scoped pulled-DB cache,
144
+ // so adopting it leaves its view and open DB-browser tab intact.
145
+ seedInitialSpec();
146
+ cleanupOnBind();
40
147
  const url = `http://localhost:${WEB_PORT}`;
41
148
  console.log(`Slack Radar Web running at ${url}`);
42
149
  // Auto-open the browser for the HUMAN launcher (`slack-radar web` / `slack-radar-web`),
@@ -46,17 +153,7 @@ function startWebServer() {
46
153
  // /radar-ui. The tool returns the URL instead, for the Claude Code session to surface.
47
154
  if (process.env.SLACK_RADAR_NO_OPEN)
48
155
  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
- }
56
- }
57
- catch {
58
- // Browser open is best-effort
59
- }
156
+ openBrowser(url);
60
157
  });
61
158
  const shutdown = () => {
62
159
  console.log("\nShutting down…");
@@ -66,4 +163,21 @@ function startWebServer() {
66
163
  process.on("SIGINT", shutdown);
67
164
  process.on("SIGTERM", shutdown);
68
165
  }
69
- startWebServer();
166
+ // Only launch when run as the bin (`slack-radar-web` / `slack-radar web`), not when
167
+ // imported (e.g. by a test that exercises isRadarSpecBody). argv[1] is the script path
168
+ // node was invoked with; compare it to this module's own path. realpathSync resolves the
169
+ // npm bin symlink (node_modules/.bin) to the real dist path so the comparison holds.
170
+ function invokedDirectly() {
171
+ const arg = process.argv[1];
172
+ if (!arg)
173
+ return false;
174
+ try {
175
+ return fileURLToPath(import.meta.url) === realpathSync(arg);
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
181
+ if (invokedDirectly()) {
182
+ startWebServer();
183
+ }
@@ -11,6 +11,8 @@
11
11
  .conn.waiting { color:#ffb454; }
12
12
  .reconnect { background:#3a2a10; border:1px solid #ffb454; color:#ffb454; border-radius:8px; padding:4px 12px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; margin-left:8px; }
13
13
  .reconnect:hover { background:#4a360f; } .reconnect:disabled { opacity:.6; cursor:default; }
14
+ .profilewrap { font-size:12px; color:#7d9bd6; margin-left:8px; }
15
+ .profilewrap b { color:#cfe0f0; }
14
16
  .modebar { display:flex; gap:6px; margin:10px 0 6px; flex-wrap:wrap; }
15
17
  .modebtn { background:#121925; border:1px solid #243244; color:#8aa0b4; border-radius:8px; padding:6px 14px; font:inherit; font-size:12.5px; cursor:pointer; }
16
18
  .modebtn:hover { border-color:#39d98a; color:#dfe7ef; }
@@ -157,11 +159,17 @@
157
159
  .scinstall { background:#0e151e; border:1px solid #1e2a3a; border-radius:6px; padding:8px 10px; margin-top:8px; font-size:11.5px; color:#cfe3d6; }
158
160
  .scinstall code { color:#7fd6a3; }
159
161
  .scinstall .sccopy { background:#172a1f; border:1px solid #2c5a3e; color:#7fd6a3; border-radius:5px; padding:2px 8px; font:inherit; font-size:11px; cursor:pointer; margin-left:6px; }
160
- </style></head>
162
+ </style>
163
+ <!-- Import map so the vendored Preact runtime resolves the bare `preact` specifier (Preact
164
+ Hooks imports `from "preact"`). Inert until a UI module imports it; ships ahead of the
165
+ component migration (container by container) so later PRs are pure client modules. -->
166
+ <script type="importmap">
167
+ { "imports": { "preact": "./vendor/preact.module.js" } }
168
+ </script></head>
161
169
  <body>
162
170
  <div id="flash"></div>
163
171
  <div id="alertbanner"></div>
164
- <h1>SLACK RADAR DASHBOARD <span class="conn waiting" id="conn">&#9679; connecting…</span> <button id="reconnect" class="reconnect" style="display:none">⟳ Reconnect Radar</button> <button id="screenbtn" class="screenbtn" title="Mirror the live device screen">▣ Screen</button></h1>
172
+ <h1>SLACK RADAR DASHBOARD <span class="conn waiting" id="conn">&#9679; connecting…</span> <span id="profilewrap" class="profilewrap" title="the device profile Radar is connected to (the first app to launch wins the socket; to watch another profile, run only that profile's Slack)">profile: <b id="profilename">detecting…</b></span> <button id="reconnect" class="reconnect" style="display:none">⟳ Reconnect Radar</button> <button id="screenbtn" class="screenbtn" title="Mirror the live device screen">▣ Screen</button></h1>
165
173
  <div id="screenpanel">
166
174
  <div class="schead">
167
175
  <span class="sctitle">Device screen</span>
@@ -354,6 +362,7 @@ async function pollHealth(){
354
362
  else if(++healthMisses>=2){ setConn('waiting','device offline, retrying…'); }
355
363
  }catch{ if(++healthMisses>=2) setConn('waiting','reconnecting…'); } }
356
364
  pollHealth(); setInterval(pollHealth,2000);
365
+ showConnectedProfile(); setInterval(showConnectedProfile,5000); // keep the connected-profile label honest as the socket re-binds
357
366
 
358
367
  // ---- the dashboard engine: build(spec) tears down and rebuilds widgets live ----
359
368
  let es=null, tickTimer=null;
@@ -541,7 +550,47 @@ function setConn(state,msg){ const d=document.getElementById('conn'); if(!d) ret
541
550
  const rc=document.getElementById('reconnect'); if(rc && !rc._busy) rc.style.display = state==='connected' ? 'none' : 'inline-block'; }
542
551
  async function doReconnect(){ const rc=document.getElementById('reconnect'); if(!rc) return; rc._busy=true; rc.disabled=true; rc.textContent='⟳ enabling…';
543
552
  try{ await fetch('/enable',{method:'POST'}); }catch{}
544
- rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; pollHealth(); pollSpec(); }
553
+ rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; showConnectedProfile(); pollHealth(); pollSpec(); }
554
+
555
+ // Show which device profile Radar is connected to. Read-only: on a phone running the
556
+ // debug build on more than one profile (personal user 0 + a work profile) only ONE
557
+ // instance binds the single Radar socket — whichever launched first wins, and a second
558
+ // profile cannot take it while the first holds it. So we cannot SWITCH profiles from here
559
+ // yet (that needs per-profile sockets, a device-side change); we just report the truth.
560
+ // The label is ALWAYS visible (operator: "profile must show even if one"): the bound
561
+ // profile comes from /health's boundUserId/boundProfile (the real socket owner). When the
562
+ // device drops, keep showing the last-known profile dimmed rather than vanishing.
563
+ let lastBoundLabel=null;
564
+ async function showConnectedProfile(){
565
+ const wrap=document.getElementById('profilewrap'), nameEl=document.getElementById('profilename');
566
+ if(!wrap||!nameEl) return;
567
+ try{
568
+ const [profilesR, healthR]=await Promise.all([
569
+ fetch('/_control/profiles').then(r=>r.json()).catch(()=>null),
570
+ fetch('/health').then(r=>r.json()).catch(()=>null),
571
+ ]);
572
+ const uid=healthR&&healthR.boundUserId;
573
+ if(uid==null){
574
+ // Device not connected right now. Do NOT hide (the label is always present), but do
575
+ // NOT claim a current binding either: the socket may re-bind to a DIFFERENT profile
576
+ // while we are down, so "<last> (disconnected)" could name the wrong one. Phrase it as
577
+ // the last profile we SAW, not the one bound now. Cleared so a fresh connect re-resolves.
578
+ nameEl.textContent = lastBoundLabel ? 'last seen '+lastBoundLabel+' — reconnecting…' : 'waiting for device…';
579
+ return;
580
+ }
581
+ const profiles=(profilesR&&profilesR.profiles)||[];
582
+ const match=profiles.find(p=>p.userId===uid);
583
+ // Fallback when the profile list does not name this uid (fetch failed, or ps and the
584
+ // live ping disagree). NEVER hard-code a user id: always print the REAL bound uid, and
585
+ // Title-case the profile word so it matches the matched-path label.
586
+ const cap=(s)=> s ? s.charAt(0).toUpperCase()+s.slice(1) : s;
587
+ const label = match ? match.label
588
+ : (healthR.boundProfile ? cap(healthR.boundProfile)+' (user '+uid+')'
589
+ : 'user '+uid);
590
+ lastBoundLabel=label;
591
+ nameEl.textContent=label;
592
+ }catch{ /* transient fetch error: leave whatever is shown */ }
593
+ }
545
594
  // live defaults true so the SSE handler (es.onmessage=onEvent) keeps firing alerts on
546
595
  // genuine arrivals; backfill() passes live=false so replayed device HISTORY counts toward
547
596
  // widgets without triggering alerts for events that already happened.
@@ -1003,7 +1052,9 @@ async function dbRun(){ if(!DBV.db) return; const sql=document.getElementById('d
1003
1052
  // Consent is granted server-side (per process) and the stream/record routes enforce it.
1004
1053
  const SCREEN={ on:false, consent:false, recording:false, caps:null };
1005
1054
  function scCopy(text,btn){ navigator.clipboard?.writeText(text).then(()=>{ if(btn){ const t=btn.textContent; btn.textContent='copied'; setTimeout(()=>btn.textContent=t,1200); } }).catch(()=>{}); }
1006
- async function scLoadCaps(){ try{ SCREEN.caps=await (await fetch('/screen/caps')).json(); SCREEN.consent=!!SCREEN.caps.consent; }catch{ SCREEN.caps=null; } }
1055
+ // refresh=1 re-probes ffmpeg/scrcpy on the server, so reopening this panel after
1056
+ // installing ffmpeg detects it without restarting the dashboard.
1057
+ async function scLoadCaps(){ try{ SCREEN.caps=await (await fetch('/screen/caps?refresh=1')).json(); SCREEN.consent=!!SCREEN.caps.consent; }catch{ SCREEN.caps=null; } }
1007
1058
  function scRenderConsent(){
1008
1059
  const body=document.getElementById('screenbody'); const hint=document.getElementById('schint');
1009
1060
  hint.textContent='';
@@ -1020,13 +1071,21 @@ function scRenderLive(){
1020
1071
  setTimeout(()=>{ if(SCREEN.on) hint.textContent='live. ▣ Screen again to close.'; },3500);
1021
1072
  } else {
1022
1073
  // No ffmpeg: live cast is unavailable. Screenshot still works (pure screencap).
1023
- // Show the platform install command (copy only never auto-installed) + scrcpy hint.
1024
- const cmd=esc(caps.installHint||'install ffmpeg');
1025
- let html='<div class="scinstall">Live mirroring needs <b>ffmpeg</b>, which is not installed. Install it, then reopen this panel:<br><br><code>'+cmd+'</code><button class="sccopy" id="scffcopy">copy</button></div>';
1074
+ // Only render a copyable command when the server returned a real platform one.
1075
+ // The fallback used to show the literal string "install ffmpeg" with a copy
1076
+ // button, which is not a runnable command and failed when pasted into a terminal.
1077
+ const cmd=caps.installHint;
1078
+ let html='<div class="scinstall">Live mirroring needs <b>ffmpeg</b>, which is not installed. ';
1079
+ if(cmd){
1080
+ html+='Install it with this command, then reopen this panel:<br><br><code>'+esc(cmd)+'</code><button class="sccopy" id="scffcopy">copy</button>';
1081
+ } else {
1082
+ html+='Install it with your system package manager (for example <code>brew install ffmpeg</code> on macOS, <code>winget install ffmpeg</code> on Windows, or <code>sudo apt install ffmpeg</code> on Debian/Ubuntu), then reopen this panel.';
1083
+ }
1084
+ html+='</div>';
1026
1085
  html+='<div class="scinstall">📷 Screenshot works without ffmpeg — use the button above.</div>';
1027
1086
  if(caps.scrcpy) html+='<div class="scinstall">scrcpy is installed; for a native mirror window run <code>scrcpy</code> in a terminal.</div>';
1028
1087
  body.innerHTML=html;
1029
- const c=document.getElementById('scffcopy'); if(c) c.onclick=()=>scCopy(caps.installHint||'',c);
1088
+ const c=document.getElementById('scffcopy'); if(c) c.onclick=()=>scCopy(cmd,c);
1030
1089
  hint.textContent='live mirroring unavailable (no ffmpeg); screenshot still works.';
1031
1090
  }
1032
1091
  }