portholejs 0.1.1 → 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.
Files changed (75) hide show
  1. package/README.md +156 -46
  2. package/client-dist/assets/index-cy7uJgaT.js +49 -0
  3. package/client-dist/index.html +1 -1
  4. package/dist/cli.js +351 -83
  5. package/dist/cli.js.map +1 -1
  6. package/dist/control-client.d.ts +1 -0
  7. package/dist/control-client.d.ts.map +1 -1
  8. package/dist/control-client.js +10 -2
  9. package/dist/control-client.js.map +1 -1
  10. package/dist/engine/scrcpy-engine.d.ts +2 -2
  11. package/dist/engine/scrcpy-engine.d.ts.map +1 -1
  12. package/dist/engine/scrcpy-engine.js.map +1 -1
  13. package/dist/engine/types.d.ts +3 -8
  14. package/dist/engine/types.d.ts.map +1 -1
  15. package/dist/focus-navigation.d.ts +27 -0
  16. package/dist/focus-navigation.d.ts.map +1 -0
  17. package/dist/focus-navigation.js +118 -0
  18. package/dist/focus-navigation.js.map +1 -0
  19. package/dist/gesture.d.ts +14 -0
  20. package/dist/gesture.d.ts.map +1 -0
  21. package/dist/gesture.js +110 -0
  22. package/dist/gesture.js.map +1 -0
  23. package/dist/index.d.ts +4 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/input-validation.d.ts.map +1 -1
  28. package/dist/input-validation.js +4 -56
  29. package/dist/input-validation.js.map +1 -1
  30. package/dist/input.d.ts +1 -21
  31. package/dist/input.d.ts.map +1 -1
  32. package/dist/keycodes.d.ts +2 -1
  33. package/dist/keycodes.d.ts.map +1 -1
  34. package/dist/keycodes.js.map +1 -1
  35. package/dist/mcp/server.d.ts.map +1 -1
  36. package/dist/mcp/server.js +185 -2
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/mp4-writer.d.ts +18 -0
  39. package/dist/mp4-writer.d.ts.map +1 -0
  40. package/dist/mp4-writer.js +195 -0
  41. package/dist/mp4-writer.js.map +1 -0
  42. package/dist/protocol.d.ts +74 -2
  43. package/dist/protocol.d.ts.map +1 -1
  44. package/dist/protocol.js +154 -7
  45. package/dist/protocol.js.map +1 -1
  46. package/dist/recording.d.ts +22 -0
  47. package/dist/recording.d.ts.map +1 -0
  48. package/dist/recording.js +132 -0
  49. package/dist/recording.js.map +1 -0
  50. package/dist/screen-diff.d.ts +21 -0
  51. package/dist/screen-diff.d.ts.map +1 -0
  52. package/dist/screen-diff.js +70 -0
  53. package/dist/screen-diff.js.map +1 -0
  54. package/dist/server/http.d.ts +5 -4
  55. package/dist/server/http.d.ts.map +1 -1
  56. package/dist/server/http.js +69 -31
  57. package/dist/server/http.js.map +1 -1
  58. package/dist/server/ws.d.ts +4 -3
  59. package/dist/server/ws.d.ts.map +1 -1
  60. package/dist/server/ws.js +32 -18
  61. package/dist/server/ws.js.map +1 -1
  62. package/dist/session.d.ts +25 -10
  63. package/dist/session.d.ts.map +1 -1
  64. package/dist/session.js +117 -67
  65. package/dist/session.js.map +1 -1
  66. package/dist/state.d.ts.map +1 -1
  67. package/dist/state.js +1 -3
  68. package/dist/state.js.map +1 -1
  69. package/dist/version.d.ts +2 -0
  70. package/dist/version.d.ts.map +1 -0
  71. package/dist/version.js +6 -0
  72. package/dist/version.js.map +1 -0
  73. package/package.json +18 -3
  74. package/skills/porthole/SKILL.md +130 -0
  75. package/client-dist/assets/index-C_qSS_Gy.js +0 -49
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
+ import { readFile, writeFile } from "node:fs/promises";
4
5
  import { createInterface } from "node:readline/promises";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { Command } from "commander";
@@ -11,10 +12,13 @@ import { startMcpServer } from "./mcp/server.js";
11
12
  import { VERSION } from "./index.js";
12
13
  import { resolveTarget } from "./target-resolution.js";
13
14
  import { defaultScreenshotPath, fetchSessionScreenshot, getSessionJson, postSessionJson, sendSessionInput, writeScreenshot, } from "./control-client.js";
14
- import { readState } from "./state.js";
15
+ import { readState, removeSession } from "./state.js";
15
16
  import { runCliAction } from "./cli-errors.js";
16
17
  import { runDoctor } from "./doctor.js";
17
18
  import { ensurePortFree } from "./port-check.js";
