@beeos-ai/device-mcp-server 0.2.3

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 (62) hide show
  1. package/LICENSE +201 -0
  2. package/dist/backends/android-adb-runner.d.ts +32 -0
  3. package/dist/backends/android-adb-runner.js +15 -0
  4. package/dist/backends/android-adb-runner.js.map +1 -0
  5. package/dist/backends/android-adb.d.ts +153 -0
  6. package/dist/backends/android-adb.js +723 -0
  7. package/dist/backends/android-adb.js.map +1 -0
  8. package/dist/backends/base.d.ts +150 -0
  9. package/dist/backends/base.js +116 -0
  10. package/dist/backends/base.js.map +1 -0
  11. package/dist/backends/desktop.d.ts +62 -0
  12. package/dist/backends/desktop.js +176 -0
  13. package/dist/backends/desktop.js.map +1 -0
  14. package/dist/backends/index.d.ts +63 -0
  15. package/dist/backends/index.js +105 -0
  16. package/dist/backends/index.js.map +1 -0
  17. package/dist/backends/linux.d.ts +69 -0
  18. package/dist/backends/linux.js +230 -0
  19. package/dist/backends/linux.js.map +1 -0
  20. package/dist/backends/mac.d.ts +154 -0
  21. package/dist/backends/mac.js +881 -0
  22. package/dist/backends/mac.js.map +1 -0
  23. package/dist/backends/stubs/ios.d.ts +17 -0
  24. package/dist/backends/stubs/ios.js +32 -0
  25. package/dist/backends/stubs/ios.js.map +1 -0
  26. package/dist/backends/stubs/macos.d.ts +13 -0
  27. package/dist/backends/stubs/macos.js +27 -0
  28. package/dist/backends/stubs/macos.js.map +1 -0
  29. package/dist/backends/stubs/windows.d.ts +69 -0
  30. package/dist/backends/stubs/windows.js +191 -0
  31. package/dist/backends/stubs/windows.js.map +1 -0
  32. package/dist/cli.d.ts +37 -0
  33. package/dist/cli.js +177 -0
  34. package/dist/cli.js.map +1 -0
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.js +14 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/server/action-mapping.d.ts +21 -0
  39. package/dist/server/action-mapping.js +153 -0
  40. package/dist/server/action-mapping.js.map +1 -0
  41. package/dist/server/app.d.ts +23 -0
  42. package/dist/server/app.js +157 -0
  43. package/dist/server/app.js.map +1 -0
  44. package/dist/server/tool-registry.d.ts +50 -0
  45. package/dist/server/tool-registry.js +504 -0
  46. package/dist/server/tool-registry.js.map +1 -0
  47. package/dist/util/adb-files.d.ts +92 -0
  48. package/dist/util/adb-files.js +221 -0
  49. package/dist/util/adb-files.js.map +1 -0
  50. package/dist/util/adb-shell.d.ts +80 -0
  51. package/dist/util/adb-shell.js +102 -0
  52. package/dist/util/adb-shell.js.map +1 -0
  53. package/dist/util/android-apps.d.ts +10 -0
  54. package/dist/util/android-apps.js +103 -0
  55. package/dist/util/android-apps.js.map +1 -0
  56. package/dist/util/image-dim.d.ts +27 -0
  57. package/dist/util/image-dim.js +37 -0
  58. package/dist/util/image-dim.js.map +1 -0
  59. package/dist/util/ui-xml.d.ts +20 -0
  60. package/dist/util/ui-xml.js +184 -0
  61. package/dist/util/ui-xml.js.map +1 -0
  62. package/package.json +56 -0
