@slack/radar-mcp 1.1.1 → 1.2.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/dist/mcp/index.js +70 -3
- package/dist/mcp/tools.js +41 -0
- package/dist/shared/android.d.ts +15 -0
- package/dist/shared/android.js +131 -15
- package/dist/shared/logcat-capture.d.ts +73 -0
- package/dist/shared/logcat-capture.js +296 -0
- package/dist/shared/transport.d.ts +39 -0
- package/dist/web/public/index.html +64 -30
- package/dist/web/server.js +6 -2
- package/package.json +1 -1
package/dist/mcp/index.js
CHANGED
|
@@ -101,8 +101,10 @@ async function handleSessionEnable(args) {
|
|
|
101
101
|
const forwarded = device.ensureForward();
|
|
102
102
|
if (!forwarded) {
|
|
103
103
|
sessionEnabled = false;
|
|
104
|
+
const reason = device.getLastForwardError?.() ?? null;
|
|
104
105
|
return textResult({
|
|
105
|
-
error:
|
|
106
|
+
error: reason ??
|
|
107
|
+
"Port forwarding failed. Ensure a device is connected.",
|
|
106
108
|
enabled: false,
|
|
107
109
|
platform: device.platform,
|
|
108
110
|
}, true);
|
|
@@ -169,10 +171,69 @@ function handleGetAppState() {
|
|
|
169
171
|
return textResult({ error: `App state query failed: ${e.message}` }, true);
|
|
170
172
|
}
|
|
171
173
|
}
|
|
174
|
+
function handleListProfiles() {
|
|
175
|
+
try {
|
|
176
|
+
// Narrow to implementations that expose listProfiles; the base
|
|
177
|
+
// DeviceTransport interface leaves it platform-specific.
|
|
178
|
+
const maybe = device;
|
|
179
|
+
if (typeof maybe.listProfiles !== "function") {
|
|
180
|
+
return textResult({ error: `Profile listing not supported on platform ${device.platform}` }, true);
|
|
181
|
+
}
|
|
182
|
+
const profiles = maybe.listProfiles();
|
|
183
|
+
const activeUserId = typeof device.getActiveUserId === "function"
|
|
184
|
+
? device.getActiveUserId()
|
|
185
|
+
: null;
|
|
186
|
+
return textResult({ profiles, active_user_id: activeUserId });
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
return textResult({ error: `Profile list failed: ${e.message}` }, true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function handleActivateForUser(args) {
|
|
193
|
+
const maybe = device;
|
|
194
|
+
if (typeof maybe.activateForUser !== "function") {
|
|
195
|
+
return textResult({ error: `Profile switching not supported on platform ${device.platform}` }, true);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const timeout = args?.timeout_minutes || device.getTimeoutMinutes() || 5;
|
|
199
|
+
maybe.activateForUser(args.user_id, timeout);
|
|
200
|
+
// Restart the SSE stream so network/RTM/clog events from the newly
|
|
201
|
+
// activated profile start flowing again.
|
|
202
|
+
destroyStream();
|
|
203
|
+
connectStream();
|
|
204
|
+
// Give the newly spawned adb logcat child a moment to emit its first
|
|
205
|
+
// lines to the capture file. Matches the warmup delay used by
|
|
206
|
+
// handleSessionEnable so an immediate get_recent_logs call does not
|
|
207
|
+
// read an empty file.
|
|
208
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
209
|
+
const sourceFile = typeof device.getCaptureFilePath === "function"
|
|
210
|
+
? device.getCaptureFilePath()
|
|
211
|
+
: null;
|
|
212
|
+
return textResult({
|
|
213
|
+
user_id: args.user_id,
|
|
214
|
+
timeout_minutes: timeout,
|
|
215
|
+
capture_file: sourceFile,
|
|
216
|
+
note: "Profile activated. Log capture restarted on a new file; old capture preserved for 7 days.",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
return textResult({ error: `Profile activation failed: ${e.message}` }, true);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
172
223
|
function handleGetRecentLogs(args) {
|
|
173
224
|
try {
|
|
174
225
|
const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
|
|
175
|
-
|
|
226
|
+
// Surface the capture file path so the model knows which on-disk file
|
|
227
|
+
// produced these lines. Helps avoid cross-session staleness (e.g. the
|
|
228
|
+
// model remembering an earlier profile's logs after a profile switch).
|
|
229
|
+
const sourceFile = device.getCaptureFilePath?.() ?? null;
|
|
230
|
+
return textResult({
|
|
231
|
+
tag: args.tag,
|
|
232
|
+
lines,
|
|
233
|
+
count: lines.length,
|
|
234
|
+
source_file: sourceFile,
|
|
235
|
+
source: sourceFile ? "capture" : "adb_logcat_d",
|
|
236
|
+
});
|
|
176
237
|
}
|
|
177
238
|
catch (e) {
|
|
178
239
|
return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
|
|
@@ -291,7 +352,7 @@ function buildApiPath(name, args) {
|
|
|
291
352
|
}
|
|
292
353
|
}
|
|
293
354
|
// --- MCP Server ---
|
|
294
|
-
const server = new Server({ name: "slack-radar", version: "1.
|
|
355
|
+
const server = new Server({ name: "slack-radar", version: "1.2.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
295
356
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
296
357
|
tools: TOOL_DEFINITIONS,
|
|
297
358
|
}));
|
|
@@ -315,6 +376,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
|
|
|
315
376
|
if (name === "get_app_state") {
|
|
316
377
|
return handleGetAppState();
|
|
317
378
|
}
|
|
379
|
+
if (name === "list_profiles") {
|
|
380
|
+
return handleListProfiles();
|
|
381
|
+
}
|
|
382
|
+
if (name === "activate_for_user") {
|
|
383
|
+
return handleActivateForUser(toolArgs);
|
|
384
|
+
}
|
|
318
385
|
if (name === "get_recent_logs") {
|
|
319
386
|
return handleGetRecentLogs(toolArgs);
|
|
320
387
|
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -32,6 +32,22 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
32
32
|
"- Use ping(timeout_minutes=N) to extend sessions. Monitor remaining_minutes proactively.",
|
|
33
33
|
"- Screenshots default to 720px. Use full_res=true only when detail matters.",
|
|
34
34
|
"- Setup: /slack-radar-setup",
|
|
35
|
+
"- Manual enable (no skill): adb shell am broadcast -a slack.debug.ENABLE_RADAR --ei timeout_minutes 5 -p com.Slack.internal.debug",
|
|
36
|
+
" Then: adb forward tcp:8099 localabstract:slack-radar",
|
|
37
|
+
"",
|
|
38
|
+
"WEB DASHBOARD (/radar-ui):",
|
|
39
|
+
"A browser-based dashboard (http://localhost:8100) shows the same live device data in a",
|
|
40
|
+
"visual UI. Launched via the /radar-ui skill, which wraps `npx -p @slack/radar-mcp slack-radar-web`.",
|
|
41
|
+
"",
|
|
42
|
+
"When to suggest /radar-ui (mention it ONCE per thread, do not auto-invoke):",
|
|
43
|
+
'- User says "watch", "monitor", "let me see", "show me live" about device traffic',
|
|
44
|
+
"- User wants to share traffic with a colleague, pair, or teammate",
|
|
45
|
+
"- User is doing a multi-minute investigation and the transcript is getting noisy",
|
|
46
|
+
'- User explicitly asks about a GUI, UI, browser view, or dashboard',
|
|
47
|
+
"",
|
|
48
|
+
"DO NOT invoke /radar-ui yourself. Opening a browser window is a user-visible action.",
|
|
49
|
+
"DO NOT suggest /radar-ui for single-query debugging or when the user is happy reading",
|
|
50
|
+
"results in the transcript. Suggest it when visual inspection adds real value.",
|
|
35
51
|
].join("\n");
|
|
36
52
|
export const TOOL_DEFINITIONS = [
|
|
37
53
|
{
|
|
@@ -171,6 +187,31 @@ export const TOOL_DEFINITIONS = [
|
|
|
171
187
|
},
|
|
172
188
|
annotations: { readOnlyHint: true },
|
|
173
189
|
},
|
|
190
|
+
{
|
|
191
|
+
name: "list_profiles",
|
|
192
|
+
description: "List Android user profiles that have the Slack debug build running. Use this before activate_for_user when the user asks to switch profiles (personal vs work) or when logs/app state seem to point at the wrong profile. Returns each profile's userId (e.g. '0' for personal, '10' for work) and a human label.",
|
|
193
|
+
inputSchema: { type: "object", properties: {} },
|
|
194
|
+
annotations: { readOnlyHint: true },
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "activate_for_user",
|
|
198
|
+
description: "Activate Slack Radar on a specific Android user profile. Use after list_profiles to switch between personal and work profile targets. Switching profiles restarts log capture with a new session-scoped file and reconnects the SSE event stream so events come from the newly activated profile; old captures remain on disk for up to 7 days. Call this when already connected and only the target profile needs to change.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
user_id: {
|
|
203
|
+
type: "string",
|
|
204
|
+
description: "Android user ID from list_profiles (e.g. '0' for personal, '10' for work).",
|
|
205
|
+
},
|
|
206
|
+
timeout_minutes: {
|
|
207
|
+
type: "number",
|
|
208
|
+
description: "Auto-shutdown timeout in minutes (default: 5).",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
required: ["user_id"],
|
|
212
|
+
},
|
|
213
|
+
annotations: {},
|
|
214
|
+
},
|
|
174
215
|
{
|
|
175
216
|
name: "screenshot",
|
|
176
217
|
description: "Capture a screenshot from the connected Android device.",
|
package/dist/shared/android.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { DeviceTransport } from "./transport.js";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the adb binary. MCP servers spawned from GUI launchers (Spotlight,
|
|
4
|
+
* Raycast, Dock) do not inherit the user's shell PATH, so a bare `adb` call
|
|
5
|
+
* fails with ENOENT even when adb works fine in the user's terminal. We look
|
|
6
|
+
* in PATH first, then fall back to common Android SDK install locations.
|
|
7
|
+
*
|
|
8
|
+
* Returns either an absolute path to the adb binary or the string "adb"
|
|
9
|
+
* (PATH-relative). When all fallbacks miss, returns null so callers can
|
|
10
|
+
* surface a clear error instead of a misleading "device not connected".
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveAdbPath(): string | null;
|
|
2
13
|
/**
|
|
3
14
|
* Parse the output of `ps -A -o USER,NAME` to extract the Android user ID
|
|
4
15
|
* for the debug build process. When multiple profiles are running, prefers
|
|
@@ -12,11 +23,15 @@ export declare class AndroidTransport implements DeviceTransport {
|
|
|
12
23
|
private detectedUserId;
|
|
13
24
|
private currentTimeoutMinutes;
|
|
14
25
|
private radarEnabled;
|
|
26
|
+
private lastForwardError;
|
|
15
27
|
ensureForward(): boolean;
|
|
28
|
+
getLastForwardError(): string | null;
|
|
16
29
|
activate(timeoutMinutes: number): void;
|
|
17
30
|
resetState(): void;
|
|
18
31
|
isForwarded(): boolean;
|
|
19
32
|
getTimeoutMinutes(): number;
|
|
33
|
+
getActiveUserId(): string | null;
|
|
34
|
+
getCaptureFilePath(): string | null;
|
|
20
35
|
screenshot(fullRes: boolean): string;
|
|
21
36
|
recordScreen(durationS: number): string;
|
|
22
37
|
getAppState(): string;
|
package/dist/shared/android.js
CHANGED
|
@@ -1,9 +1,63 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
2
3
|
import os from "os";
|
|
3
4
|
import path from "path";
|
|
5
|
+
import { getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
|
|
4
6
|
const RADAR_PORT = 8099;
|
|
5
7
|
const SOCKET_NAME = "slack-radar";
|
|
6
8
|
const ENABLE_ACTION = "slack.debug.ENABLE_RADAR";
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the adb binary. MCP servers spawned from GUI launchers (Spotlight,
|
|
11
|
+
* Raycast, Dock) do not inherit the user's shell PATH, so a bare `adb` call
|
|
12
|
+
* fails with ENOENT even when adb works fine in the user's terminal. We look
|
|
13
|
+
* in PATH first, then fall back to common Android SDK install locations.
|
|
14
|
+
*
|
|
15
|
+
* Returns either an absolute path to the adb binary or the string "adb"
|
|
16
|
+
* (PATH-relative). When all fallbacks miss, returns null so callers can
|
|
17
|
+
* surface a clear error instead of a misleading "device not connected".
|
|
18
|
+
*/
|
|
19
|
+
export function resolveAdbPath() {
|
|
20
|
+
// 1. PATH-relative: if `adb` resolves, use it as-is (fast path).
|
|
21
|
+
try {
|
|
22
|
+
const which = execSync("command -v adb", {
|
|
23
|
+
timeout: 2000,
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
shell: "/bin/sh",
|
|
26
|
+
}).trim();
|
|
27
|
+
if (which && fs.existsSync(which))
|
|
28
|
+
return "adb";
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// `command -v adb` returns non-zero when not found; fall through
|
|
32
|
+
}
|
|
33
|
+
// 2. Common install locations, in order of likelihood on macOS and Linux.
|
|
34
|
+
const home = os.homedir();
|
|
35
|
+
const candidates = [
|
|
36
|
+
process.env.ANDROID_HOME
|
|
37
|
+
? path.join(process.env.ANDROID_HOME, "platform-tools", "adb")
|
|
38
|
+
: null,
|
|
39
|
+
process.env.ANDROID_SDK_ROOT
|
|
40
|
+
? path.join(process.env.ANDROID_SDK_ROOT, "platform-tools", "adb")
|
|
41
|
+
: null,
|
|
42
|
+
path.join(home, "Library", "Android", "sdk", "platform-tools", "adb"),
|
|
43
|
+
path.join(home, "Android", "Sdk", "platform-tools", "adb"),
|
|
44
|
+
"/usr/local/bin/adb",
|
|
45
|
+
"/opt/homebrew/bin/adb",
|
|
46
|
+
].filter((p) => p !== null);
|
|
47
|
+
for (const candidate of candidates) {
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(candidate))
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Permission denied on some directory; skip
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// Module-level resolution, done once at import time. The MCP process lifetime
|
|
59
|
+
// is short enough (session-scoped) that we do not need to re-resolve.
|
|
60
|
+
const ADB = resolveAdbPath();
|
|
7
61
|
/**
|
|
8
62
|
* Parse the output of `ps -A -o USER,NAME` to extract the Android user ID
|
|
9
63
|
* for the debug build process. When multiple profiles are running, prefers
|
|
@@ -33,29 +87,46 @@ export class AndroidTransport {
|
|
|
33
87
|
detectedUserId = null;
|
|
34
88
|
currentTimeoutMinutes = 5;
|
|
35
89
|
radarEnabled = false;
|
|
90
|
+
lastForwardError = null;
|
|
36
91
|
ensureForward() {
|
|
37
92
|
if (this.portForwarded)
|
|
38
93
|
return true;
|
|
94
|
+
if (!ADB) {
|
|
95
|
+
this.lastForwardError =
|
|
96
|
+
"adb binary not found. Set ANDROID_HOME or install Android SDK platform-tools. Searched: PATH, $ANDROID_HOME/platform-tools/adb, ~/Library/Android/sdk/platform-tools/adb, ~/Android/Sdk/platform-tools/adb, /usr/local/bin/adb, /opt/homebrew/bin/adb.";
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
39
99
|
try {
|
|
40
|
-
execSync(
|
|
100
|
+
execSync(`${ADB} forward tcp:${RADAR_PORT} localabstract:${SOCKET_NAME}`, {
|
|
41
101
|
timeout: 5000,
|
|
42
102
|
});
|
|
43
103
|
this.portForwarded = true;
|
|
104
|
+
this.lastForwardError = null;
|
|
44
105
|
return true;
|
|
45
106
|
}
|
|
46
|
-
catch {
|
|
107
|
+
catch (e) {
|
|
108
|
+
this.lastForwardError = e.message;
|
|
47
109
|
return false;
|
|
48
110
|
}
|
|
49
111
|
}
|
|
112
|
+
getLastForwardError() {
|
|
113
|
+
return this.lastForwardError;
|
|
114
|
+
}
|
|
50
115
|
activate(timeoutMinutes) {
|
|
116
|
+
if (!ADB)
|
|
117
|
+
return;
|
|
51
118
|
const timeout = timeoutMinutes || this.currentTimeoutMinutes;
|
|
52
119
|
if (this.radarEnabled && timeout === this.currentTimeoutMinutes)
|
|
53
120
|
return;
|
|
54
121
|
try {
|
|
55
122
|
const userId = this.detectDebugBuildUser();
|
|
56
|
-
execSync(
|
|
123
|
+
execSync(`${ADB} shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
|
|
57
124
|
this.currentTimeoutMinutes = timeout;
|
|
58
125
|
this.radarEnabled = true;
|
|
126
|
+
// Start streaming logcat to a session-scoped file so reads are not
|
|
127
|
+
// subject to the device ring buffer rotating under load.
|
|
128
|
+
registerCleanup();
|
|
129
|
+
startCapture(ADB);
|
|
59
130
|
}
|
|
60
131
|
catch {
|
|
61
132
|
// Broadcast failed — device may not be connected
|
|
@@ -65,6 +136,8 @@ export class AndroidTransport {
|
|
|
65
136
|
this.portForwarded = false;
|
|
66
137
|
this.detectedUserId = null;
|
|
67
138
|
this.radarEnabled = false;
|
|
139
|
+
this.lastForwardError = null;
|
|
140
|
+
stopCapture();
|
|
68
141
|
}
|
|
69
142
|
isForwarded() {
|
|
70
143
|
return this.portForwarded;
|
|
@@ -72,12 +145,20 @@ export class AndroidTransport {
|
|
|
72
145
|
getTimeoutMinutes() {
|
|
73
146
|
return this.currentTimeoutMinutes;
|
|
74
147
|
}
|
|
148
|
+
getActiveUserId() {
|
|
149
|
+
return this.detectedUserId;
|
|
150
|
+
}
|
|
151
|
+
getCaptureFilePath() {
|
|
152
|
+
return getCaptureFilePath();
|
|
153
|
+
}
|
|
75
154
|
screenshot(fullRes) {
|
|
155
|
+
if (!ADB)
|
|
156
|
+
throw new Error("adb not found on PATH or common SDK locations");
|
|
76
157
|
const tmpFile = path.join(os.tmpdir(), `slack-radar-screenshot-${Date.now()}.png`);
|
|
77
158
|
const devicePath = "/data/local/tmp/slack-radar-screenshot.png";
|
|
78
|
-
execSync(
|
|
79
|
-
execSync(
|
|
80
|
-
execSync(
|
|
159
|
+
execSync(`${ADB} shell screencap -p ${devicePath}`, { timeout: 10000 });
|
|
160
|
+
execSync(`${ADB} pull ${devicePath} "${tmpFile}"`, { timeout: 10000 });
|
|
161
|
+
execSync(`${ADB} shell rm ${devicePath}`, { timeout: 5000 });
|
|
81
162
|
if (!fullRes) {
|
|
82
163
|
try {
|
|
83
164
|
execSync(`sips -Z 720 "${tmpFile}" 2>/dev/null`, { timeout: 5000 });
|
|
@@ -89,21 +170,40 @@ export class AndroidTransport {
|
|
|
89
170
|
return tmpFile;
|
|
90
171
|
}
|
|
91
172
|
recordScreen(durationS) {
|
|
173
|
+
if (!ADB)
|
|
174
|
+
throw new Error("adb not found on PATH or common SDK locations");
|
|
92
175
|
const duration = Math.min(Math.max(durationS, 1), 30);
|
|
93
176
|
const tmpFile = path.join(os.tmpdir(), `slack-radar-recording-${Date.now()}.mp4`);
|
|
94
177
|
const devicePath = "/data/local/tmp/slack-radar-recording.mp4";
|
|
95
|
-
execSync(
|
|
96
|
-
execSync(
|
|
97
|
-
execSync(
|
|
178
|
+
execSync(`${ADB} shell screenrecord --time-limit ${duration} --size 720x1280 ${devicePath}`, { timeout: (duration + 5) * 1000 });
|
|
179
|
+
execSync(`${ADB} pull ${devicePath} "${tmpFile}"`, { timeout: 10000 });
|
|
180
|
+
execSync(`${ADB} shell rm ${devicePath}`, { timeout: 5000 });
|
|
98
181
|
return tmpFile;
|
|
99
182
|
}
|
|
100
183
|
getAppState() {
|
|
101
|
-
|
|
184
|
+
if (!ADB)
|
|
185
|
+
throw new Error("adb not found on PATH or common SDK locations");
|
|
186
|
+
return execSync(`${ADB} shell "dumpsys activity activities | grep -A 5 'com.Slack.internal'"`, { timeout: 10000, encoding: "utf-8" }).trim();
|
|
102
187
|
}
|
|
103
188
|
getRecentLogs(tag, limit, grep) {
|
|
104
|
-
|
|
189
|
+
if (!ADB)
|
|
190
|
+
throw new Error("adb not found on PATH or common SDK locations");
|
|
191
|
+
// Preferred path: read from the session capture file so lines cannot
|
|
192
|
+
// have been evicted from the device ring buffer between enable and now.
|
|
193
|
+
if (isCapturing()) {
|
|
194
|
+
// Opportunistic rotation. Stop+rename+restart when the file grows
|
|
195
|
+
// past the threshold so the child never writes past a truncated EOF.
|
|
196
|
+
// Bounded to the read cadence; no extra timer.
|
|
197
|
+
rotateCaptureIfNeeded(ADB);
|
|
198
|
+
const lines = readRecentLogs(tag, limit, grep);
|
|
199
|
+
if (lines !== null)
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
// Fallback: device ring buffer snapshot. Used when capture is not running
|
|
203
|
+
// (activate() was skipped, child died, or the file was deleted).
|
|
204
|
+
let cmd = `${ADB} logcat -d | grep -F "${tag}" | tail -${limit}`;
|
|
105
205
|
if (grep) {
|
|
106
|
-
cmd =
|
|
206
|
+
cmd = `${ADB} logcat -d | grep -F "${tag}" | grep -i "${grep.replace(/"/g, '\\"')}" | tail -${limit}`;
|
|
107
207
|
}
|
|
108
208
|
const output = execSync(cmd, {
|
|
109
209
|
timeout: 10000,
|
|
@@ -113,8 +213,10 @@ export class AndroidTransport {
|
|
|
113
213
|
return output.trim().split("\n").filter(Boolean);
|
|
114
214
|
}
|
|
115
215
|
listProfiles() {
|
|
216
|
+
if (!ADB)
|
|
217
|
+
return [{ userId: "0", label: "Personal (user 0)" }];
|
|
116
218
|
try {
|
|
117
|
-
const ps = execSync(
|
|
219
|
+
const ps = execSync(`${ADB} shell "ps -A -o USER,NAME 2>/dev/null | grep Slack | grep debug"`, { timeout: 5000, encoding: "utf-8" }).trim();
|
|
118
220
|
if (!ps)
|
|
119
221
|
return [{ userId: "0", label: "Personal (user 0)" }];
|
|
120
222
|
const seen = new Set();
|
|
@@ -139,17 +241,31 @@ export class AndroidTransport {
|
|
|
139
241
|
}
|
|
140
242
|
}
|
|
141
243
|
activateForUser(userId, timeoutMinutes) {
|
|
244
|
+
if (!ADB)
|
|
245
|
+
throw new Error("adb not found on PATH or common SDK locations");
|
|
142
246
|
const timeout = timeoutMinutes || this.currentTimeoutMinutes;
|
|
143
|
-
|
|
247
|
+
const profileChanged = this.detectedUserId !== null && this.detectedUserId !== userId;
|
|
248
|
+
execSync(`${ADB} shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
|
|
144
249
|
this.detectedUserId = userId;
|
|
145
250
|
this.currentTimeoutMinutes = timeout;
|
|
146
251
|
this.radarEnabled = true;
|
|
252
|
+
// Profile switch means the interesting scope changed; drop the old
|
|
253
|
+
// capture so the new session starts from a fresh file scoped to this
|
|
254
|
+
// profile's debug run.
|
|
255
|
+
if (profileChanged)
|
|
256
|
+
stopCapture();
|
|
257
|
+
registerCleanup();
|
|
258
|
+
startCapture(ADB);
|
|
147
259
|
}
|
|
148
260
|
detectDebugBuildUser() {
|
|
149
261
|
if (this.detectedUserId !== null)
|
|
150
262
|
return this.detectedUserId;
|
|
263
|
+
if (!ADB) {
|
|
264
|
+
this.detectedUserId = "0";
|
|
265
|
+
return this.detectedUserId;
|
|
266
|
+
}
|
|
151
267
|
try {
|
|
152
|
-
const ps = execSync(
|
|
268
|
+
const ps = execSync(`${ADB} shell "ps -A -o USER,NAME 2>/dev/null | grep Slack | grep debug"`, { timeout: 5000, encoding: "utf-8" }).trim();
|
|
153
269
|
this.detectedUserId = parseUserFromPsOutput(ps);
|
|
154
270
|
}
|
|
155
271
|
catch {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ChildProcess } from "child_process";
|
|
2
|
+
export interface CaptureState {
|
|
3
|
+
child: ChildProcess | null;
|
|
4
|
+
filePath: string | null;
|
|
5
|
+
startedAt: number | null;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
|
|
9
|
+
* disk usage without losing recent forensic history. Extracted for test.
|
|
10
|
+
*/
|
|
11
|
+
export declare function gcOldCaptures(dir?: string, now?: number): number;
|
|
12
|
+
export declare function buildCaptureFilePath(now?: number): string;
|
|
13
|
+
export declare function isCapturing(): boolean;
|
|
14
|
+
export declare function getCaptureFilePath(): string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Start streaming logcat to a session-scoped file. Idempotent: a second call
|
|
17
|
+
* while the first capture is live is a no-op. If a prior capture's child is
|
|
18
|
+
* dead (crashed, killed), it is cleared first so this call starts fresh.
|
|
19
|
+
*/
|
|
20
|
+
export declare function startCapture(adbPath: string): string | null;
|
|
21
|
+
export declare function stopCapture(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Check whether the file at `filePath` exceeds ROTATION_BYTES.
|
|
24
|
+
*
|
|
25
|
+
* The caller is responsible for performing rotation — the old approach of
|
|
26
|
+
* truncating in-place was unsafe because the child `adb logcat` process
|
|
27
|
+
* still holds an open fd at its previous write offset; after a truncate
|
|
28
|
+
* the next write lands past the new EOF and produces a sparse file with a
|
|
29
|
+
* null-byte hole. See `rotateCaptureIfNeeded` for the safe rotation path:
|
|
30
|
+
* stop the child, rename the old file, start a new child with a fresh fd.
|
|
31
|
+
*
|
|
32
|
+
* Extracted so the size check is testable without running adb.
|
|
33
|
+
*/
|
|
34
|
+
export declare function needsRotation(filePath: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* If the active capture has grown past the rotation threshold, cleanly
|
|
37
|
+
* rotate it: stop the child (closing its fd), rename the old file with a
|
|
38
|
+
* `.rotated-<ts>` suffix so it remains readable by consumers for a while
|
|
39
|
+
* (GC reclaims it after the 7-day TTL), and start a fresh child writing
|
|
40
|
+
* to a new file.
|
|
41
|
+
*
|
|
42
|
+
* Only has effect if a capture is currently active. Returns the new file
|
|
43
|
+
* path (or null if no rotation happened or rotation failed).
|
|
44
|
+
*/
|
|
45
|
+
export declare function rotateCaptureIfNeeded(adbPath: string): string | null;
|
|
46
|
+
/**
|
|
47
|
+
* Filter captured log lines by tag substring (logcat threadtime format puts
|
|
48
|
+
* the tag after the PID/TID columns), optional grep (case-insensitive), and
|
|
49
|
+
* tail to `limit` most-recent matches.
|
|
50
|
+
*
|
|
51
|
+
* Extracted so the pure logic can be tested without a running adb.
|
|
52
|
+
*/
|
|
53
|
+
export declare function filterLogLines(lines: string[], tag: string, limit: number, grep?: string): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Read the last `maxBytes` of a file without loading the whole thing. Returns
|
|
56
|
+
* the tail as a UTF-8 string, dropping any partial first line so the result
|
|
57
|
+
* starts at a line boundary. Keeps memory bounded regardless of file size,
|
|
58
|
+
* which matters once the capture file grows (and once the web dashboard
|
|
59
|
+
* starts polling for logs in a later PR).
|
|
60
|
+
*
|
|
61
|
+
* Extracted for testability.
|
|
62
|
+
*/
|
|
63
|
+
export declare function readTailBytes(filePath: string, maxBytes: number): string;
|
|
64
|
+
/**
|
|
65
|
+
* Read recent captured lines matching tag/grep, tailed to `limit`. Returns
|
|
66
|
+
* null when no capture is active (caller falls back to `adb logcat -d`).
|
|
67
|
+
*
|
|
68
|
+
* Reads only the last DEFAULT_TAIL_BYTES of the capture file so cost stays
|
|
69
|
+
* bounded as the file grows. For typical logcat traffic this window covers
|
|
70
|
+
* several thousand lines, which safely exceeds practical `limit` values.
|
|
71
|
+
*/
|
|
72
|
+
export declare function readRecentLogs(tag: string, limit: number, grep?: string): string[] | null;
|
|
73
|
+
export declare function registerCleanup(): void;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
/**
|
|
6
|
+
* Streams `adb logcat` to a per-session file so log queries read from a
|
|
7
|
+
* local, non-rotating store instead of the device's ring buffer at query time.
|
|
8
|
+
*
|
|
9
|
+
* The device-side logcat buffer is ~256KB-1MB per buffer and rotates under
|
|
10
|
+
* load; a chatty session can overwrite lines a user wanted to inspect hours
|
|
11
|
+
* later. Capturing to a local file from session-enable onward preserves
|
|
12
|
+
* everything from the enable moment forward.
|
|
13
|
+
*
|
|
14
|
+
* Lifecycle: startCapture on activate, stopCapture on resetState/disable.
|
|
15
|
+
*/
|
|
16
|
+
const LOG_DIR = path.join(os.homedir(), ".slack-radar", "logs");
|
|
17
|
+
const ROTATION_BYTES = 100 * 1024 * 1024;
|
|
18
|
+
const ROTATION_KEEP_BYTES = 50 * 1024 * 1024;
|
|
19
|
+
const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
const state = {
|
|
21
|
+
child: null,
|
|
22
|
+
filePath: null,
|
|
23
|
+
startedAt: null,
|
|
24
|
+
};
|
|
25
|
+
function ensureLogDir() {
|
|
26
|
+
if (!fs.existsSync(LOG_DIR)) {
|
|
27
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
|
|
32
|
+
* disk usage without losing recent forensic history. Extracted for test.
|
|
33
|
+
*/
|
|
34
|
+
export function gcOldCaptures(dir = LOG_DIR, now = Date.now()) {
|
|
35
|
+
if (!fs.existsSync(dir))
|
|
36
|
+
return 0;
|
|
37
|
+
let removed = 0;
|
|
38
|
+
for (const name of fs.readdirSync(dir)) {
|
|
39
|
+
if (!name.startsWith("radar-") || !name.endsWith(".log"))
|
|
40
|
+
continue;
|
|
41
|
+
const full = path.join(dir, name);
|
|
42
|
+
try {
|
|
43
|
+
const stat = fs.statSync(full);
|
|
44
|
+
if (now - stat.mtimeMs > GC_TTL_MS) {
|
|
45
|
+
fs.unlinkSync(full);
|
|
46
|
+
removed += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// ignore; file may have vanished between readdir and stat
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return removed;
|
|
54
|
+
}
|
|
55
|
+
export function buildCaptureFilePath(now = Date.now()) {
|
|
56
|
+
return path.join(LOG_DIR, `radar-${now}.log`);
|
|
57
|
+
}
|
|
58
|
+
export function isCapturing() {
|
|
59
|
+
return state.child !== null && state.child.exitCode === null;
|
|
60
|
+
}
|
|
61
|
+
export function getCaptureFilePath() {
|
|
62
|
+
return state.filePath;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Start streaming logcat to a session-scoped file. Idempotent: a second call
|
|
66
|
+
* while the first capture is live is a no-op. If a prior capture's child is
|
|
67
|
+
* dead (crashed, killed), it is cleared first so this call starts fresh.
|
|
68
|
+
*/
|
|
69
|
+
export function startCapture(adbPath) {
|
|
70
|
+
if (isCapturing())
|
|
71
|
+
return state.filePath;
|
|
72
|
+
// Stale child: clear before restarting.
|
|
73
|
+
if (state.child !== null)
|
|
74
|
+
stopCapture();
|
|
75
|
+
ensureLogDir();
|
|
76
|
+
gcOldCaptures();
|
|
77
|
+
const filePath = buildCaptureFilePath();
|
|
78
|
+
const out = fs.openSync(filePath, "a");
|
|
79
|
+
try {
|
|
80
|
+
const child = spawn(adbPath, ["logcat", "-v", "threadtime"], {
|
|
81
|
+
stdio: ["ignore", out, out],
|
|
82
|
+
detached: false,
|
|
83
|
+
});
|
|
84
|
+
child.on("exit", () => {
|
|
85
|
+
try {
|
|
86
|
+
fs.closeSync(out);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// already closed
|
|
90
|
+
}
|
|
91
|
+
if (state.child === child) {
|
|
92
|
+
state.child = null;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
child.on("error", () => {
|
|
96
|
+
try {
|
|
97
|
+
fs.closeSync(out);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// already closed
|
|
101
|
+
}
|
|
102
|
+
if (state.child === child) {
|
|
103
|
+
state.child = null;
|
|
104
|
+
state.filePath = null;
|
|
105
|
+
state.startedAt = null;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
state.child = child;
|
|
109
|
+
state.filePath = filePath;
|
|
110
|
+
state.startedAt = Date.now();
|
|
111
|
+
// Child bring-up warmup is handled at the MCP handler boundary
|
|
112
|
+
// (handleSessionEnable / handleActivateForUser add a small async delay
|
|
113
|
+
// before returning). Doing it here would require either blocking the
|
|
114
|
+
// single-threaded MCP event loop with a busy-wait or making startCapture
|
|
115
|
+
// async, neither of which earns the complexity.
|
|
116
|
+
return filePath;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
try {
|
|
120
|
+
fs.closeSync(out);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function stopCapture() {
|
|
129
|
+
const child = state.child;
|
|
130
|
+
state.child = null;
|
|
131
|
+
state.filePath = null;
|
|
132
|
+
state.startedAt = null;
|
|
133
|
+
if (child && child.exitCode === null) {
|
|
134
|
+
try {
|
|
135
|
+
child.kill("SIGTERM");
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// already dead
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Check whether the file at `filePath` exceeds ROTATION_BYTES.
|
|
144
|
+
*
|
|
145
|
+
* The caller is responsible for performing rotation — the old approach of
|
|
146
|
+
* truncating in-place was unsafe because the child `adb logcat` process
|
|
147
|
+
* still holds an open fd at its previous write offset; after a truncate
|
|
148
|
+
* the next write lands past the new EOF and produces a sparse file with a
|
|
149
|
+
* null-byte hole. See `rotateCaptureIfNeeded` for the safe rotation path:
|
|
150
|
+
* stop the child, rename the old file, start a new child with a fresh fd.
|
|
151
|
+
*
|
|
152
|
+
* Extracted so the size check is testable without running adb.
|
|
153
|
+
*/
|
|
154
|
+
export function needsRotation(filePath) {
|
|
155
|
+
try {
|
|
156
|
+
const stat = fs.statSync(filePath);
|
|
157
|
+
return stat.size > ROTATION_BYTES;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* If the active capture has grown past the rotation threshold, cleanly
|
|
165
|
+
* rotate it: stop the child (closing its fd), rename the old file with a
|
|
166
|
+
* `.rotated-<ts>` suffix so it remains readable by consumers for a while
|
|
167
|
+
* (GC reclaims it after the 7-day TTL), and start a fresh child writing
|
|
168
|
+
* to a new file.
|
|
169
|
+
*
|
|
170
|
+
* Only has effect if a capture is currently active. Returns the new file
|
|
171
|
+
* path (or null if no rotation happened or rotation failed).
|
|
172
|
+
*/
|
|
173
|
+
export function rotateCaptureIfNeeded(adbPath) {
|
|
174
|
+
const current = state.filePath;
|
|
175
|
+
if (!current || !isCapturing())
|
|
176
|
+
return null;
|
|
177
|
+
if (!needsRotation(current))
|
|
178
|
+
return null;
|
|
179
|
+
stopCapture();
|
|
180
|
+
try {
|
|
181
|
+
const archived = `${current}.rotated-${Date.now()}`;
|
|
182
|
+
fs.renameSync(current, archived);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Rename failure is non-fatal: the file may have vanished. We still
|
|
186
|
+
// want to start a fresh capture, so fall through to startCapture.
|
|
187
|
+
}
|
|
188
|
+
return startCapture(adbPath);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Filter captured log lines by tag substring (logcat threadtime format puts
|
|
192
|
+
* the tag after the PID/TID columns), optional grep (case-insensitive), and
|
|
193
|
+
* tail to `limit` most-recent matches.
|
|
194
|
+
*
|
|
195
|
+
* Extracted so the pure logic can be tested without a running adb.
|
|
196
|
+
*/
|
|
197
|
+
export function filterLogLines(lines, tag, limit, grep) {
|
|
198
|
+
const tagMatches = lines.filter((line) => line.includes(tag));
|
|
199
|
+
const grepMatches = grep
|
|
200
|
+
? tagMatches.filter((line) => line.toLowerCase().includes(grep.toLowerCase()))
|
|
201
|
+
: tagMatches;
|
|
202
|
+
if (grepMatches.length <= limit)
|
|
203
|
+
return grepMatches;
|
|
204
|
+
return grepMatches.slice(grepMatches.length - limit);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Read the last `maxBytes` of a file without loading the whole thing. Returns
|
|
208
|
+
* the tail as a UTF-8 string, dropping any partial first line so the result
|
|
209
|
+
* starts at a line boundary. Keeps memory bounded regardless of file size,
|
|
210
|
+
* which matters once the capture file grows (and once the web dashboard
|
|
211
|
+
* starts polling for logs in a later PR).
|
|
212
|
+
*
|
|
213
|
+
* Extracted for testability.
|
|
214
|
+
*/
|
|
215
|
+
export function readTailBytes(filePath, maxBytes) {
|
|
216
|
+
let stat;
|
|
217
|
+
try {
|
|
218
|
+
stat = fs.statSync(filePath);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
if (stat.size === 0)
|
|
224
|
+
return "";
|
|
225
|
+
const readSize = Math.min(stat.size, maxBytes);
|
|
226
|
+
const start = stat.size - readSize;
|
|
227
|
+
const fd = fs.openSync(filePath, "r");
|
|
228
|
+
try {
|
|
229
|
+
const buf = Buffer.alloc(readSize);
|
|
230
|
+
fs.readSync(fd, buf, 0, readSize, start);
|
|
231
|
+
const text = buf.toString("utf-8");
|
|
232
|
+
// If we read from mid-file, drop the partial first line so the caller
|
|
233
|
+
// does not see half-parsed content.
|
|
234
|
+
if (start > 0) {
|
|
235
|
+
const firstNewline = text.indexOf("\n");
|
|
236
|
+
if (firstNewline !== -1)
|
|
237
|
+
return text.slice(firstNewline + 1);
|
|
238
|
+
}
|
|
239
|
+
return text;
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
fs.closeSync(fd);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Bounded tail size for tool reads. Large enough to cover most `limit <= 500`
|
|
246
|
+
// queries on chatty traffic; small enough to keep per-read memory predictable.
|
|
247
|
+
// Tuned for the radar-ui polling case (later PR) where many reads per second
|
|
248
|
+
// are plausible.
|
|
249
|
+
const DEFAULT_TAIL_BYTES = 4 * 1024 * 1024;
|
|
250
|
+
/**
|
|
251
|
+
* Read recent captured lines matching tag/grep, tailed to `limit`. Returns
|
|
252
|
+
* null when no capture is active (caller falls back to `adb logcat -d`).
|
|
253
|
+
*
|
|
254
|
+
* Reads only the last DEFAULT_TAIL_BYTES of the capture file so cost stays
|
|
255
|
+
* bounded as the file grows. For typical logcat traffic this window covers
|
|
256
|
+
* several thousand lines, which safely exceeds practical `limit` values.
|
|
257
|
+
*/
|
|
258
|
+
export function readRecentLogs(tag, limit, grep) {
|
|
259
|
+
const filePath = state.filePath;
|
|
260
|
+
if (!filePath)
|
|
261
|
+
return null;
|
|
262
|
+
// Note: rotation is not triggered here. Rotating requires an adb path to
|
|
263
|
+
// start the replacement child, and the read path is intentionally sync +
|
|
264
|
+
// adb-free. Callers that have adb (AndroidTransport.getRecentLogs) can
|
|
265
|
+
// invoke rotateCaptureIfNeeded before reading.
|
|
266
|
+
let tail;
|
|
267
|
+
try {
|
|
268
|
+
tail = readTailBytes(filePath, DEFAULT_TAIL_BYTES);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const lines = tail.split("\n").filter(Boolean);
|
|
274
|
+
return filterLogLines(lines, tag, limit, grep);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Install once at process start so the child adb logcat is killed when the
|
|
278
|
+
* MCP server exits. Prevents orphaned adb streams that would hold the
|
|
279
|
+
* device and accumulate across restarts.
|
|
280
|
+
*/
|
|
281
|
+
let cleanupRegistered = false;
|
|
282
|
+
export function registerCleanup() {
|
|
283
|
+
if (cleanupRegistered)
|
|
284
|
+
return;
|
|
285
|
+
cleanupRegistered = true;
|
|
286
|
+
const cleanup = () => stopCapture();
|
|
287
|
+
process.on("exit", cleanup);
|
|
288
|
+
process.on("SIGINT", () => {
|
|
289
|
+
cleanup();
|
|
290
|
+
process.exit(130);
|
|
291
|
+
});
|
|
292
|
+
process.on("SIGTERM", () => {
|
|
293
|
+
cleanup();
|
|
294
|
+
process.exit(143);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
@@ -55,4 +55,43 @@ export interface DeviceTransport {
|
|
|
55
55
|
* @returns Array of matching log lines
|
|
56
56
|
*/
|
|
57
57
|
getRecentLogs(tag: string, limit: number, grep?: string): string[];
|
|
58
|
+
/**
|
|
59
|
+
* Returns the last error message from an `ensureForward()` failure, or null
|
|
60
|
+
* if the last attempt succeeded or has not happened. Lets callers surface
|
|
61
|
+
* concrete causes (missing adb, unauthorized device) instead of a generic
|
|
62
|
+
* "device not connected" message. Optional; implementations may omit it.
|
|
63
|
+
*/
|
|
64
|
+
getLastForwardError?(): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Returns the path of the session-scoped log capture file, or null if no
|
|
67
|
+
* capture is running. Surfaced in tool responses so the model can reason
|
|
68
|
+
* about which file's contents it just saw and detect staleness across
|
|
69
|
+
* profile switches or session restarts. Optional; platforms without
|
|
70
|
+
* file-backed capture may omit it.
|
|
71
|
+
*/
|
|
72
|
+
getCaptureFilePath?(): string | null;
|
|
73
|
+
/**
|
|
74
|
+
* List profiles the debug build is running under. Android uses this to
|
|
75
|
+
* expose personal vs work profile; iOS will be a no-op or single entry.
|
|
76
|
+
* Optional; platforms without multi-user models may omit it.
|
|
77
|
+
*/
|
|
78
|
+
listProfiles?(): {
|
|
79
|
+
userId: string;
|
|
80
|
+
label: string;
|
|
81
|
+
}[];
|
|
82
|
+
/**
|
|
83
|
+
* Activate the on-device radar server for a specific user profile.
|
|
84
|
+
* Optional; only implementations that implement listProfiles should
|
|
85
|
+
* implement this.
|
|
86
|
+
*
|
|
87
|
+
* @param userId The user ID from listProfiles
|
|
88
|
+
* @param timeoutMinutes Auto-shutdown timeout in minutes
|
|
89
|
+
*/
|
|
90
|
+
activateForUser?(userId: string, timeoutMinutes: number): void;
|
|
91
|
+
/**
|
|
92
|
+
* Returns the currently active user ID, or null if not set. Surfaced in
|
|
93
|
+
* list_profiles output so callers can tell which profile the current
|
|
94
|
+
* session is targeting without making a second call. Optional.
|
|
95
|
+
*/
|
|
96
|
+
getActiveUserId?(): string | null;
|
|
58
97
|
}
|
|
@@ -223,8 +223,9 @@
|
|
|
223
223
|
|
|
224
224
|
<div class="toolbar">
|
|
225
225
|
<input type="text" id="filter" placeholder="Filter by URL, channel, event name, payload..." />
|
|
226
|
-
<
|
|
226
|
+
<span id="profile-label" style="display: none; font-size: 12px; color: #616061; margin-right: 4px;"></span>
|
|
227
227
|
<button id="enable-btn" style="display: none;">Enable Radar</button>
|
|
228
|
+
<button id="disconnect-btn" style="display: none;">Disconnect</button>
|
|
228
229
|
<button id="pause-btn">Pause</button>
|
|
229
230
|
<button id="clear-btn">Clear</button>
|
|
230
231
|
</div>
|
|
@@ -256,7 +257,8 @@
|
|
|
256
257
|
const detailEl = document.getElementById("detail");
|
|
257
258
|
const filterEl = document.getElementById("filter");
|
|
258
259
|
const enableBtn = document.getElementById("enable-btn");
|
|
259
|
-
const
|
|
260
|
+
const profileLabel = document.getElementById("profile-label");
|
|
261
|
+
const disconnectBtn = document.getElementById("disconnect-btn");
|
|
260
262
|
const pauseBtn = document.getElementById("pause-btn");
|
|
261
263
|
const clearBtn = document.getElementById("clear-btn");
|
|
262
264
|
const statusDot = document.getElementById("status-dot");
|
|
@@ -307,22 +309,25 @@
|
|
|
307
309
|
enableBtn.disabled = true;
|
|
308
310
|
enableBtn.textContent = "Enabling…";
|
|
309
311
|
try {
|
|
310
|
-
const
|
|
311
|
-
const qs = selectedUser ? `?user_id=${selectedUser}` : "";
|
|
312
|
-
const res = await fetch(`/_control/activate${qs}`, { method: "POST" });
|
|
312
|
+
const res = await fetch("/_control/activate", { method: "POST" });
|
|
313
313
|
const data = await res.json();
|
|
314
314
|
if (!data.ok) {
|
|
315
315
|
statusText.textContent = "enable failed";
|
|
316
316
|
alert(data.error || "Failed to enable radar");
|
|
317
317
|
} else {
|
|
318
318
|
statusText.textContent = "connecting";
|
|
319
|
+
// Device-side server needs a moment to start after the broadcast.
|
|
320
|
+
// Without this delay, the SSE connect races the server startup and
|
|
321
|
+
// gets connection-refused, snapping back to disconnected instantly.
|
|
322
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
323
|
+
connectStream();
|
|
324
|
+
return; // skip the finally reset — stay in "connecting" state
|
|
319
325
|
}
|
|
320
326
|
} catch (e) {
|
|
321
327
|
alert("Failed to reach server: " + e.message);
|
|
322
|
-
} finally {
|
|
323
|
-
enableBtn.disabled = false;
|
|
324
|
-
enableBtn.textContent = "Enable Radar";
|
|
325
328
|
}
|
|
329
|
+
enableBtn.disabled = false;
|
|
330
|
+
enableBtn.textContent = "Enable Radar";
|
|
326
331
|
});
|
|
327
332
|
|
|
328
333
|
async function fetchProfiles() {
|
|
@@ -330,32 +335,47 @@
|
|
|
330
335
|
const res = await fetch("/_control/profiles");
|
|
331
336
|
const data = await res.json();
|
|
332
337
|
const profiles = data.profiles || [];
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
338
|
+
const activeId = data.active_user_id;
|
|
339
|
+
|
|
340
|
+
if (activeId && profiles.length >= 1) {
|
|
341
|
+
const active = profiles.find((p) => p.userId === activeId) ?? profiles[0];
|
|
342
|
+
profileLabel.textContent = active.label;
|
|
343
|
+
profileLabel.style.display = "";
|
|
344
|
+
} else if (profiles.length >= 1) {
|
|
345
|
+
profileLabel.textContent = profiles[0].label;
|
|
346
|
+
profileLabel.style.display = "";
|
|
342
347
|
} else {
|
|
343
|
-
|
|
348
|
+
profileLabel.style.display = "none";
|
|
344
349
|
}
|
|
345
350
|
} catch {
|
|
346
|
-
|
|
351
|
+
profileLabel.style.display = "none";
|
|
347
352
|
}
|
|
348
353
|
}
|
|
349
354
|
|
|
350
355
|
function setDisconnectedUi(disconnected) {
|
|
351
356
|
enableBtn.style.display = disconnected ? "" : "none";
|
|
357
|
+
disconnectBtn.style.display = disconnected ? "none" : "";
|
|
352
358
|
if (disconnected) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
profileSelect.style.display = "none";
|
|
359
|
+
uptimeBadge.textContent = "";
|
|
360
|
+
extendBtn.style.display = "none";
|
|
356
361
|
}
|
|
362
|
+
fetchProfiles();
|
|
357
363
|
}
|
|
358
364
|
|
|
365
|
+
disconnectBtn.addEventListener("click", () => {
|
|
366
|
+
if (activeStream) {
|
|
367
|
+
activeStream.close();
|
|
368
|
+
activeStream = null;
|
|
369
|
+
}
|
|
370
|
+
state.connected = false;
|
|
371
|
+
statusDot.classList.remove("connected");
|
|
372
|
+
statusText.textContent = "disconnected";
|
|
373
|
+
enableBtn.disabled = false;
|
|
374
|
+
enableBtn.textContent = "Enable Radar";
|
|
375
|
+
setDisconnectedUi(true);
|
|
376
|
+
renderList();
|
|
377
|
+
});
|
|
378
|
+
|
|
359
379
|
// --- SSE ---
|
|
360
380
|
function insertReconnectMarker() {
|
|
361
381
|
const marker = {
|
|
@@ -363,14 +383,18 @@
|
|
|
363
383
|
timestamp: Date.now(),
|
|
364
384
|
label: `reconnected @ ${new Date().toLocaleTimeString("en-US", { hour12: false })}`,
|
|
365
385
|
};
|
|
386
|
+
const buffers = state.buffers;
|
|
366
387
|
for (const type of ["network", "rtm", "clog"]) {
|
|
367
|
-
const buf =
|
|
388
|
+
const buf = buffers[type];
|
|
368
389
|
if (buf.length > 0) buf.push({ ...marker }); // avoid empty-list markers
|
|
369
390
|
}
|
|
370
391
|
}
|
|
371
392
|
|
|
393
|
+
let activeStream = null;
|
|
394
|
+
|
|
372
395
|
function connectStream() {
|
|
373
396
|
const es = new EventSource("/api/stream");
|
|
397
|
+
activeStream = es;
|
|
374
398
|
es.onopen = () => {
|
|
375
399
|
const wasDisconnected = !state.connected && state.hadConnection;
|
|
376
400
|
state.connected = true;
|
|
@@ -394,15 +418,21 @@
|
|
|
394
418
|
state.connected = false;
|
|
395
419
|
statusDot.classList.remove("connected");
|
|
396
420
|
statusText.textContent = "disconnected";
|
|
421
|
+
enableBtn.disabled = false;
|
|
422
|
+
enableBtn.textContent = "Enable Radar";
|
|
397
423
|
setDisconnectedUi(true);
|
|
398
424
|
renderList();
|
|
399
425
|
es.close();
|
|
400
|
-
setTimeout(connectStream, 2000);
|
|
401
426
|
};
|
|
402
427
|
}
|
|
403
428
|
|
|
404
429
|
function ingest(type, event) {
|
|
405
|
-
|
|
430
|
+
// Attribute incoming events to whichever profile is currently active.
|
|
431
|
+
// There is a small misattribution window right at a switch boundary
|
|
432
|
+
// (a few events in flight may land in the wrong bucket), which is an
|
|
433
|
+
// acceptable cost relative to server-side event tagging.
|
|
434
|
+
const buffers = state.buffers;
|
|
435
|
+
const buf = buffers[type];
|
|
406
436
|
if (!buf) return;
|
|
407
437
|
buf.push(event);
|
|
408
438
|
if (buf.length > state.MAX_BUFFER) buf.shift();
|
|
@@ -411,9 +441,10 @@
|
|
|
411
441
|
}
|
|
412
442
|
|
|
413
443
|
function updateCounts() {
|
|
414
|
-
|
|
415
|
-
document.getElementById("
|
|
416
|
-
document.getElementById("
|
|
444
|
+
const buffers = state.buffers;
|
|
445
|
+
document.getElementById("network-count").textContent = buffers.network.length;
|
|
446
|
+
document.getElementById("rtm-count").textContent = buffers.rtm.length;
|
|
447
|
+
document.getElementById("clog-count").textContent = buffers.clog.length;
|
|
417
448
|
}
|
|
418
449
|
|
|
419
450
|
// --- Rendering ---
|
|
@@ -665,6 +696,7 @@ ${escapeHtml(tryPretty(data.data))}
|
|
|
665
696
|
});
|
|
666
697
|
|
|
667
698
|
async function pollPing() {
|
|
699
|
+
if (!state.connected) return;
|
|
668
700
|
try {
|
|
669
701
|
const res = await fetch("/api/ping");
|
|
670
702
|
const data = await res.json();
|
|
@@ -683,11 +715,13 @@ ${escapeHtml(tryPretty(data.data))}
|
|
|
683
715
|
extendBtn.style.display = "none";
|
|
684
716
|
}
|
|
685
717
|
}
|
|
686
|
-
pollPing
|
|
718
|
+
// pollPing runs on interval; skips when not connected.
|
|
687
719
|
setInterval(pollPing, 5000);
|
|
688
720
|
|
|
689
721
|
// --- Go ---
|
|
690
|
-
|
|
722
|
+
// Start in disconnected state. User picks a profile and clicks Enable.
|
|
723
|
+
// connectStream() is called after Enable succeeds.
|
|
724
|
+
setDisconnectedUi(true);
|
|
691
725
|
updateCounts();
|
|
692
726
|
renderList();
|
|
693
727
|
</script>
|
package/dist/web/server.js
CHANGED
|
@@ -43,10 +43,14 @@ export function createServer() {
|
|
|
43
43
|
}
|
|
44
44
|
function listProfiles(res) {
|
|
45
45
|
const profiles = transport.listProfiles();
|
|
46
|
+
const activeUserId = transport.getActiveUserId();
|
|
46
47
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
47
|
-
res.end(JSON.stringify({ profiles }));
|
|
48
|
+
res.end(JSON.stringify({ profiles, active_user_id: activeUserId }));
|
|
48
49
|
}
|
|
49
50
|
function activateRadar(res, userId) {
|
|
51
|
+
// Reset cached state so ensureForward() re-runs adb forward. This handles
|
|
52
|
+
// stale forwards after device-side idle timeout or profile switching.
|
|
53
|
+
transport.resetState();
|
|
50
54
|
const forwarded = transport.ensureForward();
|
|
51
55
|
if (!forwarded) {
|
|
52
56
|
res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
|
@@ -73,7 +77,7 @@ function activateRadar(res, userId) {
|
|
|
73
77
|
return;
|
|
74
78
|
}
|
|
75
79
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
76
|
-
res.end(JSON.stringify({ ok: true, user_id: userId ?? "auto" }));
|
|
80
|
+
res.end(JSON.stringify({ ok: true, user_id: userId ?? transport.getActiveUserId() ?? "auto" }));
|
|
77
81
|
}
|
|
78
82
|
function proxyToRadar(req, res) {
|
|
79
83
|
const upstream = http.request({
|