19
+ import { scrollGesture } from "./gesture.js";
20
+ import { comparePngScreens, parseDiffRegion } from "./screen-diff.js";
21
+ import { recordSession } from "./recording.js";
18
22
  const program = new Command();
19
23
  if (Number(process.versions.node.split(".")[0] ?? "0") < 20) {
20
24
  console.error("porthole: Node.js 20 or newer is required.");
@@ -75,7 +79,7 @@ program
75
79
  });
76
80
  });
77
81
  program
78
- .command("start [avd]", { isDefault: true })
82
+ .command("start [avds...]", { isDefault: true })
79
83
  .description("Boot/attach emulator and serve preview")
80
84
  .option("-p, --port <port>", "HTTP port", "3200")
81
85
  .option("-d, --device <serial>", "Target device serial")
@@ -92,7 +96,7 @@ program
92
96
  .option("--max-fps <fps>", "Maximum stream FPS", "30")
93
97
  .option("--bitrate <bps>", "Stream bitrate in bits per second")
94
98
  .option("-q, --quiet", "Quiet/JSON mode")
95
- .action(async (avd, opts) => {
99
+ .action(async (avds, opts) => {
96
100
  // Check the port before prompting, booting, or detaching — a busy port
97
101
  // otherwise surfaces as a boot wasted on a doomed session or as an
98
102
  // opaque detach timeout.
@@ -102,73 +106,47 @@ program
102
106
  process.exit(1);
103
107
  }
104
108
  if (opts.detach && process.env["PORTHOLE_DETACHED_CHILD"] !== "1") {
105
- await startDetached(avd, opts);
109
+ await startDetached(avds ?? [], opts);
106
110
  return;
107
111
  }
108
112
  const sdk = findAndroidSdk();
109
- let devices = await listDevices();
110
- let selectedAvd = avd;
111
- if (!selectedAvd && !opts.device && !opts.quiet) {
112
- selectedAvd = await promptForAvd(devices);
113
- }
114
- const resolution = resolveTarget(devices, selectedAvd, opts.device);
115
- let target;
116
- let bootedByUs = false;
117
- if (resolution.action === "error") {
118
- console.error(resolution.message);
119
- process.exit(1);
120
- }
121
- if (resolution.action === "boot") {
122
- if (!opts.quiet)
123
- console.log(`Booting ${resolution.avdName}...`);
124
- const serial = await bootDevice({
125
- sdk,
126
- avdName: resolution.avdName,
127
- emulatorArgs: emulatorArgs(opts),
128
- });
129
- devices = await listDevices();
130
- target = devices.find((d) => d.serial === serial);
131
- bootedByUs = true;
113
+ const devices = await listDevices();
114
+ let selectedAvds = avds ?? [];
115
+ if (selectedAvds.length === 0 && !opts.device && !opts.quiet) {
116
+ const selectedAvd = await promptForAvd(devices);
117
+ selectedAvds = selectedAvd ? [selectedAvd] : [];
132
118
  }
133
- else {
134
- target = resolution.device;
135
- }
136
- if (target?.state === "offline") {
137
- if (!opts.quiet)
138
- console.log(`Reconnecting ${target.serial}...`);
139
- await reconnectOfflineDevices(sdk, target.serial ?? undefined);
140
- devices = await listDevices();
141
- const reconnected = devices.find((device) => (target?.serial && device.serial === target.serial) ||
142
- device.name === target?.name);
143
- if (reconnected) {
144
- target = reconnected;
145
- }
119
+ let targets;
120
+ try {
121
+ targets = await resolveSessionTargets(devices, selectedAvds, opts, sdk);
146
122
  }
147
- if (!target || !target.serial || target.state !== "running") {
148
- console.error("No target device.");
123
+ catch (error) {
124
+ console.error(error instanceof Error ? error.message : String(error));
149
125
  process.exit(1);
150
126
  }
151
127
  const session = new Session({
152
- device: target,
128
+ devices: targets,
153
129
  port: parseInt(opts.port, 10),
154
130
  host: opts.host,
155
131
  maxSize: Number(opts.maxSize),
156
132
  maxFps: Number(opts.maxFps),
157
133
  bitrate: opts.bitrate ? Number(opts.bitrate) : undefined,
158
- bootedByUs,
159
134
  detached: process.env["PORTHOLE_DETACHED_CHILD"] === "1",
160
135
  forceMjpeg: opts.mjpeg ?? false,
161
136
  });
162
137
  try {
163
138
  const { url } = await session.start();
164
139
  if (!opts.quiet) {
165
- console.log(`Attached to ${target.name} [${target.profile}] (${target.serial})`);
140
+ for (const target of targets) {
141
+ console.log(`Attached to ${target.device.name} [${target.device.profile}] (${target.device.serial})`);
142
+ }
166
143
  console.log(`Preview: ${url}`);
167
144
  if (opts.mjpeg)
168
145
  console.log("Video mode: MJPEG screenshot polling");
169
146
  }
170
147
  else {
171
- process.stdout.write(JSON.stringify({ device: target, url }) + "\n");
148
+ process.stdout.write(JSON.stringify({ devices: targets.map((target) => target.device), url }) +
149
+ "\n");
172
150
  }
173
151
  if (opts.preview && process.env["PORTHOLE_DETACHED_CHILD"] !== "1") {
174
152
  openBrowser(url);
@@ -182,15 +160,19 @@ program
182
160
  else {
183
161
  console.error(`Failed to start: ${message}`);
184
162
  }
185
- if (bootedByUs && !opts.keepAlive && target.serial) {
186
- await shutdownDevice(sdk, target.serial);
163
+ for (const target of targets) {
164
+ if (target.bootedByUs && !opts.keepAlive && target.device.serial) {
165
+ await shutdownDevice(sdk, target.device.serial);
166
+ }
187
167
  }
188
168
  process.exit(1);
189
169
  }
190
170
  const stopForSignal = async () => {
191
171
  await session.stop();
192
- if (bootedByUs && !opts.keepAlive && target.serial) {
193
- await shutdownDevice(sdk, target.serial);
172
+ for (const target of targets) {
173
+ if (target.bootedByUs && !opts.keepAlive && target.device.serial) {
174
+ await shutdownDevice(sdk, target.device.serial);
175
+ }
194
176
  }
195
177
  process.exit(0);
196
178
  };
@@ -206,6 +188,7 @@ program
206
188
  const devices = await listDevices();
207
189
  const state = await readState();
208
190
  const targetSerials = new Set();
191
+ const matchedSessions = state.sessions.filter((session) => !avd || session.avdName === avd);
209
192
  for (const d of devices) {
210
193
  if (d.state === "running" &&
211
194
  d.serial &&
@@ -213,10 +196,14 @@ program
213
196
  targetSerials.add(d.serial);
214
197
  }
215
198
  }
216
- for (const session of state.sessions) {
217
- if (!avd || session.avdName === avd) {
218
- targetSerials.add(session.serial);
219
- if (session.detached && session.pid !== process.pid) {
199
+ for (const session of matchedSessions) {
200
+ targetSerials.add(session.serial);
201
+ if (session.detached && session.pid !== process.pid) {
202
+ const siblingOnSharedServer = avd !== undefined &&
203
+ state.sessions.some((other) => other.pid === session.pid &&
204
+ other.port === session.port &&
205
+ other.serial !== session.serial);
206
+ if (!siblingOnSharedServer) {
220
207
  try {
221
208
  process.kill(session.pid, "SIGTERM");
222
209
  }
@@ -224,6 +211,9 @@ program
224
211
  // process already gone
225
212
  }
226
213
  }
214
+ else {
215
+ await removeSession({ serial: session.serial });
216
+ }
227
217
  }
228
218
  }
229
219
  if (targetSerials.size === 0) {
@@ -251,6 +241,7 @@ program
251
241
  .command("tap <x> <y>")
252
242
  .description("Touch at normalized 0..1 coordinates")
253
243
  .option("-p, --port <port>", "Session port")
244
+ .option("-d, --device <serial>", "Target device serial")
254
245
  .option("-q, --quiet", "JSON output")
255
246
  .action((x, y, opts) => {
256
247
  void runCliAction(opts, async () => {
@@ -259,15 +250,93 @@ program
259
250
  if (isNaN(nx) || isNaN(ny) || nx < 0 || nx > 1 || ny < 0 || ny > 1) {
260
251
  throw new Error("Coordinates must be numbers in 0..1");
261
252
  }
262
- await sendSessionInput({ kind: "touch", phase: "down", x: nx, y: ny }, { port: parseOptionalPort(opts.port) });
263
- const session = await sendSessionInput({ kind: "touch", phase: "up", x: nx, y: ny }, { port: parseOptionalPort(opts.port) });
253
+ await sendSessionInput({ kind: "touch", phase: "down", x: nx, y: ny }, sessionControlOpts(opts));
254
+ const session = await sendSessionInput({ kind: "touch", phase: "up", x: nx, y: ny }, sessionControlOpts(opts));
264
255
  printResult(opts.quiet, { ok: true, session, event: "tap" }, `Tapped ${nx},${ny}`);
265
256
  });
266
257
  });
258
+ program
259
+ .command("swipe <x1> <y1> <x2> <y2>")
260
+ .description("Swipe between normalized 0..1 phone coordinates")
261
+ .option("--duration <ms>", "Gesture duration in milliseconds")
262
+ .option("--steps <count>", "Number of move events")
263
+ .option("-p, --port <port>", "Session port")
264
+ .option("-d, --device <serial>", "Target device serial")
265
+ .option("-q, --quiet", "JSON output")
266
+ .action((x1, y1, x2, y2, opts) => {
267
+ void runCliAction(opts, async () => {
268
+ const event = {
269
+ kind: "gesture",
270
+ type: "swipe",
271
+ x1: parseNormalized(x1, "x1"),
272
+ y1: parseNormalized(y1, "y1"),
273
+ x2: parseNormalized(x2, "x2"),
274
+ y2: parseNormalized(y2, "y2"),
275
+ ...(opts.duration === undefined
276
+ ? {}
277
+ : { durationMs: parsePositiveNumber(opts.duration, "duration") }),
278
+ ...(opts.steps === undefined
279
+ ? {}
280
+ : { steps: parsePositiveInteger(opts.steps, "steps") }),
281
+ };
282
+ const session = await sendSessionInput(event, {
283
+ port: parseOptionalPort(opts.port),
284
+ });
285
+ printResult(opts.quiet, { ok: true, session, event }, "Swiped");
286
+ });
287
+ });
288
+ program
289
+ .command("longpress <x> <y>")
290
+ .alias("long-press")
291
+ .description("Long-press normalized 0..1 phone coordinates")
292
+ .option("--duration <ms>", "Hold duration in milliseconds")
293
+ .option("-p, --port <port>", "Session port")
294
+ .option("-d, --device <serial>", "Target device serial")
295
+ .option("-q, --quiet", "JSON output")
296
+ .action((x, y, opts) => {
297
+ void runCliAction(opts, async () => {
298
+ const event = {
299
+ kind: "gesture",
300
+ type: "longpress",
301
+ x1: parseNormalized(x, "x"),
302
+ y1: parseNormalized(y, "y"),
303
+ ...(opts.duration === undefined
304
+ ? {}
305
+ : { durationMs: parsePositiveNumber(opts.duration, "duration") }),
306
+ };
307
+ const session = await sendSessionInput(event, {
308
+ port: parseOptionalPort(opts.port),
309
+ });
310
+ printResult(opts.quiet, { ok: true, session, event }, "Long-pressed");
311
+ });
312
+ });
313
+ program
314
+ .command("scroll <direction>")
315
+ .description("Scroll a phone screen: up, down, left, or right")
316
+ .option("--amount <value>", "Normalized scroll amount", "0.5")
317
+ .option("--duration <ms>", "Gesture duration in milliseconds")
318
+ .option("-p, --port <port>", "Session port")
319
+ .option("-d, --device <serial>", "Target device serial")
320
+ .option("-q, --quiet", "JSON output")
321
+ .action((direction, opts) => {
322
+ void runCliAction(opts, async () => {
323
+ if (!isScrollDirection(direction)) {
324
+ throw new Error("Scroll direction must be up, down, left, or right");
325
+ }
326
+ const event = scrollGesture(direction, parseNormalized(opts.amount, "amount"), opts.duration === undefined
327
+ ? undefined
328
+ : parsePositiveNumber(opts.duration, "duration"));
329
+ const session = await sendSessionInput(event, {
330
+ port: parseOptionalPort(opts.port),
331
+ });
332
+ printResult(opts.quiet, { ok: true, session, direction, event }, `Scrolled ${direction}`);
333
+ });
334
+ });
267
335
  program
268
336
  .command("key <keycode>")
269
337
  .description("Send a single Android keyevent")
270
338
  .option("-p, --port <port>", "Session port")
339
+ .option("-d, --device <serial>", "Target device serial")
271
340
  .option("-q, --quiet", "JSON output")
272
341
  .action((keycode, opts) => {
273
342
  void runCliAction(opts, async () => {
@@ -275,8 +344,8 @@ program
275
344
  if (isNaN(code)) {
276
345
  throw new Error("Keycode must be a number");
277
346
  }
278
- await sendSessionInput({ kind: "key", phase: "down", keycode: code }, { port: parseOptionalPort(opts.port) });
279
- const session = await sendSessionInput({ kind: "key", phase: "up", keycode: code }, { port: parseOptionalPort(opts.port) });
347
+ await sendSessionInput({ kind: "key", phase: "down", keycode: code }, sessionControlOpts(opts));
348
+ const session = await sendSessionInput({ kind: "key", phase: "up", keycode: code }, sessionControlOpts(opts));
280
349
  printResult(opts.quiet, { ok: true, session, keycode: code }, `Key ${code} sent`);
281
350
  });
282
351
  });
@@ -284,13 +353,14 @@ program
284
353
  .command("remote <button>")
285
354
  .description("D-pad / media remote button")
286
355
  .option("-p, --port <port>", "Session port")
356
+ .option("-d, --device <serial>", "Target device serial")
287
357
  .option("-q, --quiet", "JSON output")
288
358
  .action((button, opts) => {
289
359
  void runCliAction(opts, async () => {
290
360
  if (!(button in REMOTE_BUTTON_TO_KEYCODE)) {
291
361
  throw new Error(`Unknown button: ${button}. Valid: ${Object.keys(REMOTE_BUTTON_TO_KEYCODE).join(", ")}`);
292
362
  }
293
- const session = await sendSessionInput({ kind: "remote", button: button }, { port: parseOptionalPort(opts.port) });
363
+ const session = await sendSessionInput({ kind: "remote", button: button }, sessionControlOpts(opts));
294
364
  printResult(opts.quiet, { ok: true, session, button }, `Remote: ${button}`);
295
365
  });
296
366
  });
@@ -298,10 +368,11 @@ program
298
368
  .command("text <string>")
299
369
  .description("Type text")
300
370
  .option("-p, --port <port>", "Session port")
371
+ .option("-d, --device <serial>", "Target device serial")
301
372
  .option("-q, --quiet", "JSON output")
302
373
  .action((text, opts) => {
303
374
  void runCliAction(opts, async () => {
304
- const session = await sendSessionInput({ kind: "text", text }, { port: parseOptionalPort(opts.port) });
375
+ const session = await sendSessionInput({ kind: "text", text }, sessionControlOpts(opts));
305
376
  printResult(opts.quiet, { ok: true, session, text }, `Typed "${text}"`);
306
377
  });
307
378
  });
@@ -309,26 +380,77 @@ program
309
380
  .command("screenshot")
310
381
  .description("Take a PNG screenshot from a running session")
311
382
  .option("-p, --port <port>", "Session port")
383
+ .option("-d, --device <serial>", "Target device serial")
312
384
  .option("-o, --output <file>", "Output path")
313
385
  .option("-q, --quiet", "JSON output")
314
386
  .action((opts) => {
315
387
  void runCliAction(opts, async () => {
316
- const { session, png } = await fetchSessionScreenshot({
317
- port: parseOptionalPort(opts.port),
318
- });
388
+ const { session, png } = await fetchSessionScreenshot(sessionControlOpts(opts));
319
389
  const output = opts.output ?? defaultScreenshotPath(session.serial);
320
390
  await writeScreenshot(output, png);
321
391
  printResult(opts.quiet, { ok: true, path: output, session }, output);
322
392
  });
323
393
  });
394
+ program
395
+ .command("assert-screen <baseline>")
396
+ .description("Compare the current screenshot against a PNG baseline")
397
+ .option("--threshold <ratio>", "Maximum mismatch ratio", "0.02")
398
+ .option("--diff <file>", "Write a PNG diff image")
399
+ .option("--region <x,y,w,h>", "Compare a pixel region")
400
+ .option("-p, --port <port>", "Session port")
401
+ .option("-d, --device <serial>", "Target device serial")
402
+ .option("-q, --quiet", "JSON output")
403
+ .action((baseline, opts) => {
404
+ void runCliAction(opts, async () => {
405
+ const { session, png } = await fetchSessionScreenshot({
406
+ port: parseOptionalPort(opts.port),
407
+ });
408
+ const result = comparePngScreens(await readFile(baseline), png, {
409
+ thresholdRatio: parseRatio(opts.threshold, "threshold"),
410
+ region: opts.region === undefined ? undefined : parseDiffRegion(opts.region),
411
+ });
412
+ if (opts.diff && result.diffPng) {
413
+ await writeFile(opts.diff, result.diffPng);
414
+ }
415
+ const payload = {
416
+ ok: result.ok,
417
+ mismatchRatio: result.mismatchRatio,
418
+ diffPath: opts.diff,
419
+ session,
420
+ };
421
+ printResult(opts.quiet, payload, result.ok
422
+ ? `Screen matched (${formatRatio(result.mismatchRatio)})`
423
+ : `Screen differed (${formatRatio(result.mismatchRatio)})`);
424
+ if (!result.ok)
425
+ process.exitCode = 1;
426
+ });
427
+ });
428
+ program
429
+ .command("record <output>")
430
+ .description("Record the current H.264 stream to an MP4 file")
431
+ .option("--duration <duration>", "Recording duration, e.g. 30s or 1500ms (bare = ms)")
432
+ .option("-p, --port <port>", "Session port")
433
+ .option("-d, --device <serial>", "Target device serial")
434
+ .option("-q, --quiet", "JSON output")
435
+ .action((output, opts) => {
436
+ void runCliAction(opts, async () => {
437
+ const result = await recordSession({
438
+ output,
439
+ durationMs: opts.duration === undefined ? undefined : parseDurationMs(opts.duration),
440
+ ...sessionControlOpts(opts),
441
+ });
442
+ printResult(opts.quiet, result, `Recorded ${result.path}`);
443
+ });
444
+ });
324
445
  program
325
446
  .command("rotate <orientation>")
326
447
  .description("Rotate a phone session: portrait, landscape, left, or right")
327
448
  .option("-p, --port <port>", "Session port")
449
+ .option("-d, --device <serial>", "Target device serial")
328
450
  .option("-q, --quiet", "JSON output")
329
451
  .action((orientation, opts) => {
330
452
  void runCliAction(opts, async () => {
331
- const result = await postSessionJson("/api/rotate", { orientation }, { port: parseOptionalPort(opts.port) });
453
+ const result = await postSessionJson("/api/rotate", { orientation }, sessionControlOpts(opts));
332
454
  printResult(opts.quiet, { ok: true, ...result }, `Rotated ${orientation}`);
333
455
  });
334
456
  });
@@ -336,10 +458,11 @@ program
336
458
  .command("emu <args...>")
337
459
  .description("Pass arguments to adb emu for the active session")
338
460
  .option("-p, --port <port>", "Session port")
461
+ .option("-d, --device <serial>", "Target device serial")
339
462
  .option("-q, --quiet", "JSON output")
340
463
  .action((args, opts) => {
341
464
  void runCliAction(opts, async () => {
342
- const result = await postSessionJson("/api/emu", { args }, { port: parseOptionalPort(opts.port) });
465
+ const result = await postSessionJson("/api/emu", { args }, sessionControlOpts(opts));
343
466
  printResult(opts.quiet, { ok: true, ...result }, "Emulator command sent");
344
467
  });
345
468
  });
@@ -347,19 +470,37 @@ program
347
470
  .command("focused")
348
471
  .description("Print the currently focused UI node")
349
472
  .option("-p, --port <port>", "Session port")
473
+ .option("-d, --device <serial>", "Target device serial")
350
474
  .option("-q, --quiet", "JSON output")
351
475
  .action((opts) => {
352
476
  void runCliAction(opts, async () => {
353
- const result = await getSessionJson("/api/focused", {
354
- port: parseOptionalPort(opts.port),
355
- });
477
+ const result = await getSessionJson("/api/focused", sessionControlOpts(opts));
356
478
  printResult(opts.quiet, result, JSON.stringify(result.response, null, 2));
357
479
  });
358
480
  });
481
+ program
482
+ .command("focus-on <text>")
483
+ .description("Move TV D-pad focus to matching text")
484
+ .option("--select", "Press select after focusing")
485
+ .option("--max-steps <count>", "Maximum D-pad steps", "15")
486
+ .option("-p, --port <port>", "Session port")
487
+ .option("-d, --device <serial>", "Target device serial")
488
+ .option("-q, --quiet", "JSON output")
489
+ .action((text, opts) => {
490
+ void runCliAction(opts, async () => {
491
+ const result = await postSessionJson("/api/focus_on", {
492
+ text,
493
+ select: opts.select === true,
494
+ maxSteps: parsePositiveInteger(opts.maxSteps, "max-steps"),
495
+ }, sessionControlOpts(opts));
496
+ printResult(opts.quiet, result, `Focused ${text}`);
497
+ });
498
+ });
359
499
  program
360
500
  .command("dump-ui")
361
501
  .description("Dump the Android accessibility hierarchy")
362
502
  .option("-p, --port <port>", "Session port")
503
+ .option("-d, --device <serial>", "Target device serial")
363
504
  .option("--filter <text>", "Filter by text/resource id/content description")
364
505
  .option("-q, --quiet", "JSON output")
365
506
  .action((opts) => {
@@ -368,7 +509,7 @@ program
368
509
  ? `?${new URLSearchParams({ filter: opts.filter }).toString()}`
369
510
  : "";
370
511
  const result = await getSessionJson(`/api/ui${query}`, {
371
- port: parseOptionalPort(opts.port),
512
+ ...sessionControlOpts(opts),
372
513
  });
373
514
  printResult(opts.quiet, result, JSON.stringify(result.response, null, 2));
374
515
  });
@@ -377,11 +518,12 @@ program
377
518
  .command("wait-for <text>")
378
519
  .description("Wait until text appears in the UI hierarchy")
379
520
  .option("-p, --port <port>", "Session port")
521
+ .option("-d, --device <serial>", "Target device serial")
380
522
  .option("--timeout <ms>", "Timeout in milliseconds", "10000")
381
523
  .option("-q, --quiet", "JSON output")
382
524
  .action((text, opts) => {
383
525
  void runCliAction(opts, async () => {
384
- const result = await postSessionJson("/api/wait_for", { text, timeoutMs: Number(opts.timeout) }, { port: parseOptionalPort(opts.port) });
526
+ const result = await postSessionJson("/api/wait_for", { text, timeoutMs: Number(opts.timeout) }, sessionControlOpts(opts));
385
527
  printResult(opts.quiet, { ok: true, ...result }, `Found "${text}"`);
386
528
  });
387
529
  });
@@ -389,10 +531,11 @@ program
389
531
  .command("open-url <url>")
390
532
  .description("Open an Android deep link or URL")
391
533
  .option("-p, --port <port>", "Session port")
534
+ .option("-d, --device <serial>", "Target device serial")
392
535
  .option("-q, --quiet", "JSON output")
393
536
  .action((url, opts) => {
394
537
  void runCliAction(opts, async () => {
395
- const result = await postSessionJson("/api/open_url", { url }, { port: parseOptionalPort(opts.port) });
538
+ const result = await postSessionJson("/api/open_url", { url }, sessionControlOpts(opts));
396
539
  printResult(opts.quiet, { ok: true, ...result }, `Opened ${url}`);
397
540
  });
398
541
  });
@@ -400,10 +543,11 @@ program
400
543
  .command("stop-app <package>")
401
544
  .description("Force-stop an app package")
402
545
  .option("-p, --port <port>", "Session port")
546
+ .option("-d, --device <serial>", "Target device serial")
403
547
  .option("-q, --quiet", "JSON output")
404
548
  .action((packageName, opts) => {
405
549
  void runCliAction(opts, async () => {
406
- const result = await postSessionJson("/api/stop_app", { packageName }, { port: parseOptionalPort(opts.port) });
550
+ const result = await postSessionJson("/api/stop_app", { packageName }, sessionControlOpts(opts));
407
551
  printResult(opts.quiet, { ok: true, ...result }, `Stopped ${packageName}`);
408
552
  });
409
553
  });
@@ -411,10 +555,11 @@ program
411
555
  .command("clear-app <package>")
412
556
  .description("Clear an app package's data")
413
557
  .option("-p, --port <port>", "Session port")
558
+ .option("-d, --device <serial>", "Target device serial")
414
559
  .option("-q, --quiet", "JSON output")
415
560
  .action((packageName, opts) => {
416
561
  void runCliAction(opts, async () => {
417
- const result = await postSessionJson("/api/clear_app", { packageName }, { port: parseOptionalPort(opts.port) });
562
+ const result = await postSessionJson("/api/clear_app", { packageName }, sessionControlOpts(opts));
418
563
  printResult(opts.quiet, { ok: true, ...result }, `Cleared ${packageName}`);
419
564
  });
420
565
  });
@@ -425,11 +570,88 @@ program
425
570
  await startMcpServer();
426
571
  });
427
572
  program.parse();
428
- async function startDetached(avd, opts) {
573
+ async function resolveSessionTargets(initialDevices, avds, opts, sdk) {
574
+ let devices = initialDevices;
575
+ const serials = parseSerialList(opts.device);
576
+ if (serials.length > 0) {
577
+ const targets = [];
578
+ for (const serial of serials) {
579
+ let target = devices.find((device) => device.serial === serial);
580
+ if (target?.state === "offline") {
581
+ if (!opts.quiet)
582
+ console.log(`Reconnecting ${target.serial}...`);
583
+ await reconnectOfflineDevices(sdk, target.serial ?? undefined);
584
+ devices = await listDevices();
585
+ target = devices.find((device) => device.serial === serial);
586
+ }
587
+ if (!target || !target.serial || target.state !== "running") {
588
+ throw new Error(`No running target device for serial ${serial}.`);
589
+ }
590
+ targets.push({ device: target, bootedByUs: false });
591
+ }
592
+ return targets;
593
+ }
594
+ const requested = avds.length > 0 ? avds : [undefined];
595
+ const targets = [];
596
+ for (const avd of requested) {
597
+ const resolution = resolveTarget(devices, avd, undefined);
598
+ let target;
599
+ let bootedByUs = false;
600
+ if (resolution.action === "error") {
601
+ throw new Error(resolution.message);
602
+ }
603
+ if (resolution.action === "boot") {
604
+ if (!opts.quiet)
605
+ console.log(`Booting ${resolution.avdName}...`);
606
+ const serial = await bootDevice({
607
+ sdk,
608
+ avdName: resolution.avdName,
609
+ emulatorArgs: emulatorArgs(opts),
610
+ });
611
+ devices = await listDevices();
612
+ target = devices.find((device) => device.serial === serial);
613
+ bootedByUs = true;
614
+ }
615
+ else {
616
+ target = resolution.device;
617
+ }
618
+ if (target?.state === "offline") {
619
+ if (!opts.quiet)
620
+ console.log(`Reconnecting ${target.serial}...`);
621
+ await reconnectOfflineDevices(sdk, target.serial ?? undefined);
622
+ devices = await listDevices();
623
+ const reconnected = devices.find((device) => (target?.serial && device.serial === target.serial) ||
624
+ device.name === target?.name);
625
+ if (reconnected)
626
+ target = reconnected;
627
+ }
628
+ if (!target || !target.serial || target.state !== "running") {
629
+ throw new Error("No target device.");
630
+ }
631
+ if (!targets.some((entry) => entry.device.serial === target?.serial)) {
632
+ targets.push({ device: target, bootedByUs });
633
+ }
634
+ }
635
+ return targets;
636
+ }
637
+ function parseSerialList(value) {
638
+ return value
639
+ ? value
640
+ .split(",")
641
+ .map((serial) => serial.trim())
642
+ .filter(Boolean)
643
+ : [];
644
+ }
645
+ function sessionControlOpts(opts) {
646
+ return {
647
+ port: parseOptionalPort(opts.port),
648
+ device: opts.device,
649
+ };
650
+ }
651
+ async function startDetached(avds, opts) {
429
652
  const cliPath = fileURLToPath(import.meta.url);
430
653
  const args = [cliPath, "start"];
431
- if (avd)
432
- args.push(avd);
654
+ args.push(...avds);
433
655
  args.push("--port", opts.port, "--host", opts.host, "--no-preview");
434
656
  if (opts.device)
435
657
  args.push("--device", opts.device);
@@ -456,24 +678,25 @@ async function startDetached(avd, opts) {
456
678
  child.unref();
457
679
  const port = parsePort(opts.port);
458
680
  const deadline = Date.now() + 120_000;
459
- let session;
681
+ let sessions = [];
682
+ const expectedSessions = Math.max(1, avds.length, parseSerialList(opts.device).length);
460
683
  while (Date.now() < deadline) {
461
684
  const state = await readState();
462
- session = state.sessions.find((record) => record.port === port && record.pid === child.pid);
463
- if (session)
685
+ sessions = state.sessions.filter((record) => record.port === port && record.pid === child.pid);
686
+ if (sessions.length >= expectedSessions)
464
687
  break;
465
688
  await new Promise((resolve) => setTimeout(resolve, 500));
466
689
  }
467
- if (!session) {
690
+ if (sessions.length === 0) {
468
691
  console.error("Detached server did not become ready before the timeout.");
469
692
  process.exit(1);
470
693
  }
471
694
  if (opts.quiet) {
472
- process.stdout.write(JSON.stringify(session) + "\n");
695
+ process.stdout.write(JSON.stringify(sessions.length === 1 ? sessions[0] : { sessions }) + "\n");
473
696
  }
474
697
  else {
475
- console.log(`Detached Porthole server pid=${session.pid}`);
476
- console.log(`Preview: ${session.url}`);
698
+ console.log(`Detached Porthole server pid=${sessions[0]?.pid}`);
699
+ console.log(`Preview: ${sessions[0]?.url}`);
477
700
  }
478
701
  }
479
702
  function emulatorArgs(opts) {
@@ -531,6 +754,51 @@ function parsePort(port) {
531
754
  }
532
755
  return parsed;
533
756
  }
757
+ function parseNormalized(value, label) {
758
+ const parsed = Number(value);
759
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
760
+ throw new Error(`${label} must be a number in 0..1`);
761
+ }
762
+ return parsed;
763
+ }
764
+ function parsePositiveNumber(value, label) {
765
+ const parsed = Number(value);
766
+ if (!Number.isFinite(parsed) || parsed <= 0) {
767
+ throw new Error(`${label} must be a positive number`);
768
+ }
769
+ return parsed;
770
+ }
771
+ function parsePositiveInteger(value, label) {
772
+ const parsed = Number(value);
773
+ if (!Number.isInteger(parsed) || parsed <= 0) {
774
+ throw new Error(`${label} must be a positive integer`);
775
+ }
776
+ return parsed;
777
+ }
778
+ function parseRatio(value, label) {
779
+ const parsed = Number(value);
780
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
781
+ throw new Error(`${label} must be a number in 0..1`);
782
+ }
783
+ return parsed;
784
+ }
785
+ function parseDurationMs(value) {
786
+ const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value);
787
+ if (!match)
788
+ throw new Error("duration must look like 1500ms, 30s, or 1500 (ms)");
789
+ const amount = Number(match[1]);
790
+ if (!Number.isFinite(amount) || amount <= 0) {
791
+ throw new Error("duration must be a positive number");
792
+ }
793
+ // Bare numbers are milliseconds, matching every other --duration flag.
794
+ return Math.round(amount * (match[2] === "s" ? 1000 : 1));
795
+ }
796
+ function isScrollDirection(value) {
797
+ return value === "up" || value === "down" || value === "left" || value === "right";
798
+ }
799
+ function formatRatio(value) {
800
+ return `${(value * 100).toFixed(2)}% mismatch`;
801
+ }
534
802
  function printResult(quiet, json, text) {
535
803
  if (quiet) {
536
804
  process.stdout.write(JSON.stringify(json) + "\n");