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.
Files changed (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +201 -0
  3. package/README.md +196 -0
  4. package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
  5. package/dist/ui/assets/index-CyUIa9dV.js +42 -0
  6. package/dist/ui/index.html +13 -0
  7. package/package.json +67 -0
  8. package/scripts/fetch-scrcpy.ts +28 -0
  9. package/scripts/release.ts +136 -0
  10. package/src/accessibility.ts +88 -0
  11. package/src/adb.ts +209 -0
  12. package/src/app-info.ts +114 -0
  13. package/src/app-management.ts +150 -0
  14. package/src/cli.ts +149 -0
  15. package/src/emulator.ts +229 -0
  16. package/src/input.ts +258 -0
  17. package/src/location.ts +135 -0
  18. package/src/route-playback.ts +359 -0
  19. package/src/scrcpy.ts +466 -0
  20. package/src/server.ts +1260 -0
  21. package/src/session-recorder.ts +149 -0
  22. package/src/ui/app.tsx +111 -0
  23. package/src/ui/components/accessibility-panel.tsx +113 -0
  24. package/src/ui/components/app-management-panel.tsx +256 -0
  25. package/src/ui/components/control-bar.tsx +24 -0
  26. package/src/ui/components/device-panel.tsx +532 -0
  27. package/src/ui/components/device-stream.tsx +142 -0
  28. package/src/ui/components/location-panel.tsx +584 -0
  29. package/src/ui/components/logcat-panel.tsx +100 -0
  30. package/src/ui/components/session-panel.tsx +127 -0
  31. package/src/ui/components/status-bar.tsx +19 -0
  32. package/src/ui/index.html +12 -0
  33. package/src/ui/lib/h264.ts +35 -0
  34. package/src/ui/lib/use-stream.ts +368 -0
  35. package/src/ui/main.tsx +7 -0
  36. package/src/ui/styles.css +708 -0
  37. package/src/ui/tsconfig.json +17 -0
  38. package/src/update-check.ts +93 -0
  39. package/vendor/scrcpy-server-v2.7 +0 -0
  40. package/vendor/scrcpy-server-v3.1 +0 -0
  41. package/vendor/scrcpy-server-v3.3.4 +0 -0
  42. package/vendor/scrcpy-server-v4.0 +0 -0
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bun
2
+ import { mkdir } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+
7
+ export const SCRCPY_VERSION = "4.0";
8
+ const URL = `https://github.com/Genymobile/scrcpy/releases/download/v${SCRCPY_VERSION}/scrcpy-server-v${SCRCPY_VERSION}`;
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const VENDOR_DIR = join(__dirname, "..", "vendor");
12
+ export const SCRCPY_SERVER_PATH = join(VENDOR_DIR, `scrcpy-server-v${SCRCPY_VERSION}`);
13
+
14
+ export async function ensureScrcpyServer(): Promise<string> {
15
+ if (existsSync(SCRCPY_SERVER_PATH)) return SCRCPY_SERVER_PATH;
16
+ await mkdir(VENDOR_DIR, { recursive: true });
17
+ console.log(`Downloading scrcpy-server v${SCRCPY_VERSION}…`);
18
+ const res = await fetch(URL);
19
+ if (!res.ok) throw new Error(`Failed to download ${URL}: ${res.status} ${res.statusText}`);
20
+ const buf = new Uint8Array(await res.arrayBuffer());
21
+ await Bun.write(SCRCPY_SERVER_PATH, buf);
22
+ console.log(`Saved ${SCRCPY_SERVER_PATH} (${buf.byteLength} bytes)`);
23
+ return SCRCPY_SERVER_PATH;
24
+ }
25
+
26
+ if (import.meta.main) {
27
+ await ensureScrcpyServer();
28
+ }
@@ -0,0 +1,136 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ type Bump = "patch" | "minor" | "major";
6
+
7
+ const packageDir = resolve(import.meta.dir, "..");
8
+ const packageJsonPath = resolve(packageDir, "package.json");
9
+ const changelogPath = resolve(packageDir, "CHANGELOG.md");
10
+
11
+ const args = Bun.argv.slice(2);
12
+ const bumpArg = args.find((arg) => !arg.startsWith("--"));
13
+ const dryRun = args.includes("--dry-run");
14
+
15
+ function usage(): never {
16
+ console.error("Usage: bun run release <patch|minor|major|x.y.z> [--dry-run]");
17
+ process.exit(1);
18
+ }
19
+
20
+ function readJson(path: string): { version?: unknown; [key: string]: unknown } {
21
+ return JSON.parse(readFileSync(path, "utf8"));
22
+ }
23
+
24
+ function assertVersion(version: unknown): string {
25
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) {
26
+ throw new Error(`Expected a plain semver version, got ${String(version)}`);
27
+ }
28
+ return version;
29
+ }
30
+
31
+ function nextVersion(current: string, bump: string): string {
32
+ if (/^\d+\.\d+\.\d+$/.test(bump)) return bump;
33
+
34
+ if (!["patch", "minor", "major"].includes(bump)) usage();
35
+
36
+ const [major, minor, patch] = current.split(".").map((part) => Number(part));
37
+ switch (bump as Bump) {
38
+ case "major":
39
+ return `${major + 1}.0.0`;
40
+ case "minor":
41
+ return `${major}.${minor + 1}.0`;
42
+ case "patch":
43
+ return `${major}.${minor}.${patch + 1}`;
44
+ }
45
+ }
46
+
47
+ function runGit(args: string[]): string | null {
48
+ const result = spawnSync("git", args, {
49
+ cwd: packageDir,
50
+ encoding: "utf8",
51
+ });
52
+
53
+ if (result.status !== 0) return null;
54
+ return result.stdout.trim();
55
+ }
56
+
57
+ function latestTag(): string | null {
58
+ const tag = runGit(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"]);
59
+ return tag && tag.length > 0 ? tag : null;
60
+ }
61
+
62
+ function commitSubjectsSince(tag: string | null): string[] {
63
+ const range = tag ? `${tag}..HEAD` : "HEAD";
64
+ const output = runGit(["log", "--format=%s", "--no-merges", range]);
65
+ if (!output) return [];
66
+ return output
67
+ .split("\n")
68
+ .map((line) => line.trim())
69
+ .filter(Boolean);
70
+ }
71
+
72
+ function changelogEntry(version: string, subjects: string[]): string {
73
+ const date = new Date().toISOString().slice(0, 10);
74
+ const lines = subjects.length > 0 ? subjects : ["Release maintenance."];
75
+
76
+ return [
77
+ `## ${version} - ${date}`,
78
+ "",
79
+ "### Changed",
80
+ "",
81
+ ...lines.map((line) => `- ${line}`),
82
+ "",
83
+ ].join("\n");
84
+ }
85
+
86
+ function prependChangelog(version: string, entry: string): string {
87
+ const current = readFileSync(changelogPath, "utf8");
88
+
89
+ if (current.includes(`## ${version} - `)) {
90
+ throw new Error(`CHANGELOG.md already has an entry for ${version}`);
91
+ }
92
+
93
+ const firstRelease = current.match(/^## \d+\.\d+\.\d+ - /m);
94
+ if (!firstRelease?.index) {
95
+ return `${current.trimEnd()}\n\n${entry}`;
96
+ }
97
+
98
+ return `${current.slice(0, firstRelease.index)}${entry}\n${current.slice(firstRelease.index)}`;
99
+ }
100
+
101
+ if (!bumpArg) usage();
102
+
103
+ const packageJson = readJson(packageJsonPath);
104
+ const currentVersion = assertVersion(packageJson.version);
105
+ const version = nextVersion(currentVersion, bumpArg);
106
+
107
+ if (version === currentVersion) {
108
+ throw new Error(`Version is already ${version}`);
109
+ }
110
+
111
+ packageJson.version = version;
112
+ const tag = latestTag();
113
+ const subjects = commitSubjectsSince(tag);
114
+ const nextChangelog = prependChangelog(version, changelogEntry(version, subjects));
115
+
116
+ console.log(`${currentVersion} -> ${version}`);
117
+ if (tag) console.log(`Changes since ${tag}`);
118
+ if (subjects.length === 0) console.log("No commit subjects found; using a maintenance placeholder.");
119
+
120
+ if (dryRun) {
121
+ console.log("\nDry run only. No files changed.");
122
+ process.exit(0);
123
+ }
124
+
125
+ writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
126
+ writeFileSync(changelogPath, nextChangelog);
127
+
128
+ console.log("\nUpdated:");
129
+ console.log(`- ${packageJsonPath}`);
130
+ console.log(`- ${changelogPath}`);
131
+ console.log("\nNext:");
132
+ console.log("1. Review CHANGELOG.md and edit sections if needed.");
133
+ console.log("2. Run: bun run check");
134
+ console.log(`3. Commit: git commit -m "Release v${version}" -- packages/serve-emul/package.json packages/serve-emul/CHANGELOG.md`);
135
+ console.log(`4. Tag: git tag v${version}`);
136
+ console.log("5. Publish from a clean tree: npm publish packages/serve-emul");
@@ -0,0 +1,88 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export type AccessibilityNode = {
4
+ id: string;
5
+ text: string;
6
+ contentDescription: string;
7
+ resourceId: string;
8
+ className: string;
9
+ packageName: string;
10
+ clickable: boolean;
11
+ enabled: boolean;
12
+ bounds: { left: number; top: number; right: number; bottom: number };
13
+ };
14
+
15
+ export type AccessibilitySnapshot = {
16
+ ok: true;
17
+ capturedAt: string;
18
+ nodes: AccessibilityNode[];
19
+ };
20
+
21
+ function decodeXml(value: string): string {
22
+ return value
23
+ .replace(/&quot;/g, "\"")
24
+ .replace(/&apos;/g, "'")
25
+ .replace(/&lt;/g, "<")
26
+ .replace(/&gt;/g, ">")
27
+ .replace(/&amp;/g, "&");
28
+ }
29
+
30
+ function attrsFor(node: string): Record<string, string> {
31
+ const attrs: Record<string, string> = {};
32
+ for (const match of node.matchAll(/\s([a-zA-Z0-9_-]+)="([^"]*)"/g)) {
33
+ attrs[match[1]!] = decodeXml(match[2] ?? "");
34
+ }
35
+ return attrs;
36
+ }
37
+
38
+ function parseBounds(value: string | undefined) {
39
+ const match = value?.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
40
+ if (!match) return null;
41
+ return {
42
+ left: Number(match[1]),
43
+ top: Number(match[2]),
44
+ right: Number(match[3]),
45
+ bottom: Number(match[4]),
46
+ };
47
+ }
48
+
49
+ function boolAttr(value: string | undefined): boolean {
50
+ return value === "true";
51
+ }
52
+
53
+ function dumpXml(serial: string): string {
54
+ const path = `/sdcard/window-${Date.now()}.xml`;
55
+ const command = `uiautomator dump ${path} >/dev/null && cat ${path} && rm ${path}`;
56
+ const result = spawnSync("adb", ["-s", serial, "shell", "sh", "-c", command], {
57
+ encoding: "utf8",
58
+ maxBuffer: 16 * 1024 * 1024,
59
+ timeout: 8_000,
60
+ });
61
+ if (result.status !== 0) {
62
+ throw new Error((result.stderr || result.stdout || "uiautomator dump failed").trim());
63
+ }
64
+ return result.stdout;
65
+ }
66
+
67
+ export function getAccessibilitySnapshot(serial: string): AccessibilitySnapshot {
68
+ const xml = dumpXml(serial);
69
+ const nodes: AccessibilityNode[] = [];
70
+ let index = 0;
71
+ for (const match of xml.matchAll(/<node\b[^>]*>/g)) {
72
+ const attrs = attrsFor(match[0]);
73
+ const bounds = parseBounds(attrs.bounds);
74
+ if (!bounds || bounds.right <= bounds.left || bounds.bottom <= bounds.top) continue;
75
+ nodes.push({
76
+ id: `${index++}`,
77
+ text: attrs.text ?? "",
78
+ contentDescription: attrs["content-desc"] ?? "",
79
+ resourceId: attrs["resource-id"] ?? "",
80
+ className: attrs.class ?? "",
81
+ packageName: attrs.package ?? "",
82
+ clickable: boolAttr(attrs.clickable),
83
+ enabled: boolAttr(attrs.enabled),
84
+ bounds,
85
+ });
86
+ }
87
+ return { ok: true, capturedAt: new Date().toISOString(), nodes };
88
+ }
package/src/adb.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+
3
+ export type Device = { serial: string; state: string };
4
+ export type OrientationMode = "auto" | "portrait" | "landscape";
5
+ export type NightMode = "auto" | "dark" | "light";
6
+ export type OrientationStatus = {
7
+ mode: "free" | "lock" | "unknown";
8
+ rotation: number | null;
9
+ orientation: OrientationMode | "unknown";
10
+ raw: string;
11
+ };
12
+ export type FontScaleStatus = {
13
+ scale: number;
14
+ raw: string;
15
+ };
16
+ export type NightModeStatus = {
17
+ mode: NightMode | "unknown";
18
+ raw: string;
19
+ };
20
+ export type NetworkRadioStatus = "enabled" | "disabled" | "unknown";
21
+ export type NetworkStatus = {
22
+ enabled: boolean | null;
23
+ wifi: NetworkRadioStatus;
24
+ mobileData: NetworkRadioStatus;
25
+ raw: {
26
+ wifi: string;
27
+ mobileData: string;
28
+ };
29
+ };
30
+
31
+ export function listAllDevices(): Device[] {
32
+ const r = spawnSync("adb", ["devices"], { encoding: "utf8" });
33
+ if (r.status !== 0) throw new Error(`adb devices failed: ${r.stderr}`);
34
+ return r.stdout
35
+ .split("\n")
36
+ .slice(1)
37
+ .map((l) => l.trim())
38
+ .filter(Boolean)
39
+ .map((l) => {
40
+ const [serial, state] = l.split(/\s+/);
41
+ return { serial, state };
42
+ });
43
+ }
44
+
45
+ export function listDevices(): Device[] {
46
+ return listAllDevices().filter((d) => d.state === "device");
47
+ }
48
+
49
+ export function pickDevice(explicit?: string): string {
50
+ if (explicit) return explicit;
51
+ const devices = listDevices();
52
+ if (devices.length === 0) throw new Error("No booted Android device found. Start an emulator or attach a device.");
53
+ if (devices.length > 1)
54
+ throw new Error(
55
+ `Multiple devices online (${devices.map((d) => d.serial).join(", ")}). Pass -s <serial>.`,
56
+ );
57
+ return devices[0].serial;
58
+ }
59
+
60
+ export function screencapPng(serial: string): Buffer {
61
+ const r = spawnSync("adb", ["-s", serial, "exec-out", "screencap", "-p"], {
62
+ encoding: "buffer",
63
+ maxBuffer: 64 * 1024 * 1024,
64
+ });
65
+ if (r.status !== 0) throw new Error(`screencap failed: ${r.stderr.toString()}`);
66
+ return r.stdout;
67
+ }
68
+
69
+ export function shell(serial: string, cmd: string[]): void {
70
+ const r = spawnSync("adb", ["-s", serial, "shell", ...cmd], { encoding: "utf8" });
71
+ if (r.status !== 0) throw new Error(`adb shell ${cmd.join(" ")} failed: ${r.stderr}`);
72
+ }
73
+
74
+ export function shellSpawn(serial: string, cmd: string[]) {
75
+ return spawn("adb", ["-s", serial, "shell", ...cmd]);
76
+ }
77
+
78
+ export function getDeviceSize(serial: string): { width: number; height: number } {
79
+ const r = spawnSync("adb", ["-s", serial, "shell", "wm", "size"], { encoding: "utf8" });
80
+ if (r.status !== 0) throw new Error(`wm size failed: ${r.stderr}`);
81
+ const m = r.stdout.match(/(\d+)x(\d+)/);
82
+ if (!m) throw new Error(`Could not parse wm size output: ${r.stdout}`);
83
+ return { width: Number(m[1]), height: Number(m[2]) };
84
+ }
85
+
86
+ function orientationFromRotation(mode: "free" | "lock" | "unknown", rotation: number | null): OrientationStatus["orientation"] {
87
+ if (mode === "free") return "auto";
88
+ if (rotation === 0 || rotation === 2) return "portrait";
89
+ if (rotation === 1 || rotation === 3) return "landscape";
90
+ return "unknown";
91
+ }
92
+
93
+ export function getUserRotation(serial: string): OrientationStatus {
94
+ const r = spawnSync("adb", ["-s", serial, "shell", "cmd", "window", "user-rotation"], {
95
+ encoding: "utf8",
96
+ });
97
+ if (r.status !== 0) throw new Error(`cmd window user-rotation failed: ${r.stderr}`);
98
+ const raw = r.stdout.trim();
99
+ const match = raw.match(/^(free|lock)(?:\s+(\d+))?$/);
100
+ if (!match) {
101
+ return { mode: "unknown", rotation: null, orientation: "unknown", raw };
102
+ }
103
+ const mode = match[1] as "free" | "lock";
104
+ const rotation = match[2] === undefined ? null : Number(match[2]);
105
+ return { mode, rotation, orientation: orientationFromRotation(mode, rotation), raw };
106
+ }
107
+
108
+ export function setUserRotation(serial: string, orientation: OrientationMode): OrientationStatus {
109
+ const args =
110
+ orientation === "auto"
111
+ ? ["cmd", "window", "user-rotation", "free"]
112
+ : ["cmd", "window", "user-rotation", "lock", orientation === "portrait" ? "0" : "1"];
113
+ const r = spawnSync("adb", ["-s", serial, "shell", ...args], { encoding: "utf8" });
114
+ if (r.status !== 0) throw new Error(`adb shell ${args.join(" ")} failed: ${r.stderr}`);
115
+ return getUserRotation(serial);
116
+ }
117
+
118
+ export function getFontScale(serial: string): FontScaleStatus {
119
+ const r = spawnSync("adb", ["-s", serial, "shell", "settings", "get", "system", "font_scale"], {
120
+ encoding: "utf8",
121
+ });
122
+ if (r.status !== 0) throw new Error(`settings get system font_scale failed: ${r.stderr}`);
123
+ const raw = r.stdout.trim();
124
+ const scale = Number(raw);
125
+ if (!Number.isFinite(scale) || scale <= 0) {
126
+ throw new Error(`Could not parse font_scale output: ${r.stdout}`);
127
+ }
128
+ return { scale, raw };
129
+ }
130
+
131
+ export function setFontScale(serial: string, scale: number): FontScaleStatus {
132
+ if (!Number.isFinite(scale) || scale < 0.7 || scale > 2) {
133
+ throw new Error("font scale must be between 0.7 and 2.0");
134
+ }
135
+ const normalized = scale.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
136
+ const args = ["settings", "put", "system", "font_scale", normalized];
137
+ const r = spawnSync("adb", ["-s", serial, "shell", ...args], { encoding: "utf8" });
138
+ if (r.status !== 0) throw new Error(`adb shell ${args.join(" ")} failed: ${r.stderr}`);
139
+ return getFontScale(serial);
140
+ }
141
+
142
+ function nightModeFromRaw(raw: string): NightMode | "unknown" {
143
+ const match = raw.match(/Night mode:\s*(\S+)/i);
144
+ const value = (match?.[1] ?? raw).trim().toLowerCase();
145
+ if (value === "yes") return "dark";
146
+ if (value === "no") return "light";
147
+ if (value === "auto") return "auto";
148
+ return "unknown";
149
+ }
150
+
151
+ export function getNightMode(serial: string): NightModeStatus {
152
+ const r = spawnSync("adb", ["-s", serial, "shell", "cmd", "uimode", "night"], {
153
+ encoding: "utf8",
154
+ });
155
+ if (r.status !== 0) throw new Error(`cmd uimode night failed: ${r.stderr}`);
156
+ const raw = r.stdout.trim();
157
+ return { mode: nightModeFromRaw(raw), raw };
158
+ }
159
+
160
+ export function setNightMode(serial: string, mode: NightMode): NightModeStatus {
161
+ const value = mode === "dark" ? "yes" : mode === "light" ? "no" : "auto";
162
+ const args = ["cmd", "uimode", "night", value];
163
+ const r = spawnSync("adb", ["-s", serial, "shell", ...args], { encoding: "utf8" });
164
+ if (r.status !== 0) throw new Error(`adb shell ${args.join(" ")} failed: ${r.stderr}`);
165
+ return getNightMode(serial);
166
+ }
167
+
168
+ function globalSetting(serial: string, name: string): string {
169
+ const r = spawnSync("adb", ["-s", serial, "shell", "settings", "get", "global", name], {
170
+ encoding: "utf8",
171
+ });
172
+ if (r.status !== 0) throw new Error(`settings get global ${name} failed: ${r.stderr}`);
173
+ return r.stdout.trim();
174
+ }
175
+
176
+ function radioStatusFromSetting(raw: string): NetworkRadioStatus {
177
+ if (raw === "1") return "enabled";
178
+ if (raw === "0") return "disabled";
179
+ return "unknown";
180
+ }
181
+
182
+ export function getNetworkStatus(serial: string): NetworkStatus {
183
+ const wifiRaw = globalSetting(serial, "wifi_on");
184
+ const mobileDataRaw = globalSetting(serial, "mobile_data");
185
+ const wifi = radioStatusFromSetting(wifiRaw);
186
+ const mobileData = radioStatusFromSetting(mobileDataRaw);
187
+ const radios = [wifi, mobileData];
188
+ const knownRadios = radios.filter((radio) => radio !== "unknown");
189
+ const enabled = knownRadios.length === 0 ? null : knownRadios.some((radio) => radio === "enabled");
190
+ return {
191
+ enabled,
192
+ wifi,
193
+ mobileData,
194
+ raw: {
195
+ wifi: wifiRaw,
196
+ mobileData: mobileDataRaw,
197
+ },
198
+ };
199
+ }
200
+
201
+ export function setNetworkEnabled(serial: string, enabled: boolean): NetworkStatus {
202
+ const action = enabled ? "enable" : "disable";
203
+ for (const service of ["wifi", "data"]) {
204
+ const args = ["svc", service, action];
205
+ const r = spawnSync("adb", ["-s", serial, "shell", ...args], { encoding: "utf8" });
206
+ if (r.status !== 0) throw new Error(`adb shell ${args.join(" ")} failed: ${r.stderr}`);
207
+ }
208
+ return getNetworkStatus(serial);
209
+ }
@@ -0,0 +1,114 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export type ForegroundApp = {
4
+ packageName: string | null;
5
+ activity: string | null;
6
+ pid: number | null;
7
+ label: string | null;
8
+ versionName: string | null;
9
+ versionCode: string | null;
10
+ debuggable: boolean | null;
11
+ };
12
+
13
+ function adbShell(serial: string, args: string[], timeout = 4_000): string {
14
+ const result = spawnSync("adb", ["-s", serial, "shell", ...args], {
15
+ encoding: "utf8",
16
+ maxBuffer: 8 * 1024 * 1024,
17
+ timeout,
18
+ });
19
+ if (result.status !== 0) {
20
+ throw new Error((result.stderr || result.stdout || `adb shell ${args.join(" ")} failed`).trim());
21
+ }
22
+ return result.stdout;
23
+ }
24
+
25
+ function firstMatch(text: string, patterns: RegExp[]): RegExpMatchArray | null {
26
+ for (const pattern of patterns) {
27
+ const match = text.match(pattern);
28
+ if (match) return match;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function parseComponent(value: string): { packageName: string; activity: string | null } | null {
34
+ const clean = value.trim().replace(/^\{|\}$/g, "");
35
+ const component = clean.split(/\s+/).find((part) => part.includes("/")) ?? clean;
36
+ const [packageName, activityRaw] = component.split("/", 2);
37
+ if (!packageName || !/^[A-Za-z0-9_.]+$/.test(packageName)) return null;
38
+ const activity = activityRaw
39
+ ? activityRaw.startsWith(".")
40
+ ? `${packageName}${activityRaw}`
41
+ : activityRaw
42
+ : null;
43
+ return { packageName, activity };
44
+ }
45
+
46
+ function foregroundComponent(serial: string): { packageName: string; activity: string | null } | null {
47
+ const windowDump = adbShell(serial, ["dumpsys", "window"], 5_000);
48
+ const windowMatch = firstMatch(windowDump, [
49
+ /mCurrentFocus=Window\{[^}]*\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+)\}/,
50
+ /mFocusedApp=ActivityRecord\{[^}]*\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+)\s/,
51
+ /mInputMethodTarget=Window\{[^}]*\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+)\}/,
52
+ ]);
53
+ if (windowMatch?.[1]) {
54
+ const parsed = parseComponent(windowMatch[1]);
55
+ if (parsed) return parsed;
56
+ }
57
+
58
+ const activityDump = adbShell(serial, ["dumpsys", "activity", "activities"], 5_000);
59
+ const activityMatch = firstMatch(activityDump, [
60
+ /topResumedActivity=ActivityRecord\{[^}]*\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+)\s/,
61
+ /mResumedActivity: ActivityRecord\{[^}]*\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+)\s/,
62
+ /ResumedActivity: ActivityRecord\{[^}]*\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.$]+)\s/,
63
+ ]);
64
+ return activityMatch?.[1] ? parseComponent(activityMatch[1]) : null;
65
+ }
66
+
67
+ function packagePid(serial: string, packageName: string): number | null {
68
+ try {
69
+ const out = adbShell(serial, ["pidof", packageName], 2_000).trim();
70
+ const first = out.split(/\s+/)[0];
71
+ const pid = first ? Number(first) : NaN;
72
+ return Number.isFinite(pid) ? pid : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function packageDetails(serial: string, packageName: string) {
79
+ try {
80
+ const dump = adbShell(serial, ["dumpsys", "package", packageName], 5_000);
81
+ const versionName = dump.match(/versionName=([^\s]+)/)?.[1] ?? null;
82
+ const versionCode = dump.match(/versionCode=(\d+)/)?.[1] ?? null;
83
+ const label =
84
+ dump.match(/application-label(?:-[a-zA-Z]+)?:'([^']+)'/)?.[1] ??
85
+ dump.match(/labelRes=0x[0-9a-fA-F]+ nonLocalizedLabel=([^\n]+)/)?.[1]?.trim() ??
86
+ null;
87
+ const debuggable = /pkgFlags=\[[^\]]*\bDEBUGGABLE\b/.test(dump) || /\bDEBUGGABLE\b/.test(dump);
88
+ return { label, versionName, versionCode, debuggable };
89
+ } catch {
90
+ return { label: null, versionName: null, versionCode: null, debuggable: null };
91
+ }
92
+ }
93
+
94
+ export function getForegroundApp(serial: string): ForegroundApp {
95
+ const component = foregroundComponent(serial);
96
+ if (!component) {
97
+ return {
98
+ packageName: null,
99
+ activity: null,
100
+ pid: null,
101
+ label: null,
102
+ versionName: null,
103
+ versionCode: null,
104
+ debuggable: null,
105
+ };
106
+ }
107
+ const details = packageDetails(serial, component.packageName);
108
+ return {
109
+ packageName: component.packageName,
110
+ activity: component.activity,
111
+ pid: packagePid(serial, component.packageName),
112
+ ...details,
113
+ };
114
+ }