@@ -0,0 +1,723 @@
1
+ /**
2
+ * AndroidAdbBackend — concrete `SandboxBackend` over Android `adb`.
3
+ *
4
+ * Migrated from the legacy `device-agent` files:
5
+ * - `src/device/android.ts`
6
+ * - `src/adapters/device-android-adb.ts`
7
+ *
8
+ * Owns ALL `adb` invocations. The MCP server is the only process that talks
9
+ * to the device daemon — `device-agent` reaches it exclusively via HTTP.
10
+ *
11
+ * **Testability**: like `MacOsBackend`, the constructor accepts an injected
12
+ * `runner` (an `execFile`-like async callable). Defaults bind to a
13
+ * spawn-backed runner. Unit tests pass spy runners so the suite runs
14
+ * identically on Linux CI without any real `adb` binary on `PATH`.
15
+ *
16
+ * **Error normalisation**: every `adb` invocation routes through
17
+ * `wrapAdbFailure(verb, args, err, timeoutMs)` so callers see a single
18
+ * `DeviceError` shape regardless of whether the failure came from a
19
+ * `SIGTERM` timeout, a missing `adb` binary (`ENOENT`), or a non-zero
20
+ * exit. In particular `executeCommand` no longer silently coerces
21
+ * `ENOENT` to `exitCode:1` — it throws `adb_not_installed` so callers
22
+ * can surface the real cause.
23
+ *
24
+ * **Shell quoting invariant** (v0.2.3): every `adb shell ...` invocation
25
+ * goes through the `sh\`...\`` tagged template from `util/adb-shell.ts`,
26
+ * collapsing argv to the single-string pattern that adb's wire protocol
27
+ * actually wants. Multi-arg `["shell", "verb", a, b, ...]` is forbidden
28
+ * because adb on the device joins argv with spaces and re-evaluates
29
+ * through `/system/bin/sh -c`, which silently drops shell metacharacters
30
+ * (`$`, backticks, etc.) the host thought it was passing literally. See
31
+ * `util/adb-shell.ts` for the gory details.
32
+ *
33
+ * **File IO** (v0.2.3): `fileRead` / `fileWrite` / `listDirectory` no
34
+ * longer use `adb exec-out cat` or `adb shell sh -c "echo ... | base64
35
+ * -d"` shell hacks. They go through `adb pull` / `adb push` (the actual
36
+ * file-sync protocol) and `adb shell ls -laL` (deref symlinks) via the
37
+ * `util/adb-files.ts` helpers. This fixes:
38
+ * - `fileRead` of a missing path now throws `file_not_found` instead
39
+ * of returning the device-side `cat` stderr base64-encoded.
40
+ * - `fileWrite` actually writes the bytes (the old `echo | base64 -d`
41
+ * pipeline was eaten by the nested-`sh -c` re-evaluation).
42
+ * - `listDirectory("/sdcard")` returns the contents of the linked dir
43
+ * (`/storage/self/primary`) instead of a single symlink row.
44
+ *
45
+ * **Screenshot contract** (v0.2.3): `screenshot()` returns the structured
46
+ * `{ data, width, height, format }` shape so the MCP wire layer can keep
47
+ * `width/height` consistent with the actual decoded image bytes (and
48
+ * `deviceWidth/deviceHeight` separately track the click coordinate
49
+ * system). The Android backend no longer compresses by default — the
50
+ * agent layer decides whether to resize.
51
+ */
52
+ import { spawn } from "node:child_process";
53
+ import sharp from "sharp";
54
+ import { COMMON_TOOLS, DeviceError, MOBILE_TOOLS, } from "@beeos-ai/device-common";
55
+ import { BaseSandboxBackend, } from "./base.js";
56
+ const ADB_TOOLS = new Set([
57
+ ...COMMON_TOOLS,
58
+ ...MOBILE_TOOLS,
59
+ ]);
60
+ import { getAppPackage, looksLikePackage } from "../util/android-apps.js";
61
+ import { sh } from "../util/adb-shell.js";
62
+ import { adbListDir, adbPull, adbPush } from "../util/adb-files.js";
63
+ import { parsePngDimensions } from "../util/image-dim.js";
64
+ const SS_MAX_EDGE_RAW = process.env.GUI_AGENT_SS_MAX_EDGE;
65
+ /**
66
+ * Resize-on-capture threshold for screenshots. v0.2.3 changes the default
67
+ * from `1280` to "no resize" (`POSITIVE_INFINITY`) so the bytes the agent
68
+ * receives match the device's logical dimensions exactly. Set the env to
69
+ * an integer (e.g. `1280`) to opt back into resizing.
70
+ */
71
+ const SS_MAX_EDGE = SS_MAX_EDGE_RAW
72
+ ? parseInt(SS_MAX_EDGE_RAW, 10)
73
+ : Number.POSITIVE_INFINITY;
74
+ const SS_JPEG_QUALITY = parseInt(process.env.GUI_AGENT_SS_JPEG_QUALITY ?? "80", 10);
75
+ const DEFAULT_TIMEOUT_MS = 10_000;
76
+ const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
77
+ const DEFAULT_SCREENSHOT_TIMEOUT_MS = 15_000;
78
+ const DEFAULT_INSTALL_TIMEOUT_MS = 120_000;
79
+ const DEFAULT_FILE_TIMEOUT_MS = 60_000;
80
+ const SHELL_OUTPUT_CAP_BYTES = 8 * 1024 * 1024;
81
+ const DELAYS = {
82
+ tap: 1500,
83
+ doubleTap: 1000,
84
+ doubleTapInterval: 100,
85
+ longPress: 1500,
86
+ swipe: 2000,
87
+ back: 1500,
88
+ home: 1500,
89
+ launch: 3000,
90
+ keyboardSwitch: 500,
91
+ textClear: 300,
92
+ textInput: 500,
93
+ keyboardRestore: 300,
94
+ };
95
+ /* ----------------------------------------------------------------------- */
96
+ /* Default spawn-backed runner */
97
+ /* ----------------------------------------------------------------------- */
98
+ function defaultRunner(file, args, opts = {}) {
99
+ return new Promise((resolve, reject) => {
100
+ let child;
101
+ try {
102
+ child = spawn(file, args, {
103
+ stdio: ["ignore", "pipe", "pipe"],
104
+ cwd: opts.cwd,
105
+ });
106
+ }
107
+ catch (e) {
108
+ reject(e);
109
+ return;
110
+ }
111
+ const stdoutChunks = [];
112
+ const stderrChunks = [];
113
+ let totalStdoutBytes = 0;
114
+ const cap = opts.maxBufferBytes ?? SHELL_OUTPUT_CAP_BYTES;
115
+ let capExceeded = false;
116
+ child.stdout?.on("data", (c) => {
117
+ totalStdoutBytes += c.length;
118
+ if (totalStdoutBytes > cap) {
119
+ capExceeded = true;
120
+ try {
121
+ child.kill("SIGTERM");
122
+ }
123
+ catch {
124
+ /* swallow */
125
+ }
126
+ return;
127
+ }
128
+ stdoutChunks.push(c);
129
+ });
130
+ child.stderr?.on("data", (c) => stderrChunks.push(c));
131
+ let timer;
132
+ let timedOut = false;
133
+ if (opts.timeoutMs && opts.timeoutMs > 0) {
134
+ timer = setTimeout(() => {
135
+ timedOut = true;
136
+ try {
137
+ child.kill("SIGTERM");
138
+ }
139
+ catch {
140
+ /* swallow */
141
+ }
142
+ }, opts.timeoutMs);
143
+ }
144
+ child.on("error", (err) => {
145
+ if (timer)
146
+ clearTimeout(timer);
147
+ reject(err);
148
+ });
149
+ child.on("close", (code, signal) => {
150
+ if (timer)
151
+ clearTimeout(timer);
152
+ const stdoutBuf = Buffer.concat(stdoutChunks);
153
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
154
+ const stdout = opts.encoding === "buffer" ? stdoutBuf : stdoutBuf.toString("utf8");
155
+ if (timedOut || capExceeded) {
156
+ const e = new Error(capExceeded
157
+ ? `${file} stdout exceeded ${cap} bytes`
158
+ : `${file} timed out after ${opts.timeoutMs}ms`);
159
+ e.signal = "SIGTERM";
160
+ e.killed = true;
161
+ e.stdout = stdout;
162
+ e.stderr = stderr;
163
+ reject(e);
164
+ return;
165
+ }
166
+ if (code === 0) {
167
+ resolve({ stdout, stderr });
168
+ return;
169
+ }
170
+ const err = new Error(`${file} exited with code ${code}${signal ? ` (signal ${signal})` : ""}`);
171
+ err.stdout = stdout;
172
+ err.stderr = stderr;
173
+ err.code =
174
+ typeof code === "number" ? code : 1;
175
+ reject(err);
176
+ });
177
+ });
178
+ }
179
+ export class AndroidAdbBackend extends BaseSandboxBackend {
180
+ os = "android";
181
+ tools = ADB_TOOLS;
182
+ serial;
183
+ skipConnectValidation;
184
+ runner;
185
+ sleep;
186
+ defaultTimeoutMs;
187
+ shellTimeoutMs;
188
+ screenshotTimeoutMs;
189
+ installTimeoutMs;
190
+ fileTimeoutMs;
191
+ constructor(opts = {}) {
192
+ super();
193
+ this.serial = opts.serial;
194
+ this.skipConnectValidation = !!opts.skipConnectValidation;
195
+ this.runner = opts.runner ?? defaultRunner;
196
+ this.sleep =
197
+ opts.sleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
198
+ this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
199
+ this.shellTimeoutMs = opts.shellTimeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
200
+ this.screenshotTimeoutMs =
201
+ opts.screenshotTimeoutMs ?? DEFAULT_SCREENSHOT_TIMEOUT_MS;
202
+ this.installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
203
+ this.fileTimeoutMs = opts.fileTimeoutMs ?? DEFAULT_FILE_TIMEOUT_MS;
204
+ }
205
+ async connect() {
206
+ if (this.skipConnectValidation)
207
+ return;
208
+ const devices = await listAdbDevices(this.runner, this.defaultTimeoutMs);
209
+ if (devices.length === 0) {
210
+ throw new DeviceError("No ADB devices connected. Run `adb devices`.", {
211
+ subtype: "no_device",
212
+ retriable: true,
213
+ });
214
+ }
215
+ }
216
+ async disconnect() {
217
+ // No-op — adb daemon stays alive between commands.
218
+ }
219
+ /* ------------------------------------------------------------------ */
220
+ /* Info / screen */
221
+ /* ------------------------------------------------------------------ */
222
+ async info() {
223
+ const [size, density, propDump, currentApp] = await Promise.all([
224
+ this.runShell("wm size").catch(() => ""),
225
+ this.runShell("wm density").catch(() => ""),
226
+ this.runShell("getprop").catch(() => ""),
227
+ this.getCurrentApp().catch(() => "System Home"),
228
+ ]);
229
+ const sizeMatch = size.match(/Physical size:\s*(\d+)x(\d+)/i);
230
+ const width = sizeMatch ? Number(sizeMatch[1]) : 1080;
231
+ const height = sizeMatch ? Number(sizeMatch[2]) : 1920;
232
+ const densityMatch = density.match(/Physical density:\s*(\d+)/i);
233
+ const dpi = densityMatch ? Number(densityMatch[1]) : 0;
234
+ const props = parseGetpropDump(propDump);
235
+ const model = props.get("ro.product.model") ?? "";
236
+ const version = props.get("ro.build.version.release") ?? "";
237
+ const sdk = props.get("ro.build.version.sdk") ?? "";
238
+ const abi = props.get("ro.product.cpu.abi") ?? "";
239
+ const manufacturer = props.get("ro.product.manufacturer") ?? "";
240
+ const brand = props.get("ro.product.brand") ?? "";
241
+ const serial = this.serial ?? props.get("ro.serialno") ?? "";
242
+ const capabilities = [
243
+ "screenshot",
244
+ "ui-tree",
245
+ "tap",
246
+ "swipe",
247
+ "type",
248
+ "press_key",
249
+ "install",
250
+ "shell",
251
+ ];
252
+ return {
253
+ type: "android",
254
+ name: model || "Android",
255
+ os: "android",
256
+ width,
257
+ height,
258
+ capabilities,
259
+ osVersion: version,
260
+ density: dpi,
261
+ metadata: {
262
+ currentApp,
263
+ serial,
264
+ sdk,
265
+ model,
266
+ abi,
267
+ manufacturer,
268
+ brand,
269
+ },
270
+ };
271
+ }
272
+ async screenshot() {
273
+ // Capture the raw screencap PNG. The 0.2.3 default is to forward
274
+ // these bytes to the agent unchanged so `width/height` always match
275
+ // `data`. Callers that need a smaller payload set
276
+ // `GUI_AGENT_SS_MAX_EDGE` to opt back into the legacy resize path.
277
+ const raw = await this.captureRawPng();
278
+ const [rawW, rawH] = parsePngDimensions(raw);
279
+ if (!Number.isFinite(SS_MAX_EDGE) || Math.max(rawW, rawH) <= SS_MAX_EDGE) {
280
+ return { data: raw, width: rawW, height: rawH, format: "png" };
281
+ }
282
+ // Resize-on-capture path: re-encode to JPEG and recompute dimensions
283
+ // so the wire layer reports the actual encoded payload, not the
284
+ // original device-side resolution.
285
+ try {
286
+ const pipeline = sharp(raw, { failOn: "none" });
287
+ if (rawW >= rawH)
288
+ pipeline.resize({ width: SS_MAX_EDGE });
289
+ else
290
+ pipeline.resize({ height: SS_MAX_EDGE });
291
+ const data = await pipeline
292
+ .jpeg({ quality: SS_JPEG_QUALITY, mozjpeg: true })
293
+ .toBuffer();
294
+ const meta = await sharp(data).metadata();
295
+ return {
296
+ data,
297
+ width: meta.width ?? rawW,
298
+ height: meta.height ?? rawH,
299
+ format: "jpeg",
300
+ };
301
+ }
302
+ catch {
303
+ return { data: raw, width: rawW, height: rawH, format: "png" };
304
+ }
305
+ }
306
+ async screenSize() {
307
+ const out = await this.runShell("wm size").catch(() => "");
308
+ const m = out.match(/Physical size:\s*(\d+)x(\d+)/i);
309
+ return { w: m ? Number(m[1]) : 1080, h: m ? Number(m[2]) : 1920 };
310
+ }
311
+ async uiDump() {
312
+ await this.adbShell(sh `uiautomator dump /sdcard/ui_dump.xml`);
313
+ const { stdout } = await this.adbShell(sh `cat /sdcard/ui_dump.xml`);
314
+ if (!stdout || stdout.includes("ERROR")) {
315
+ return { supported: true };
316
+ }
317
+ return { supported: true, content: stdout.trim() };
318
+ }
319
+ /* ------------------------------------------------------------------ */
320
+ /* Pointer / gesture */
321
+ /* ------------------------------------------------------------------ */
322
+ async tap(x, y) {
323
+ await this.adbShell(sh `input tap ${x} ${y}`);
324
+ await this.sleep(DELAYS.tap);
325
+ }
326
+ async click(x, y) {
327
+ return this.tap(x, y);
328
+ }
329
+ async doubleClick(x, y) {
330
+ await this.adbShell(sh `input tap ${x} ${y}`);
331
+ await this.sleep(DELAYS.doubleTapInterval);
332
+ await this.adbShell(sh `input tap ${x} ${y}`);
333
+ await this.sleep(DELAYS.doubleTap);
334
+ }
335
+ async longPress(x, y, durationMs = 3000) {
336
+ await this.adbShell(sh `input swipe ${x} ${y} ${x} ${y} ${durationMs}`);
337
+ await this.sleep(DELAYS.longPress);
338
+ }
339
+ async swipe(x1, y1, x2, y2, durationMs) {
340
+ const dur = durationMs ??
341
+ Math.max(1000, Math.min(Math.round(Math.hypot(x1 - x2, y1 - y2)), 2000));
342
+ await this.adbShell(sh `input swipe ${x1} ${y1} ${x2} ${y2} ${dur}`);
343
+ await this.sleep(DELAYS.swipe);
344
+ }
345
+ async drag(x1, y1, x2, y2, durationMs) {
346
+ return this.swipe(x1, y1, x2, y2, durationMs);
347
+ }
348
+ async scroll(x, y, direction, _amount) {
349
+ let { w, h } = { w: 1080, h: 1920 };
350
+ if (!x && !y) {
351
+ ({ w, h } = await this.screenSize().catch(() => ({ w: 1080, h: 1920 })));
352
+ x = Math.round(w / 2);
353
+ y = Math.round(h / 2);
354
+ }
355
+ const horizontal = direction === "left" || direction === "right";
356
+ const dist = horizontal ? 300 : 600;
357
+ const dx = direction === "left" ? dist : direction === "right" ? -dist : 0;
358
+ const dy = direction === "up" ? dist : direction === "down" ? -dist : 0;
359
+ await this.swipe(x, y, x + dx, y + dy);
360
+ }
361
+ /* ------------------------------------------------------------------ */
362
+ /* Keyboard */
363
+ /* ------------------------------------------------------------------ */
364
+ async typeText(text) {
365
+ // 1) Snapshot the current IME *before* we touch anything. We MUST do
366
+ // this in its own try-block so that, if the AdbIME swap below
367
+ // later fails, we still know which IME to restore on the way
368
+ // out — Redroid / unrooted devices that lack `com.android.adbkeyboard`
369
+ // must NOT be left stranded on a half-set IME.
370
+ let originalIme = "";
371
+ try {
372
+ originalIme = await this.getCurrentIme();
373
+ }
374
+ catch {
375
+ // Detection itself failed (e.g. adb gone). Without an original IME
376
+ // to restore, skip the swap entirely — the broadcast still works
377
+ // for many keyboards even without the swap.
378
+ originalIme = "";
379
+ }
380
+ try {
381
+ if (originalIme) {
382
+ await this.setIme("com.android.adbkeyboard/.AdbIME");
383
+ await this.sleep(DELAYS.keyboardSwitch);
384
+ }
385
+ await this.clearText();
386
+ await this.sleep(DELAYS.textClear);
387
+ // ADB_INPUT_TEXT broadcast — `--es msg <value>` ships the raw text.
388
+ // Going through `sh\`...\`` quotes the value as a single argv
389
+ // element on the device side, neutralising any `$`, `;`, `|`, etc.
390
+ // that might otherwise be re-evaluated by the device shell.
391
+ await this.adbShell(sh `am broadcast -a ADB_INPUT_TEXT --es msg ${text}`);
392
+ await this.sleep(DELAYS.textInput);
393
+ }
394
+ finally {
395
+ if (originalIme) {
396
+ await this.setIme(originalIme).catch(() => undefined);
397
+ await this.sleep(DELAYS.keyboardRestore);
398
+ }
399
+ }
400
+ }
401
+ async pressKey(key) {
402
+ await this.adbShell(sh `input keyevent ${key}`);
403
+ }
404
+ async back() {
405
+ await this.adbShell(sh `input keyevent KEYCODE_BACK`);
406
+ await this.sleep(DELAYS.back);
407
+ }
408
+ async home() {
409
+ await this.adbShell(sh `input keyevent KEYCODE_HOME`);
410
+ await this.sleep(DELAYS.home);
411
+ }
412
+ async navigate(direction) {
413
+ if (direction === "back")
414
+ return this.back();
415
+ if (direction === "up")
416
+ return this.home();
417
+ throw new DeviceError(`navigate '${direction}' not supported on android`, {
418
+ subtype: "unsupported",
419
+ retriable: false,
420
+ });
421
+ }
422
+ /* ------------------------------------------------------------------ */
423
+ /* App lifecycle */
424
+ /* ------------------------------------------------------------------ */
425
+ async launchApp(pkgOrName, activity) {
426
+ const pkg = looksLikePackage(pkgOrName)
427
+ ? pkgOrName
428
+ : (getAppPackage(pkgOrName) ?? pkgOrName);
429
+ if (activity && activity.length > 0) {
430
+ // Caller wants a specific activity → use `am start -n pkg/activity`.
431
+ // Activities can be bare class names (`.MainActivity`) or fully
432
+ // qualified (`com.example.app.MainActivity`); `am start` accepts both.
433
+ // We MUST quote the component as a single shell argv element — the
434
+ // canonical Settings-style `Settings$WifiSettingsActivity` would
435
+ // otherwise be silently truncated by the device shell's `$` expansion
436
+ // (regression bug #1 in 0.2.2 → fixed here).
437
+ const component = activity.includes("/") ? activity : `${pkg}/${activity}`;
438
+ await this.adbShell(sh `am start -n ${component}`);
439
+ }
440
+ else {
441
+ // No activity hint → fall back to the launcher intent. `monkey` is
442
+ // intentionally used here (not `am start`) because some Redroid
443
+ // images have flaky `am` behaviour for LAUNCHER intents and `monkey`
444
+ // is the well-trodden path the legacy adapter has shipped for years.
445
+ await this.adbShell(sh `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`);
446
+ }
447
+ await this.sleep(DELAYS.launch);
448
+ }
449
+ /* ------------------------------------------------------------------ */
450
+ /* Shell / files */
451
+ /* ------------------------------------------------------------------ */
452
+ async executeCommand(body, opts = {}) {
453
+ const timeoutMs = (opts.timeoutS ?? this.shellTimeoutMs / 1000) * 1000;
454
+ // `body` is treated as opaque shell — callers pass arbitrary commands
455
+ // to run on the device. Quoting `cwd` keeps it safe; the user's body
456
+ // itself is their responsibility (we cannot disambiguate "user wants
457
+ // a subshell expansion" from "user wants a literal `$`" here).
458
+ const cmd = opts.cwd ? `cd ${shQuote(opts.cwd)} && ${body}` : body;
459
+ try {
460
+ const { stdout, stderr } = await this.runner("adb", this.adbArgs(["shell", cmd]), {
461
+ encoding: "utf8",
462
+ timeoutMs,
463
+ maxBufferBytes: SHELL_OUTPUT_CAP_BYTES,
464
+ });
465
+ return {
466
+ stdout: typeof stdout === "string" ? stdout : stdout.toString("utf8"),
467
+ stderr,
468
+ exitCode: 0,
469
+ };
470
+ }
471
+ catch (e) {
472
+ const err = e;
473
+ // Timeout → DeviceError so callers can retry. Same as legacy.
474
+ if (err.killed && err.signal === "SIGTERM") {
475
+ throw wrapAdbFailure("shell", ["shell", cmd], err, timeoutMs);
476
+ }
477
+ // ENOENT / non-numeric `code` means adb itself isn't installed (or
478
+ // bricked). Don't silently coerce to exitCode:1 — that hides the
479
+ // real cause from the agent. Throw `adb_not_installed` instead.
480
+ if (typeof err.code !== "number") {
481
+ throw wrapAdbFailure("shell", ["shell", cmd], err, timeoutMs);
482
+ }
483
+ const stdout = typeof err.stdout === "string"
484
+ ? err.stdout
485
+ : Buffer.isBuffer(err.stdout)
486
+ ? err.stdout.toString("utf8")
487
+ : "";
488
+ return {
489
+ stdout,
490
+ stderr: err.stderr ?? err.message,
491
+ exitCode: err.code,
492
+ };
493
+ }
494
+ }
495
+ async fileRead(path) {
496
+ return adbPull(this.runner, this.serial, path, {
497
+ timeoutMs: this.fileTimeoutMs,
498
+ });
499
+ }
500
+ async fileWrite(path, content) {
501
+ const buf = typeof content === "string" ? Buffer.from(content, "utf8") : content;
502
+ await adbPush(this.runner, this.serial, path, buf, {
503
+ timeoutMs: this.fileTimeoutMs,
504
+ });
505
+ }
506
+ async listDirectory(path) {
507
+ try {
508
+ return await adbListDir(this.runner, this.serial, path, {
509
+ timeoutMs: this.defaultTimeoutMs,
510
+ });
511
+ }
512
+ catch (e) {
513
+ throw wrapAdbFailure("shell", ["shell", `ls -laL ${path}`], e, this.defaultTimeoutMs);
514
+ }
515
+ }
516
+ async install(path) {
517
+ try {
518
+ await this.runner("adb", this.adbArgs(["install", "-r", path]), {
519
+ encoding: "utf8",
520
+ timeoutMs: this.installTimeoutMs,
521
+ });
522
+ }
523
+ catch (e) {
524
+ throw wrapAdbFailure("install", ["install", "-r", path], e, this.installTimeoutMs);
525
+ }
526
+ }
527
+ /* ------------------------------------------------------------------ */
528
+ /* Internal helpers */
529
+ /* ------------------------------------------------------------------ */
530
+ async getCurrentApp() {
531
+ const { stdout } = await this.adbShell(sh `dumpsys window`);
532
+ const target = stdout.match(/(mCurrentFocus|mFocusedApp)=.*/g)?.[0] ?? "";
533
+ if (!target)
534
+ return "System Home";
535
+ const m = target.match(/[a-zA-Z][\w.]+\/[\w.$]+/);
536
+ return m ? m[0] : "System Home";
537
+ }
538
+ async getCurrentIme() {
539
+ const { stdout } = await this.adbShell(sh `settings get secure default_input_method`);
540
+ return stdout.trim();
541
+ }
542
+ async setIme(ime) {
543
+ if (ime)
544
+ await this.adbShell(sh `ime set ${ime}`);
545
+ }
546
+ async clearText() {
547
+ await this.adbShell(sh `input keyevent KEYCODE_CTRL_A`);
548
+ await this.adbShell(sh `input keyevent KEYCODE_DEL`);
549
+ }
550
+ async runShell(command) {
551
+ try {
552
+ const { stdout } = await this.runner("adb", this.adbArgs(["shell", command]), {
553
+ encoding: "utf8",
554
+ maxBufferBytes: SHELL_OUTPUT_CAP_BYTES,
555
+ timeoutMs: this.shellTimeoutMs,
556
+ });
557
+ return typeof stdout === "string" ? stdout : stdout.toString("utf8");
558
+ }
559
+ catch (e) {
560
+ throw wrapAdbFailure("shell", ["shell", command], e, this.shellTimeoutMs);
561
+ }
562
+ }
563
+ async captureRawPng() {
564
+ try {
565
+ const { stdout } = await this.runner("adb", this.adbArgs(["exec-out", "screencap", "-p"]), {
566
+ encoding: "buffer",
567
+ maxBufferBytes: 32 * 1024 * 1024,
568
+ timeoutMs: this.screenshotTimeoutMs,
569
+ });
570
+ return Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
571
+ }
572
+ catch (e) {
573
+ throw wrapAdbFailure("screencap", ["exec-out", "screencap", "-p"], e, this.screenshotTimeoutMs);
574
+ }
575
+ }
576
+ /**
577
+ * Run an `adb shell <single-command>`. Callers MUST build the command
578
+ * string via `sh\`...\`` (or pass a static literal). This is the ONLY
579
+ * sanctioned shape for shell invocations — any `["shell", a, b, ...]`
580
+ * call that bypasses this helper is a bug (see CHANGELOG 0.2.3).
581
+ */
582
+ async adbShell(command) {
583
+ try {
584
+ const { stdout, stderr } = await this.runner("adb", this.adbArgs(["shell", command]), {
585
+ encoding: "utf8",
586
+ timeoutMs: this.defaultTimeoutMs,
587
+ maxBufferBytes: 32 * 1024 * 1024,
588
+ });
589
+ return {
590
+ stdout: typeof stdout === "string" ? stdout : stdout.toString("utf8"),
591
+ stderr,
592
+ };
593
+ }
594
+ catch (e) {
595
+ throw wrapAdbFailure("shell", ["shell", command], e, this.defaultTimeoutMs);
596
+ }
597
+ }
598
+ adbArgs(args) {
599
+ return this.serial ? ["-s", this.serial, ...args] : args;
600
+ }
601
+ }
602
+ /* ----------------------------------------------------------------------- */
603
+ /* Module-level helpers */
604
+ /* ----------------------------------------------------------------------- */
605
+ /**
606
+ * List connected ADB devices. Exposed at module scope (and as a thin
607
+ * member-bound helper inside `connect()`) so tests / EdgeAgent can probe
608
+ * device presence without instantiating a backend.
609
+ */
610
+ export async function listAdbDevices(runner = defaultRunner, timeoutMs = DEFAULT_TIMEOUT_MS) {
611
+ try {
612
+ const { stdout } = await runner("adb", ["devices"], {
613
+ encoding: "utf8",
614
+ timeoutMs,
615
+ });
616
+ const text = typeof stdout === "string" ? stdout : stdout.toString("utf8");
617
+ return text
618
+ .split("\n")
619
+ .slice(1)
620
+ .filter((l) => l.includes("\tdevice"))
621
+ .map((l) => l.split("\t")[0]);
622
+ }
623
+ catch (e) {
624
+ throw wrapAdbFailure("devices", ["devices"], e, timeoutMs);
625
+ }
626
+ }
627
+ function parseGetpropDump(out) {
628
+ const map = new Map();
629
+ if (!out)
630
+ return map;
631
+ const re = /^\[([^\]]+)\]:\s*\[([^\]]*)\]\s*$/gm;
632
+ let m;
633
+ while ((m = re.exec(out)) !== null) {
634
+ const key = m[1];
635
+ const val = m[2];
636
+ if (key !== undefined && val !== undefined) {
637
+ map.set(key, val);
638
+ }
639
+ }
640
+ return map;
641
+ }
642
+ /**
643
+ * Local POSIX-quote helper used ONLY by `executeCommand`'s `cwd` prefix.
644
+ * The general-purpose `sh\`...\`` template lives in `util/adb-shell.ts`
645
+ * and should be used everywhere else; we inline a single-character helper
646
+ * here to avoid pulling the tagged template into a code path where the
647
+ * rest of the command is opaque (the user-supplied `body`).
648
+ */
649
+ function shQuote(s) {
650
+ return `'${s.replace(/'/g, "'\\''")}'`;
651
+ }
652
+ /**
653
+ * Normalise every adb runner failure into a single `DeviceError` shape.
654
+ *
655
+ * - `killed && signal === "SIGTERM"` → `adb_timeout`, retriable
656
+ * - `code === "ENOENT"` (string) → `adb_not_installed`, NOT retriable
657
+ * - any non-numeric `code` → `adb_not_installed`, NOT retriable
658
+ * - everything else (numeric exit) → `adb_<verb>_failed` / `adb_failed`
659
+ *
660
+ * The verb-specific subtypes (`adb_shell_failed`, `adb_install_failed`,
661
+ * `adb_screencap_failed`, `adb_devices_failed`) keep the legacy adapter's
662
+ * shape so existing wire consumers don't break.
663
+ *
664
+ * `DeviceError` instances are passed through unchanged — `adbPull` /
665
+ * `adbPush` already produce well-shaped errors and we don't want to wrap
666
+ * a `DeviceError` in another `DeviceError`.
667
+ */
668
+ export function wrapAdbFailure(verb, args, cause, timeoutMs) {
669
+ if (cause instanceof DeviceError)
670
+ return cause;
671
+ const err = cause;
672
+ const isTimeout = err.killed && err.signal === "SIGTERM";
673
+ if (isTimeout) {
674
+ const ms = timeoutMs ?? 0;
675
+ return new DeviceError(ms > 0
676
+ ? `adb ${verb} timed out after ${ms}ms`
677
+ : `adb ${verb} timed out`, {
678
+ subtype: "adb_timeout",
679
+ retriable: true,
680
+ cause: err,
681
+ });
682
+ }
683
+ // String `code` (typically `"ENOENT"` for missing binary) means adb
684
+ // itself isn't installed / not on PATH. This is unambiguously
685
+ // non-retriable — surface it explicitly.
686
+ if (typeof err.code === "string") {
687
+ return new DeviceError(`adb not installed (${err.code}): ${err.message}`, {
688
+ subtype: "adb_not_installed",
689
+ retriable: false,
690
+ cause: err,
691
+ });
692
+ }
693
+ // Some shells / spawn shapes set `code === undefined` when the binary
694
+ // simply could not be located. Treat that the same as ENOENT.
695
+ if (err.code === undefined &&
696
+ /ENOENT|not found|spawn .*not found/i.test(err.message ?? "")) {
697
+ return new DeviceError(`adb not installed: ${err.message}`, {
698
+ subtype: "adb_not_installed",
699
+ retriable: false,
700
+ cause: err,
701
+ });
702
+ }
703
+ const summary = err.stderr?.trim() || err.message || "unknown error";
704
+ const subtype = verb === "shell"
705
+ ? "adb_shell_failed"
706
+ : verb === "install"
707
+ ? "adb_install_failed"
708
+ : verb === "screencap"
709
+ ? "screenshot_failed"
710
+ : verb === "devices"
711
+ ? "adb_failed"
712
+ : verb === "exec-out"
713
+ ? "adb_failed"
714
+ : "adb_failed";
715
+ // Reference unused args param for symmetry with previous signature.
716
+ void args;
717
+ return new DeviceError(`adb ${verb} failed: ${summary}`, {
718
+ subtype,
719
+ retriable: false,
720
+ cause: err,
721
+ });
722
+ }
723
+ //# sourceMappingURL=android-adb.js.map