@gravitylabsllc/porthole 0.1.0 → 0.2.1
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 +123 -0
- package/dist/adb.js +430 -21
- package/dist/adb.js.map +1 -1
- package/dist/args.js +144 -0
- package/dist/args.js.map +1 -0
- package/dist/capture.js +139 -30
- package/dist/capture.js.map +1 -1
- package/dist/cli.js +221 -62
- package/dist/cli.js.map +1 -1
- package/dist/device.js +337 -4
- package/dist/device.js.map +1 -1
- package/dist/index.js +2030 -377
- package/dist/index.js.map +1 -1
- package/dist/moment.js +240 -0
- package/dist/moment.js.map +1 -0
- package/dist/perfetto.js +826 -0
- package/dist/perfetto.js.map +1 -0
- package/dist/report.js +68 -7
- package/dist/report.js.map +1 -1
- package/dist/save.js +252 -0
- package/dist/save.js.map +1 -0
- package/dist/sessions.js +704 -0
- package/dist/sessions.js.map +1 -0
- package/dist/system.js +169 -0
- package/dist/system.js.map +1 -0
- package/dist/systrace.js +198 -0
- package/dist/systrace.js.map +1 -0
- package/dist/timeline.js +731 -29
- package/dist/timeline.js.map +1 -1
- package/dist/trace.js +317 -27
- package/dist/trace.js.map +1 -1
- package/dist/watermark.js +220 -0
- package/dist/watermark.js.map +1 -0
- package/package.json +10 -4
- package/src/adb.ts +583 -0
- package/src/args.ts +177 -0
- package/src/capture.ts +292 -0
- package/src/cli.ts +367 -0
- package/src/device.ts +635 -0
- package/src/index.ts +2545 -0
- package/src/moment.ts +306 -0
- package/src/perfetto.ts +972 -0
- package/src/report.ts +285 -0
- package/src/save.ts +322 -0
- package/src/sessions.ts +894 -0
- package/src/system.ts +221 -0
- package/src/systrace.ts +258 -0
- package/src/timeline.ts +1036 -0
- package/src/trace.ts +769 -0
- package/src/watermark.ts +337 -0
- package/ui/dist/assets/index-DtnyBXCM.css +1 -0
- package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index--1mlZuNZ.css +0 -1
- package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/cli.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright 2026 Gravity Labs
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { DeviceClient, type ConnectionState } from "./device.js";
|
|
6
|
+
import { TimelineServer, type PortInUse } from "./timeline.js";
|
|
7
|
+
import { capture, compare, parseCapture, report } from "./capture.js";
|
|
8
|
+
import { resolveProjectRoot, runAdb } from "./adb.js";
|
|
9
|
+
import { bootPortholeServer } from "./index.js";
|
|
10
|
+
import { parseDuration, parseMillis, parsePort, requiredValue } from "./args.js";
|
|
11
|
+
import { sessionsRoot } from "./sessions.js";
|
|
12
|
+
import { listSessionsText, saveFromSessions, type SaveFromSessionsOptions } from "./save.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The human entry point.
|
|
16
|
+
*
|
|
17
|
+
* The MCP server exists for an agent and talks stdio; this exists for a person
|
|
18
|
+
* and talks to a browser. Opening the timeline should not require asking an
|
|
19
|
+
* agent to call a tool on your behalf.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface Options {
|
|
23
|
+
port: number;
|
|
24
|
+
uiPort: number;
|
|
25
|
+
serial?: string;
|
|
26
|
+
forward: boolean;
|
|
27
|
+
open: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const USAGE = `
|
|
31
|
+
porthole — a window into a running Android app
|
|
32
|
+
|
|
33
|
+
porthole ui open the live timeline
|
|
34
|
+
porthole capture --scenario <name> -- <command> record a run to a trace
|
|
35
|
+
porthole save (--since <dur> | --from <ms> --to <ms>) save what already happened
|
|
36
|
+
porthole sessions list what is recorded on disk
|
|
37
|
+
porthole report <trace.json> what the run is worth looking at
|
|
38
|
+
porthole compare <base> <trace> regressions against a baseline
|
|
39
|
+
porthole mcp the MCP server (stdio)
|
|
40
|
+
|
|
41
|
+
porthole ui — open the live timeline for a running debug build
|
|
42
|
+
|
|
43
|
+
npx @gravitylabsllc/porthole ui [options]
|
|
44
|
+
|
|
45
|
+
--port <n> device port the porthole is listening on (default 8677)
|
|
46
|
+
--ui-port <n> port to serve the timeline on (default 8678)
|
|
47
|
+
--serial <id> adb device serial, when more than one is attached
|
|
48
|
+
--no-forward skip 'adb forward'; use it if you set the bridge up yourself
|
|
49
|
+
--no-open do not launch a browser, just print the URL
|
|
50
|
+
|
|
51
|
+
Needs a device or emulator with the debug build running: the porthole lives inside
|
|
52
|
+
the app process, and adb forward is what makes its socket reachable from here.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Exported so a test can drive the argv loop itself, not just the pure
|
|
57
|
+
* validators it calls. A test that only calls `parsePort` directly cannot
|
|
58
|
+
* tell the difference between this loop checking its result and ignoring it —
|
|
59
|
+
* deleting the `process.exit(2)` branches below left every prior test green.
|
|
60
|
+
*/
|
|
61
|
+
export function parse(argv: string[]): Options {
|
|
62
|
+
const options: Options = {
|
|
63
|
+
port: 8677,
|
|
64
|
+
uiPort: 8678,
|
|
65
|
+
forward: true,
|
|
66
|
+
open: true,
|
|
67
|
+
};
|
|
68
|
+
for (let i = 0; i < argv.length; i++) {
|
|
69
|
+
const arg = argv[i];
|
|
70
|
+
if (arg === "--port") {
|
|
71
|
+
const value = parsePort(argv[++i], "--port");
|
|
72
|
+
if (typeof value !== "number") {
|
|
73
|
+
process.stderr.write(`${value.message}\n`);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
options.port = value;
|
|
77
|
+
} else if (arg === "--ui-port") {
|
|
78
|
+
const value = parsePort(argv[++i], "--ui-port");
|
|
79
|
+
if (typeof value !== "number") {
|
|
80
|
+
process.stderr.write(`${value.message}\n`);
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
options.uiPort = value;
|
|
84
|
+
} else if (arg === "--serial") {
|
|
85
|
+
// Previously `argv[++i]` raw: a missing value was consumed silently, and
|
|
86
|
+
// `--serial --port 8677` swallowed "--port" as the serial and left
|
|
87
|
+
// "8677" to be rejected next as a nonsense option — blaming the wrong
|
|
88
|
+
// token for the actual mistake. requiredValue names --serial instead.
|
|
89
|
+
const value = requiredValue(argv[++i], "--serial");
|
|
90
|
+
if (typeof value !== "string") {
|
|
91
|
+
process.stderr.write(`${value.message}\n`);
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
options.serial = value;
|
|
95
|
+
} else if (arg === "--no-forward") options.forward = false;
|
|
96
|
+
else if (arg === "--no-open") options.open = false;
|
|
97
|
+
else if (arg === "--help" || arg === "-h") {
|
|
98
|
+
process.stdout.write(USAGE);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
} else {
|
|
101
|
+
process.stderr.write(`unknown option: ${arg}\n${USAGE}`);
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const SAVE_USAGE = `
|
|
109
|
+
porthole save — turn a window of what already happened into a trace file
|
|
110
|
+
|
|
111
|
+
porthole save (--since <duration> | --from <ms> --to <ms>) [options]
|
|
112
|
+
|
|
113
|
+
--since <duration> how far back to look: 10m, 90s, 2h, or a millisecond count
|
|
114
|
+
--from <ms> absolute start, device uptime clock (quote a finding's window)
|
|
115
|
+
--to <ms> absolute end, same clock
|
|
116
|
+
--scenario <name> what to call it. Defaults to moment-<from>-<to>
|
|
117
|
+
--out <file> where to write the trace. Defaults to .porthole/traces/<scenario>.json
|
|
118
|
+
|
|
119
|
+
Resolves against whichever session on disk was most recently written to —
|
|
120
|
+
there is no running MCP server here to ask "what counts as now" of.
|
|
121
|
+
|
|
122
|
+
porthole sessions list every session recorded on disk
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
export interface SaveCliOptions {
|
|
126
|
+
scenario?: string;
|
|
127
|
+
sinceMs?: number;
|
|
128
|
+
from?: number;
|
|
129
|
+
to?: number;
|
|
130
|
+
out?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* GRA-54. Same discipline as `parseCapture`/`parse()` above: every value
|
|
135
|
+
* goes through a validator that names the option and refuses rather than
|
|
136
|
+
* silently accepting `NaN` or swallowing the next flag as its own value.
|
|
137
|
+
* Exported, like `parse()`, so a test can drive the argv loop itself and not
|
|
138
|
+
* just the pure validators it calls (deleting the `process.exit(2)`
|
|
139
|
+
* branches below would otherwise leave every prior test green).
|
|
140
|
+
*/
|
|
141
|
+
export function parseSave(argv: string[]): SaveCliOptions {
|
|
142
|
+
const options: SaveCliOptions = {};
|
|
143
|
+
for (let i = 0; i < argv.length; i++) {
|
|
144
|
+
const arg = argv[i];
|
|
145
|
+
if (arg === "--scenario") {
|
|
146
|
+
const value = requiredValue(argv[++i], "--scenario");
|
|
147
|
+
if (typeof value !== "string") {
|
|
148
|
+
process.stderr.write(`${value.message}\n`);
|
|
149
|
+
process.exit(2);
|
|
150
|
+
}
|
|
151
|
+
options.scenario = value;
|
|
152
|
+
} else if (arg === "--since") {
|
|
153
|
+
const value = parseDuration(argv[++i], "--since");
|
|
154
|
+
if (typeof value !== "number") {
|
|
155
|
+
process.stderr.write(`${value.message}\n`);
|
|
156
|
+
process.exit(2);
|
|
157
|
+
}
|
|
158
|
+
options.sinceMs = value;
|
|
159
|
+
} else if (arg === "--from") {
|
|
160
|
+
const value = parseMillis(argv[++i], "--from");
|
|
161
|
+
if (typeof value !== "number") {
|
|
162
|
+
process.stderr.write(`${value.message}\n`);
|
|
163
|
+
process.exit(2);
|
|
164
|
+
}
|
|
165
|
+
options.from = value;
|
|
166
|
+
} else if (arg === "--to") {
|
|
167
|
+
const value = parseMillis(argv[++i], "--to");
|
|
168
|
+
if (typeof value !== "number") {
|
|
169
|
+
process.stderr.write(`${value.message}\n`);
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|
|
172
|
+
options.to = value;
|
|
173
|
+
} else if (arg === "--out") {
|
|
174
|
+
const value = requiredValue(argv[++i], "--out");
|
|
175
|
+
if (typeof value !== "string") {
|
|
176
|
+
process.stderr.write(`${value.message}\n`);
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
options.out = value;
|
|
180
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
181
|
+
process.stdout.write(SAVE_USAGE);
|
|
182
|
+
process.exit(0);
|
|
183
|
+
} else {
|
|
184
|
+
process.stderr.write(`unknown option: ${arg}\n${SAVE_USAGE}`);
|
|
185
|
+
process.exit(2);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const hasSince = options.sinceMs !== undefined;
|
|
190
|
+
const hasFrom = options.from !== undefined;
|
|
191
|
+
const hasTo = options.to !== undefined;
|
|
192
|
+
if (hasSince && (hasFrom || hasTo)) {
|
|
193
|
+
process.stderr.write(`--since cannot be combined with --from/--to\n${SAVE_USAGE}`);
|
|
194
|
+
process.exit(2);
|
|
195
|
+
}
|
|
196
|
+
if (hasFrom !== hasTo) {
|
|
197
|
+
process.stderr.write(`--from and --to must be given together\n${SAVE_USAGE}`);
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|
|
200
|
+
if (!hasSince && !hasFrom) {
|
|
201
|
+
process.stderr.write(`give either --since <duration> or both --from and --to\n${SAVE_USAGE}`);
|
|
202
|
+
process.exit(2);
|
|
203
|
+
}
|
|
204
|
+
return options;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function openBrowser(url: string): void {
|
|
208
|
+
const [command, args] =
|
|
209
|
+
process.platform === "win32"
|
|
210
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
211
|
+
: process.platform === "darwin"
|
|
212
|
+
? ["open", [url]]
|
|
213
|
+
: ["xdg-open", [url]];
|
|
214
|
+
try {
|
|
215
|
+
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
216
|
+
} catch {
|
|
217
|
+
// Printing the URL is the fallback, and it is already printed.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function ui(argv: string[]): Promise<void> {
|
|
222
|
+
const options = parse(argv);
|
|
223
|
+
if (options.forward) {
|
|
224
|
+
// Same call `porthole capture` makes, through the same runAdb: this used to
|
|
225
|
+
// be a second copy of adb discovery and a second copy of the advice to pass
|
|
226
|
+
// --serial, and the copy here was the one that could not find the SDK.
|
|
227
|
+
const forwarded = runAdb(
|
|
228
|
+
["forward", `tcp:${options.port}`, `tcp:${options.port}`],
|
|
229
|
+
options.serial,
|
|
230
|
+
);
|
|
231
|
+
if (forwarded.ok) {
|
|
232
|
+
console.error(`forwarded 127.0.0.1:${options.port} to the device`);
|
|
233
|
+
} else {
|
|
234
|
+
console.error(forwarded.output);
|
|
235
|
+
console.error("Pass --no-forward if the bridge is already up.");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// GRA-53: the same sessions root `createPortholeServer` uses (index.ts),
|
|
240
|
+
// so a session started here — `porthole ui` is a real entry point someone
|
|
241
|
+
// launches directly, not only through an agent's MCP server — persists to
|
|
242
|
+
// disk the same as any other. Without this, `porthole ui` silently wrote
|
|
243
|
+
// to nothing: `DeviceClient`'s sessions root defaults to disabled when
|
|
244
|
+
// omitted, and nobody watching a browser tab would notice a feature that
|
|
245
|
+
// fails silent.
|
|
246
|
+
const device = new DeviceClient("127.0.0.1", options.port, sessionsRoot(resolveProjectRoot().directory));
|
|
247
|
+
const timeline = new TimelineServer(device, options.uiPort, options.serial);
|
|
248
|
+
device.start();
|
|
249
|
+
|
|
250
|
+
let url: string;
|
|
251
|
+
try {
|
|
252
|
+
url = await timeline.start();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const problem = error as PortInUse;
|
|
255
|
+
console.error(problem.message);
|
|
256
|
+
// An instance already serving this device is not a failure — it is the
|
|
257
|
+
// thing that was asked for. Point at it and stop.
|
|
258
|
+
if (problem.portholeAlreadyRunning) {
|
|
259
|
+
if (options.open) openBrowser(problem.url);
|
|
260
|
+
process.exit(0);
|
|
261
|
+
}
|
|
262
|
+
console.error(`Pass --ui-port to use a port other than ${options.uiPort}.`);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.error(`timeline at ${url}`);
|
|
267
|
+
if (options.open) openBrowser(url);
|
|
268
|
+
|
|
269
|
+
device.on("state", (state: ConnectionState) => {
|
|
270
|
+
// GRA-162: was an if/else-if chain with no final else, so "connecting"
|
|
271
|
+
// printed nothing — silently correct, but silently, and a fifth state
|
|
272
|
+
// would have joined it there without tsc ever noticing. A switch with
|
|
273
|
+
// an explicit (still silent) "connecting" case and a never-guarded
|
|
274
|
+
// default gives that same behaviour a name and makes the next state
|
|
275
|
+
// addition fail here instead of joining "connecting" by accident. Same
|
|
276
|
+
// precedent as pendingMessage() in device.ts.
|
|
277
|
+
switch (state) {
|
|
278
|
+
case "handshaking":
|
|
279
|
+
// GRA-157: this fires exactly when the socket comes up, which is the
|
|
280
|
+
// real event the 2-second setTimeout below used to guess at. Printing
|
|
281
|
+
// here instead means the CLI says something true immediately on a
|
|
282
|
+
// slow device and does not need a fixed wait on a fast one — the
|
|
283
|
+
// opposite of what a timer can do.
|
|
284
|
+
console.error("connected, waiting on the app's first check-in...");
|
|
285
|
+
break;
|
|
286
|
+
case "connected": {
|
|
287
|
+
// hello is guaranteed non-null here — DeviceClient does not enter
|
|
288
|
+
// "connected" until it is (see device.ts's setState()) — so this no
|
|
289
|
+
// longer hedges with a ternary the way it had to before that was true.
|
|
290
|
+
const hello = device.hello as NonNullable<typeof device.hello>;
|
|
291
|
+
console.error(`connected to ${hello.packageName} on ${hello.device}`);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
case "disconnected":
|
|
295
|
+
// Expected constantly during development: the app gets reinstalled and
|
|
296
|
+
// relaunched, and the client reconnects on its own.
|
|
297
|
+
console.error("waiting for the app...");
|
|
298
|
+
break;
|
|
299
|
+
case "connecting":
|
|
300
|
+
// No behaviour change (GRA-162 AC3): the original chain had no
|
|
301
|
+
// branch for "connecting" either, so this stays deliberately silent.
|
|
302
|
+
break;
|
|
303
|
+
default: {
|
|
304
|
+
const exhaustive: never = state;
|
|
305
|
+
throw new Error(`porthole ui: unhandled ConnectionState '${exhaustive as string}'`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const shutdown = () => {
|
|
311
|
+
device.stop();
|
|
312
|
+
timeline.stop();
|
|
313
|
+
process.exit(0);
|
|
314
|
+
};
|
|
315
|
+
process.on("SIGINT", shutdown);
|
|
316
|
+
process.on("SIGTERM", shutdown);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
320
|
+
|
|
321
|
+
if (command === "ui") {
|
|
322
|
+
await ui(rest);
|
|
323
|
+
} else if (command === "mcp") {
|
|
324
|
+
// Calls the same boot function `node dist/index.js` uses under its own
|
|
325
|
+
// `isMainModule()` guard, rather than `import("./index.js")`ing for the
|
|
326
|
+
// side effect. That side-effect import used to be how this worked, but it
|
|
327
|
+
// silently stopped booting anything once the boot moved behind the guard:
|
|
328
|
+
// `argv[1]` here is `cli.js`, so the guard (correctly) never fires for us.
|
|
329
|
+
// One boot path, two callers.
|
|
330
|
+
await bootPortholeServer();
|
|
331
|
+
} else if (command === "capture") {
|
|
332
|
+
const options = parseCapture(rest);
|
|
333
|
+
if (options.forward) {
|
|
334
|
+
const forwarded = runAdb(
|
|
335
|
+
["forward", `tcp:${options.port}`, `tcp:${options.port}`],
|
|
336
|
+
options.serial,
|
|
337
|
+
);
|
|
338
|
+
if (!forwarded.ok) console.error(forwarded.output);
|
|
339
|
+
}
|
|
340
|
+
process.exit(await capture(options));
|
|
341
|
+
} else if (command === "save") {
|
|
342
|
+
const options = parseSave(rest);
|
|
343
|
+
const projectRoot = resolveProjectRoot().directory;
|
|
344
|
+
const saveOptions: SaveFromSessionsOptions = { root: sessionsRoot(projectRoot), projectRoot, ...options };
|
|
345
|
+
const result = await saveFromSessions(saveOptions);
|
|
346
|
+
process.stderr.write(`${result.message}\n`);
|
|
347
|
+
process.exit(result.code);
|
|
348
|
+
} else if (command === "sessions") {
|
|
349
|
+
const result = await listSessionsText(sessionsRoot(resolveProjectRoot().directory));
|
|
350
|
+
process.stdout.write(`${result.message}\n`);
|
|
351
|
+
process.exit(result.code);
|
|
352
|
+
} else if (command === "report") {
|
|
353
|
+
if (!rest[0]) {
|
|
354
|
+
process.stderr.write("porthole report <trace.json>\n");
|
|
355
|
+
process.exit(2);
|
|
356
|
+
}
|
|
357
|
+
process.exit(await report(rest[0]));
|
|
358
|
+
} else if (command === "compare") {
|
|
359
|
+
if (!rest[0] || !rest[1]) {
|
|
360
|
+
process.stderr.write("porthole compare <baseline.json> <trace.json>\n");
|
|
361
|
+
process.exit(2);
|
|
362
|
+
}
|
|
363
|
+
process.exit(await compare(rest[0], rest[1]));
|
|
364
|
+
} else {
|
|
365
|
+
process.stdout.write(USAGE);
|
|
366
|
+
process.exit(command === undefined || command === "--help" || command === "-h" ? 0 : 2);
|
|
367
|
+
}
|