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.
- package/README.md +156 -46
- package/client-dist/assets/index-cy7uJgaT.js +49 -0
- package/client-dist/index.html +1 -1
- package/dist/cli.js +351 -83
- package/dist/cli.js.map +1 -1
- package/dist/control-client.d.ts +1 -0
- package/dist/control-client.d.ts.map +1 -1
- package/dist/control-client.js +10 -2
- package/dist/control-client.js.map +1 -1
- package/dist/engine/scrcpy-engine.d.ts +2 -2
- package/dist/engine/scrcpy-engine.d.ts.map +1 -1
- package/dist/engine/scrcpy-engine.js.map +1 -1
- package/dist/engine/types.d.ts +3 -8
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/focus-navigation.d.ts +27 -0
- package/dist/focus-navigation.d.ts.map +1 -0
- package/dist/focus-navigation.js +118 -0
- package/dist/focus-navigation.js.map +1 -0
- package/dist/gesture.d.ts +14 -0
- package/dist/gesture.d.ts.map +1 -0
- package/dist/gesture.js +110 -0
- package/dist/gesture.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/input-validation.d.ts.map +1 -1
- package/dist/input-validation.js +4 -56
- package/dist/input-validation.js.map +1 -1
- package/dist/input.d.ts +1 -21
- package/dist/input.d.ts.map +1 -1
- package/dist/keycodes.d.ts +2 -1
- package/dist/keycodes.d.ts.map +1 -1
- package/dist/keycodes.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +185 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mp4-writer.d.ts +18 -0
- package/dist/mp4-writer.d.ts.map +1 -0
- package/dist/mp4-writer.js +195 -0
- package/dist/mp4-writer.js.map +1 -0
- package/dist/protocol.d.ts +74 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +154 -7
- package/dist/protocol.js.map +1 -1
- package/dist/recording.d.ts +22 -0
- package/dist/recording.d.ts.map +1 -0
- package/dist/recording.js +132 -0
- package/dist/recording.js.map +1 -0
- package/dist/screen-diff.d.ts +21 -0
- package/dist/screen-diff.d.ts.map +1 -0
- package/dist/screen-diff.js +70 -0
- package/dist/screen-diff.js.map +1 -0
- package/dist/server/http.d.ts +5 -4
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +69 -31
- package/dist/server/http.js.map +1 -1
- package/dist/server/ws.d.ts +4 -3
- package/dist/server/ws.d.ts.map +1 -1
- package/dist/server/ws.js +32 -18
- package/dist/server/ws.js.map +1 -1
- package/dist/session.d.ts +25 -10
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +117 -67
- package/dist/session.js.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +1 -3
- package/dist/state.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +18 -3
- package/skills/porthole/SKILL.md +130 -0
- 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 [
|
|
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 (
|
|
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(
|
|
109
|
+
await startDetached(avds ?? [], opts);
|
|
106
110
|
return;
|
|
107
111
|
}
|
|
108
112
|
const sdk = findAndroidSdk();
|
|
109
|
-
|
|
110
|
-
let
|
|
111
|
-
if (
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
148
|
-
console.error(
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 },
|
|
263
|
-
const session = await sendSessionInput({ kind: "touch", phase: "up", x: nx, y: ny },
|
|
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 },
|
|
279
|
-
const session = await sendSessionInput({ kind: "key", phase: "up", keycode: code },
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
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) },
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
463
|
-
if (
|
|
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 (
|
|
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(
|
|
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=${
|
|
476
|
-
console.log(`Preview: ${
|
|
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");
|