@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.
- package/README.md +1 -1
- package/dist/mcp/db-tools.d.ts +46 -0
- package/dist/mcp/db-tools.js +160 -0
- package/dist/mcp/index.js +34 -2
- package/dist/mcp/tools.js +41 -2
- package/dist/shared/android.js +15 -3
- package/dist/shared/constants.d.ts +15 -0
- package/dist/shared/constants.js +24 -0
- package/dist/shared/db.d.ts +31 -14
- package/dist/shared/db.js +44 -32
- package/dist/shared/debug-log.d.ts +50 -0
- package/dist/shared/debug-log.js +108 -0
- package/dist/shared/screen.d.ts +25 -2
- package/dist/shared/screen.js +62 -5
- package/dist/shared/transport.d.ts +10 -0
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +133 -19
- package/dist/web/public/index.html +67 -8
- package/dist/web/public/vendor/README.md +25 -0
- package/dist/web/public/vendor/hooks.module.js +2 -0
- package/dist/web/public/vendor/htm.LICENSE +202 -0
- package/dist/web/public/vendor/htm.module.js +1 -0
- package/dist/web/public/vendor/preact.LICENSE +21 -0
- package/dist/web/public/vendor/preact.module.js +2 -0
- package/dist/web/server.d.ts +17 -0
- package/dist/web/server.js +188 -16
- package/package.json +5 -2
|
@@ -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
|
+
}
|
package/dist/shared/screen.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
package/dist/shared/screen.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
173
|
+
return "winget install ffmpeg";
|
|
122
174
|
default:
|
|
123
|
-
return "sudo apt install ffmpeg
|
|
175
|
+
return "sudo apt install ffmpeg";
|
|
124
176
|
}
|
|
125
177
|
}
|
|
126
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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">● 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">● 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
|
-
|
|
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
|
-
//
|
|
1024
|
-
|
|
1025
|
-
|
|
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(
|
|
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
|
}
|