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.
- package/client-dist/assets/index-C_qSS_Gy.js +49 -0
- package/client-dist/index.html +12 -0
- package/dist/adb-actions.d.ts +4 -0
- package/dist/adb-actions.d.ts.map +1 -0
- package/dist/adb-actions.js +40 -0
- package/dist/adb-actions.js.map +1 -0
- package/dist/cli-errors.d.ts +5 -0
- package/dist/cli-errors.d.ts.map +1 -0
- package/dist/cli-errors.js +19 -0
- package/dist/cli-errors.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +542 -0
- package/dist/cli.js.map +1 -0
- package/dist/control-client.d.ts +22 -0
- package/dist/control-client.d.ts.map +1 -0
- package/dist/control-client.js +100 -0
- package/dist/control-client.js.map +1 -0
- package/dist/crashes.d.ts +8 -0
- package/dist/crashes.d.ts.map +1 -0
- package/dist/crashes.js +38 -0
- package/dist/crashes.js.map +1 -0
- package/dist/device-manager.d.ts +29 -0
- package/dist/device-manager.d.ts.map +1 -0
- package/dist/device-manager.js +213 -0
- package/dist/device-manager.js.map +1 -0
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +115 -0
- package/dist/doctor.js.map +1 -0
- package/dist/engine/scrcpy-engine.d.ts +38 -0
- package/dist/engine/scrcpy-engine.d.ts.map +1 -0
- package/dist/engine/scrcpy-engine.js +216 -0
- package/dist/engine/scrcpy-engine.js.map +1 -0
- package/dist/engine/types.d.ts +26 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +2 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/input-validation.d.ts +5 -0
- package/dist/input-validation.d.ts.map +1 -0
- package/dist/input-validation.js +62 -0
- package/dist/input-validation.js.map +1 -0
- package/dist/input.d.ts +22 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +2 -0
- package/dist/input.js.map +1 -0
- package/dist/keycodes.d.ts +19 -0
- package/dist/keycodes.d.ts.map +1 -0
- package/dist/keycodes.js +32 -0
- package/dist/keycodes.js.map +1 -0
- package/dist/log.d.ts +3 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +10 -0
- package/dist/log.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +401 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/middleware.d.ts +13 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +31 -0
- package/dist/middleware.js.map +1 -0
- package/dist/paths.d.ts +5 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +27 -0
- package/dist/paths.js.map +1 -0
- package/dist/port-check.d.ts +5 -0
- package/dist/port-check.d.ts.map +1 -0
- package/dist/port-check.js +27 -0
- package/dist/port-check.js.map +1 -0
- package/dist/profiles.d.ts +4 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +25 -0
- package/dist/profiles.js.map +1 -0
- package/dist/protocol.d.ts +10 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +27 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server/frame-convert.d.ts +6 -0
- package/dist/server/frame-convert.d.ts.map +1 -0
- package/dist/server/frame-convert.js +33 -0
- package/dist/server/frame-convert.js.map +1 -0
- package/dist/server/http.d.ts +20 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +509 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/mjpeg.d.ts +21 -0
- package/dist/server/mjpeg.d.ts.map +1 -0
- package/dist/server/mjpeg.js +92 -0
- package/dist/server/mjpeg.js.map +1 -0
- package/dist/server/ws.d.ts +15 -0
- package/dist/server/ws.d.ts.map +1 -0
- package/dist/server/ws.js +102 -0
- package/dist/server/ws.js.map +1 -0
- package/dist/session.d.ts +45 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +188 -0
- package/dist/session.js.map +1 -0
- package/dist/state.d.ts +27 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +70 -0
- package/dist/state.js.map +1 -0
- package/dist/target-resolution.d.ts +14 -0
- package/dist/target-resolution.d.ts.map +1 -0
- package/dist/target-resolution.js +37 -0
- package/dist/target-resolution.js.map +1 -0
- package/dist/ui-tree.d.ts +47 -0
- package/dist/ui-tree.d.ts.map +1 -0
- package/dist/ui-tree.js +179 -0
- package/dist/ui-tree.js.map +1 -0
- package/package.json +63 -0
- 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 @@
|
|
|
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 @@
|
|
|
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
|