@slack/radar-mcp 1.2.0 → 1.4.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 +16 -13
- package/dist/mcp/index.js +568 -347
- package/dist/mcp/snapshot.d.ts +63 -0
- package/dist/mcp/snapshot.js +301 -0
- package/dist/mcp/tools.js +165 -10
- package/dist/shared/android.d.ts +3 -2
- package/dist/shared/android.js +8 -7
- package/dist/shared/constants.d.ts +6 -0
- package/dist/shared/constants.js +6 -0
- package/dist/shared/logcat-capture.d.ts +15 -0
- package/dist/shared/logcat-capture.js +118 -0
- package/dist/shared/transport.d.ts +9 -6
- package/dist/web/bin.js +45 -30
- package/dist/web/public/index.html +771 -726
- package/dist/web/server.d.ts +27 -0
- package/dist/web/server.js +549 -28
- package/dist/web/spec.d.ts +83 -0
- package/dist/web/spec.js +135 -0
- package/package.json +2 -1
package/dist/web/server.d.ts
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
import http from "http";
|
|
2
2
|
declare const WEB_PORT: number;
|
|
3
3
|
export declare function createServer(): http.Server;
|
|
4
|
+
/**
|
|
5
|
+
* Finish a /detail proxy response after an upstream error or timeout. The success
|
|
6
|
+
* callback streams the upstream response, so headers may already be sent when a later
|
|
7
|
+
* "error"/"timeout" fires (a partial response then the device drops the socket mid-stream,
|
|
8
|
+
* common when the device idle-shuts). Calling writeHead a second time throws
|
|
9
|
+
* ERR_HTTP_HEADERS_SENT and crashes the whole process, so only write a status when headers
|
|
10
|
+
* have not gone out yet; otherwise just end the already-started response. Exported for tests.
|
|
11
|
+
*/
|
|
12
|
+
export declare function failDetailResponse(res: Pick<http.ServerResponse, "headersSent" | "writeHead" | "end">, status: number): void;
|
|
13
|
+
interface ParsedLog {
|
|
14
|
+
id: number;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
log_ts: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
tid: number;
|
|
19
|
+
level: string;
|
|
20
|
+
tag: string;
|
|
21
|
+
message: string;
|
|
22
|
+
package?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parse one `adb logcat -v threadtime` line into a structured event, or null if it does
|
|
26
|
+
* not match the format. `resolvePkg` maps a pid to its package name (defaults to the
|
|
27
|
+
* live adb-backed resolver); injectable so this stays a pure, unit-testable function.
|
|
28
|
+
* Exported for tests.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseLogcat(line: string, resolvePkg?: (pid: number) => string | undefined): ParsedLog | null;
|
|
4
31
|
export { WEB_PORT };
|
package/dist/web/server.js
CHANGED
|
@@ -1,38 +1,110 @@
|
|
|
1
1
|
import http from "http";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
3
4
|
import path from "path";
|
|
5
|
+
import { spawn } from "child_process";
|
|
4
6
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { AndroidTransport } from "../shared/android.js";
|
|
6
|
-
import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
|
|
7
|
+
import { AndroidTransport, resolveAdbPath } from "../shared/android.js";
|
|
8
|
+
import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js";
|
|
9
|
+
import { BLANK_SPEC, coerceSpec, validateSpec } from "./spec.js";
|
|
7
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ??
|
|
11
|
+
const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? WEB_PORT_DEFAULT);
|
|
9
12
|
const INDEX_PATH = path.join(__dirname, "public", "index.html");
|
|
13
|
+
// The active dashboard spec is session-scoped runtime state, NOT source. It lives
|
|
14
|
+
// under the user's home data dir (never inside the repo / published tarball) so it
|
|
15
|
+
// cannot be accidentally committed. The path is scoped by port so two dashboards
|
|
16
|
+
// running on different ports do not stomp each other's spec (a browser attached to
|
|
17
|
+
// one instance polls /spec and would otherwise overwrite the shared file for all).
|
|
18
|
+
// Each server start resets its own file to blank.
|
|
19
|
+
const SPEC_DIR = path.join(os.homedir(), ".slack-radar");
|
|
20
|
+
const SPEC_PATH = path.join(SPEC_DIR, `web-spec-${WEB_PORT}.json`);
|
|
10
21
|
const transport = new AndroidTransport();
|
|
22
|
+
function writeSpec(spec) {
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(SPEC_DIR, { recursive: true });
|
|
25
|
+
fs.writeFileSync(SPEC_PATH, JSON.stringify(spec, null, 2));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Best-effort; a failed write just means the next /spec read falls back to blank.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function loadSpec() {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(SPEC_PATH, "utf8"));
|
|
34
|
+
const spec = coerceSpec(parsed);
|
|
35
|
+
return validateSpec(spec) ? BLANK_SPEC : spec;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return BLANK_SPEC;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Start every run from a clean slate so a refresh/restart opens blank — UNLESS the
|
|
42
|
+
// process was spawned with an initial spec (SLACK_RADAR_INITIAL_SPEC). The MCP
|
|
43
|
+
// open_radar_dashboard handler sets that env on a cold spawn so the spec file is
|
|
44
|
+
// already the custom spec BEFORE the browser loads and runs bootDefault(); without
|
|
45
|
+
// this seed the browser's bootDefault (fires only on a blank spec) and the handler's
|
|
46
|
+
// /setspec POST race, and a lost race leaves the Live preset showing while the tool
|
|
47
|
+
// already reported the custom dashboard is live. Seeding makes the handler the sole
|
|
48
|
+
// startup writer and removes the race deterministically. Invalid seed falls back to blank.
|
|
49
|
+
function initialSpec() {
|
|
50
|
+
const raw = process.env.SLACK_RADAR_INITIAL_SPEC;
|
|
51
|
+
if (!raw)
|
|
52
|
+
return BLANK_SPEC;
|
|
53
|
+
try {
|
|
54
|
+
const spec = coerceSpec(JSON.parse(raw));
|
|
55
|
+
return validateSpec(spec) ? BLANK_SPEC : spec;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return BLANK_SPEC;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
writeSpec(initialSpec());
|
|
11
62
|
export function createServer() {
|
|
12
63
|
const server = http.createServer((req, res) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
21
|
-
res.end(`Failed to read ${INDEX_PATH}: ${e.message}`);
|
|
22
|
-
}
|
|
64
|
+
const url = req.url ?? "";
|
|
65
|
+
if (url === "/" || url === "/index.html") {
|
|
66
|
+
serveIndex(res);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (url === "/spec" && req.method === "GET") {
|
|
70
|
+
sendJson(res, 200, loadSpec());
|
|
23
71
|
return;
|
|
24
72
|
}
|
|
25
|
-
if (
|
|
73
|
+
if (url === "/setspec" && req.method === "POST") {
|
|
74
|
+
handleSetSpec(req, res);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (url === "/health" && req.method === "GET") {
|
|
78
|
+
handleHealth(res);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (url === "/enable" && req.method === "POST") {
|
|
82
|
+
handleEnable(res);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (url === "/stream" && req.method === "GET") {
|
|
86
|
+
handleStream(req, res);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (url.startsWith("/backfill")) {
|
|
90
|
+
handleBackfill(req, res);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (url.startsWith("/detail")) {
|
|
94
|
+
handleDetail(req, res);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (url === "/_control/profiles" && req.method === "GET") {
|
|
26
98
|
listProfiles(res);
|
|
27
99
|
return;
|
|
28
100
|
}
|
|
29
|
-
if (
|
|
30
|
-
const
|
|
31
|
-
const userId =
|
|
101
|
+
if (url.startsWith("/_control/activate") && req.method === "POST") {
|
|
102
|
+
const parsed = new URL(url, `http://localhost:${WEB_PORT}`);
|
|
103
|
+
const userId = parsed.searchParams.get("user_id") ?? undefined;
|
|
32
104
|
activateRadar(res, userId);
|
|
33
105
|
return;
|
|
34
106
|
}
|
|
35
|
-
if (
|
|
107
|
+
if (url.startsWith("/api/")) {
|
|
36
108
|
proxyToRadar(req, res);
|
|
37
109
|
return;
|
|
38
110
|
}
|
|
@@ -41,11 +113,460 @@ export function createServer() {
|
|
|
41
113
|
});
|
|
42
114
|
return server;
|
|
43
115
|
}
|
|
116
|
+
function serveIndex(res) {
|
|
117
|
+
try {
|
|
118
|
+
const html = fs.readFileSync(INDEX_PATH);
|
|
119
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
120
|
+
res.end(html);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
124
|
+
res.end(`Failed to read ${INDEX_PATH}: ${e.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function sendJson(res, status, payload) {
|
|
128
|
+
// A device probe can fire both "end" and "error"/"timeout" for one request, so its
|
|
129
|
+
// callbacks may call sendJson twice. The second writeHead would throw
|
|
130
|
+
// ERR_HTTP_HEADERS_SENT and crash the whole process. Swallow the duplicate.
|
|
131
|
+
if (res.headersSent || res.writableEnded)
|
|
132
|
+
return;
|
|
133
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
134
|
+
res.end(JSON.stringify(payload));
|
|
135
|
+
}
|
|
136
|
+
function readBody(req) {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
let body = "";
|
|
139
|
+
req.on("data", (chunk) => {
|
|
140
|
+
body += chunk.toString();
|
|
141
|
+
});
|
|
142
|
+
req.on("end", () => resolve(body));
|
|
143
|
+
req.on("error", () => resolve(body));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Persist a spec pushed by the client (a pre-built tab, a filter edit, a chip
|
|
148
|
+
* cancel) or by an MCP caller. Coerce + validate before writing; a bad spec is
|
|
149
|
+
* rejected with its reason so the UI can surface it rather than render zeros.
|
|
150
|
+
*/
|
|
151
|
+
async function handleSetSpec(req, res) {
|
|
152
|
+
const body = await readBody(req);
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(body);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
sendJson(res, 400, { error: "bad json" });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const spec = coerceSpec(parsed);
|
|
162
|
+
const err = validateSpec(spec);
|
|
163
|
+
if (err) {
|
|
164
|
+
sendJson(res, 400, { error: err });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
writeSpec(spec);
|
|
168
|
+
sendJson(res, 200, {
|
|
169
|
+
ok: true,
|
|
170
|
+
url: `http://localhost:${WEB_PORT}`,
|
|
171
|
+
title: spec.title ?? "",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Always-available device-reachability probe, independent of whether a dashboard
|
|
176
|
+
* is built. Drives the connection dot even on a blank dashboard. Reuses the same
|
|
177
|
+
* forward/proxy base as the rest of the server.
|
|
178
|
+
*/
|
|
179
|
+
function handleHealth(res) {
|
|
180
|
+
const probe = http.request({
|
|
181
|
+
host: RADAR_HOST,
|
|
182
|
+
port: RADAR_PORT,
|
|
183
|
+
path: "/api/ping",
|
|
184
|
+
method: "GET",
|
|
185
|
+
timeout: 2500,
|
|
186
|
+
}, (up) => {
|
|
187
|
+
up.resume();
|
|
188
|
+
up.on("end", () => sendJson(res, 200, { device: up.statusCode === 200 }));
|
|
189
|
+
});
|
|
190
|
+
probe.on("error", () => sendJson(res, 200, { device: false }));
|
|
191
|
+
probe.on("timeout", () => {
|
|
192
|
+
probe.destroy();
|
|
193
|
+
sendJson(res, 200, { device: false });
|
|
194
|
+
});
|
|
195
|
+
probe.end();
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Re-arm Radar from the dashboard. Mirrors the recovery sequence: reset cached
|
|
199
|
+
* forward state, rebuild a clean adb forward, and re-broadcast activation, so the
|
|
200
|
+
* user never has to drop to a terminal. Delegates entirely to AndroidTransport;
|
|
201
|
+
* no adb shelling here.
|
|
202
|
+
*/
|
|
203
|
+
function handleEnable(res) {
|
|
204
|
+
transport.resetState();
|
|
205
|
+
const forwarded = transport.ensureForward();
|
|
206
|
+
if (!forwarded) {
|
|
207
|
+
const reason = transport.getLastForwardError?.() ?? null;
|
|
208
|
+
sendJson(res, 200, {
|
|
209
|
+
enabled: false,
|
|
210
|
+
error: reason ?? "ADB forward failed. Is a device connected?",
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
transport.activate(transport.getTimeoutMinutes());
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
sendJson(res, 200, {
|
|
219
|
+
enabled: false,
|
|
220
|
+
error: `Broadcast failed: ${e.message}. Debug build not running?`,
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Verify by pinging the device the same way /health does.
|
|
225
|
+
const probe = http.request({
|
|
226
|
+
host: RADAR_HOST,
|
|
227
|
+
port: RADAR_PORT,
|
|
228
|
+
path: "/api/ping",
|
|
229
|
+
method: "GET",
|
|
230
|
+
timeout: 2500,
|
|
231
|
+
}, (up) => {
|
|
232
|
+
up.resume();
|
|
233
|
+
up.on("end", () => sendJson(res, 200, { enabled: up.statusCode === 200 }));
|
|
234
|
+
});
|
|
235
|
+
probe.on("error", () => sendJson(res, 200, { enabled: false }));
|
|
236
|
+
probe.on("timeout", () => {
|
|
237
|
+
probe.destroy();
|
|
238
|
+
sendJson(res, 200, { enabled: false });
|
|
239
|
+
});
|
|
240
|
+
probe.end();
|
|
241
|
+
}
|
|
242
|
+
const SOURCE_ARRAY_KEY = {
|
|
243
|
+
network: "calls",
|
|
244
|
+
rtm: "events",
|
|
245
|
+
clog: "clogs",
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Replay the device's recent ring buffers for a source so a freshly-built tab
|
|
249
|
+
* shows history immediately instead of filling only going forward. The device is
|
|
250
|
+
* the source of truth. "all" pulls network+rtm+clog and merges them oldest->newest;
|
|
251
|
+
* "log" is host-side (no device buffer) and so has nothing to replay. Returns
|
|
252
|
+
* `[{type, event}]` frames mirroring the stream shape.
|
|
253
|
+
*/
|
|
254
|
+
function handleBackfill(req, res) {
|
|
255
|
+
const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
|
|
256
|
+
const src = u.searchParams.get("source") ?? "network";
|
|
257
|
+
const limit = Math.min(500, Number(u.searchParams.get("limit")) || 200);
|
|
258
|
+
const kinds = src === "all"
|
|
259
|
+
? ["network", "rtm", "clog"]
|
|
260
|
+
: [src].filter((k) => k !== "log" && k in SOURCE_ARRAY_KEY);
|
|
261
|
+
const fetchKind = (kind) => new Promise((resolve) => {
|
|
262
|
+
const apiPath = `/api/${kind === "clog" ? "clogs" : kind}?limit=${limit}`;
|
|
263
|
+
const rq = http.request({
|
|
264
|
+
host: RADAR_HOST,
|
|
265
|
+
port: RADAR_PORT,
|
|
266
|
+
path: apiPath,
|
|
267
|
+
method: "GET",
|
|
268
|
+
timeout: 4000,
|
|
269
|
+
}, (up) => {
|
|
270
|
+
let b = "";
|
|
271
|
+
up.on("data", (c) => (b += c));
|
|
272
|
+
up.on("end", () => {
|
|
273
|
+
try {
|
|
274
|
+
const j = JSON.parse(b);
|
|
275
|
+
const arr = j[SOURCE_ARRAY_KEY[kind]] ?? [];
|
|
276
|
+
resolve(arr.map((e) => ({ type: kind, event: e })));
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
resolve([]);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
rq.on("error", () => resolve([]));
|
|
284
|
+
rq.on("timeout", () => {
|
|
285
|
+
rq.destroy();
|
|
286
|
+
resolve([]);
|
|
287
|
+
});
|
|
288
|
+
rq.end();
|
|
289
|
+
});
|
|
290
|
+
Promise.all(kinds.map(fetchKind))
|
|
291
|
+
.then((lists) => {
|
|
292
|
+
const merged = lists
|
|
293
|
+
.flat()
|
|
294
|
+
.sort((a, b) => (Number(a.event.timestamp) || 0) - (Number(b.event.timestamp) || 0));
|
|
295
|
+
sendJson(res, 200, { frames: merged });
|
|
296
|
+
})
|
|
297
|
+
.catch(() => sendJson(res, 200, { frames: [] }));
|
|
298
|
+
}
|
|
299
|
+
// Detail kinds the inspector may request, mapped to their device route segment. The
|
|
300
|
+
// device serves clog detail at /api/clogs/ (plural); the rest match the kind name.
|
|
301
|
+
const DETAIL_ROUTES = {
|
|
302
|
+
network: "network",
|
|
303
|
+
rtm: "rtm",
|
|
304
|
+
clog: "clogs",
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Finish a /detail proxy response after an upstream error or timeout. The success
|
|
308
|
+
* callback streams the upstream response, so headers may already be sent when a later
|
|
309
|
+
* "error"/"timeout" fires (a partial response then the device drops the socket mid-stream,
|
|
310
|
+
* common when the device idle-shuts). Calling writeHead a second time throws
|
|
311
|
+
* ERR_HTTP_HEADERS_SENT and crashes the whole process, so only write a status when headers
|
|
312
|
+
* have not gone out yet; otherwise just end the already-started response. Exported for tests.
|
|
313
|
+
*/
|
|
314
|
+
export function failDetailResponse(res, status) {
|
|
315
|
+
if (!res.headersSent) {
|
|
316
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
317
|
+
res.end("{}");
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
res.end();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Proxy a single record's full detail from the device ring buffer. Thin pass-through
|
|
325
|
+
* to `/api/<kind>/<id>` so the inspector can fetch a clicked row's payload.
|
|
326
|
+
*/
|
|
327
|
+
function handleDetail(req, res) {
|
|
328
|
+
const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
|
|
329
|
+
const id = u.searchParams.get("id");
|
|
330
|
+
const kind = u.searchParams.get("kind") ?? "network";
|
|
331
|
+
const route = DETAIL_ROUTES[kind];
|
|
332
|
+
if (!id || !route) {
|
|
333
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
334
|
+
res.end("{}");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const upstream = http.request({
|
|
338
|
+
host: RADAR_HOST,
|
|
339
|
+
port: RADAR_PORT,
|
|
340
|
+
path: `/api/${route}/${encodeURIComponent(id)}`,
|
|
341
|
+
method: "GET",
|
|
342
|
+
timeout: 4000,
|
|
343
|
+
}, (up) => {
|
|
344
|
+
res.writeHead(up.statusCode ?? 502, {
|
|
345
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
346
|
+
});
|
|
347
|
+
up.on("data", (c) => res.write(c));
|
|
348
|
+
up.on("end", () => res.end());
|
|
349
|
+
});
|
|
350
|
+
upstream.on("error", () => failDetailResponse(res, 502));
|
|
351
|
+
upstream.on("timeout", () => {
|
|
352
|
+
upstream.destroy();
|
|
353
|
+
failDetailResponse(res, 504);
|
|
354
|
+
});
|
|
355
|
+
upstream.end();
|
|
356
|
+
}
|
|
357
|
+
// Logcat line parsing for the host-side log merge. Format:
|
|
358
|
+
// "MM-DD HH:MM:SS.mmm PID TID L Tag: message"
|
|
359
|
+
const LOG_LINE_RE = /^(\d\d-\d\d \d\d:\d\d:\d\d\.\d+)\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+([^:]*?):\s?(.*)$/;
|
|
360
|
+
const LOG_LEVELS = {
|
|
361
|
+
V: "VERBOSE",
|
|
362
|
+
D: "DEBUG",
|
|
363
|
+
I: "INFO",
|
|
364
|
+
W: "WARN",
|
|
365
|
+
E: "ERROR",
|
|
366
|
+
F: "FATAL",
|
|
367
|
+
};
|
|
368
|
+
// A logcat line carries a PID, not a package, but the dashboard filters logs by
|
|
369
|
+
// package (e.g. "Slack app only"). Resolve PID -> process name from
|
|
370
|
+
// `adb shell ps -A -o PID,NAME`, cached and refreshed on a miss (rate-limited), so
|
|
371
|
+
// log rows get a readable `package` and the package filter works. Without this the
|
|
372
|
+
// Live/Logs presets (match {package: ...}) drop every log line.
|
|
373
|
+
const pidToPackage = new Map();
|
|
374
|
+
let pidRefreshing = false;
|
|
375
|
+
let lastPidRefresh = 0;
|
|
376
|
+
function refreshPidMap() {
|
|
377
|
+
if (pidRefreshing)
|
|
378
|
+
return;
|
|
379
|
+
const adb = resolveAdbPath();
|
|
380
|
+
if (!adb)
|
|
381
|
+
return;
|
|
382
|
+
pidRefreshing = true;
|
|
383
|
+
lastPidRefresh = Date.now();
|
|
384
|
+
const ps = spawn(adb, ["shell", "ps", "-A", "-o", "PID,NAME"]);
|
|
385
|
+
let out = "";
|
|
386
|
+
ps.stdout?.on("data", (c) => (out += c.toString()));
|
|
387
|
+
ps.on("error", () => {
|
|
388
|
+
pidRefreshing = false;
|
|
389
|
+
});
|
|
390
|
+
ps.on("close", () => {
|
|
391
|
+
pidRefreshing = false;
|
|
392
|
+
for (const line of out.split("\n")) {
|
|
393
|
+
const m = line.trim().match(/^(\d+)\s+(\S+)$/);
|
|
394
|
+
if (m)
|
|
395
|
+
pidToPackage.set(Number(m[1]), m[2]);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function packageForPid(pid) {
|
|
400
|
+
const hit = pidToPackage.get(pid);
|
|
401
|
+
if (hit)
|
|
402
|
+
return hit;
|
|
403
|
+
// Unknown pid: trigger a rate-limited refresh so the NEXT line resolves.
|
|
404
|
+
if (Date.now() - lastPidRefresh > 3000)
|
|
405
|
+
refreshPidMap();
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
let logIdSeq = 0;
|
|
409
|
+
/**
|
|
410
|
+
* Parse one `adb logcat -v threadtime` line into a structured event, or null if it does
|
|
411
|
+
* not match the format. `resolvePkg` maps a pid to its package name (defaults to the
|
|
412
|
+
* live adb-backed resolver); injectable so this stays a pure, unit-testable function.
|
|
413
|
+
* Exported for tests.
|
|
414
|
+
*/
|
|
415
|
+
export function parseLogcat(line, resolvePkg = packageForPid) {
|
|
416
|
+
const m = line.match(LOG_LINE_RE);
|
|
417
|
+
if (!m)
|
|
418
|
+
return null;
|
|
419
|
+
const [, ts, pid, tid, level, tag, message] = m;
|
|
420
|
+
const pidN = Number(pid);
|
|
421
|
+
return {
|
|
422
|
+
id: ++logIdSeq,
|
|
423
|
+
timestamp: Date.now(),
|
|
424
|
+
log_ts: ts,
|
|
425
|
+
pid: pidN,
|
|
426
|
+
tid: Number(tid),
|
|
427
|
+
level: LOG_LEVELS[level] ?? level,
|
|
428
|
+
tag: tag.trim(),
|
|
429
|
+
message,
|
|
430
|
+
package: resolvePkg(pidN),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Self-healing SSE proxy. Keeps ONE long-lived response open to the browser and
|
|
435
|
+
* internally (re)connects to the device `/api/stream` every couple seconds while
|
|
436
|
+
* it is down, so the browser's EventSource never sees a close and never gives up.
|
|
437
|
+
* Every other retry it rebuilds the adb forward (the real cause of recurring
|
|
438
|
+
* "unreachable"), reusing AndroidTransport rather than shelling adb directly.
|
|
439
|
+
*
|
|
440
|
+
* When the active spec wants logs (source "log" or "all"), it also spawns a
|
|
441
|
+
* host-side `adb logcat -v threadtime -T 1` and merges parsed lines as
|
|
442
|
+
* `type: "log"` frames. The adb binary is resolved via resolveAdbPath() so GUI
|
|
443
|
+
* launchers without the user's PATH still work.
|
|
444
|
+
*/
|
|
445
|
+
function handleStream(req, res) {
|
|
446
|
+
res.writeHead(200, {
|
|
447
|
+
"Content-Type": "text/event-stream",
|
|
448
|
+
"Cache-Control": "no-cache",
|
|
449
|
+
Connection: "keep-alive",
|
|
450
|
+
});
|
|
451
|
+
let upstream = null;
|
|
452
|
+
let closed = false;
|
|
453
|
+
let lastState = "";
|
|
454
|
+
const announce = (state, msg) => {
|
|
455
|
+
if (state === lastState)
|
|
456
|
+
return;
|
|
457
|
+
lastState = state;
|
|
458
|
+
res.write(`data: ${JSON.stringify({ type: "_status", event: { state, msg } })}\n\n`);
|
|
459
|
+
};
|
|
460
|
+
let retries = 0;
|
|
461
|
+
// One scheduled reconnect per attempt. up.on("end"), up.on("error"), and
|
|
462
|
+
// upstream.on("error") can all fire for the same attempt; without this guard each
|
|
463
|
+
// would schedule its own connect(), fanning out duplicate streams (and re-spawning
|
|
464
|
+
// logcat on the rebuild path). Reset at the top of connect() so the next attempt
|
|
465
|
+
// can retry again.
|
|
466
|
+
let settled = false;
|
|
467
|
+
const retry = () => {
|
|
468
|
+
if (closed)
|
|
469
|
+
return;
|
|
470
|
+
if (settled)
|
|
471
|
+
return;
|
|
472
|
+
settled = true;
|
|
473
|
+
retries++;
|
|
474
|
+
// Every other retry, rebuild a clean forward before reconnecting. resetState()
|
|
475
|
+
// drops the cached forward flag so ensureForward() actually re-runs adb forward.
|
|
476
|
+
if (retries % 2 === 0) {
|
|
477
|
+
transport.resetState();
|
|
478
|
+
transport.ensureForward();
|
|
479
|
+
setTimeout(connect, 1500);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
setTimeout(connect, 2000);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const connect = () => {
|
|
486
|
+
if (closed)
|
|
487
|
+
return;
|
|
488
|
+
settled = false;
|
|
489
|
+
upstream = http.request({
|
|
490
|
+
host: RADAR_HOST,
|
|
491
|
+
port: RADAR_PORT,
|
|
492
|
+
path: "/api/stream",
|
|
493
|
+
method: "GET",
|
|
494
|
+
}, (up) => {
|
|
495
|
+
announce("connected", "device stream connected");
|
|
496
|
+
up.on("data", (c) => res.write(c));
|
|
497
|
+
up.on("end", () => {
|
|
498
|
+
announce("waiting", "device stream ended, retrying…");
|
|
499
|
+
retry();
|
|
500
|
+
});
|
|
501
|
+
up.on("error", () => {
|
|
502
|
+
announce("waiting", "device stream error, retrying…");
|
|
503
|
+
retry();
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
upstream.on("error", () => {
|
|
507
|
+
announce("waiting", "device unreachable, retrying…");
|
|
508
|
+
retry();
|
|
509
|
+
});
|
|
510
|
+
upstream.end();
|
|
511
|
+
};
|
|
512
|
+
connect();
|
|
513
|
+
const heartbeat = setInterval(() => {
|
|
514
|
+
if (!closed)
|
|
515
|
+
res.write(": hb\n\n");
|
|
516
|
+
}, 15000);
|
|
517
|
+
// Host-side logcat merge for log/all sources. -T 1 streams only NEW lines;
|
|
518
|
+
// without it logcat replays the entire existing buffer on connect and floods
|
|
519
|
+
// the browser.
|
|
520
|
+
let logcat = null;
|
|
521
|
+
const spec = loadSpec();
|
|
522
|
+
const wantsLogs = spec.source === "log" || spec.source === "all";
|
|
523
|
+
const adb = resolveAdbPath();
|
|
524
|
+
if (wantsLogs && adb) {
|
|
525
|
+
try {
|
|
526
|
+
refreshPidMap(); // warm PID->package so the first log rows resolve their app
|
|
527
|
+
logcat = spawn(adb, ["logcat", "-v", "threadtime", "-T", "1"], {
|
|
528
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
529
|
+
});
|
|
530
|
+
let lineBuf = "";
|
|
531
|
+
logcat.stdout?.on("data", (c) => {
|
|
532
|
+
lineBuf += c.toString();
|
|
533
|
+
let nl;
|
|
534
|
+
while ((nl = lineBuf.indexOf("\n")) >= 0) {
|
|
535
|
+
const line = lineBuf.slice(0, nl);
|
|
536
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
537
|
+
const ev = parseLogcat(line);
|
|
538
|
+
if (ev && !closed) {
|
|
539
|
+
res.write(`data: ${JSON.stringify({ type: "log", event: ev })}\n\n`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
logcat.on("error", () => { });
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
logcat = null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
req.on("close", () => {
|
|
550
|
+
closed = true;
|
|
551
|
+
clearInterval(heartbeat);
|
|
552
|
+
try {
|
|
553
|
+
upstream?.destroy();
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// already torn down
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
logcat?.kill();
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// already dead
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
44
566
|
function listProfiles(res) {
|
|
45
567
|
const profiles = transport.listProfiles();
|
|
46
568
|
const activeUserId = transport.getActiveUserId();
|
|
47
|
-
res
|
|
48
|
-
res.end(JSON.stringify({ profiles, active_user_id: activeUserId }));
|
|
569
|
+
sendJson(res, 200, { profiles, active_user_id: activeUserId });
|
|
49
570
|
}
|
|
50
571
|
function activateRadar(res, userId) {
|
|
51
572
|
// Reset cached state so ensureForward() re-runs adb forward. This handles
|
|
@@ -53,11 +574,10 @@ function activateRadar(res, userId) {
|
|
|
53
574
|
transport.resetState();
|
|
54
575
|
const forwarded = transport.ensureForward();
|
|
55
576
|
if (!forwarded) {
|
|
56
|
-
res
|
|
57
|
-
res.end(JSON.stringify({
|
|
577
|
+
sendJson(res, 502, {
|
|
58
578
|
ok: false,
|
|
59
579
|
error: "ADB forward failed. Is a device connected?",
|
|
60
|
-
})
|
|
580
|
+
});
|
|
61
581
|
return;
|
|
62
582
|
}
|
|
63
583
|
try {
|
|
@@ -69,15 +589,16 @@ function activateRadar(res, userId) {
|
|
|
69
589
|
}
|
|
70
590
|
}
|
|
71
591
|
catch (e) {
|
|
72
|
-
res
|
|
73
|
-
res.end(JSON.stringify({
|
|
592
|
+
sendJson(res, 502, {
|
|
74
593
|
ok: false,
|
|
75
594
|
error: `Broadcast failed: ${e.message}. Debug build not running?`,
|
|
76
|
-
})
|
|
595
|
+
});
|
|
77
596
|
return;
|
|
78
597
|
}
|
|
79
|
-
res
|
|
80
|
-
|
|
598
|
+
sendJson(res, 200, {
|
|
599
|
+
ok: true,
|
|
600
|
+
user_id: userId ?? transport.getActiveUserId() ?? "auto",
|
|
601
|
+
});
|
|
81
602
|
}
|
|
82
603
|
function proxyToRadar(req, res) {
|
|
83
604
|
const upstream = http.request({
|