@slack/radar-mcp 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -14
- package/dist/mcp/index.js +565 -374
- package/dist/mcp/tools.js +135 -10
- package/dist/shared/android.d.ts +11 -2
- package/dist/shared/android.js +17 -6
- package/dist/shared/constants.d.ts +20 -0
- package/dist/shared/constants.js +20 -0
- package/dist/shared/db.d.ts +75 -0
- package/dist/shared/db.js +403 -0
- package/dist/shared/screen.d.ts +132 -0
- package/dist/shared/screen.js +576 -0
- package/dist/web/bin.js +64 -30
- package/dist/web/log-session.d.ts +89 -0
- package/dist/web/log-session.js +144 -0
- package/dist/web/public/index.html +1065 -712
- package/dist/web/server.d.ts +29 -0
- package/dist/web/server.js +900 -28
- package/dist/web/spec.d.ts +91 -0
- package/dist/web/spec.js +153 -0
- package/package.json +2 -1
package/dist/web/server.js
CHANGED
|
@@ -1,38 +1,182 @@
|
|
|
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 { cleanupPulledDatabases, initializeDatabasePath, listDatabases, pullDatabase, pulledSize, queryDatabase, resetDbState, } from "../shared/db.js";
|
|
10
|
+
import { LogSession } from "./log-session.js";
|
|
11
|
+
import { BLANK_SPEC, coerceSpec, validateSpec } from "./spec.js";
|
|
12
|
+
import { screenCapabilities, grantScreenConsent, hasScreenConsent, detectDisplay, displayNote, captureScreenshot, startLive, addViewer, removeViewer, primeIfNeeded, startRecording, stopRecording, recordingPath, resetScreenState, registerScreenCleanup, resolveFfmpeg, ffmpegInstallHint, SCREEN_BOUNDARY, } from "../shared/screen.js";
|
|
7
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ??
|
|
14
|
+
const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? WEB_PORT_DEFAULT);
|
|
9
15
|
const INDEX_PATH = path.join(__dirname, "public", "index.html");
|
|
16
|
+
// Backpressure ceiling for an SSE connection's unwritten log frames. If a
|
|
17
|
+
// client's socket buffer is already this deep (slow/backgrounded tab), new log
|
|
18
|
+
// frames are dropped for that connection rather than queued without bound. ~4 MB
|
|
19
|
+
// is far above a healthy localhost client's transient buffer but well under a
|
|
20
|
+
// memory problem; a dropped frame is recovered on the client's next reconnect
|
|
21
|
+
// replay. Only the log fan-out is bounded this way — the device proxy stream is
|
|
22
|
+
// already paced by the upstream socket.
|
|
23
|
+
const MAX_SSE_WRITABLE_BYTES = 4 * 1024 * 1024;
|
|
24
|
+
/**
|
|
25
|
+
* Decide whether to drop a log frame under backpressure. LIVE frames are dropped
|
|
26
|
+
* when the connection's unwritten buffer is already past the cap (an unbounded
|
|
27
|
+
* queue to a slow/backgrounded tab). REPLAY frames are NEVER dropped: replay is a
|
|
28
|
+
* bounded one-time send of the whole ring and IS the connection's purpose; it runs
|
|
29
|
+
* synchronously so writableLength climbs monotonically, and dropping past the cap
|
|
30
|
+
* would silently cut the newest history and reintroduce the reconnect log-loss
|
|
31
|
+
* this feature fixes. Exported pure so the replay-exemption is unit-testable
|
|
32
|
+
* without a socket. `cap` defaults to the live ceiling.
|
|
33
|
+
*/
|
|
34
|
+
export function shouldDropLogFrame(replay, writableLength, cap = MAX_SSE_WRITABLE_BYTES) {
|
|
35
|
+
if (replay)
|
|
36
|
+
return false;
|
|
37
|
+
return writableLength > cap;
|
|
38
|
+
}
|
|
39
|
+
// The active dashboard spec is session-scoped runtime state, NOT source. It lives
|
|
40
|
+
// under the user's home data dir (never inside the repo / published tarball) so it
|
|
41
|
+
// cannot be accidentally committed. The path is scoped by port so two dashboards
|
|
42
|
+
// running on different ports do not stomp each other's spec (a browser attached to
|
|
43
|
+
// one instance polls /spec and would otherwise overwrite the shared file for all).
|
|
44
|
+
// Each server start resets its own file to blank.
|
|
45
|
+
const SPEC_DIR = path.join(os.homedir(), ".slack-radar");
|
|
46
|
+
const SPEC_PATH = path.join(SPEC_DIR, `web-spec-${WEB_PORT}.json`);
|
|
10
47
|
const transport = new AndroidTransport();
|
|
48
|
+
// One session-long logcat capture shared across every dashboard stream
|
|
49
|
+
// connection. The device cannot replay logcat history to us, so the dashboard
|
|
50
|
+
// keeps its own bounded session ring here and replays it to each newly opened
|
|
51
|
+
// stream — this is what makes a tab switch (which reopens /stream) show the full
|
|
52
|
+
// log history instead of only lines that arrive after the reconnect, matching
|
|
53
|
+
// how Android Studio retains logcat for the whole session. Spawn + parse are
|
|
54
|
+
// injected so the capture logic is unit-testable without a real `adb logcat`.
|
|
55
|
+
const logSession = new LogSession(() => spawn(resolveAdbPath() ?? "adb", ["logcat", "-v", "threadtime", "-T", "1"], {
|
|
56
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
57
|
+
}), (line) => parseLogcat(line));
|
|
58
|
+
// Kill the persistent logcat child on process exit so it does not orphan when the
|
|
59
|
+
// dashboard is spawned and later terminated by the open_radar_dashboard MCP tool.
|
|
60
|
+
logSession.registerCleanup();
|
|
61
|
+
// By design the capture is NOT stopped when the last log stream disconnects (e.g.
|
|
62
|
+
// switching to the Network tab): it keeps filling the session ring so returning to
|
|
63
|
+
// a log tab — even much later — still shows the full history, the way Android
|
|
64
|
+
// Studio retains logcat for the whole session. The cost is a logcat reader running
|
|
65
|
+
// while no log tab is open; memory stays bounded by the ring cap and by the
|
|
66
|
+
// per-connection backpressure guard in handleStream (a stalled client drops frames
|
|
67
|
+
// rather than buffering without limit).
|
|
68
|
+
function writeSpec(spec) {
|
|
69
|
+
try {
|
|
70
|
+
fs.mkdirSync(SPEC_DIR, { recursive: true });
|
|
71
|
+
fs.writeFileSync(SPEC_PATH, JSON.stringify(spec, null, 2));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Best-effort; a failed write just means the next /spec read falls back to blank.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function loadSpec() {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(fs.readFileSync(SPEC_PATH, "utf8"));
|
|
80
|
+
const spec = coerceSpec(parsed);
|
|
81
|
+
return validateSpec(spec) ? BLANK_SPEC : spec;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return BLANK_SPEC;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Start every run from a clean slate so a refresh/restart opens blank — UNLESS the
|
|
88
|
+
// process was spawned with an initial spec (SLACK_RADAR_INITIAL_SPEC). The MCP
|
|
89
|
+
// open_radar_dashboard handler sets that env on a cold spawn so the spec file is
|
|
90
|
+
// already the custom spec BEFORE the browser loads and runs bootDefault(); without
|
|
91
|
+
// this seed the browser's bootDefault (fires only on a blank spec) and the handler's
|
|
92
|
+
// /setspec POST race, and a lost race leaves the Live preset showing while the tool
|
|
93
|
+
// already reported the custom dashboard is live. Seeding makes the handler the sole
|
|
94
|
+
// startup writer and removes the race deterministically. Invalid seed falls back to blank.
|
|
95
|
+
function initialSpec() {
|
|
96
|
+
const raw = process.env.SLACK_RADAR_INITIAL_SPEC;
|
|
97
|
+
if (!raw)
|
|
98
|
+
return BLANK_SPEC;
|
|
99
|
+
try {
|
|
100
|
+
const spec = coerceSpec(JSON.parse(raw));
|
|
101
|
+
return validateSpec(spec) ? BLANK_SPEC : spec;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return BLANK_SPEC;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
writeSpec(initialSpec());
|
|
108
|
+
// Initialize the database path with the web server port, so concurrent dashboards
|
|
109
|
+
// on different ports do not collide in tmpdir.
|
|
110
|
+
initializeDatabasePath(WEB_PORT);
|
|
111
|
+
// Pulled DB copies are plaintext message stores; never let them linger across runs.
|
|
112
|
+
// Clean on start, and on the process signals the bin wires for shutdown.
|
|
113
|
+
cleanupPulledDatabases();
|
|
114
|
+
for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
|
|
115
|
+
process.once(sig, cleanupPulledDatabases);
|
|
116
|
+
}
|
|
11
117
|
export function createServer() {
|
|
118
|
+
registerScreenCleanup(); // tear down any live screen cast on process exit/signals
|
|
12
119
|
const server = http.createServer((req, res) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
120
|
+
const url = req.url ?? "";
|
|
121
|
+
if (url === "/" || url === "/index.html") {
|
|
122
|
+
serveIndex(res);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (url === "/spec" && req.method === "GET") {
|
|
126
|
+
sendJson(res, 200, loadSpec());
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (url === "/setspec" && req.method === "POST") {
|
|
130
|
+
handleSetSpec(req, res);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (url === "/health" && req.method === "GET") {
|
|
134
|
+
handleHealth(res);
|
|
23
135
|
return;
|
|
24
136
|
}
|
|
25
|
-
if (
|
|
137
|
+
if (url === "/enable" && req.method === "POST") {
|
|
138
|
+
handleEnable(res);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (url === "/stream" && req.method === "GET") {
|
|
142
|
+
handleStream(req, res);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (url.startsWith("/backfill")) {
|
|
146
|
+
handleBackfill(req, res);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (url.startsWith("/detail")) {
|
|
150
|
+
handleDetail(req, res);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (url.startsWith("/dbs") && req.method === "GET") {
|
|
154
|
+
handleDbList(req, res);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (url.startsWith("/dbtables") && req.method === "GET") {
|
|
158
|
+
handleDbTables(req, res);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (url.startsWith("/dbquery") && req.method === "GET") {
|
|
162
|
+
handleDbQuery(req, res);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (url === "/_control/profiles" && req.method === "GET") {
|
|
26
166
|
listProfiles(res);
|
|
27
167
|
return;
|
|
28
168
|
}
|
|
29
|
-
if (
|
|
30
|
-
const
|
|
31
|
-
const userId =
|
|
169
|
+
if (url.startsWith("/_control/activate") && req.method === "POST") {
|
|
170
|
+
const parsed = new URL(url, `http://localhost:${WEB_PORT}`);
|
|
171
|
+
const userId = parsed.searchParams.get("user_id") ?? undefined;
|
|
32
172
|
activateRadar(res, userId);
|
|
33
173
|
return;
|
|
34
174
|
}
|
|
35
|
-
if (
|
|
175
|
+
if (url.startsWith("/screen/")) {
|
|
176
|
+
handleScreen(req, res);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (url.startsWith("/api/")) {
|
|
36
180
|
proxyToRadar(req, res);
|
|
37
181
|
return;
|
|
38
182
|
}
|
|
@@ -41,11 +185,739 @@ export function createServer() {
|
|
|
41
185
|
});
|
|
42
186
|
return server;
|
|
43
187
|
}
|
|
188
|
+
function serveIndex(res) {
|
|
189
|
+
try {
|
|
190
|
+
const html = fs.readFileSync(INDEX_PATH);
|
|
191
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
192
|
+
res.end(html);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
196
|
+
res.end(`Failed to read ${INDEX_PATH}: ${e.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function sendJson(res, status, payload) {
|
|
200
|
+
// A device probe can fire both "end" and "error"/"timeout" for one request, so its
|
|
201
|
+
// callbacks may call sendJson twice. The second writeHead would throw
|
|
202
|
+
// ERR_HTTP_HEADERS_SENT and crash the whole process. Swallow the duplicate.
|
|
203
|
+
if (res.headersSent || res.writableEnded)
|
|
204
|
+
return;
|
|
205
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
206
|
+
res.end(JSON.stringify(payload));
|
|
207
|
+
}
|
|
208
|
+
function readBody(req) {
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
let body = "";
|
|
211
|
+
req.on("data", (chunk) => {
|
|
212
|
+
body += chunk.toString();
|
|
213
|
+
});
|
|
214
|
+
req.on("end", () => resolve(body));
|
|
215
|
+
req.on("error", () => resolve(body));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Persist a spec pushed by the client (a pre-built tab, a filter edit, a chip
|
|
220
|
+
* cancel) or by an MCP caller. Coerce + validate before writing; a bad spec is
|
|
221
|
+
* rejected with its reason so the UI can surface it rather than render zeros.
|
|
222
|
+
*/
|
|
223
|
+
async function handleSetSpec(req, res) {
|
|
224
|
+
const body = await readBody(req);
|
|
225
|
+
let parsed;
|
|
226
|
+
try {
|
|
227
|
+
parsed = JSON.parse(body);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
sendJson(res, 400, { error: "bad json" });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const spec = coerceSpec(parsed);
|
|
234
|
+
const err = validateSpec(spec);
|
|
235
|
+
if (err) {
|
|
236
|
+
sendJson(res, 400, { error: err });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
writeSpec(spec);
|
|
240
|
+
sendJson(res, 200, {
|
|
241
|
+
ok: true,
|
|
242
|
+
url: `http://localhost:${WEB_PORT}`,
|
|
243
|
+
title: spec.title ?? "",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Always-available device-reachability probe, independent of whether a dashboard
|
|
248
|
+
* is built. Drives the connection dot even on a blank dashboard. Reuses the same
|
|
249
|
+
* forward/proxy base as the rest of the server.
|
|
250
|
+
*/
|
|
251
|
+
function handleHealth(res) {
|
|
252
|
+
// A probe can fire BOTH an "end" and an "error"/"timeout" (partial response then socket
|
|
253
|
+
// drop), so guard the response to exactly one write. Without this the second sendJson
|
|
254
|
+
// does a second writeHead -> ERR_HTTP_HEADERS_SENT -> uncaught -> the whole server dies.
|
|
255
|
+
let replied = false;
|
|
256
|
+
const reply = (device) => {
|
|
257
|
+
if (replied)
|
|
258
|
+
return;
|
|
259
|
+
replied = true;
|
|
260
|
+
sendJson(res, 200, { device, screen: screenCapabilities() });
|
|
261
|
+
};
|
|
262
|
+
const probe = http.request({
|
|
263
|
+
host: RADAR_HOST,
|
|
264
|
+
port: RADAR_PORT,
|
|
265
|
+
path: "/api/ping",
|
|
266
|
+
method: "GET",
|
|
267
|
+
timeout: 2500,
|
|
268
|
+
}, (up) => {
|
|
269
|
+
up.resume();
|
|
270
|
+
up.on("end", () => reply(up.statusCode === 200));
|
|
271
|
+
up.on("error", () => reply(false));
|
|
272
|
+
});
|
|
273
|
+
probe.on("error", () => reply(false));
|
|
274
|
+
probe.on("timeout", () => {
|
|
275
|
+
probe.destroy();
|
|
276
|
+
reply(false);
|
|
277
|
+
});
|
|
278
|
+
probe.end();
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Re-arm Radar from the dashboard. Mirrors the recovery sequence: reset cached
|
|
282
|
+
* forward state, rebuild a clean adb forward, and re-broadcast activation, so the
|
|
283
|
+
* user never has to drop to a terminal. Delegates entirely to AndroidTransport;
|
|
284
|
+
* no adb shelling here.
|
|
285
|
+
*/
|
|
286
|
+
function handleEnable(res) {
|
|
287
|
+
transport.resetState();
|
|
288
|
+
// A reconnect may be a different device / re-armed profile, so drop the cached DB user
|
|
289
|
+
// set + sqlite path; the next /dbs re-detects the profiles fresh.
|
|
290
|
+
resetDbState();
|
|
291
|
+
resetScreenState(); // re-detect the active display + clear any stale cast on re-arm
|
|
292
|
+
const forwarded = transport.ensureForward();
|
|
293
|
+
if (!forwarded) {
|
|
294
|
+
const reason = transport.getLastForwardError?.() ?? null;
|
|
295
|
+
sendJson(res, 200, {
|
|
296
|
+
enabled: false,
|
|
297
|
+
error: reason ?? "ADB forward failed. Is a device connected?",
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
transport.activate(transport.getTimeoutMinutes());
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
sendJson(res, 200, {
|
|
306
|
+
enabled: false,
|
|
307
|
+
error: `Broadcast failed: ${e.message}. Debug build not running?`,
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Verify by pinging the device the same way /health does. Reply exactly once (an "end"
|
|
312
|
+
// and an "error"/"timeout" can both fire) to avoid a double writeHead crashing the server.
|
|
313
|
+
let replied = false;
|
|
314
|
+
const reply = (enabled) => {
|
|
315
|
+
if (replied)
|
|
316
|
+
return;
|
|
317
|
+
replied = true;
|
|
318
|
+
sendJson(res, 200, { enabled });
|
|
319
|
+
};
|
|
320
|
+
const probe = http.request({
|
|
321
|
+
host: RADAR_HOST,
|
|
322
|
+
port: RADAR_PORT,
|
|
323
|
+
path: "/api/ping",
|
|
324
|
+
method: "GET",
|
|
325
|
+
timeout: 2500,
|
|
326
|
+
}, (up) => {
|
|
327
|
+
up.resume();
|
|
328
|
+
up.on("end", () => reply(up.statusCode === 200));
|
|
329
|
+
up.on("error", () => reply(false));
|
|
330
|
+
});
|
|
331
|
+
probe.on("error", () => reply(false));
|
|
332
|
+
probe.on("timeout", () => {
|
|
333
|
+
probe.destroy();
|
|
334
|
+
reply(false);
|
|
335
|
+
});
|
|
336
|
+
probe.end();
|
|
337
|
+
}
|
|
338
|
+
const SOURCE_ARRAY_KEY = {
|
|
339
|
+
network: "calls",
|
|
340
|
+
rtm: "events",
|
|
341
|
+
clog: "clogs",
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* Replay the device's recent ring buffers for a source so a freshly-built tab
|
|
345
|
+
* shows history immediately instead of filling only going forward. The device is
|
|
346
|
+
* the source of truth. "all" pulls network+rtm+clog and merges them oldest->newest;
|
|
347
|
+
* "log" is host-side (no device buffer) and so has nothing to replay. Returns
|
|
348
|
+
* `[{type, event}]` frames mirroring the stream shape.
|
|
349
|
+
*/
|
|
350
|
+
function handleBackfill(req, res) {
|
|
351
|
+
const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
|
|
352
|
+
const src = u.searchParams.get("source") ?? "network";
|
|
353
|
+
const limit = Math.min(500, Number(u.searchParams.get("limit")) || 200);
|
|
354
|
+
const kinds = src === "all"
|
|
355
|
+
? ["network", "rtm", "clog"]
|
|
356
|
+
: [src].filter((k) => k !== "log" && k in SOURCE_ARRAY_KEY);
|
|
357
|
+
const fetchKind = (kind) => new Promise((resolve) => {
|
|
358
|
+
const apiPath = `/api/${kind === "clog" ? "clogs" : kind}?limit=${limit}`;
|
|
359
|
+
const rq = http.request({
|
|
360
|
+
host: RADAR_HOST,
|
|
361
|
+
port: RADAR_PORT,
|
|
362
|
+
path: apiPath,
|
|
363
|
+
method: "GET",
|
|
364
|
+
timeout: 4000,
|
|
365
|
+
}, (up) => {
|
|
366
|
+
let b = "";
|
|
367
|
+
up.on("data", (c) => (b += c));
|
|
368
|
+
up.on("end", () => {
|
|
369
|
+
try {
|
|
370
|
+
const j = JSON.parse(b);
|
|
371
|
+
const arr = j[SOURCE_ARRAY_KEY[kind]] ?? [];
|
|
372
|
+
resolve(arr.map((e) => ({ type: kind, event: e })));
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
resolve([]);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
rq.on("error", () => resolve([]));
|
|
380
|
+
rq.on("timeout", () => {
|
|
381
|
+
rq.destroy();
|
|
382
|
+
resolve([]);
|
|
383
|
+
});
|
|
384
|
+
rq.end();
|
|
385
|
+
});
|
|
386
|
+
Promise.all(kinds.map(fetchKind))
|
|
387
|
+
.then((lists) => {
|
|
388
|
+
const merged = lists
|
|
389
|
+
.flat()
|
|
390
|
+
.sort((a, b) => (Number(a.event.timestamp) || 0) - (Number(b.event.timestamp) || 0));
|
|
391
|
+
sendJson(res, 200, { frames: merged });
|
|
392
|
+
})
|
|
393
|
+
.catch(() => sendJson(res, 200, { frames: [] }));
|
|
394
|
+
}
|
|
395
|
+
// Detail kinds the inspector may request, mapped to their device route segment. The
|
|
396
|
+
// device serves clog detail at /api/clogs/ (plural); the rest match the kind name.
|
|
397
|
+
const DETAIL_ROUTES = {
|
|
398
|
+
network: "network",
|
|
399
|
+
rtm: "rtm",
|
|
400
|
+
clog: "clogs",
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* Finish a /detail proxy response after an upstream error or timeout. The success
|
|
404
|
+
* callback streams the upstream response, so headers may already be sent when a later
|
|
405
|
+
* "error"/"timeout" fires (a partial response then the device drops the socket mid-stream,
|
|
406
|
+
* common when the device idle-shuts). Calling writeHead a second time throws
|
|
407
|
+
* ERR_HTTP_HEADERS_SENT and crashes the whole process, so only write a status when headers
|
|
408
|
+
* have not gone out yet; otherwise just end the already-started response. Exported for tests.
|
|
409
|
+
*/
|
|
410
|
+
export function failDetailResponse(res, status) {
|
|
411
|
+
if (!res.headersSent) {
|
|
412
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
413
|
+
res.end("{}");
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
res.end();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Proxy a single record's full detail from the device ring buffer. Thin pass-through
|
|
421
|
+
* to `/api/<kind>/<id>` so the inspector can fetch a clicked row's payload.
|
|
422
|
+
*/
|
|
423
|
+
function handleDetail(req, res) {
|
|
424
|
+
const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
|
|
425
|
+
const id = u.searchParams.get("id");
|
|
426
|
+
const kind = u.searchParams.get("kind") ?? "network";
|
|
427
|
+
const route = DETAIL_ROUTES[kind];
|
|
428
|
+
if (!id || !route) {
|
|
429
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
430
|
+
res.end("{}");
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const upstream = http.request({
|
|
434
|
+
host: RADAR_HOST,
|
|
435
|
+
port: RADAR_PORT,
|
|
436
|
+
path: `/api/${route}/${encodeURIComponent(id)}`,
|
|
437
|
+
method: "GET",
|
|
438
|
+
timeout: 4000,
|
|
439
|
+
}, (up) => {
|
|
440
|
+
res.writeHead(up.statusCode ?? 502, {
|
|
441
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
442
|
+
});
|
|
443
|
+
up.on("data", (c) => res.write(c));
|
|
444
|
+
up.on("end", () => res.end());
|
|
445
|
+
});
|
|
446
|
+
upstream.on("error", () => failDetailResponse(res, 502));
|
|
447
|
+
upstream.on("timeout", () => {
|
|
448
|
+
upstream.destroy();
|
|
449
|
+
failDetailResponse(res, 504);
|
|
450
|
+
});
|
|
451
|
+
upstream.end();
|
|
452
|
+
}
|
|
453
|
+
// --- On-device SQLite browser (source: "db") ---
|
|
454
|
+
//
|
|
455
|
+
// These delegate to shared/db.ts (run-as + host sqlite3, read-only). Each computes its
|
|
456
|
+
// payload+status BEFORE writing headers so a throw still yields a clean JSON error rather
|
|
457
|
+
// than a half-written response. Errors come back as 200 {error} for the list/tables views
|
|
458
|
+
// (so the UI renders the message in-place) and 400 {error} for a rejected query.
|
|
459
|
+
// The Android user whose store the DB browser targets. Prefer an explicit ?user= (the UI's
|
|
460
|
+
// profile switcher, when a dual-profile device exists), else the profile the dashboard is
|
|
461
|
+
// bound to. Threaded through every db call so list / pull / query never disagree on profile.
|
|
462
|
+
function dbUser(req) {
|
|
463
|
+
const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
|
|
464
|
+
const explicit = u.searchParams.get("user");
|
|
465
|
+
if (explicit !== null)
|
|
466
|
+
return explicit;
|
|
467
|
+
// No explicit pick: default to the profile the debug build is actually running as
|
|
468
|
+
// (resolveActiveUser prefers the non-zero/secondary profile Radar binds to), not user 0.
|
|
469
|
+
try {
|
|
470
|
+
return transport.resolveActiveUser();
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return undefined; // let db.ts fall back to its first available user
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function handleDbList(req, res) {
|
|
477
|
+
try {
|
|
478
|
+
sendJson(res, 200, listDatabases(dbUser(req)));
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
sendJson(res, 200, { error: e.message, names: [], availableUsers: [] });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async function handleDbTables(req, res) {
|
|
485
|
+
const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
|
|
486
|
+
const db = u.searchParams.get("db") ?? "";
|
|
487
|
+
const user = dbUser(req);
|
|
488
|
+
const force = u.searchParams.get("refresh") === "1";
|
|
489
|
+
let payload;
|
|
490
|
+
let status;
|
|
491
|
+
try {
|
|
492
|
+
await pullDatabase(db, user, force);
|
|
493
|
+
const tableRows = (await queryDatabase(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", user));
|
|
494
|
+
// Row counts: awaited serially against the already-pulled LOCAL copy (each is fast and
|
|
495
|
+
// keeps the event loop free between calls; 91 parallel sqlite procs would spike).
|
|
496
|
+
const tables = [];
|
|
497
|
+
for (const t of tableRows) {
|
|
498
|
+
let rows = "?";
|
|
499
|
+
try {
|
|
500
|
+
rows = (await queryDatabase(db, `SELECT COUNT(*) c FROM "${t.name}"`, user))[0].c;
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// a view or virtual table may not count; leave "?"
|
|
504
|
+
}
|
|
505
|
+
tables.push({ name: t.name, rows });
|
|
506
|
+
}
|
|
507
|
+
payload = { db, sizeBytes: pulledSize(db, user), tables };
|
|
508
|
+
status = 200;
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
payload = { error: e.message };
|
|
512
|
+
status = 200; // surfaced in the tables pane, not a transport error
|
|
513
|
+
}
|
|
514
|
+
sendJson(res, status, payload);
|
|
515
|
+
}
|
|
516
|
+
async function handleDbQuery(req, res) {
|
|
517
|
+
const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
|
|
518
|
+
const db = u.searchParams.get("db") ?? "";
|
|
519
|
+
const sql = u.searchParams.get("sql") ?? "";
|
|
520
|
+
let payload;
|
|
521
|
+
let status;
|
|
522
|
+
try {
|
|
523
|
+
payload = { rows: await queryDatabase(db, sql, dbUser(req)) };
|
|
524
|
+
status = 200;
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
payload = { error: String(e.message).split("\n")[0] };
|
|
528
|
+
status = 400;
|
|
529
|
+
}
|
|
530
|
+
sendJson(res, status, payload);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Screen overlay routes. The live screen shows real DMs and message content, so the
|
|
534
|
+
* stream and record routes are gated behind an explicit per-process consent (granted
|
|
535
|
+
* by POST /screen/consent from the in-UI consent panel). The gate is enforced HERE,
|
|
536
|
+
* server-side, not just in the browser, so a direct request cannot bypass it.
|
|
537
|
+
*
|
|
538
|
+
* /screen/caps capabilities (ffmpeg/scrcpy present, install hint, consent state)
|
|
539
|
+
* /screen/consent POST: grant consent for this server process
|
|
540
|
+
* /screen/shot full-resolution PNG screenshot (pure screencap; works without ffmpeg)
|
|
541
|
+
* /screen/stream multipart/x-mixed-replace MJPEG live cast (needs ffmpeg + consent)
|
|
542
|
+
* /screen/record/start|stop record the live cast to mp4 (needs ffmpeg + consent)
|
|
543
|
+
* /screen/download download a finished recording
|
|
544
|
+
* /screen/redetect re-detect the active display
|
|
545
|
+
*/
|
|
546
|
+
function handleScreen(req, res) {
|
|
547
|
+
const url = req.url ?? "";
|
|
548
|
+
// Capabilities + consent state. Always available; drives the overlay's degrade UI.
|
|
549
|
+
if (url.startsWith("/screen/caps")) {
|
|
550
|
+
sendJson(res, 200, { ...screenCapabilities(), consent: hasScreenConsent() });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// Grant consent for this process. Required before any pixels are mirrored.
|
|
554
|
+
if (url === "/screen/consent" && req.method === "POST") {
|
|
555
|
+
grantScreenConsent();
|
|
556
|
+
sendJson(res, 200, { consent: true });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// Screenshot: pure screencap, no ffmpeg. Still consent-gated — it is the live screen.
|
|
560
|
+
if (url.startsWith("/screen/shot")) {
|
|
561
|
+
if (!hasScreenConsent()) {
|
|
562
|
+
sendJson(res, 403, { error: "screen consent required" });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
void (async () => {
|
|
566
|
+
await detectDisplay();
|
|
567
|
+
const png = await captureScreenshot();
|
|
568
|
+
if (!png) {
|
|
569
|
+
sendJson(res, 502, { error: "screencap failed; is the device awake and connected?" });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
res.writeHead(200, {
|
|
573
|
+
"Content-Type": "image/png",
|
|
574
|
+
"Content-Disposition": `attachment; filename="radar-screen-${Date.now()}.png"`,
|
|
575
|
+
});
|
|
576
|
+
res.end(png);
|
|
577
|
+
})();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// Live MJPEG stream. Needs consent AND ffmpeg.
|
|
581
|
+
if (url.startsWith("/screen/stream")) {
|
|
582
|
+
if (!hasScreenConsent()) {
|
|
583
|
+
sendJson(res, 403, { error: "screen consent required" });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (!resolveFfmpeg()) {
|
|
587
|
+
sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const fps = Number(new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("fps")) || 20;
|
|
591
|
+
void (async () => {
|
|
592
|
+
await detectDisplay();
|
|
593
|
+
const L = startLive(fps);
|
|
594
|
+
if (!L) {
|
|
595
|
+
sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
res.writeHead(200, {
|
|
599
|
+
"Content-Type": `multipart/x-mixed-replace; boundary=${SCREEN_BOUNDARY}`,
|
|
600
|
+
"Cache-Control": "no-cache",
|
|
601
|
+
Connection: "keep-alive",
|
|
602
|
+
});
|
|
603
|
+
addViewer(L, res);
|
|
604
|
+
req.on("close", () => removeViewer(res));
|
|
605
|
+
void primeIfNeeded();
|
|
606
|
+
})();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (url === "/screen/record/start" && req.method === "POST") {
|
|
610
|
+
if (!hasScreenConsent()) {
|
|
611
|
+
sendJson(res, 403, { error: "screen consent required" });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
void (async () => {
|
|
615
|
+
await detectDisplay();
|
|
616
|
+
if (!startLive(20)) {
|
|
617
|
+
sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const p = startRecording();
|
|
621
|
+
sendJson(res, 200, { ok: p !== null, file: p ? path.basename(p) : null });
|
|
622
|
+
})();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (url === "/screen/record/stop" && req.method === "POST") {
|
|
626
|
+
void (async () => {
|
|
627
|
+
const p = await stopRecording();
|
|
628
|
+
const file = p ? path.basename(p) : null;
|
|
629
|
+
let sizeBytes = 0;
|
|
630
|
+
if (p) {
|
|
631
|
+
try {
|
|
632
|
+
sizeBytes = fs.statSync(p).size;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
sizeBytes = 0;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
sendJson(res, 200, {
|
|
639
|
+
ok: true,
|
|
640
|
+
file,
|
|
641
|
+
sizeBytes,
|
|
642
|
+
download: file ? "/screen/download?f=" + encodeURIComponent(file) : null,
|
|
643
|
+
});
|
|
644
|
+
})();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (url.startsWith("/screen/download")) {
|
|
648
|
+
// A recording is device-screen content, so gate download on consent too — a fresh
|
|
649
|
+
// (unconsented) process must not serve a prior session's screen recording.
|
|
650
|
+
if (!hasScreenConsent()) {
|
|
651
|
+
sendJson(res, 403, { error: "screen consent required" });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const f = new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("f") ?? "";
|
|
655
|
+
const p = recordingPath(f);
|
|
656
|
+
if (!p) {
|
|
657
|
+
res.writeHead(404);
|
|
658
|
+
res.end("Not found");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
res.writeHead(200, {
|
|
662
|
+
"Content-Type": "video/mp4",
|
|
663
|
+
"Content-Disposition": `attachment; filename="${path.basename(p)}"`,
|
|
664
|
+
});
|
|
665
|
+
fs.createReadStream(p).pipe(res);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (url === "/screen/redetect") {
|
|
669
|
+
resetScreenState();
|
|
670
|
+
void detectDisplay().then(() => sendJson(res, 200, { display: displayNote() }));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
res.writeHead(404);
|
|
674
|
+
res.end("Not found");
|
|
675
|
+
}
|
|
676
|
+
// Logcat line parsing for the host-side log merge. Format:
|
|
677
|
+
// "MM-DD HH:MM:SS.mmm PID TID L Tag: message"
|
|
678
|
+
const LOG_LINE_RE = /^(\d\d-\d\d \d\d:\d\d:\d\d\.\d+)\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+([^:]*?):\s?(.*)$/;
|
|
679
|
+
const LOG_LEVELS = {
|
|
680
|
+
V: "VERBOSE",
|
|
681
|
+
D: "DEBUG",
|
|
682
|
+
I: "INFO",
|
|
683
|
+
W: "WARN",
|
|
684
|
+
E: "ERROR",
|
|
685
|
+
F: "FATAL",
|
|
686
|
+
};
|
|
687
|
+
// A logcat line carries a PID, not a package, but the dashboard filters logs by
|
|
688
|
+
// package (e.g. "Slack app only"). Resolve PID -> process name from
|
|
689
|
+
// `adb shell ps -A -o PID,NAME`, cached and refreshed on a miss (rate-limited), so
|
|
690
|
+
// log rows get a readable `package` and the package filter works. Without this the
|
|
691
|
+
// Live/Logs presets (match {package: ...}) drop every log line.
|
|
692
|
+
const pidToPackage = new Map();
|
|
693
|
+
let pidRefreshing = false;
|
|
694
|
+
let lastPidRefresh = 0;
|
|
695
|
+
function refreshPidMap() {
|
|
696
|
+
if (pidRefreshing)
|
|
697
|
+
return;
|
|
698
|
+
const adb = resolveAdbPath();
|
|
699
|
+
if (!adb)
|
|
700
|
+
return;
|
|
701
|
+
pidRefreshing = true;
|
|
702
|
+
lastPidRefresh = Date.now();
|
|
703
|
+
const ps = spawn(adb, ["shell", "ps", "-A", "-o", "PID,NAME"]);
|
|
704
|
+
let out = "";
|
|
705
|
+
ps.stdout?.on("data", (c) => (out += c.toString()));
|
|
706
|
+
ps.on("error", () => {
|
|
707
|
+
pidRefreshing = false;
|
|
708
|
+
});
|
|
709
|
+
ps.on("close", () => {
|
|
710
|
+
pidRefreshing = false;
|
|
711
|
+
for (const line of out.split("\n")) {
|
|
712
|
+
const m = line.trim().match(/^(\d+)\s+(\S+)$/);
|
|
713
|
+
if (m)
|
|
714
|
+
pidToPackage.set(Number(m[1]), m[2]);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
function packageForPid(pid) {
|
|
719
|
+
const hit = pidToPackage.get(pid);
|
|
720
|
+
if (hit)
|
|
721
|
+
return hit;
|
|
722
|
+
// Unknown pid: trigger a rate-limited refresh so the NEXT line resolves.
|
|
723
|
+
if (Date.now() - lastPidRefresh > 3000)
|
|
724
|
+
refreshPidMap();
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
let logIdSeq = 0;
|
|
728
|
+
/**
|
|
729
|
+
* Parse one `adb logcat -v threadtime` line into a structured event, or null if it does
|
|
730
|
+
* not match the format. `resolvePkg` maps a pid to its package name (defaults to the
|
|
731
|
+
* live adb-backed resolver); injectable so this stays a pure, unit-testable function.
|
|
732
|
+
* Exported for tests.
|
|
733
|
+
*/
|
|
734
|
+
export function parseLogcat(line, resolvePkg = packageForPid) {
|
|
735
|
+
const m = line.match(LOG_LINE_RE);
|
|
736
|
+
if (!m)
|
|
737
|
+
return null;
|
|
738
|
+
const [, ts, pid, tid, level, tag, message] = m;
|
|
739
|
+
const pidN = Number(pid);
|
|
740
|
+
return {
|
|
741
|
+
id: ++logIdSeq,
|
|
742
|
+
timestamp: Date.now(),
|
|
743
|
+
log_ts: ts,
|
|
744
|
+
pid: pidN,
|
|
745
|
+
tid: Number(tid),
|
|
746
|
+
level: LOG_LEVELS[level] ?? level,
|
|
747
|
+
tag: tag.trim(),
|
|
748
|
+
message,
|
|
749
|
+
package: resolvePkg(pidN),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Self-healing SSE proxy. Keeps ONE long-lived response open to the browser and
|
|
754
|
+
* internally (re)connects to the device `/api/stream` every couple seconds while
|
|
755
|
+
* it is down, so the browser's EventSource never sees a close and never gives up.
|
|
756
|
+
* Every other retry it rebuilds the adb forward (the real cause of recurring
|
|
757
|
+
* "unreachable"), reusing AndroidTransport rather than shelling adb directly.
|
|
758
|
+
*
|
|
759
|
+
* When the active spec wants logs (source "log" or "all"), the handler subscribes
|
|
760
|
+
* to the shared session-long logSession: it is first replayed the entire retained
|
|
761
|
+
* ring buffer (so a tab switch shows full history), then receives live lines as
|
|
762
|
+
* they arrive. The logcat process itself is persistent and shared across all
|
|
763
|
+
* /stream clients, so tab switches never drop history AND the active tab keeps
|
|
764
|
+
* streaming live. The adb binary is resolved via resolveAdbPath() so GUI launchers
|
|
765
|
+
* without the user's PATH still work.
|
|
766
|
+
*/
|
|
767
|
+
function handleStream(req, res) {
|
|
768
|
+
res.writeHead(200, {
|
|
769
|
+
"Content-Type": "text/event-stream",
|
|
770
|
+
"Cache-Control": "no-cache",
|
|
771
|
+
Connection: "keep-alive",
|
|
772
|
+
});
|
|
773
|
+
let upstream = null;
|
|
774
|
+
let closed = false;
|
|
775
|
+
let lastState = "";
|
|
776
|
+
const announce = (state, msg) => {
|
|
777
|
+
if (state === lastState)
|
|
778
|
+
return;
|
|
779
|
+
lastState = state;
|
|
780
|
+
res.write(`data: ${JSON.stringify({ type: "_status", event: { state, msg } })}\n\n`);
|
|
781
|
+
};
|
|
782
|
+
// Keep the persistent logcat capture armed for the life of this connection.
|
|
783
|
+
// start() is idempotent, so it is safe to call both on initial setup and from
|
|
784
|
+
// the device-proxy reconnect path: when the device sleeps/unplugs, `adb logcat`
|
|
785
|
+
// exits and LogSession clears its child, and without a re-arm the live log feed
|
|
786
|
+
// would stay dead (until a tab switch) while network/rtm/clog self-heal via the
|
|
787
|
+
// proxy retry loop. Calling this on reconnect heals logs on the same path.
|
|
788
|
+
const spec = loadSpec();
|
|
789
|
+
const wantsLogs = spec.source === "log" || spec.source === "all";
|
|
790
|
+
const armLogcat = () => {
|
|
791
|
+
if (!wantsLogs || closed)
|
|
792
|
+
return;
|
|
793
|
+
const ok = logSession.start();
|
|
794
|
+
if (!ok) {
|
|
795
|
+
// Spawn failed (e.g. no adb on PATH). The capture is a named session-scoped
|
|
796
|
+
// thing now, so surface the reason rather than showing silent emptiness.
|
|
797
|
+
announce("waiting", "device logs unavailable (adb not found?)");
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
let retries = 0;
|
|
801
|
+
// One scheduled reconnect per attempt. up.on("end"), up.on("error"), and
|
|
802
|
+
// upstream.on("error") can all fire for the same attempt; without this guard each
|
|
803
|
+
// would schedule its own connect(), fanning out duplicate streams (and re-spawning
|
|
804
|
+
// logcat on the rebuild path). Reset at the top of connect() so the next attempt
|
|
805
|
+
// can retry again.
|
|
806
|
+
let settled = false;
|
|
807
|
+
const retry = () => {
|
|
808
|
+
if (closed)
|
|
809
|
+
return;
|
|
810
|
+
if (settled)
|
|
811
|
+
return;
|
|
812
|
+
settled = true;
|
|
813
|
+
retries++;
|
|
814
|
+
// Every other retry, rebuild a clean forward before reconnecting. resetState()
|
|
815
|
+
// drops the cached forward flag so ensureForward() actually re-runs adb forward.
|
|
816
|
+
if (retries % 2 === 0) {
|
|
817
|
+
transport.resetState();
|
|
818
|
+
transport.ensureForward();
|
|
819
|
+
setTimeout(connect, 1500);
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
setTimeout(connect, 2000);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
const connect = () => {
|
|
826
|
+
if (closed)
|
|
827
|
+
return;
|
|
828
|
+
settled = false;
|
|
829
|
+
upstream = http.request({
|
|
830
|
+
host: RADAR_HOST,
|
|
831
|
+
port: RADAR_PORT,
|
|
832
|
+
path: "/api/stream",
|
|
833
|
+
method: "GET",
|
|
834
|
+
}, (up) => {
|
|
835
|
+
announce("connected", "device stream connected");
|
|
836
|
+
// Re-arm logs on the same path the device proxy heals on: if the device
|
|
837
|
+
// dropped and came back, `adb logcat` had exited; restart it (idempotent)
|
|
838
|
+
// so the live log feed resumes without needing a browser reconnect.
|
|
839
|
+
armLogcat();
|
|
840
|
+
up.on("data", (c) => res.write(c));
|
|
841
|
+
up.on("end", () => {
|
|
842
|
+
announce("waiting", "device stream ended, retrying…");
|
|
843
|
+
retry();
|
|
844
|
+
});
|
|
845
|
+
up.on("error", () => {
|
|
846
|
+
announce("waiting", "device stream error, retrying…");
|
|
847
|
+
retry();
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
upstream.on("error", () => {
|
|
851
|
+
announce("waiting", "device unreachable, retrying…");
|
|
852
|
+
retry();
|
|
853
|
+
});
|
|
854
|
+
upstream.end();
|
|
855
|
+
};
|
|
856
|
+
connect();
|
|
857
|
+
const heartbeat = setInterval(() => {
|
|
858
|
+
if (!closed)
|
|
859
|
+
res.write(": hb\n\n");
|
|
860
|
+
}, 15000);
|
|
861
|
+
// Host-side logcat merge for log/all sources. Subscribe to the shared,
|
|
862
|
+
// session-long logSession: it replays the full retained ring buffer to this
|
|
863
|
+
// connection first (so a tab switch shows complete history), then delivers
|
|
864
|
+
// live lines. Both replayed and live frames are written to THIS response, so
|
|
865
|
+
// the tab you are watching keeps streaming in real time. The logcat process
|
|
866
|
+
// is persistent and shared, so a tab switch only adds/removes a listener — it
|
|
867
|
+
// never tears down the capture or loses lines in a reconnect gap.
|
|
868
|
+
let unsubscribeLogs = null;
|
|
869
|
+
if (wantsLogs) {
|
|
870
|
+
refreshPidMap(); // warm PID->package so the first log rows resolve their app
|
|
871
|
+
armLogcat(); // idempotent start; surfaces a status frame if spawn fails
|
|
872
|
+
unsubscribeLogs = logSession.subscribe((ev, replay) => {
|
|
873
|
+
if (closed)
|
|
874
|
+
return;
|
|
875
|
+
// Backpressure guard: if this client is slow or its tab is backgrounded
|
|
876
|
+
// (an EventSource stays subscribed when backgrounded), res.write() queues
|
|
877
|
+
// unwritten bytes in Node's writable buffer without bound. The ring is
|
|
878
|
+
// bounded but that per-connection buffer is not, so a stalled consumer
|
|
879
|
+
// would grow host memory for the whole (now persistent) session. When the
|
|
880
|
+
// socket buffer is already deep, DROP this frame rather than buffer it —
|
|
881
|
+
// the client replays the full ring on its next reconnect and catches up.
|
|
882
|
+
//
|
|
883
|
+
// EXEMPT replay: the replay burst is a bounded one-time send of the whole
|
|
884
|
+
// ring (~10 MB worst case at the 20000-frame cap) and IS the connection's
|
|
885
|
+
// purpose. It runs synchronously oldest->newest, so the I/O phase cannot
|
|
886
|
+
// drain between writes and writableLength climbs monotonically; gating it
|
|
887
|
+
// on the 4 MB cap would silently drop the NEWEST history past ~4 MB and
|
|
888
|
+
// reintroduce the very "log count shrinks on reconnect" loss this feature
|
|
889
|
+
// fixes. The guard is only meant for the unbounded LIVE queue.
|
|
890
|
+
if (shouldDropLogFrame(replay, res.writableLength))
|
|
891
|
+
return;
|
|
892
|
+
// Tag replayed history with replay:true so the client counts it as history
|
|
893
|
+
// (live=false) and does NOT fire alerts on it. The client's own cache is
|
|
894
|
+
// capped (count + TTL) while the server ring is larger, so on a long
|
|
895
|
+
// session the client can evict frames the ring still holds; without this
|
|
896
|
+
// flag those replayed-on-reconnect frames would look new and re-fire
|
|
897
|
+
// `alert {at:N}` and re-count. The flag makes replay alert-safe regardless.
|
|
898
|
+
const frame = replay
|
|
899
|
+
? { type: "log", event: ev, replay: true }
|
|
900
|
+
: { type: "log", event: ev };
|
|
901
|
+
res.write(`data: ${JSON.stringify(frame)}\n\n`);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
req.on("close", () => {
|
|
905
|
+
closed = true;
|
|
906
|
+
clearInterval(heartbeat);
|
|
907
|
+
if (unsubscribeLogs)
|
|
908
|
+
unsubscribeLogs(); // detach listener; capture stays up
|
|
909
|
+
try {
|
|
910
|
+
upstream?.destroy();
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
// already torn down
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
}
|
|
44
917
|
function listProfiles(res) {
|
|
45
918
|
const profiles = transport.listProfiles();
|
|
46
919
|
const activeUserId = transport.getActiveUserId();
|
|
47
|
-
res
|
|
48
|
-
res.end(JSON.stringify({ profiles, active_user_id: activeUserId }));
|
|
920
|
+
sendJson(res, 200, { profiles, active_user_id: activeUserId });
|
|
49
921
|
}
|
|
50
922
|
function activateRadar(res, userId) {
|
|
51
923
|
// Reset cached state so ensureForward() re-runs adb forward. This handles
|
|
@@ -53,11 +925,10 @@ function activateRadar(res, userId) {
|
|
|
53
925
|
transport.resetState();
|
|
54
926
|
const forwarded = transport.ensureForward();
|
|
55
927
|
if (!forwarded) {
|
|
56
|
-
res
|
|
57
|
-
res.end(JSON.stringify({
|
|
928
|
+
sendJson(res, 502, {
|
|
58
929
|
ok: false,
|
|
59
930
|
error: "ADB forward failed. Is a device connected?",
|
|
60
|
-
})
|
|
931
|
+
});
|
|
61
932
|
return;
|
|
62
933
|
}
|
|
63
934
|
try {
|
|
@@ -69,15 +940,16 @@ function activateRadar(res, userId) {
|
|
|
69
940
|
}
|
|
70
941
|
}
|
|
71
942
|
catch (e) {
|
|
72
|
-
res
|
|
73
|
-
res.end(JSON.stringify({
|
|
943
|
+
sendJson(res, 502, {
|
|
74
944
|
ok: false,
|
|
75
945
|
error: `Broadcast failed: ${e.message}. Debug build not running?`,
|
|
76
|
-
})
|
|
946
|
+
});
|
|
77
947
|
return;
|
|
78
948
|
}
|
|
79
|
-
res
|
|
80
|
-
|
|
949
|
+
sendJson(res, 200, {
|
|
950
|
+
ok: true,
|
|
951
|
+
user_id: userId ?? transport.getActiveUserId() ?? "auto",
|
|
952
|
+
});
|
|
81
953
|
}
|
|
82
954
|
function proxyToRadar(req, res) {
|
|
83
955
|
const upstream = http.request({
|