@gravitylabsllc/porthole 0.1.0 → 0.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.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-BzqwnvoU.js +70 -0
  52. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/adb.ts ADDED
@@ -0,0 +1,583 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import path from "node:path";
6
+
7
+ /**
8
+ * How far up from the working directory to look for local.properties.
9
+ *
10
+ * The working directory is usually the build root, but an agent or a shell is
11
+ * just as often sitting in a module inside it. Five levels covers that without
12
+ * letting a build that has no local.properties at all walk to the top of the
13
+ * disk looking for one.
14
+ */
15
+ const LOCAL_PROPERTIES_LEVELS = 5;
16
+
17
+ /**
18
+ * A .properties file, read the way java.util.Properties reads it.
19
+ *
20
+ * local.properties is written by Android Studio, and on Windows what it writes
21
+ * is `sdk.dir=C\:\\Users\\james\\AppData\\Local\\Android\\Sdk`. Splitting on
22
+ * the first `=` and keeping the rest verbatim yields a path with an escaped
23
+ * colon and doubled separators that no filesystem call will accept, so the
24
+ * escapes have to come off. The rules honoured here are Java's, because Java's
25
+ * are what wrote the file and what the Gradle plugin reads it back with: `#`
26
+ * and `!` comment out a whole line and nothing else — a `#` partway through a
27
+ * value belongs to the value — `=`, `:` or whitespace ends the key, and a line
28
+ * ending in an odd number of backslashes continues onto the next.
29
+ */
30
+ export function parseProperties(text: string): Map<string, string> {
31
+ const properties = new Map<string, string>();
32
+ const lines = text.split(/\r\n|\n|\r/);
33
+
34
+ for (let i = 0; i < lines.length; i++) {
35
+ let line = lines[i].replace(/^[ \t\f]+/, "");
36
+ if (line === "" || line.startsWith("#") || line.startsWith("!")) continue;
37
+
38
+ while (trailingBackslashes(line) % 2 === 1 && i + 1 < lines.length) {
39
+ line = line.slice(0, -1) + lines[++i].replace(/^[ \t\f]+/, "");
40
+ }
41
+
42
+ // The key runs to the first separator that is not itself escaped.
43
+ let end = 0;
44
+ while (end < line.length) {
45
+ const character = line[end];
46
+ if (character === "\\") {
47
+ end += 2;
48
+ continue;
49
+ }
50
+ if ("=: \t\f".includes(character)) break;
51
+ end++;
52
+ }
53
+
54
+ let value = line.slice(end).replace(/^[ \t\f]*/, "");
55
+ if (value.startsWith("=") || value.startsWith(":")) {
56
+ value = value.slice(1).replace(/^[ \t\f]*/, "");
57
+ }
58
+ properties.set(unescape(line.slice(0, end)), unescape(value));
59
+ }
60
+ return properties;
61
+ }
62
+
63
+ function trailingBackslashes(line: string): number {
64
+ let count = 0;
65
+ while (count < line.length && line[line.length - 1 - count] === "\\") count++;
66
+ return count;
67
+ }
68
+
69
+ function unescape(raw: string): string {
70
+ let out = "";
71
+ for (let i = 0; i < raw.length; i++) {
72
+ if (raw[i] !== "\\") {
73
+ out += raw[i];
74
+ continue;
75
+ }
76
+ const escaped = raw[++i];
77
+ if (escaped === undefined) break;
78
+ if (escaped === "u" && /^[0-9a-fA-F]{4}$/.test(raw.slice(i + 1, i + 5))) {
79
+ out += String.fromCharCode(parseInt(raw.slice(i + 1, i + 5), 16));
80
+ i += 4;
81
+ continue;
82
+ }
83
+ // Java drops the backslash before anything it does not recognise, which is
84
+ // what turns `C\:\\Users` back into `C:\Users`.
85
+ out += { t: "\t", n: "\n", r: "\r", f: "\f" }[escaped] ?? escaped;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * Where PORTHOLE_PROJECT_ROOT and PORTHOLE_SDK_DIR come from: `.mcp.json`,
92
+ * generated at Gradle configure time by `PortholeMcpConfigTask`. That task
93
+ * knows both values with certainty — the Gradle root project directory, and
94
+ * `sdk.dir`/`ANDROID_HOME` resolved the same way `PortholePlugin` resolves
95
+ * them for `adb` itself — because it is the build, not a process launched by
96
+ * whatever an MCP client decided to use as `cwd`.
97
+ */
98
+ const PORTHOLE_PROJECT_ROOT = "PORTHOLE_PROJECT_ROOT";
99
+ const PORTHOLE_SDK_DIR = "PORTHOLE_SDK_DIR";
100
+
101
+ export type ProjectRootSource = "PORTHOLE_PROJECT_ROOT" | "cwd";
102
+
103
+ export interface ResolvedProjectRoot {
104
+ directory: string;
105
+ source: ProjectRootSource;
106
+ }
107
+
108
+ /**
109
+ * The project root anything here should treat as "the build", in order:
110
+ * `PORTHOLE_PROJECT_ROOT` if the generated config set it, otherwise
111
+ * `process.cwd()` on the stated assumption GRA-87 already made and this
112
+ * ticket exists to stop relying on — that an MCP client launches its stdio
113
+ * server from the workspace root. When a generated `.mcp.json` is what
114
+ * started this process, that assumption is no longer needed at all.
115
+ */
116
+ export function resolveProjectRoot(): ResolvedProjectRoot {
117
+ const declared = process.env[PORTHOLE_PROJECT_ROOT];
118
+ if (declared && declared.trim()) {
119
+ return { directory: declared.trim(), source: "PORTHOLE_PROJECT_ROOT" };
120
+ }
121
+ return { directory: process.cwd(), source: "cwd" };
122
+ }
123
+
124
+ /**
125
+ * True for a Windows drive-relative path — a letter, a colon, and then
126
+ * anything other than a separator (`C:foo`, or bare `C:`): "foo, relative to
127
+ * whatever the current directory on drive C happens to be", a real Windows
128
+ * path concept distinct from both absolute and an ordinary relative path.
129
+ * This is the TypeScript analogue of `isWindowsDriveRelative` in
130
+ * PortholeTasks.kt (GRA-150), kept for the same reason: `path.join(directory,
131
+ * "C:foo")` does not anchor it under `directory` — Node happily concatenates
132
+ * the strings into `<directory>\C:foo`, a colon spliced into the middle of a
133
+ * path segment that Windows refuses to open. There is no reliable way to ask
134
+ * Node, any more than the JVM, what "the current directory on drive C" is for
135
+ * a directory other than this process's own, so — matching the Kotlin
136
+ * resolver's choice — this shape is deliberately left to resolve however
137
+ * `path.resolve` on its own (no `directory` argument) already resolves it,
138
+ * rather than inventing an anchor. Gated on `win32`: everywhere else a colon
139
+ * is an ordinary filename character, and `C:foo` there is exactly as
140
+ * relative as it looks.
141
+ */
142
+ function isWindowsDriveRelative(value: string): boolean {
143
+ return process.platform === "win32" && /^[A-Za-z]:($|[^\\/])/.test(value);
144
+ }
145
+
146
+ /**
147
+ * True for a path `java.io.File#isAbsolute()` — and so PortholeTasks.kt's
148
+ * `resolveSdkDir` — would call absolute. On Windows this deliberately
149
+ * disagrees with Node's own `path.isAbsolute`: a bare POSIX-style leading
150
+ * slash (`/opt/sdk`) is absolute to Node there (it roots the path at
151
+ * whatever the current drive is) but not to Java, which requires a drive
152
+ * letter or a UNC prefix. GRA-150's QA moved the Kotlin resolver to route
153
+ * that shape through the project-root join instead of the current-drive
154
+ * root; using Node's native check here would silently put TypeScript back on
155
+ * the old, rejected answer for this one shape. See PortholeTasks.kt's
156
+ * `resolveSdkDir` doc comment for the full story. Off Windows, Java's rule
157
+ * and Node's agree (both are simply "starts with /"), so this just defers to
158
+ * `path.isAbsolute`.
159
+ */
160
+ function isJavaStyleAbsolute(value: string): boolean {
161
+ if (process.platform !== "win32") return path.isAbsolute(value);
162
+ return /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}/.test(value);
163
+ }
164
+
165
+ /**
166
+ * Anchors a `sdk.dir` value read out of local.properties against `directory`
167
+ * — [sdkDirFromLocalProperties]'s current directory in its walk, i.e. the
168
+ * directory the file was actually found in. This is deliberately NOT
169
+ * `resolveProjectRoot().directory`: the two differ whenever the walk climbs
170
+ * past the project root to find the file, and a relative path written inside
171
+ * that file means "relative to where the file lives", not "relative to
172
+ * wherever PORTHOLE_PROJECT_ROOT happens to point". This mirrors
173
+ * PortholeTasks.kt's `resolveSdkDir`, which anchors on `projectRoot` — its
174
+ * exact analogue, for the exact same reason (GRA-160 AC1).
175
+ *
176
+ * `path.join`, not `path.resolve`, does the anchoring for the ordinary case:
177
+ * `path.resolve` special-cases any argument it considers absolute — which,
178
+ * on Windows, includes the POSIX-style shape `isJavaStyleAbsolute`
179
+ * deliberately excludes — by discarding every argument before it and rooting
180
+ * at the current drive instead. That is exactly the answer this function
181
+ * exists to avoid. `path.join` treats every argument as a plain segment
182
+ * regardless of what it looks like, so it always anchors under `directory`;
183
+ * the outer `path.resolve` then only normalizes the result (drops a trailing
184
+ * separator, collapses a redundant `.`/`..`), which is also where the
185
+ * "GRA-160 AC4" trim below actually pays for itself — an unstripped
186
+ * whitespace character survives straight into this join as part of the path
187
+ * segment, and lands on a directory that does not exist.
188
+ */
189
+ function resolveRelativeSdkDir(value: string, directory: string): string {
190
+ if (isWindowsDriveRelative(value)) return path.resolve(value);
191
+ if (isJavaStyleAbsolute(value)) return path.resolve(value);
192
+ return path.resolve(path.join(directory, value));
193
+ }
194
+
195
+ /** `sdk.dir` from the nearest local.properties at or above `from`. */
196
+ function sdkDirFromLocalProperties(from: string): string | null {
197
+ let directory = path.resolve(from);
198
+ for (let level = 0; level <= LOCAL_PROPERTIES_LEVELS; level++) {
199
+ const file = path.join(directory, "local.properties");
200
+ try {
201
+ if (existsSync(file)) {
202
+ // A local.properties with no sdk.dir in it is not an answer, so the
203
+ // walk continues past it rather than stopping at the first file found.
204
+ const value = parseProperties(readFileSync(file, "utf8")).get("sdk.dir");
205
+ // GRA-160 AC4: value.trim() here is the only thing standing between
206
+ // a value parseProperties handed back un-trimmed and a filesystem
207
+ // check on a directory that does not exist. Plain ASCII whitespace
208
+ // around the value mostly never reaches this line at all —
209
+ // parseProperties' own `[ \t\f]` stripping already ate the leading
210
+ // run, and a value with nothing but trailing ASCII space is realistic
211
+ // (a hand-edited file, or a stray editor auto-format) but easy to
212
+ // write a fixture for and forget. A non-breaking space (U+00A0) is
213
+ // the sharper case: `[ \t\f]` does not include it, so it survives
214
+ // parseProperties untouched either way, and only JS's own
215
+ // Unicode-aware `trim()` — not a regex character class copied from
216
+ // Java's — removes it here. adb.test.ts covers both.
217
+ if (value && value.trim()) return resolveRelativeSdkDir(value.trim(), directory);
218
+ }
219
+ } catch {
220
+ // Unreadable is the same as absent: the environment is next, and a
221
+ // permissions problem on someone's parent directory is not adb's fault.
222
+ }
223
+ const parent = path.dirname(directory);
224
+ if (parent === directory) break; // The root of the filesystem is its own parent.
225
+ directory = parent;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function isDirectory(candidate: string): boolean {
231
+ try {
232
+ return statSync(candidate).isDirectory();
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * GRA-119, AC5: `porthole_status` (in `index.ts`) names which of these two it
240
+ * used and where the value came from, as of GRA-152 — that ticket exists
241
+ * because this criterion fell through the gap between GRA-119's `Owns`
242
+ * (which excluded `index.ts`) and GRA-90's rewrite of `index.ts` afterwards.
243
+ * `resolveSdkDir()` and `resolveProjectRoot()`'s `.source` fields
244
+ * (`"PORTHOLE_SDK_DIR"` / `"local.properties"` / `"ANDROID_HOME"` /
245
+ * `"ANDROID_SDK_ROOT"` / `"PATH"` for the SDK; `"PORTHOLE_PROJECT_ROOT"` /
246
+ * `"cwd"` for the project root) are what `porthole_status` reports alongside
247
+ * the resolved `.directory`.
248
+ */
249
+ export type SdkDirSource = "PORTHOLE_SDK_DIR" | "local.properties" | "ANDROID_HOME" | "ANDROID_SDK_ROOT" | "PATH";
250
+
251
+ export interface ResolvedSdkDir {
252
+ /** Null only when `source` is "PATH" — nothing named an SDK directory at all. */
253
+ directory: string | null;
254
+ source: SdkDirSource;
255
+ }
256
+
257
+ /**
258
+ * The Android SDK, in the order a developer would look for it — and, ahead
259
+ * of all of them, the order this ticket adds: an explicit `PORTHOLE_SDK_DIR`
260
+ * from a generated `.mcp.json` wins outright and short-circuits before any
261
+ * filesystem access, deliberately. GRA-87's walk up from the project root for
262
+ * local.properties, and the ANDROID_HOME/ANDROID_SDK_ROOT fallback after it,
263
+ * both stay exactly as they were for a server that was not started from a
264
+ * generated config — see [sdkDirFromLocalProperties] and its tests.
265
+ *
266
+ * The walk-and-environment order below is deliberately the Gradle plugin's —
267
+ * `sdkDirectory` in PortholePlugin.kt — and the two agreeing is the whole
268
+ * point of it. sdk.dir is what Android Studio writes into local.properties,
269
+ * and on a stock install it is the only one of these that is set: neither
270
+ * environment variable exists, and platform-tools is not on PATH, least of
271
+ * all on Windows. Reading only the environment is why `system_context` used
272
+ * to tell the user to go fix something that `./gradlew portholeConnect`, on
273
+ * the same machine and the same SDK, had no trouble with.
274
+ *
275
+ * Once local.properties names a directory, that is the answer even if adb
276
+ * turns out not to be beneath it. The plugin stops there too, and one tool
277
+ * quietly falling through to a different SDK than the other is the exact
278
+ * split personality this replaced.
279
+ */
280
+ export function resolveSdkDir(): ResolvedSdkDir {
281
+ const declared = process.env[PORTHOLE_SDK_DIR];
282
+ if (declared && declared.trim()) {
283
+ return { directory: declared.trim(), source: "PORTHOLE_SDK_DIR" };
284
+ }
285
+
286
+ const fromProperties = sdkDirFromLocalProperties(resolveProjectRoot().directory);
287
+ if (fromProperties) return { directory: fromProperties, source: "local.properties" };
288
+
289
+ for (const variable of ["ANDROID_HOME", "ANDROID_SDK_ROOT"] as const) {
290
+ const root = process.env[variable];
291
+ if (root && isDirectory(root)) return { directory: root, source: variable };
292
+ }
293
+ return { directory: null, source: "PATH" };
294
+ }
295
+
296
+ /**
297
+ * adb, from the SDK if one can be found, and otherwise left to the PATH.
298
+ *
299
+ * GRA-160 AC3: falling through to a bare binary name is the right answer
300
+ * only when nothing named an SDK at all (`directory` is null, `source` is
301
+ * "PATH") — that is an honest "I don't know", and PATH is the reasonable
302
+ * last resort, unremarked. When `directory` IS known — Android Studio wrote
303
+ * it, or this ticket's fix just anchored a relative one against the right
304
+ * place — but platform-tools is not actually there, falling through to PATH
305
+ * the same silent way is a different thing: it runs *some* adb, possibly a
306
+ * different SDK's, without a word to whoever asked. The wave-4 integration
307
+ * QA measured exactly this. This writes that specific case to stderr rather
308
+ * than swallowing it — stdout is the MCP transport's JSON-RPC channel, so
309
+ * stderr is the only channel available here that cannot corrupt it. Fully
310
+ * surfacing it through `porthole_status`'s payload would need a change in
311
+ * index.ts, which this ticket's Owns excludes; see the ticket report.
312
+ */
313
+ export function findAdb(): string {
314
+ const binary = process.platform === "win32" ? "adb.exe" : "adb";
315
+ const { directory } = resolveSdkDir();
316
+ if (!directory) return binary;
317
+ const candidate = path.join(directory, "platform-tools", binary);
318
+ if (existsSync(candidate)) return candidate;
319
+ process.stderr.write(
320
+ `[porthole] sdk.dir resolved to ${directory}, but ` +
321
+ `${path.join("platform-tools", binary)} was not found there; falling back to ${binary} on PATH.\n`,
322
+ );
323
+ return binary;
324
+ }
325
+
326
+ export interface AdbResult {
327
+ ok: boolean;
328
+ output: string;
329
+ }
330
+
331
+ export function runAdb(args: string[], serial?: string): AdbResult {
332
+ const prefix = serial ? ["-s", serial] : [];
333
+ const result = spawnSync(findAdb(), [...prefix, ...args], { encoding: "utf8" });
334
+
335
+ if (result.error) {
336
+ return {
337
+ ok: false,
338
+ output:
339
+ `Could not run adb (${result.error.message}). ` +
340
+ "Set ANDROID_HOME, or put adb on your PATH.",
341
+ };
342
+ }
343
+
344
+ const output = ((result.stdout || "") + (result.stderr || "")).trim();
345
+ if (result.status !== 0) {
346
+ return {
347
+ ok: false,
348
+ output:
349
+ output.includes("more than one") && !serial
350
+ ? `${output}\nStart the UI with --serial <id>; 'adb devices' lists them.`
351
+ : output || `adb exited ${result.status}`,
352
+ };
353
+ }
354
+ return { ok: true, output };
355
+ }
356
+
357
+ /**
358
+ * How long `runAdbAsync` waits before presuming an adb child is wedged.
359
+ *
360
+ * 150s, not `capture_system_trace`'s own 120s maximum: a caller recording a
361
+ * near-maximum-length trace overrides this per-call to `seconds * 1000` plus
362
+ * a startup buffer (see `index.ts`), so this default only ever governs the
363
+ * short calls — the pull and the on-device cleanup — where anything near a
364
+ * minute already means adb itself, not the recording, is stuck.
365
+ */
366
+ const DEFAULT_ADB_TIMEOUT_MS = Number(process.env.PORTHOLE_ADB_TIMEOUT_MS) || 150_000;
367
+
368
+ /** "One per few seconds", per the ticket: not so chatty it drowns stderr, not so sparse a caller watching the log wonders if the server is still alive. */
369
+ const DEFAULT_ADB_TICK_MS = 5_000;
370
+
371
+ /** Writes one line to stderr — never stdout, which on this server is the MCP transport's own JSON-RPC channel. */
372
+ function defaultAdbProgress(elapsedMs: number, args: string[]): void {
373
+ process.stderr.write(
374
+ `[porthole] adb ${args[0] ?? ""} still running after ${Math.round(elapsedMs / 1000)}s...\n`,
375
+ );
376
+ }
377
+
378
+ export interface RunAdbAsyncOptions {
379
+ serial?: string;
380
+ /**
381
+ * Overrides `findAdb()` — the same seam `perfetto.ts`'s `runScript` takes
382
+ * as a plain parameter rather than resolving `trace_processor_shell`
383
+ * itself, for the same reason: a test can hand this a real, controllable
384
+ * process (`cmd.exe`, `/bin/sh`, or a stand-in "adb" on PATH) without a
385
+ * real device or a real SDK on the machine running the suite.
386
+ */
387
+ binary?: string;
388
+ timeoutMs?: number;
389
+ /** Fires every `tickMs` while the child is still running. Default writes progress to stderr; a caller wanting a different message overrides it, not the ticking. */
390
+ onProgress?: (elapsedMs: number, args: string[]) => void;
391
+ tickMs?: number;
392
+ /**
393
+ * Overrides the child's environment. Undefined (the default, and what
394
+ * every real caller leaves it as) means `spawn` does what it always does:
395
+ * inherit `process.env` as it stood at the moment this call was made.
396
+ *
397
+ * This exists for one reason: a test that needs a spawned child to see a
398
+ * *different* `NODE_OPTIONS` (or any other variable) than the rest of the
399
+ * process would otherwise have to mutate the real `process.env` for the
400
+ * duration of the call — a global, shared by every other test running in
401
+ * the same worker, including ones that spawn their own child processes
402
+ * concurrently. That is exactly the kind of cross-test interference this
403
+ * project's own rules warn about elsewhere, and it was measured here, not
404
+ * hypothesised: `index.test.ts`'s GRA-89 rig test used to set
405
+ * `process.env.NODE_OPTIONS` globally for its ~3s capture window, and an
406
+ * unrelated `cli.test.ts` case that spawns its own child process during
407
+ * that window failed intermittently — once, across the handful of
408
+ * full-suite runs made while building this fix, with an exit code its own
409
+ * assertions could not explain — and it did not recur once this parameter
410
+ * replaced the global mutation. A real instance of the leak this
411
+ * parameter exists to make unnecessary. Scoping the override to one
412
+ * `spawn()` call removes the shared mutable state instead of narrowing
413
+ * the window it is exposed for.
414
+ */
415
+ env?: NodeJS.ProcessEnv;
416
+ }
417
+
418
+ /**
419
+ * `runAdb`'s async twin: `spawn`, awaited, standing in for `spawnSync`.
420
+ *
421
+ * `capture_system_trace`'s three adb calls — the recording, the pull, and
422
+ * the on-device cleanup — used to run through `runAdb` above, which blocks
423
+ * Node's single thread for as long as the child takes. At the tool's own
424
+ * 120s maximum that froze the whole MCP server for over two minutes: nothing
425
+ * read the device socket, nothing answered another tool call, and the
426
+ * timeline WebSocket went silent. This is the way back, and it is
427
+ * deliberately the same shape GRA-82 already proved out for
428
+ * `trace_processor_shell` in `perfetto.ts`'s `runScript` — `spawn` instead
429
+ * of `spawnSync`, a `setTimeout` that kills the child and reports how long
430
+ * it waited, one shared helper rather than a second way to run a child
431
+ * process asynchronously.
432
+ *
433
+ * Progress is reported on stderr, not returned, because nothing here knows
434
+ * whether anyone is listening for it — it exists so a long recording does
435
+ * not look identically alive and wedged from the outside.
436
+ */
437
+ export function runAdbAsync(args: string[], options: RunAdbAsyncOptions = {}): Promise<AdbResult> {
438
+ const {
439
+ serial,
440
+ binary = findAdb(),
441
+ timeoutMs = DEFAULT_ADB_TIMEOUT_MS,
442
+ onProgress = defaultAdbProgress,
443
+ tickMs = DEFAULT_ADB_TICK_MS,
444
+ env,
445
+ } = options;
446
+ const prefix = serial ? ["-s", serial] : [];
447
+ const fullArgs = [...prefix, ...args];
448
+
449
+ return new Promise((resolvePromise) => {
450
+ const start = Date.now();
451
+ let settled = false;
452
+ let stdout = "";
453
+ let stderr = "";
454
+ let timedOut = false;
455
+
456
+ // `env` is only passed through when given: `spawn(binary, fullArgs)`
457
+ // with no third argument at all is what every real, non-test call makes
458
+ // (production behaviour is unchanged either way, since Node's own
459
+ // default is already "inherit process.env").
460
+ const child = env ? spawn(binary, fullArgs, { env }) : spawn(binary, fullArgs);
461
+
462
+ const ticker = setInterval(() => {
463
+ if (!settled) onProgress(Date.now() - start, fullArgs);
464
+ }, tickMs);
465
+
466
+ const timer = setTimeout(() => {
467
+ timedOut = true;
468
+ child.kill();
469
+ }, timeoutMs);
470
+
471
+ const finish = (result: AdbResult) => {
472
+ if (settled) return;
473
+ settled = true;
474
+ clearInterval(ticker);
475
+ clearTimeout(timer);
476
+ resolvePromise(result);
477
+ };
478
+
479
+ child.stdout?.setEncoding("utf8");
480
+ child.stderr?.setEncoding("utf8");
481
+ child.stdout?.on("data", (chunk: string) => {
482
+ stdout += chunk;
483
+ });
484
+ child.stderr?.on("data", (chunk: string) => {
485
+ stderr += chunk;
486
+ });
487
+
488
+ // The binary itself did not run (ENOENT, EACCES, ...) — same wording as
489
+ // the sync version above, so a caller cannot tell which path answered.
490
+ child.on("error", (error) => {
491
+ finish({
492
+ ok: false,
493
+ output: `Could not run adb (${error.message}). Set ANDROID_HOME, or put adb on your PATH.`,
494
+ });
495
+ });
496
+
497
+ child.on("close", (code) => {
498
+ if (timedOut) {
499
+ finish({
500
+ ok: false,
501
+ output:
502
+ `adb did not finish within ${timeoutMs}ms running '${fullArgs.join(" ")}'; ` +
503
+ "it may be wedged, so it was killed rather than left to hang.",
504
+ });
505
+ return;
506
+ }
507
+ const output = (stdout + stderr).trim();
508
+ if (code !== 0) {
509
+ finish({
510
+ ok: false,
511
+ output:
512
+ output.includes("more than one") && !serial
513
+ ? `${output}\nStart the UI with --serial <id>; 'adb devices' lists them.`
514
+ : output || `adb exited ${code}`,
515
+ });
516
+ return;
517
+ }
518
+ finish({ ok: true, output });
519
+ });
520
+ });
521
+ }
522
+
523
+ /**
524
+ * The `am force-stop` / launcher-intent pair both `restartApp` and
525
+ * `restartAppAsync` send, and the one honest way to tell whether the second
526
+ * half actually launched anything.
527
+ *
528
+ * monkey reports success on stdout even when it launched nothing, so the
529
+ * absence of its "Events injected" line — not its exit code — is the signal
530
+ * that there was no launcher activity to hit.
531
+ */
532
+ function finishRestart(packageName: string, started: AdbResult): AdbResult {
533
+ if (!started.ok) return started;
534
+ if (!started.output.includes("Events injected")) {
535
+ return {
536
+ ok: false,
537
+ output: started.output || `No launcher activity found for ${packageName}.`,
538
+ };
539
+ }
540
+ return { ok: true, output: `restarted ${packageName}` };
541
+ }
542
+
543
+ /**
544
+ * Stop the app and start it again.
545
+ *
546
+ * Driven from this side rather than from inside the app: a process cannot
547
+ * reliably restart itself, and asking it to try is how you end up with a
548
+ * half-dead process that no longer answers the socket.
549
+ */
550
+ export function restartApp(packageName: string, serial?: string): AdbResult {
551
+ const stopped = runAdb(["shell", "am", "force-stop", packageName], serial);
552
+ if (!stopped.ok) return stopped;
553
+
554
+ const started = runAdb(
555
+ ["shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1"],
556
+ serial,
557
+ );
558
+ return finishRestart(packageName, started);
559
+ }
560
+
561
+ /**
562
+ * `restartApp`'s async twin, spawned with `runAdbAsync` instead of
563
+ * `runAdb`'s blocking `spawnSync`.
564
+ *
565
+ * GRA-186: `capture_system_trace` restarts the app it is tracing partway
566
+ * through an already-running recording (see `index.ts`), so it cannot use
567
+ * the sync version without freezing the event loop for as long as the
568
+ * force-stop/relaunch pair takes — exactly the problem GRA-89 already fixed
569
+ * for the recording, pull and cleanup calls in the same tool. This shares
570
+ * `finishRestart`'s arg-building result and "Events injected" check with the
571
+ * sync version rather than re-deriving them, so the two can only drift by a
572
+ * change that touches both call sites.
573
+ */
574
+ export async function restartAppAsync(packageName: string, options: RunAdbAsyncOptions = {}): Promise<AdbResult> {
575
+ const stopped = await runAdbAsync(["shell", "am", "force-stop", packageName], options);
576
+ if (!stopped.ok) return stopped;
577
+
578
+ const started = await runAdbAsync(
579
+ ["shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1"],
580
+ options,
581
+ );
582
+ return finishRestart(packageName, started);
583
+ }