serve-emul 0.0.4
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/CHANGELOG.md +29 -0
- package/LICENSE +201 -0
- package/README.md +196 -0
- package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
- package/dist/ui/assets/index-CyUIa9dV.js +42 -0
- package/dist/ui/index.html +13 -0
- package/package.json +67 -0
- package/scripts/fetch-scrcpy.ts +28 -0
- package/scripts/release.ts +136 -0
- package/src/accessibility.ts +88 -0
- package/src/adb.ts +209 -0
- package/src/app-info.ts +114 -0
- package/src/app-management.ts +150 -0
- package/src/cli.ts +149 -0
- package/src/emulator.ts +229 -0
- package/src/input.ts +258 -0
- package/src/location.ts +135 -0
- package/src/route-playback.ts +359 -0
- package/src/scrcpy.ts +466 -0
- package/src/server.ts +1260 -0
- package/src/session-recorder.ts +149 -0
- package/src/ui/app.tsx +111 -0
- package/src/ui/components/accessibility-panel.tsx +113 -0
- package/src/ui/components/app-management-panel.tsx +256 -0
- package/src/ui/components/control-bar.tsx +24 -0
- package/src/ui/components/device-panel.tsx +532 -0
- package/src/ui/components/device-stream.tsx +142 -0
- package/src/ui/components/location-panel.tsx +584 -0
- package/src/ui/components/logcat-panel.tsx +100 -0
- package/src/ui/components/session-panel.tsx +127 -0
- package/src/ui/components/status-bar.tsx +19 -0
- package/src/ui/index.html +12 -0
- package/src/ui/lib/h264.ts +35 -0
- package/src/ui/lib/use-stream.ts +368 -0
- package/src/ui/main.tsx +7 -0
- package/src/ui/styles.css +708 -0
- package/src/ui/tsconfig.json +17 -0
- package/src/update-check.ts +93 -0
- package/vendor/scrcpy-server-v2.7 +0 -0
- package/vendor/scrcpy-server-v3.1 +0 -0
- package/vendor/scrcpy-server-v3.3.4 +0 -0
- package/vendor/scrcpy-server-v4.0 +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type AppActionResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
output: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type FileImportResult = AppActionResult & {
|
|
12
|
+
path: string;
|
|
13
|
+
kind: "image" | "video" | "file";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
|
17
|
+
const PERMISSION_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
|
18
|
+
const ACTIVITY_RE = /^([A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+|\.?[A-Za-z][A-Za-z0-9_.$]*)(\/[A-Za-z0-9_.$]+)?$/;
|
|
19
|
+
|
|
20
|
+
function output(stdout: string, stderr: string): string {
|
|
21
|
+
return `${stdout}${stderr}`.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function adb(serial: string, args: string[], timeout = 30_000): AppActionResult {
|
|
25
|
+
const result = spawnSync("adb", ["-s", serial, ...args], {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
28
|
+
timeout,
|
|
29
|
+
});
|
|
30
|
+
const text = output(result.stdout, result.stderr);
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
throw new Error(text || `adb ${args.join(" ")} failed`);
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, output: text };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function adbHost(serial: string, args: string[], timeout = 30_000): AppActionResult {
|
|
38
|
+
return adb(serial, args, timeout);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validate(value: unknown, name: string, pattern: RegExp): string {
|
|
42
|
+
if (typeof value !== "string" || !pattern.test(value.trim())) {
|
|
43
|
+
throw new Error(`${name} is invalid`);
|
|
44
|
+
}
|
|
45
|
+
return value.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function packageName(value: unknown): string {
|
|
49
|
+
return validate(value, "packageName", PACKAGE_RE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function activityName(value: unknown): string {
|
|
53
|
+
return validate(value, "activity", ACTIVITY_RE);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function permissionName(value: unknown): string {
|
|
57
|
+
return validate(value, "permission", PERMISSION_RE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function installApk(serial: string, file: File): Promise<AppActionResult> {
|
|
61
|
+
if (!file.name.toLowerCase().endsWith(".apk")) throw new Error("APK file must end with .apk");
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), "serve-emul-apk-"));
|
|
63
|
+
const path = join(dir, "upload.apk");
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(path, new Uint8Array(await file.arrayBuffer()));
|
|
66
|
+
return adb(serial, ["install", "-r", path], 120_000);
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeFileName(name: string): string {
|
|
73
|
+
const clean = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
74
|
+
return clean || `upload-${Date.now()}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mediaKind(file: File): FileImportResult["kind"] {
|
|
78
|
+
if (file.type.startsWith("image/")) return "image";
|
|
79
|
+
if (file.type.startsWith("video/")) return "video";
|
|
80
|
+
const lower = file.name.toLowerCase();
|
|
81
|
+
if (/\.(png|jpe?g|gif|webp|heic|heif)$/.test(lower)) return "image";
|
|
82
|
+
if (/\.(mp4|m4v|mov|webm|3gp|mkv)$/.test(lower)) return "video";
|
|
83
|
+
return "file";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function importMediaFile(serial: string, file: File): Promise<FileImportResult> {
|
|
87
|
+
const dir = mkdtempSync(join(tmpdir(), "serve-emul-media-"));
|
|
88
|
+
const localPath = join(dir, safeFileName(file.name));
|
|
89
|
+
const kind = mediaKind(file);
|
|
90
|
+
const remoteDir =
|
|
91
|
+
kind === "image" ? "/sdcard/Pictures" : kind === "video" ? "/sdcard/Movies" : "/sdcard/Download";
|
|
92
|
+
const remotePath = `${remoteDir}/${safeFileName(file.name)}`;
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(localPath, new Uint8Array(await file.arrayBuffer()));
|
|
95
|
+
adb(serial, ["shell", "mkdir", "-p", remoteDir]);
|
|
96
|
+
adbHost(serial, ["push", localPath, remotePath], 120_000);
|
|
97
|
+
adb(serial, [
|
|
98
|
+
"shell",
|
|
99
|
+
"am",
|
|
100
|
+
"broadcast",
|
|
101
|
+
"-a",
|
|
102
|
+
"android.intent.action.MEDIA_SCANNER_SCAN_FILE",
|
|
103
|
+
"-d",
|
|
104
|
+
`file://${remotePath}`,
|
|
105
|
+
]);
|
|
106
|
+
return { ok: true, output: `Imported ${file.name} to ${remotePath}`, path: remotePath, kind };
|
|
107
|
+
} finally {
|
|
108
|
+
rmSync(dir, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function launchApp(serial: string, packageNameValue: string, activity?: string): AppActionResult {
|
|
113
|
+
const pkg = packageName(packageNameValue);
|
|
114
|
+
if (activity) {
|
|
115
|
+
const act = activityName(activity);
|
|
116
|
+
const component = act.includes("/") ? act : `${pkg}/${act}`;
|
|
117
|
+
return adb(serial, ["shell", "am", "start", "-n", component]);
|
|
118
|
+
}
|
|
119
|
+
return adb(serial, [
|
|
120
|
+
"shell",
|
|
121
|
+
"monkey",
|
|
122
|
+
"-p",
|
|
123
|
+
pkg,
|
|
124
|
+
"-c",
|
|
125
|
+
"android.intent.category.LAUNCHER",
|
|
126
|
+
"1",
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function clearAppData(serial: string, packageNameValue: string): AppActionResult {
|
|
131
|
+
return adb(serial, ["shell", "pm", "clear", packageName(packageNameValue)]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function forceStopApp(serial: string, packageNameValue: string): AppActionResult {
|
|
135
|
+
return adb(serial, ["shell", "am", "force-stop", packageName(packageNameValue)]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function grantPermission(
|
|
139
|
+
serial: string,
|
|
140
|
+
packageNameValue: string,
|
|
141
|
+
permissionValue: string,
|
|
142
|
+
): AppActionResult {
|
|
143
|
+
return adb(serial, [
|
|
144
|
+
"shell",
|
|
145
|
+
"pm",
|
|
146
|
+
"grant",
|
|
147
|
+
packageName(packageNameValue),
|
|
148
|
+
permissionName(permissionValue),
|
|
149
|
+
]);
|
|
150
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { pickDevice } from "./adb.ts";
|
|
4
|
+
import { listAvds, listRunningAvds, startEmulator } from "./emulator.ts";
|
|
5
|
+
import { startServer } from "./server.ts";
|
|
6
|
+
import { getUpdateNotice } from "./update-check.ts";
|
|
7
|
+
import packageJson from "../package.json";
|
|
8
|
+
|
|
9
|
+
const argv = Bun.argv.slice(2);
|
|
10
|
+
const { values } = parseArgs({
|
|
11
|
+
args: argv,
|
|
12
|
+
options: {
|
|
13
|
+
port: { type: "string", short: "p", default: "3300" },
|
|
14
|
+
serial: { type: "string", short: "s" },
|
|
15
|
+
"max-fps": { type: "string", default: "60" },
|
|
16
|
+
"bit-rate": { type: "string", default: "8000000" },
|
|
17
|
+
"max-size": { type: "string", default: "1920" },
|
|
18
|
+
"key-frame-interval": { type: "string", default: "1" },
|
|
19
|
+
avd: { type: "string" },
|
|
20
|
+
"avd-list": { type: "boolean" },
|
|
21
|
+
"running-avds": { type: "boolean" },
|
|
22
|
+
"restart-avd": { type: "boolean" },
|
|
23
|
+
emulator: { type: "string" },
|
|
24
|
+
"emulator-port": { type: "string" },
|
|
25
|
+
help: { type: "boolean", short: "h" },
|
|
26
|
+
},
|
|
27
|
+
allowPositionals: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function numberOption(name: string, fallback: number): number {
|
|
31
|
+
const value = values[name as keyof typeof values];
|
|
32
|
+
if (typeof value !== "string") return fallback;
|
|
33
|
+
const n = Number(value);
|
|
34
|
+
if (!Number.isFinite(n)) throw new Error(`--${name} must be a number.`);
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function checkForUpdate() {
|
|
39
|
+
if (process.env.SERVE_EMUL_UPDATE_CHECK === "0") return;
|
|
40
|
+
|
|
41
|
+
const notice = await getUpdateNotice({
|
|
42
|
+
packageName: packageJson.name,
|
|
43
|
+
currentVersion: packageJson.version,
|
|
44
|
+
cachePath: process.env.SERVE_EMUL_UPDATE_CHECK_CACHE,
|
|
45
|
+
});
|
|
46
|
+
if (notice) console.error(notice);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (values.help) {
|
|
50
|
+
console.log(`serve-emul — host an Android device over scrcpy + WebSocket
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
serve-emul [-p <port>] [-s <serial>] [--max-fps N] [--bit-rate N] [--max-size N] [--key-frame-interval sec]
|
|
54
|
+
serve-emul --avd <name> [--restart-avd]
|
|
55
|
+
serve-emul --avd-list
|
|
56
|
+
serve-emul --running-avds
|
|
57
|
+
|
|
58
|
+
Options:
|
|
59
|
+
-p, --port <port> Port to listen on (default: 3300)
|
|
60
|
+
-s, --serial <serial> adb device serial (defaults to the only booted device)
|
|
61
|
+
--max-fps <n> Cap source frame rate (default: 60)
|
|
62
|
+
--bit-rate <bps> H.264 bit rate (default: 8000000)
|
|
63
|
+
--max-size <px> Cap longest screen edge in pixels; 0 = native, but many
|
|
64
|
+
emulators reject native resolutions above ~2560 so this
|
|
65
|
+
defaults to 1920.
|
|
66
|
+
--key-frame-interval <sec>
|
|
67
|
+
Ask the encoder for regular keyframes; 0 disables this
|
|
68
|
+
codec option (default: 1)
|
|
69
|
+
--avd <name> Launch this Android Virtual Device before streaming
|
|
70
|
+
--restart-avd Stop a running matching AVD before launching it
|
|
71
|
+
--avd-list Print available Android Virtual Device names
|
|
72
|
+
--running-avds Print currently running emulator AVDs
|
|
73
|
+
--emulator <path> Android Emulator binary (default: PATH or Android SDK)
|
|
74
|
+
--emulator-port <n>
|
|
75
|
+
Emulator console port for --avd (even 5554-5682)
|
|
76
|
+
-h, --help Show this help
|
|
77
|
+
`);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main() {
|
|
82
|
+
await checkForUpdate().catch(() => {});
|
|
83
|
+
|
|
84
|
+
if (values["avd-list"]) {
|
|
85
|
+
console.log(listAvds(values.emulator).join("\n"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (values["running-avds"]) {
|
|
90
|
+
const running = listRunningAvds();
|
|
91
|
+
console.log(running.length ? running.map((avd) => `${avd.serial}\t${avd.avd}\t${avd.state}`).join("\n") : "");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if ((values["emulator-port"] || values["restart-avd"]) && !values.avd) {
|
|
96
|
+
throw new Error("--emulator-port and --restart-avd require --avd.");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (values.avd && values.serial) {
|
|
100
|
+
throw new Error("Use either --avd to launch an emulator or --serial to attach to an existing device, not both.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let emulatorLaunch: Awaited<ReturnType<typeof startEmulator>> | null = null;
|
|
104
|
+
const serial = values.avd
|
|
105
|
+
? (emulatorLaunch = await startEmulator({
|
|
106
|
+
avd: values.avd,
|
|
107
|
+
emulatorPath: values.emulator,
|
|
108
|
+
port: values["emulator-port"] ? Number(values["emulator-port"]) : undefined,
|
|
109
|
+
restartAvd: values["restart-avd"],
|
|
110
|
+
})).serial
|
|
111
|
+
: pickDevice(values.serial);
|
|
112
|
+
const port = Number(values.port);
|
|
113
|
+
const maxFps = numberOption("max-fps", 60);
|
|
114
|
+
const bitRate = numberOption("bit-rate", 8_000_000);
|
|
115
|
+
const maxSize = numberOption("max-size", 1920);
|
|
116
|
+
const keyFrameInterval = numberOption("key-frame-interval", 1);
|
|
117
|
+
|
|
118
|
+
const { server, stop: stopServer } = await startServer({
|
|
119
|
+
serial,
|
|
120
|
+
port,
|
|
121
|
+
maxFps,
|
|
122
|
+
bitRate,
|
|
123
|
+
maxSize,
|
|
124
|
+
keyFrameInterval,
|
|
125
|
+
}).catch((err) => {
|
|
126
|
+
emulatorLaunch?.stop();
|
|
127
|
+
throw err;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const stop = () => {
|
|
131
|
+
stopServer();
|
|
132
|
+
emulatorLaunch?.stop();
|
|
133
|
+
};
|
|
134
|
+
process.once("SIGINT", () => {
|
|
135
|
+
stop();
|
|
136
|
+
process.exit(0);
|
|
137
|
+
});
|
|
138
|
+
process.once("SIGTERM", () => {
|
|
139
|
+
stop();
|
|
140
|
+
process.exit(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log(`serve-emul → http://localhost:${server.port} (device: ${serial})`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await main().catch((err) => {
|
|
147
|
+
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
package/src/emulator.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { listAllDevices } from "./adb.ts";
|
|
6
|
+
|
|
7
|
+
export type EmulatorLaunch = {
|
|
8
|
+
serial: string;
|
|
9
|
+
proc: ChildProcess | null;
|
|
10
|
+
ownsProcess: boolean;
|
|
11
|
+
stop: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type RunningAvd = {
|
|
15
|
+
serial: string;
|
|
16
|
+
avd: string;
|
|
17
|
+
state: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type StartEmulatorOpts = {
|
|
21
|
+
avd: string;
|
|
22
|
+
emulatorPath?: string;
|
|
23
|
+
port?: number;
|
|
24
|
+
restartAvd?: boolean;
|
|
25
|
+
bootTimeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function sdkEmulatorCandidates(): string[] {
|
|
29
|
+
const roots = [
|
|
30
|
+
process.env.ANDROID_HOME,
|
|
31
|
+
process.env.ANDROID_SDK_ROOT,
|
|
32
|
+
process.env.HOME ? join(process.env.HOME, "Library", "Android", "sdk") : undefined,
|
|
33
|
+
].filter((v): v is string => Boolean(v));
|
|
34
|
+
return [...new Set(roots)].flatMap((root) => [
|
|
35
|
+
join(root, "emulator", "emulator"),
|
|
36
|
+
join(root, "tools", "emulator"),
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveEmulator(explicit?: string): string {
|
|
41
|
+
if (explicit) return explicit;
|
|
42
|
+
|
|
43
|
+
const pathProbe = spawnSync("emulator", ["-version"], { encoding: "utf8" });
|
|
44
|
+
if (pathProbe.status === 0 || pathProbe.error?.message.includes("EPIPE")) return "emulator";
|
|
45
|
+
|
|
46
|
+
for (const candidate of sdkEmulatorCandidates()) {
|
|
47
|
+
if (existsSync(candidate)) return candidate;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Could not find Android Emulator. Put `emulator` on PATH or set ANDROID_HOME / ANDROID_SDK_ROOT.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listAvdsWithEmulator(emulator: string): string[] {
|
|
56
|
+
const r = spawnSync(emulator, ["-list-avds"], { encoding: "utf8" });
|
|
57
|
+
if (r.status !== 0) {
|
|
58
|
+
throw new Error(`emulator -list-avds failed: ${r.stderr || r.stdout}`);
|
|
59
|
+
}
|
|
60
|
+
return r.stdout
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.map((line) => line.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function listAvds(emulatorPath?: string): string[] {
|
|
67
|
+
return listAvdsWithEmulator(resolveEmulator(emulatorPath));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function avdName(avd: string): string {
|
|
71
|
+
return avd.startsWith("@") ? avd.slice(1) : avd;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function emulatorAvdArg(avd: string): string {
|
|
75
|
+
return avd.startsWith("@") ? avd : `@${avd}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function usedEmulatorPorts(): Set<number> {
|
|
79
|
+
const ports = new Set<number>();
|
|
80
|
+
for (const device of listAllDevices()) {
|
|
81
|
+
const match = device.serial.match(/^emulator-(\d+)$/);
|
|
82
|
+
if (match) ports.add(Number(match[1]));
|
|
83
|
+
}
|
|
84
|
+
return ports;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pickEmulatorPort(): number {
|
|
88
|
+
const used = usedEmulatorPorts();
|
|
89
|
+
for (let port = 5554; port <= 5682; port += 2) {
|
|
90
|
+
if (!used.has(port)) return port;
|
|
91
|
+
}
|
|
92
|
+
throw new Error("No available emulator console ports in the 5554-5682 range.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateEmulatorPort(port: number): void {
|
|
96
|
+
if (!Number.isInteger(port) || port < 5554 || port > 5682 || port % 2 !== 0) {
|
|
97
|
+
throw new Error("--emulator-port must be an even integer from 5554 through 5682.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function adb(serial: string, args: string[]) {
|
|
102
|
+
return spawnSync("adb", ["-s", serial, ...args], {
|
|
103
|
+
encoding: "utf8",
|
|
104
|
+
timeout: 5_000,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseEmuAvdName(stdout: string): string | null {
|
|
109
|
+
return (
|
|
110
|
+
stdout
|
|
111
|
+
.split(/\r?\n/)
|
|
112
|
+
.map((line) => line.trim())
|
|
113
|
+
.find((line) => line && line !== "OK" && !line.startsWith("KO:")) ?? null
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function runningAvdName(serial: string): string | null {
|
|
118
|
+
const fromConsole = adb(serial, ["emu", "avd", "name"]);
|
|
119
|
+
if (fromConsole.status === 0) {
|
|
120
|
+
const name = parseEmuAvdName(fromConsole.stdout);
|
|
121
|
+
if (name) return name;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fromProp = adb(serial, ["shell", "getprop", "ro.boot.qemu.avd_name"]);
|
|
125
|
+
if (fromProp.status === 0) {
|
|
126
|
+
const name = fromProp.stdout.trim();
|
|
127
|
+
if (name) return name;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function listRunningAvds(): RunningAvd[] {
|
|
134
|
+
return listAllDevices()
|
|
135
|
+
.filter((device) => /^emulator-\d+$/.test(device.serial))
|
|
136
|
+
.flatMap((device) => {
|
|
137
|
+
const avd = runningAvdName(device.serial);
|
|
138
|
+
return avd ? [{ serial: device.serial, avd, state: device.state }] : [];
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function findRunningAvd(name: string): RunningAvd | null {
|
|
143
|
+
return listRunningAvds().find((running) => running.avd === name) ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function stopEmulator(serial: string): void {
|
|
147
|
+
const r = adb(serial, ["emu", "kill"]);
|
|
148
|
+
if (r.status !== 0) {
|
|
149
|
+
throw new Error(`Failed to stop ${serial}: ${r.stderr || r.stdout}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function waitForEmulatorExit(serial: string, timeoutMs = 30_000): Promise<void> {
|
|
154
|
+
const startedAt = Date.now();
|
|
155
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
156
|
+
if (!listAllDevices().some((device) => device.serial === serial)) return;
|
|
157
|
+
await sleep(500);
|
|
158
|
+
}
|
|
159
|
+
throw new Error(`Timed out waiting for ${serial} to stop.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function waitForBoot(serial: string, proc: ChildProcess, timeoutMs: number): Promise<void> {
|
|
163
|
+
const startedAt = Date.now();
|
|
164
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
165
|
+
if (proc.exitCode !== null || proc.signalCode !== null) {
|
|
166
|
+
throw new Error(`emulator exited before boot completed (code ${proc.exitCode ?? "null"})`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const state = adb(serial, ["get-state"]);
|
|
170
|
+
if (state.status === 0 && state.stdout.trim() === "device") {
|
|
171
|
+
const boot = adb(serial, ["shell", "getprop", "sys.boot_completed"]);
|
|
172
|
+
if (boot.status === 0 && boot.stdout.trim() === "1") return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await sleep(1_000);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new Error(`Timed out waiting for ${serial} to boot.`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function startEmulator(opts: StartEmulatorOpts): Promise<EmulatorLaunch> {
|
|
182
|
+
const emulator = resolveEmulator(opts.emulatorPath);
|
|
183
|
+
const name = avdName(opts.avd);
|
|
184
|
+
const avds = listAvdsWithEmulator(emulator);
|
|
185
|
+
if (!avds.includes(name)) {
|
|
186
|
+
const available = avds.length ? avds.join(", ") : "(none)";
|
|
187
|
+
throw new Error(`Unknown AVD "${name}". Available AVDs: ${available}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const running = findRunningAvd(name);
|
|
191
|
+
if (running) {
|
|
192
|
+
if (!opts.restartAvd) {
|
|
193
|
+
return { serial: running.serial, proc: null, ownsProcess: false, stop: () => {} };
|
|
194
|
+
}
|
|
195
|
+
stopEmulator(running.serial);
|
|
196
|
+
await waitForEmulatorExit(running.serial);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const port = opts.port ?? pickEmulatorPort();
|
|
200
|
+
validateEmulatorPort(port);
|
|
201
|
+
|
|
202
|
+
const args = [emulatorAvdArg(name), "-port", String(port)];
|
|
203
|
+
|
|
204
|
+
const proc = spawn(emulator, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
205
|
+
const spawnError = new Promise<never>((_, reject) => {
|
|
206
|
+
proc.once("error", reject);
|
|
207
|
+
});
|
|
208
|
+
const serial = `emulator-${port}`;
|
|
209
|
+
let stopped = false;
|
|
210
|
+
|
|
211
|
+
const stop = () => {
|
|
212
|
+
if (stopped) return;
|
|
213
|
+
stopped = true;
|
|
214
|
+
try {
|
|
215
|
+
adb(serial, ["emu", "kill"]);
|
|
216
|
+
} catch {}
|
|
217
|
+
try {
|
|
218
|
+
proc.kill("SIGTERM");
|
|
219
|
+
} catch {}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await Promise.race([waitForBoot(serial, proc, opts.bootTimeoutMs ?? 120_000), spawnError]);
|
|
224
|
+
return { serial, proc, ownsProcess: true, stop };
|
|
225
|
+
} catch (err) {
|
|
226
|
+
stop();
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|