portholejs 0.1.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 (116) hide show
  1. package/client-dist/assets/index-C_qSS_Gy.js +49 -0
  2. package/client-dist/index.html +12 -0
  3. package/dist/adb-actions.d.ts +4 -0
  4. package/dist/adb-actions.d.ts.map +1 -0
  5. package/dist/adb-actions.js +40 -0
  6. package/dist/adb-actions.js.map +1 -0
  7. package/dist/cli-errors.d.ts +5 -0
  8. package/dist/cli-errors.d.ts.map +1 -0
  9. package/dist/cli-errors.js +19 -0
  10. package/dist/cli-errors.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +542 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/control-client.d.ts +22 -0
  16. package/dist/control-client.d.ts.map +1 -0
  17. package/dist/control-client.js +100 -0
  18. package/dist/control-client.js.map +1 -0
  19. package/dist/crashes.d.ts +8 -0
  20. package/dist/crashes.d.ts.map +1 -0
  21. package/dist/crashes.js +38 -0
  22. package/dist/crashes.js.map +1 -0
  23. package/dist/device-manager.d.ts +29 -0
  24. package/dist/device-manager.d.ts.map +1 -0
  25. package/dist/device-manager.js +213 -0
  26. package/dist/device-manager.js.map +1 -0
  27. package/dist/doctor.d.ts +8 -0
  28. package/dist/doctor.d.ts.map +1 -0
  29. package/dist/doctor.js +115 -0
  30. package/dist/doctor.js.map +1 -0
  31. package/dist/engine/scrcpy-engine.d.ts +38 -0
  32. package/dist/engine/scrcpy-engine.d.ts.map +1 -0
  33. package/dist/engine/scrcpy-engine.js +216 -0
  34. package/dist/engine/scrcpy-engine.js.map +1 -0
  35. package/dist/engine/types.d.ts +26 -0
  36. package/dist/engine/types.d.ts.map +1 -0
  37. package/dist/engine/types.js +2 -0
  38. package/dist/engine/types.js.map +1 -0
  39. package/dist/index.d.ts +14 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +9 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/input-validation.d.ts +5 -0
  44. package/dist/input-validation.d.ts.map +1 -0
  45. package/dist/input-validation.js +62 -0
  46. package/dist/input-validation.js.map +1 -0
  47. package/dist/input.d.ts +22 -0
  48. package/dist/input.d.ts.map +1 -0
  49. package/dist/input.js +2 -0
  50. package/dist/input.js.map +1 -0
  51. package/dist/keycodes.d.ts +19 -0
  52. package/dist/keycodes.d.ts.map +1 -0
  53. package/dist/keycodes.js +32 -0
  54. package/dist/keycodes.js.map +1 -0
  55. package/dist/log.d.ts +3 -0
  56. package/dist/log.d.ts.map +1 -0
  57. package/dist/log.js +10 -0
  58. package/dist/log.js.map +1 -0
  59. package/dist/mcp/server.d.ts +2 -0
  60. package/dist/mcp/server.d.ts.map +1 -0
  61. package/dist/mcp/server.js +401 -0
  62. package/dist/mcp/server.js.map +1 -0
  63. package/dist/middleware.d.ts +13 -0
  64. package/dist/middleware.d.ts.map +1 -0
  65. package/dist/middleware.js +31 -0
  66. package/dist/middleware.js.map +1 -0
  67. package/dist/paths.d.ts +5 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +27 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/port-check.d.ts +5 -0
  72. package/dist/port-check.d.ts.map +1 -0
  73. package/dist/port-check.js +27 -0
  74. package/dist/port-check.js.map +1 -0
  75. package/dist/profiles.d.ts +4 -0
  76. package/dist/profiles.d.ts.map +1 -0
  77. package/dist/profiles.js +25 -0
  78. package/dist/profiles.js.map +1 -0
  79. package/dist/protocol.d.ts +10 -0
  80. package/dist/protocol.d.ts.map +1 -0
  81. package/dist/protocol.js +27 -0
  82. package/dist/protocol.js.map +1 -0
  83. package/dist/server/frame-convert.d.ts +6 -0
  84. package/dist/server/frame-convert.d.ts.map +1 -0
  85. package/dist/server/frame-convert.js +33 -0
  86. package/dist/server/frame-convert.js.map +1 -0
  87. package/dist/server/http.d.ts +20 -0
  88. package/dist/server/http.d.ts.map +1 -0
  89. package/dist/server/http.js +509 -0
  90. package/dist/server/http.js.map +1 -0
  91. package/dist/server/mjpeg.d.ts +21 -0
  92. package/dist/server/mjpeg.d.ts.map +1 -0
  93. package/dist/server/mjpeg.js +92 -0
  94. package/dist/server/mjpeg.js.map +1 -0
  95. package/dist/server/ws.d.ts +15 -0
  96. package/dist/server/ws.d.ts.map +1 -0
  97. package/dist/server/ws.js +102 -0
  98. package/dist/server/ws.js.map +1 -0
  99. package/dist/session.d.ts +45 -0
  100. package/dist/session.d.ts.map +1 -0
  101. package/dist/session.js +188 -0
  102. package/dist/session.js.map +1 -0
  103. package/dist/state.d.ts +27 -0
  104. package/dist/state.d.ts.map +1 -0
  105. package/dist/state.js +70 -0
  106. package/dist/state.js.map +1 -0
  107. package/dist/target-resolution.d.ts +14 -0
  108. package/dist/target-resolution.d.ts.map +1 -0
  109. package/dist/target-resolution.js +37 -0
  110. package/dist/target-resolution.js.map +1 -0
  111. package/dist/ui-tree.d.ts +47 -0
  112. package/dist/ui-tree.d.ts.map +1 -0
  113. package/dist/ui-tree.js +179 -0
  114. package/dist/ui-tree.js.map +1 -0
  115. package/package.json +63 -0
  116. package/scripts/download-scrcpy-server.mjs +67 -0
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Porthole</title>
7
+ <script type="module" crossorigin src="/assets/index-C_qSS_Gy.js"></script>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ </body>
12
+ </html>
@@ -0,0 +1,4 @@
1
+ export declare function openUrl(serial: string, url: string): Promise<string>;
2
+ export declare function stopApp(serial: string, packageName: string): Promise<void>;
3
+ export declare function clearApp(serial: string, packageName: string): Promise<void>;
4
+ //# sourceMappingURL=adb-actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adb-actions.d.ts","sourceRoot":"","sources":["../src/adb-actions.ts"],"names":[],"mappings":"AAMA,wBAAsB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAc1E;AAED,wBAAsB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAShF;AAED,wBAAsB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CASjF"}
@@ -0,0 +1,40 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { adbBin, findAndroidSdk } from "./device-manager.js";
4
+ const execFileAsync = promisify(execFile);
5
+ export async function openUrl(serial, url) {
6
+ const { stdout } = await execFileAsync(adbBin(findAndroidSdk()), [
7
+ "-s",
8
+ serial,
9
+ "shell",
10
+ "am",
11
+ "start",
12
+ "-W",
13
+ "-a",
14
+ "android.intent.action.VIEW",
15
+ "-d",
16
+ url,
17
+ ]);
18
+ return stdout;
19
+ }
20
+ export async function stopApp(serial, packageName) {
21
+ await execFileAsync(adbBin(findAndroidSdk()), [
22
+ "-s",
23
+ serial,
24
+ "shell",
25
+ "am",
26
+ "force-stop",
27
+ packageName,
28
+ ]);
29
+ }
30
+ export async function clearApp(serial, packageName) {
31
+ await execFileAsync(adbBin(findAndroidSdk()), [
32
+ "-s",
33
+ serial,
34
+ "shell",
35
+ "pm",
36
+ "clear",
37
+ packageName,
38
+ ]);
39
+ }
40
+ //# sourceMappingURL=adb-actions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adb-actions.js","sourceRoot":"","sources":["../src/adb-actions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE7D,MAAM,aAAa,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC;AAE1C,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,MAAc,EAAE,GAAW;IACvD,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,aAAa,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,EAAE;QAC/D,IAAI;QACJ,MAAM;QACN,OAAO;QACP,IAAI;QACJ,OAAO;QACP,IAAI;QACJ,IAAI;QACJ,4BAA4B;QAC5B,IAAI;QACJ,GAAG;KACJ,CAAC,CAAC;IACH,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,MAAc,EAAE,WAAmB;IAC/D,MAAM,aAAa,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,EAAE;QAC5C,IAAI;QACJ,MAAM;QACN,OAAO;QACP,IAAI;QACJ,YAAY;QACZ,WAAW;KACZ,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,MAAc,EAAE,WAAmB;IAChE,MAAM,aAAa,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,EAAE;QAC5C,IAAI;QACJ,MAAM;QACN,OAAO;QACP,IAAI;QACJ,OAAO;QACP,WAAW;KACZ,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,5 @@
1
+ export interface CliErrorOptions {
2
+ quiet?: boolean;
3
+ }
4
+ export declare function runCliAction(opts: CliErrorOptions, action: () => Promise<void>): Promise<void>;
5
+ //# sourceMappingURL=cli-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-errors.d.ts","sourceRoot":"","sources":["../src/cli-errors.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,eAAe,EACrB,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CAcf"}
@@ -0,0 +1,19 @@
1
+ export async function runCliAction(opts, action) {
2
+ try {
3
+ await action();
4
+ }
5
+ catch (error) {
6
+ const message = error instanceof Error ? error.message : String(error);
7
+ if (process.env["PORTHOLE_DEBUG"]) {
8
+ console.error(error);
9
+ }
10
+ else if (opts.quiet) {
11
+ process.stderr.write(JSON.stringify({ error: message }) + "\n");
12
+ }
13
+ else {
14
+ process.stderr.write(`porthole: ${message}\n`);
15
+ }
16
+ process.exit(1);
17
+ }
18
+ }
19
+ //# sourceMappingURL=cli-errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-errors.js","sourceRoot":"","sources":["../src/cli-errors.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,IAAqB,EACrB,MAA2B;IAE3B,IAAI,CAAC;QACH,MAAM,MAAM,EAAE,CAAC;IACjB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACvE,IAAI,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,EAAE,CAAC;YAClC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACvB,CAAC;aAAM,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YACtB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;QAClE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,aAAa,OAAO,IAAI,CAAC,CAAC;QACjD,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,542 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Command } from "commander";
7
+ import { listDevices, findAndroidSdk, bootDevice, shutdownDevice, wasBootedByUs, reconnectOfflineDevices, } from "./device-manager.js";
8
+ import { REMOTE_BUTTON_TO_KEYCODE } from "./keycodes.js";
9
+ import { Session } from "./session.js";
10
+ import { startMcpServer } from "./mcp/server.js";
11
+ import { VERSION } from "./index.js";
12
+ import { resolveTarget } from "./target-resolution.js";
13
+ import { defaultScreenshotPath, fetchSessionScreenshot, getSessionJson, postSessionJson, sendSessionInput, writeScreenshot, } from "./control-client.js";
14
+ import { readState } from "./state.js";
15
+ import { runCliAction } from "./cli-errors.js";
16
+ import { runDoctor } from "./doctor.js";
17
+ import { ensurePortFree } from "./port-check.js";
18
+ const program = new Command();
19
+ if (Number(process.versions.node.split(".")[0] ?? "0") < 20) {
20
+ console.error("porthole: Node.js 20 or newer is required.");
21
+ process.exit(1);
22
+ }
23
+ program.name("porthole").description("Android emulator preview").version(VERSION);
24
+ program
25
+ .command("list", { isDefault: false })
26
+ .description("List AVDs and running emulators")
27
+ .option("-q, --quiet", "JSON output")
28
+ .action(async (opts) => {
29
+ const devices = await listDevices();
30
+ const state = await readState();
31
+ if (opts.quiet) {
32
+ process.stdout.write(JSON.stringify({ devices, sessions: state.sessions }) + "\n");
33
+ }
34
+ else {
35
+ if (devices.length === 0) {
36
+ console.log("No AVDs found.");
37
+ }
38
+ else {
39
+ for (const d of devices) {
40
+ const status = d.state === "running"
41
+ ? `running (${d.serial})`
42
+ : d.state === "offline"
43
+ ? `offline (${d.serial})`
44
+ : "stopped";
45
+ console.log(` ${d.name} [${d.profile}] ${status}`);
46
+ }
47
+ }
48
+ if (state.sessions.length > 0) {
49
+ console.log("\nActive sessions:");
50
+ for (const session of state.sessions) {
51
+ console.log(` ${session.avdName} ${session.url} pid=${session.pid} serial=${session.serial}`);
52
+ }
53
+ }
54
+ }
55
+ });
56
+ program
57
+ .command("doctor")
58
+ .description("Check local Porthole and Android emulator prerequisites")
59
+ .option("-q, --quiet", "JSON output")
60
+ .action((opts) => {
61
+ void runCliAction(opts, async () => {
62
+ const checks = await runDoctor();
63
+ if (opts.quiet) {
64
+ process.stdout.write(JSON.stringify({ checks }) + "\n");
65
+ return;
66
+ }
67
+ for (const check of checks) {
68
+ const mark = check.ok ? "OK" : "FAIL";
69
+ console.log(`${mark} ${check.name}: ${check.detail}`);
70
+ if (!check.ok && check.fix)
71
+ console.log(` fix: ${check.fix}`);
72
+ }
73
+ if (checks.some((check) => !check.ok))
74
+ process.exit(1);
75
+ });
76
+ });
77
+ program
78
+ .command("start [avd]", { isDefault: true })
79
+ .description("Boot/attach emulator and serve preview")
80
+ .option("-p, --port <port>", "HTTP port", "3200")
81
+ .option("-d, --device <serial>", "Target device serial")
82
+ .option("--host <host>", "Bind address", "127.0.0.1")
83
+ .option("--no-preview", "Don't open browser")
84
+ .option("--mjpeg", "MJPEG mode")
85
+ .option("--detach", "Run the preview server in the background")
86
+ .option("--keep-alive", "Leave a Porthole-booted emulator running on exit")
87
+ .option("--wipe-data", "Wipe emulator data before boot")
88
+ .option("--no-snapshot", "Disable loading/saving emulator snapshots")
89
+ .option("--cold-boot", "Alias for --no-snapshot")
90
+ .option("--gpu <mode>", "Pass emulator GPU mode")
91
+ .option("--max-size <px>", "Maximum stream dimension", "1280")
92
+ .option("--max-fps <fps>", "Maximum stream FPS", "30")
93
+ .option("--bitrate <bps>", "Stream bitrate in bits per second")
94
+ .option("-q, --quiet", "Quiet/JSON mode")
95
+ .action(async (avd, opts) => {
96
+ // Check the port before prompting, booting, or detaching — a busy port
97
+ // otherwise surfaces as a boot wasted on a doomed session or as an
98
+ // opaque detach timeout.
99
+ const portError = await ensurePortFree(parseInt(opts.port, 10), opts.host);
100
+ if (portError) {
101
+ console.error(portError);
102
+ process.exit(1);
103
+ }
104
+ if (opts.detach && process.env["PORTHOLE_DETACHED_CHILD"] !== "1") {
105
+ await startDetached(avd, opts);
106
+ return;
107
+ }
108
+ 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;
132
+ }
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
+ }
146
+ }
147
+ if (!target || !target.serial || target.state !== "running") {
148
+ console.error("No target device.");
149
+ process.exit(1);
150
+ }
151
+ const session = new Session({
152
+ device: target,
153
+ port: parseInt(opts.port, 10),
154
+ host: opts.host,
155
+ maxSize: Number(opts.maxSize),
156
+ maxFps: Number(opts.maxFps),
157
+ bitrate: opts.bitrate ? Number(opts.bitrate) : undefined,
158
+ bootedByUs,
159
+ detached: process.env["PORTHOLE_DETACHED_CHILD"] === "1",
160
+ forceMjpeg: opts.mjpeg ?? false,
161
+ });
162
+ try {
163
+ const { url } = await session.start();
164
+ if (!opts.quiet) {
165
+ console.log(`Attached to ${target.name} [${target.profile}] (${target.serial})`);
166
+ console.log(`Preview: ${url}`);
167
+ if (opts.mjpeg)
168
+ console.log("Video mode: MJPEG screenshot polling");
169
+ }
170
+ else {
171
+ process.stdout.write(JSON.stringify({ device: target, url }) + "\n");
172
+ }
173
+ if (opts.preview && process.env["PORTHOLE_DETACHED_CHILD"] !== "1") {
174
+ openBrowser(url);
175
+ }
176
+ }
177
+ catch (e) {
178
+ const message = e instanceof Error ? e.message : String(e);
179
+ if (message.includes("EADDRINUSE")) {
180
+ console.error(`Port ${opts.port} is already in use. Try another port with -p ${Number(opts.port) + 1}.`);
181
+ }
182
+ else {
183
+ console.error(`Failed to start: ${message}`);
184
+ }
185
+ if (bootedByUs && !opts.keepAlive && target.serial) {
186
+ await shutdownDevice(sdk, target.serial);
187
+ }
188
+ process.exit(1);
189
+ }
190
+ const stopForSignal = async () => {
191
+ await session.stop();
192
+ if (bootedByUs && !opts.keepAlive && target.serial) {
193
+ await shutdownDevice(sdk, target.serial);
194
+ }
195
+ process.exit(0);
196
+ };
197
+ process.on("SIGINT", () => void stopForSignal());
198
+ process.on("SIGTERM", () => void stopForSignal());
199
+ });
200
+ program
201
+ .command("kill [avd]")
202
+ .description("Stop emulator(s) Porthole started")
203
+ .option("-q, --quiet", "JSON output")
204
+ .action(async (avd, opts) => {
205
+ const sdk = findAndroidSdk();
206
+ const devices = await listDevices();
207
+ const state = await readState();
208
+ const targetSerials = new Set();
209
+ for (const d of devices) {
210
+ if (d.state === "running" &&
211
+ d.serial &&
212
+ (avd ? d.name === avd : await wasBootedByUs(d.serial))) {
213
+ targetSerials.add(d.serial);
214
+ }
215
+ }
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) {
220
+ try {
221
+ process.kill(session.pid, "SIGTERM");
222
+ }
223
+ catch {
224
+ // process already gone
225
+ }
226
+ }
227
+ }
228
+ }
229
+ if (targetSerials.size === 0) {
230
+ if (opts.quiet) {
231
+ process.stdout.write(JSON.stringify({ killed: [] }) + "\n");
232
+ }
233
+ else {
234
+ console.log("No emulators to kill.");
235
+ }
236
+ return;
237
+ }
238
+ const killed = [];
239
+ for (const serial of targetSerials) {
240
+ if (!opts.quiet) {
241
+ console.log(`Shutting down ${serial}...`);
242
+ }
243
+ await shutdownDevice(sdk, serial);
244
+ killed.push(serial);
245
+ }
246
+ if (opts.quiet) {
247
+ process.stdout.write(JSON.stringify({ killed }) + "\n");
248
+ }
249
+ });
250
+ program
251
+ .command("tap <x> <y>")
252
+ .description("Touch at normalized 0..1 coordinates")
253
+ .option("-p, --port <port>", "Session port")
254
+ .option("-q, --quiet", "JSON output")
255
+ .action((x, y, opts) => {
256
+ void runCliAction(opts, async () => {
257
+ const nx = parseFloat(x);
258
+ const ny = parseFloat(y);
259
+ if (isNaN(nx) || isNaN(ny) || nx < 0 || nx > 1 || ny < 0 || ny > 1) {
260
+ throw new Error("Coordinates must be numbers in 0..1");
261
+ }
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) });
264
+ printResult(opts.quiet, { ok: true, session, event: "tap" }, `Tapped ${nx},${ny}`);
265
+ });
266
+ });
267
+ program
268
+ .command("key <keycode>")
269
+ .description("Send a single Android keyevent")
270
+ .option("-p, --port <port>", "Session port")
271
+ .option("-q, --quiet", "JSON output")
272
+ .action((keycode, opts) => {
273
+ void runCliAction(opts, async () => {
274
+ const code = parseInt(keycode, 10);
275
+ if (isNaN(code)) {
276
+ throw new Error("Keycode must be a number");
277
+ }
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) });
280
+ printResult(opts.quiet, { ok: true, session, keycode: code }, `Key ${code} sent`);
281
+ });
282
+ });
283
+ program
284
+ .command("remote <button>")
285
+ .description("D-pad / media remote button")
286
+ .option("-p, --port <port>", "Session port")
287
+ .option("-q, --quiet", "JSON output")
288
+ .action((button, opts) => {
289
+ void runCliAction(opts, async () => {
290
+ if (!(button in REMOTE_BUTTON_TO_KEYCODE)) {
291
+ throw new Error(`Unknown button: ${button}. Valid: ${Object.keys(REMOTE_BUTTON_TO_KEYCODE).join(", ")}`);
292
+ }
293
+ const session = await sendSessionInput({ kind: "remote", button: button }, { port: parseOptionalPort(opts.port) });
294
+ printResult(opts.quiet, { ok: true, session, button }, `Remote: ${button}`);
295
+ });
296
+ });
297
+ program
298
+ .command("text <string>")
299
+ .description("Type text")
300
+ .option("-p, --port <port>", "Session port")
301
+ .option("-q, --quiet", "JSON output")
302
+ .action((text, opts) => {
303
+ void runCliAction(opts, async () => {
304
+ const session = await sendSessionInput({ kind: "text", text }, { port: parseOptionalPort(opts.port) });
305
+ printResult(opts.quiet, { ok: true, session, text }, `Typed "${text}"`);
306
+ });
307
+ });
308
+ program
309
+ .command("screenshot")
310
+ .description("Take a PNG screenshot from a running session")
311
+ .option("-p, --port <port>", "Session port")
312
+ .option("-o, --output <file>", "Output path")
313
+ .option("-q, --quiet", "JSON output")
314
+ .action((opts) => {
315
+ void runCliAction(opts, async () => {
316
+ const { session, png } = await fetchSessionScreenshot({
317
+ port: parseOptionalPort(opts.port),
318
+ });
319
+ const output = opts.output ?? defaultScreenshotPath(session.serial);
320
+ await writeScreenshot(output, png);
321
+ printResult(opts.quiet, { ok: true, path: output, session }, output);
322
+ });
323
+ });
324
+ program
325
+ .command("rotate <orientation>")
326
+ .description("Rotate a phone session: portrait, landscape, left, or right")
327
+ .option("-p, --port <port>", "Session port")
328
+ .option("-q, --quiet", "JSON output")
329
+ .action((orientation, opts) => {
330
+ void runCliAction(opts, async () => {
331
+ const result = await postSessionJson("/api/rotate", { orientation }, { port: parseOptionalPort(opts.port) });
332
+ printResult(opts.quiet, { ok: true, ...result }, `Rotated ${orientation}`);
333
+ });
334
+ });
335
+ program
336
+ .command("emu <args...>")
337
+ .description("Pass arguments to adb emu for the active session")
338
+ .option("-p, --port <port>", "Session port")
339
+ .option("-q, --quiet", "JSON output")
340
+ .action((args, opts) => {
341
+ void runCliAction(opts, async () => {
342
+ const result = await postSessionJson("/api/emu", { args }, { port: parseOptionalPort(opts.port) });
343
+ printResult(opts.quiet, { ok: true, ...result }, "Emulator command sent");
344
+ });
345
+ });
346
+ program
347
+ .command("focused")
348
+ .description("Print the currently focused UI node")
349
+ .option("-p, --port <port>", "Session port")
350
+ .option("-q, --quiet", "JSON output")
351
+ .action((opts) => {
352
+ void runCliAction(opts, async () => {
353
+ const result = await getSessionJson("/api/focused", {
354
+ port: parseOptionalPort(opts.port),
355
+ });
356
+ printResult(opts.quiet, result, JSON.stringify(result.response, null, 2));
357
+ });
358
+ });
359
+ program
360
+ .command("dump-ui")
361
+ .description("Dump the Android accessibility hierarchy")
362
+ .option("-p, --port <port>", "Session port")
363
+ .option("--filter <text>", "Filter by text/resource id/content description")
364
+ .option("-q, --quiet", "JSON output")
365
+ .action((opts) => {
366
+ void runCliAction(opts, async () => {
367
+ const query = opts.filter
368
+ ? `?${new URLSearchParams({ filter: opts.filter }).toString()}`
369
+ : "";
370
+ const result = await getSessionJson(`/api/ui${query}`, {
371
+ port: parseOptionalPort(opts.port),
372
+ });
373
+ printResult(opts.quiet, result, JSON.stringify(result.response, null, 2));
374
+ });
375
+ });
376
+ program
377
+ .command("wait-for <text>")
378
+ .description("Wait until text appears in the UI hierarchy")
379
+ .option("-p, --port <port>", "Session port")
380
+ .option("--timeout <ms>", "Timeout in milliseconds", "10000")
381
+ .option("-q, --quiet", "JSON output")
382
+ .action((text, opts) => {
383
+ void runCliAction(opts, async () => {
384
+ const result = await postSessionJson("/api/wait_for", { text, timeoutMs: Number(opts.timeout) }, { port: parseOptionalPort(opts.port) });
385
+ printResult(opts.quiet, { ok: true, ...result }, `Found "${text}"`);
386
+ });
387
+ });
388
+ program
389
+ .command("open-url <url>")
390
+ .description("Open an Android deep link or URL")
391
+ .option("-p, --port <port>", "Session port")
392
+ .option("-q, --quiet", "JSON output")
393
+ .action((url, opts) => {
394
+ void runCliAction(opts, async () => {
395
+ const result = await postSessionJson("/api/open_url", { url }, { port: parseOptionalPort(opts.port) });
396
+ printResult(opts.quiet, { ok: true, ...result }, `Opened ${url}`);
397
+ });
398
+ });
399
+ program
400
+ .command("stop-app <package>")
401
+ .description("Force-stop an app package")
402
+ .option("-p, --port <port>", "Session port")
403
+ .option("-q, --quiet", "JSON output")
404
+ .action((packageName, opts) => {
405
+ void runCliAction(opts, async () => {
406
+ const result = await postSessionJson("/api/stop_app", { packageName }, { port: parseOptionalPort(opts.port) });
407
+ printResult(opts.quiet, { ok: true, ...result }, `Stopped ${packageName}`);
408
+ });
409
+ });
410
+ program
411
+ .command("clear-app <package>")
412
+ .description("Clear an app package's data")
413
+ .option("-p, --port <port>", "Session port")
414
+ .option("-q, --quiet", "JSON output")
415
+ .action((packageName, opts) => {
416
+ void runCliAction(opts, async () => {
417
+ const result = await postSessionJson("/api/clear_app", { packageName }, { port: parseOptionalPort(opts.port) });
418
+ printResult(opts.quiet, { ok: true, ...result }, `Cleared ${packageName}`);
419
+ });
420
+ });
421
+ program
422
+ .command("mcp")
423
+ .description("Run the MCP server on stdio")
424
+ .action(async () => {
425
+ await startMcpServer();
426
+ });
427
+ program.parse();
428
+ async function startDetached(avd, opts) {
429
+ const cliPath = fileURLToPath(import.meta.url);
430
+ const args = [cliPath, "start"];
431
+ if (avd)
432
+ args.push(avd);
433
+ args.push("--port", opts.port, "--host", opts.host, "--no-preview");
434
+ if (opts.device)
435
+ args.push("--device", opts.device);
436
+ if (opts.mjpeg)
437
+ args.push("--mjpeg");
438
+ if (opts.keepAlive)
439
+ args.push("--keep-alive");
440
+ if (opts.wipeData)
441
+ args.push("--wipe-data");
442
+ if (opts.snapshot === false || opts.coldBoot)
443
+ args.push("--no-snapshot");
444
+ if (opts.gpu)
445
+ args.push("--gpu", opts.gpu);
446
+ args.push("--max-size", opts.maxSize, "--max-fps", opts.maxFps);
447
+ if (opts.bitrate)
448
+ args.push("--bitrate", opts.bitrate);
449
+ if (opts.quiet)
450
+ args.push("--quiet");
451
+ const child = spawn(process.execPath, args, {
452
+ detached: true,
453
+ stdio: "ignore",
454
+ env: { ...process.env, PORTHOLE_DETACHED_CHILD: "1" },
455
+ });
456
+ child.unref();
457
+ const port = parsePort(opts.port);
458
+ const deadline = Date.now() + 120_000;
459
+ let session;
460
+ while (Date.now() < deadline) {
461
+ const state = await readState();
462
+ session = state.sessions.find((record) => record.port === port && record.pid === child.pid);
463
+ if (session)
464
+ break;
465
+ await new Promise((resolve) => setTimeout(resolve, 500));
466
+ }
467
+ if (!session) {
468
+ console.error("Detached server did not become ready before the timeout.");
469
+ process.exit(1);
470
+ }
471
+ if (opts.quiet) {
472
+ process.stdout.write(JSON.stringify(session) + "\n");
473
+ }
474
+ else {
475
+ console.log(`Detached Porthole server pid=${session.pid}`);
476
+ console.log(`Preview: ${session.url}`);
477
+ }
478
+ }
479
+ function emulatorArgs(opts) {
480
+ const args = [];
481
+ if (opts.wipeData)
482
+ args.push("-wipe-data");
483
+ if (opts.snapshot === false || opts.coldBoot) {
484
+ args.push("-no-snapshot-load", "-no-snapshot-save");
485
+ }
486
+ else {
487
+ args.push("-no-snapshot-load");
488
+ }
489
+ if (opts.gpu)
490
+ args.push("-gpu", opts.gpu);
491
+ return args;
492
+ }
493
+ async function promptForAvd(devices) {
494
+ const stopped = devices.filter((device) => device.state === "stopped");
495
+ if (stopped.length === 0)
496
+ return undefined;
497
+ console.log("Choose an AVD to boot:");
498
+ stopped.forEach((device, index) => {
499
+ console.log(` ${index + 1}. ${device.name} [${device.profile}]`);
500
+ });
501
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
502
+ try {
503
+ const answer = await rl.question("AVD number: ");
504
+ const index = Number(answer) - 1;
505
+ return stopped[index]?.name;
506
+ }
507
+ finally {
508
+ rl.close();
509
+ }
510
+ }
511
+ function openBrowser(url) {
512
+ const command = process.platform === "darwin"
513
+ ? "open"
514
+ : process.platform === "win32"
515
+ ? "cmd"
516
+ : "xdg-open";
517
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
518
+ if (process.platform !== "win32" && !existsSync(`/usr/bin/${command}`)) {
519
+ return;
520
+ }
521
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
522
+ child.unref();
523
+ }
524
+ function parseOptionalPort(port) {
525
+ return port === undefined ? undefined : parsePort(port);
526
+ }
527
+ function parsePort(port) {
528
+ const parsed = Number(port);
529
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
530
+ throw new Error(`Invalid port: ${port}`);
531
+ }
532
+ return parsed;
533
+ }
534
+ function printResult(quiet, json, text) {
535
+ if (quiet) {
536
+ process.stdout.write(JSON.stringify(json) + "\n");
537
+ }
538
+ else {
539
+ console.log(text);
540
+ }
541
+ }
542
+ //# sourceMappingURL=cli.js.map