@beeos-ai/device-mcp-server 0.2.3 → 0.4.2

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 (59) hide show
  1. package/dist/backends/android-adb.d.ts +147 -6
  2. package/dist/backends/android-adb.js +776 -40
  3. package/dist/backends/android-adb.js.map +1 -1
  4. package/dist/backends/base.d.ts +243 -7
  5. package/dist/backends/base.js +81 -2
  6. package/dist/backends/base.js.map +1 -1
  7. package/dist/backends/desktop.d.ts +3 -2
  8. package/dist/backends/desktop.js +9 -3
  9. package/dist/backends/desktop.js.map +1 -1
  10. package/dist/backends/linux.js +3 -0
  11. package/dist/backends/linux.js.map +1 -1
  12. package/dist/backends/mac.d.ts +11 -2
  13. package/dist/backends/mac.js +39 -1
  14. package/dist/backends/mac.js.map +1 -1
  15. package/dist/backends/stubs/windows.js +3 -0
  16. package/dist/backends/stubs/windows.js.map +1 -1
  17. package/dist/cli.d.ts +40 -26
  18. package/dist/cli.js +118 -84
  19. package/dist/cli.js.map +1 -1
  20. package/dist/index.d.ts +9 -6
  21. package/dist/index.js +9 -6
  22. package/dist/index.js.map +1 -1
  23. package/dist/server/app.d.ts +60 -17
  24. package/dist/server/app.js +182 -138
  25. package/dist/server/app.js.map +1 -1
  26. package/dist/server/mcp-server.d.ts +25 -0
  27. package/dist/server/mcp-server.js +33 -0
  28. package/dist/server/mcp-server.js.map +1 -0
  29. package/dist/server/registry.d.ts +111 -0
  30. package/dist/server/registry.js +191 -0
  31. package/dist/server/registry.js.map +1 -0
  32. package/dist/server/stdio.d.ts +29 -0
  33. package/dist/server/stdio.js +35 -0
  34. package/dist/server/stdio.js.map +1 -0
  35. package/dist/server/tool-registry.d.ts +60 -35
  36. package/dist/server/tool-registry.js +911 -434
  37. package/dist/server/tool-registry.js.map +1 -1
  38. package/dist/util/adb-files.d.ts +25 -1
  39. package/dist/util/adb-files.js +95 -0
  40. package/dist/util/adb-files.js.map +1 -1
  41. package/dist/util/locale.d.ts +16 -0
  42. package/dist/util/locale.js +31 -0
  43. package/dist/util/locale.js.map +1 -0
  44. package/dist/util/logger.d.ts +27 -0
  45. package/dist/util/logger.js +27 -0
  46. package/dist/util/logger.js.map +1 -0
  47. package/dist/util/output-path.d.ts +60 -0
  48. package/dist/util/output-path.js +123 -0
  49. package/dist/util/output-path.js.map +1 -0
  50. package/dist/util/package-name.d.ts +26 -0
  51. package/dist/util/package-name.js +41 -0
  52. package/dist/util/package-name.js.map +1 -0
  53. package/package.json +6 -4
  54. package/dist/backends/stubs/macos.d.ts +0 -13
  55. package/dist/backends/stubs/macos.js +0 -27
  56. package/dist/backends/stubs/macos.js.map +0 -1
  57. package/dist/server/action-mapping.d.ts +0 -21
  58. package/dist/server/action-mapping.js +0 -153
  59. package/dist/server/action-mapping.js.map +0 -1
@@ -50,9 +50,14 @@
50
50
  * agent layer decides whether to resize.
51
51
  */
52
52
  import { spawn } from "node:child_process";
53
+ import { mkdtemp } from "node:fs/promises";
54
+ import * as os from "node:os";
55
+ import * as path from "node:path";
56
+ import { randomUUID } from "node:crypto";
53
57
  import sharp from "sharp";
54
58
  import { COMMON_TOOLS, DeviceError, MOBILE_TOOLS, } from "@beeos-ai/device-common";
55
- import { BaseSandboxBackend, } from "./base.js";
59
+ import { BaseSandboxBackend, normalizeLaunchAppOptions, } from "./base.js";
60
+ import { searchUiXmlNodes } from "../util/ui-xml.js";
56
61
  const ADB_TOOLS = new Set([
57
62
  ...COMMON_TOOLS,
58
63
  ...MOBILE_TOOLS,
@@ -63,14 +68,26 @@ import { adbListDir, adbPull, adbPush } from "../util/adb-files.js";
63
68
  import { parsePngDimensions } from "../util/image-dim.js";
64
69
  const SS_MAX_EDGE_RAW = process.env.GUI_AGENT_SS_MAX_EDGE;
65
70
  /**
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.
71
+ * Resize-on-capture threshold for screenshots.
72
+ *
73
+ * 0.4.0 (paired with the SDK migration that removes the binary
74
+ * `/screen.png` fast-path): default back to **1280px max edge** so the
75
+ * base64 payload over the SDK Streamable HTTP transport stays bounded.
76
+ * Capturing a raw 4K Android screenshot every step balloons the JSON-RPC
77
+ * envelope to ~4MB which dominates round-trip time on slow links.
78
+ *
79
+ * Override via env:
80
+ * - `GUI_AGENT_SS_MAX_EDGE=4096` to keep full resolution
81
+ * - `GUI_AGENT_SS_MAX_EDGE=0` (or negative) to disable resizing entirely
70
82
  */
71
- const SS_MAX_EDGE = SS_MAX_EDGE_RAW
72
- ? parseInt(SS_MAX_EDGE_RAW, 10)
73
- : Number.POSITIVE_INFINITY;
83
+ const SS_MAX_EDGE = (() => {
84
+ if (SS_MAX_EDGE_RAW === undefined)
85
+ return 1280;
86
+ const n = parseInt(SS_MAX_EDGE_RAW, 10);
87
+ if (!Number.isFinite(n) || n <= 0)
88
+ return Number.POSITIVE_INFINITY;
89
+ return n;
90
+ })();
74
91
  const SS_JPEG_QUALITY = parseInt(process.env.GUI_AGENT_SS_JPEG_QUALITY ?? "80", 10);
75
92
  const DEFAULT_TIMEOUT_MS = 10_000;
76
93
  const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
@@ -93,6 +110,58 @@ const DELAYS = {
93
110
  keyboardRestore: 300,
94
111
  };
95
112
  /* ----------------------------------------------------------------------- */
113
+ /* uiautomator dump retry — 0.4.1 */
114
+ /* ----------------------------------------------------------------------- */
115
+ /**
116
+ * `uiautomator dump` racing the foreground accessibility window can return
117
+ * `ERROR: null root node returned by UiTestAutomationBridge` (real-device
118
+ * regression on devices with frequent fullscreen transitions). Mobile-mcp
119
+ * documents the same flake. Retry the dump up to this many times — the
120
+ * window-state usually settles within 2-3 attempts.
121
+ */
122
+ const UIDUMP_MAX_RETRIES = 10;
123
+ const UIDUMP_RETRY_INTERVAL_MS = 200;
124
+ const UIDUMP_RETRY_SUBSTRINGS = [
125
+ "null root node returned by UiTestAutomationBridge",
126
+ // toybox `cat` reports this when uiautomator failed to write the file.
127
+ "No such file or directory",
128
+ ];
129
+ /* ----------------------------------------------------------------------- */
130
+ /* press_button keymap — 0.4.1 */
131
+ /* ----------------------------------------------------------------------- */
132
+ /**
133
+ * Friendly button name → Android `KEYCODE_*`. Names are case-insensitive.
134
+ *
135
+ * The vocabulary mirrors mobile-mcp `mobile_press_button` so prompts that
136
+ * already work against mobile-mcp port over without rewording. The
137
+ * registry exposes `Object.keys(PRESS_BUTTON_KEYMAP)` so the MCP tool's
138
+ * `enum` is auto-derived rather than maintained in two places.
139
+ */
140
+ export const PRESS_BUTTON_KEYMAP = {
141
+ BACK: "KEYCODE_BACK",
142
+ HOME: "KEYCODE_HOME",
143
+ POWER: "KEYCODE_POWER",
144
+ MENU: "KEYCODE_MENU",
145
+ CAMERA: "KEYCODE_CAMERA",
146
+ ENTER: "KEYCODE_ENTER",
147
+ // Volume + media — Android TV agents rely on these.
148
+ VOLUME_UP: "KEYCODE_VOLUME_UP",
149
+ VOLUME_DOWN: "KEYCODE_VOLUME_DOWN",
150
+ VOLUME_MUTE: "KEYCODE_VOLUME_MUTE",
151
+ MUTE: "KEYCODE_MUTE",
152
+ MEDIA_PLAY_PAUSE: "KEYCODE_MEDIA_PLAY_PAUSE",
153
+ MEDIA_PLAY: "KEYCODE_MEDIA_PLAY",
154
+ MEDIA_PAUSE: "KEYCODE_MEDIA_PAUSE",
155
+ MEDIA_NEXT: "KEYCODE_MEDIA_NEXT",
156
+ MEDIA_PREVIOUS: "KEYCODE_MEDIA_PREVIOUS",
157
+ // DPAD — Android TV / car-head-unit / accessibility navigation.
158
+ DPAD_UP: "KEYCODE_DPAD_UP",
159
+ DPAD_DOWN: "KEYCODE_DPAD_DOWN",
160
+ DPAD_LEFT: "KEYCODE_DPAD_LEFT",
161
+ DPAD_RIGHT: "KEYCODE_DPAD_RIGHT",
162
+ DPAD_CENTER: "KEYCODE_DPAD_CENTER",
163
+ };
164
+ /* ----------------------------------------------------------------------- */
96
165
  /* Default spawn-backed runner */
97
166
  /* ----------------------------------------------------------------------- */
98
167
  function defaultRunner(file, args, opts = {}) {
@@ -188,6 +257,14 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
188
257
  screenshotTimeoutMs;
189
258
  installTimeoutMs;
190
259
  fileTimeoutMs;
260
+ processSpawner;
261
+ recordKillGraceMs;
262
+ recordTimeLimitS;
263
+ recordings = new Map();
264
+ /** Cached `android.software.leanback` system feature — drives `info().isTv`. */
265
+ isLeanback = false;
266
+ /** True after `probeLeanback()` has resolved (success OR failure). */
267
+ leanbackProbed = false;
191
268
  constructor(opts = {}) {
192
269
  super();
193
270
  this.serial = opts.serial;
@@ -201,6 +278,13 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
201
278
  opts.screenshotTimeoutMs ?? DEFAULT_SCREENSHOT_TIMEOUT_MS;
202
279
  this.installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
203
280
  this.fileTimeoutMs = opts.fileTimeoutMs ?? DEFAULT_FILE_TIMEOUT_MS;
281
+ // `spawn` itself is overloaded; wrap it so the resolved overload
282
+ // matches `AdbProcessSpawner`'s `(file, args, opts)` shape.
283
+ this.processSpawner =
284
+ opts.processSpawner ??
285
+ ((file, args, spawnOpts) => spawn(file, args, spawnOpts ?? {}));
286
+ this.recordKillGraceMs = opts.recordKillGraceMs ?? 5_000;
287
+ this.recordTimeLimitS = opts.recordTimeLimitS ?? 300;
204
288
  }
205
289
  async connect() {
206
290
  if (this.skipConnectValidation)
@@ -213,6 +297,31 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
213
297
  });
214
298
  }
215
299
  }
300
+ /**
301
+ * Detect Android TV / leanback so `info().isTv` can hint at LLM
302
+ * prompts that should default to DPAD navigation. Cached for the
303
+ * lifetime of the backend — running `pm has-feature` once per device
304
+ * is enough; the answer doesn't change at runtime. Lazy on purpose —
305
+ * `connect()` with `skipConnectValidation: true` (the registry path)
306
+ * MUST NOT issue any adb commands, so we defer this probe to the
307
+ * first `info()` call.
308
+ */
309
+ async ensureLeanbackProbed() {
310
+ if (this.leanbackProbed)
311
+ return;
312
+ this.leanbackProbed = true;
313
+ try {
314
+ const { stdout } = await this.adbShell(sh `pm has-feature android.software.leanback`);
315
+ const text = stdout.trim().toLowerCase();
316
+ // Newer Android prints `true` / `false`; older builds emit the
317
+ // raw feature line `feature:android.software.leanback`. Accept
318
+ // either positive shape.
319
+ this.isLeanback = text === "true" || /leanback/.test(text);
320
+ }
321
+ catch {
322
+ this.isLeanback = false;
323
+ }
324
+ }
216
325
  async disconnect() {
217
326
  // No-op — adb daemon stays alive between commands.
218
327
  }
@@ -220,11 +329,15 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
220
329
  /* Info / screen */
221
330
  /* ------------------------------------------------------------------ */
222
331
  async info() {
332
+ // Lazy leanback probe — see comment on `ensureLeanbackProbed`. Run
333
+ // it in parallel with the other info shell-outs so the call adds no
334
+ // latency to the first `info()` invocation.
223
335
  const [size, density, propDump, currentApp] = await Promise.all([
224
336
  this.runShell("wm size").catch(() => ""),
225
337
  this.runShell("wm density").catch(() => ""),
226
338
  this.runShell("getprop").catch(() => ""),
227
339
  this.getCurrentApp().catch(() => "System Home"),
340
+ this.ensureLeanbackProbed(),
228
341
  ]);
229
342
  const sizeMatch = size.match(/Physical size:\s*(\d+)x(\d+)/i);
230
343
  const width = sizeMatch ? Number(sizeMatch[1]) : 1080;
@@ -266,37 +379,82 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
266
379
  abi,
267
380
  manufacturer,
268
381
  brand,
382
+ // 0.4.1: leanback feature is the canonical AOSP marker for TV /
383
+ // car-head-unit form factors. Surface as a string in `metadata`
384
+ // (DeviceInfo.metadata is `Record<string, string>` per
385
+ // common/device-info.ts) so LLM prompts can default to DPAD
386
+ // navigation without parsing extra fields.
387
+ isTv: this.isLeanback ? "true" : "false",
269
388
  },
270
389
  };
271
390
  }
272
- async screenshot() {
391
+ async screenshot(opts = {}) {
273
392
  // Capture the raw screencap PNG. The 0.2.3 default is to forward
274
393
  // these bytes to the agent unchanged so `width/height` always match
275
394
  // `data`. Callers that need a smaller payload set
276
395
  // `GUI_AGENT_SS_MAX_EDGE` to opt back into the legacy resize path.
277
- const raw = await this.captureRawPng();
396
+ //
397
+ // 0.4.1: per-call overrides. The MCP tool surfaces `maxEdge`,
398
+ // `quality`, and `format` so an LLM running on a 4K device can
399
+ // shrink a single screenshot without restarting the server.
400
+ // - `opts.maxEdge` overrides the env default. `0` means "no
401
+ // resize", matching the env semantics.
402
+ // - `opts.quality` overrides JPEG quality; clamped 1-100 by zod.
403
+ // - `opts.format = "jpeg"` forces re-encode even when the image
404
+ // is already under the resize threshold (for deterministic
405
+ // payload sizing). `"png"` forces raw passthrough.
406
+ const raw = await this.captureRawPng(opts.displayId);
278
407
  const [rawW, rawH] = parsePngDimensions(raw);
279
- if (!Number.isFinite(SS_MAX_EDGE) || Math.max(rawW, rawH) <= SS_MAX_EDGE) {
408
+ const effectiveMaxEdge = (() => {
409
+ if (opts.maxEdge !== undefined) {
410
+ return opts.maxEdge <= 0 ? Number.POSITIVE_INFINITY : opts.maxEdge;
411
+ }
412
+ return SS_MAX_EDGE;
413
+ })();
414
+ const effectiveQuality = (() => {
415
+ const q = opts.quality;
416
+ if (q !== undefined)
417
+ return Math.max(1, Math.min(100, Math.round(q)));
418
+ return SS_JPEG_QUALITY;
419
+ })();
420
+ const formatHint = opts.format;
421
+ const exceedsCap = Number.isFinite(effectiveMaxEdge) &&
422
+ Math.max(rawW, rawH) > effectiveMaxEdge;
423
+ const wantJpeg = formatHint === "jpeg";
424
+ const wantPngPassthrough = formatHint === "png" && !exceedsCap;
425
+ // Fast path: caller asked for raw PNG (or default behaviour with
426
+ // a small enough capture).
427
+ if (!exceedsCap && !wantJpeg) {
280
428
  return { data: raw, width: rawW, height: rawH, format: "png" };
281
429
  }
282
- // Resize-on-capture path: re-encode to JPEG and recompute dimensions
430
+ // Caller explicitly asked for `format: "png"` but the capture is
431
+ // larger than the cap → we still resize, just keep the PNG codec.
432
+ if (wantPngPassthrough) {
433
+ return { data: raw, width: rawW, height: rawH, format: "png" };
434
+ }
435
+ // Resize-on-capture path: re-encode and recompute dimensions
283
436
  // so the wire layer reports the actual encoded payload, not the
284
437
  // original device-side resolution.
285
438
  try {
286
439
  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();
440
+ if (Number.isFinite(effectiveMaxEdge)) {
441
+ if (rawW >= rawH)
442
+ pipeline.resize({ width: effectiveMaxEdge });
443
+ else
444
+ pipeline.resize({ height: effectiveMaxEdge });
445
+ }
446
+ const usePng = formatHint === "png";
447
+ const encoded = usePng
448
+ ? await pipeline.png().toBuffer()
449
+ : await pipeline
450
+ .jpeg({ quality: effectiveQuality, mozjpeg: true })
451
+ .toBuffer();
452
+ const meta = await sharp(encoded).metadata();
295
453
  return {
296
- data,
454
+ data: encoded,
297
455
  width: meta.width ?? rawW,
298
456
  height: meta.height ?? rawH,
299
- format: "jpeg",
457
+ format: usePng ? "png" : "jpeg",
300
458
  };
301
459
  }
302
460
  catch {
@@ -309,12 +467,50 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
309
467
  return { w: m ? Number(m[1]) : 1080, h: m ? Number(m[2]) : 1920 };
310
468
  }
311
469
  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 };
470
+ // Mobile-mcp parity: real devices occasionally surface
471
+ // `null root node returned by UiTestAutomationBridge` while a
472
+ // fullscreen transition is in flight. Retry up to 10× with a
473
+ // 200 ms cool-off — the window state always settles within a
474
+ // few attempts in practice.
475
+ let lastErr;
476
+ for (let attempt = 0; attempt < UIDUMP_MAX_RETRIES; attempt++) {
477
+ try {
478
+ const { stdout: dumpStdout, stderr: dumpStderr } = await this.adbShell(sh `uiautomator dump /sdcard/ui_dump.xml`);
479
+ // `uiautomator dump` reports flake on stdout AND stderr depending
480
+ // on Android version. Check both before assuming success.
481
+ const dumpBlob = `${dumpStdout}\n${dumpStderr}`;
482
+ if (UIDUMP_RETRY_SUBSTRINGS.some((s) => dumpBlob.includes(s))) {
483
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
484
+ continue;
485
+ }
486
+ const { stdout } = await this.adbShell(sh `cat /sdcard/ui_dump.xml`);
487
+ if (!stdout || stdout.includes("ERROR")) {
488
+ // Empty / error output — flake; retry.
489
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
490
+ continue;
491
+ }
492
+ if (UIDUMP_RETRY_SUBSTRINGS.some((s) => stdout.includes(s))) {
493
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
494
+ continue;
495
+ }
496
+ return { supported: true, content: stdout.trim() };
497
+ }
498
+ catch (e) {
499
+ lastErr = e;
500
+ const msg = e instanceof Error ? e.message : String(e);
501
+ if (UIDUMP_RETRY_SUBSTRINGS.some((s) => msg.includes(s))) {
502
+ await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
503
+ continue;
504
+ }
505
+ // Non-flake error — surface immediately so callers see the
506
+ // real cause (timeout, adb gone, permission, …).
507
+ throw e;
508
+ }
509
+ }
510
+ if (lastErr) {
511
+ throw lastErr;
316
512
  }
317
- return { supported: true, content: stdout.trim() };
513
+ return { supported: true };
318
514
  }
319
515
  /* ------------------------------------------------------------------ */
320
516
  /* Pointer / gesture */
@@ -345,15 +541,18 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
345
541
  async drag(x1, y1, x2, y2, durationMs) {
346
542
  return this.swipe(x1, y1, x2, y2, durationMs);
347
543
  }
348
- async scroll(x, y, direction, _amount) {
544
+ async scroll(x, y, direction, amount) {
349
545
  let { w, h } = { w: 1080, h: 1920 };
350
546
  if (!x && !y) {
351
547
  ({ w, h } = await this.screenSize().catch(() => ({ w: 1080, h: 1920 })));
352
548
  x = Math.round(w / 2);
353
549
  y = Math.round(h / 2);
354
550
  }
355
- const horizontal = direction === "left" || direction === "right";
356
- const dist = horizontal ? 300 : 600;
551
+ // 0.4.1: `amount` is now a real multiplier (mobile-mcp parity). Each
552
+ // unit 300 px of swipe distance regardless of direction; defaults
553
+ // to 1 when caller omits it. Previous behaviour silently ignored
554
+ // `amount` and shipped a fixed 300 px / 600 px asymmetric distance.
555
+ const dist = Math.max(50, Math.round(300 * (amount ?? 1)));
357
556
  const dx = direction === "left" ? dist : direction === "right" ? -dist : 0;
358
557
  const dy = direction === "up" ? dist : direction === "down" ? -dist : 0;
359
558
  await this.swipe(x, y, x + dx, y + dy);
@@ -422,7 +621,8 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
422
621
  /* ------------------------------------------------------------------ */
423
622
  /* App lifecycle */
424
623
  /* ------------------------------------------------------------------ */
425
- async launchApp(pkgOrName, activity) {
624
+ async launchApp(pkgOrName, opts) {
625
+ const { activity, locale } = normalizeLaunchAppOptions(opts);
426
626
  const pkg = looksLikePackage(pkgOrName)
427
627
  ? pkgOrName
428
628
  : (getAppPackage(pkgOrName) ?? pkgOrName);
@@ -444,9 +644,121 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
444
644
  // is the well-trodden path the legacy adapter has shipped for years.
445
645
  await this.adbShell(sh `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`);
446
646
  }
647
+ // 0.4.1: per-app locale (mobile-mcp parity). Best-effort — Android 12
648
+ // and below do not have `cmd locale`, so a non-zero exit just means
649
+ // "the OS predates per-app locales". We swallow that silently and
650
+ // log so operators can spot the platform mismatch.
651
+ if (locale && locale.length > 0) {
652
+ try {
653
+ await this.adbShell(sh `cmd locale set-app-locales ${pkg} --locales ${locale}`);
654
+ }
655
+ catch (e) {
656
+ // best-effort — do NOT fail the launch; Android 12- silently
657
+ // returns a non-zero exit on `cmd locale set-app-locales`.
658
+ // Surface as a debug-level warning so noisy CI doesn't drown
659
+ // every multi-region test run in stderr.
660
+ const msg = e instanceof Error ? e.message : String(e);
661
+ // Use the runner-level log at warn so callers can opt-in via
662
+ // LOG_LEVEL=warn — this never blocks the launch.
663
+ // (No structured logger handle here; rely on stderr.)
664
+ process.stderr.write(`[android] cmd locale set-app-locales failed for ${pkg} (${locale}): ${msg}\n`);
665
+ }
666
+ }
447
667
  await this.sleep(DELAYS.launch);
448
668
  }
449
669
  /* ------------------------------------------------------------------ */
670
+ /* 0.4.0: cross-OS open_url + mobile-mcp parity */
671
+ /* ------------------------------------------------------------------ */
672
+ async openUrl(url) {
673
+ assertSafeUrl(url);
674
+ await this.adbShell(sh `am start -a android.intent.action.VIEW -d ${url}`);
675
+ }
676
+ async listApps(opts = {}) {
677
+ // 0.4.1 (mobile-mcp parity): `launchableOnly: true` filters to apps
678
+ // that declare a `MAIN`/`LAUNCHER` activity — drops 200+ headless
679
+ // system services that pollute `pm list packages`. Implemented via
680
+ // `cmd package query-activities`, which prints one block per
681
+ // matching activity with a `packageName=...` line we dedupe on.
682
+ if (opts.launchableOnly) {
683
+ const { stdout } = await this.adbShell(sh `cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.LAUNCHER`);
684
+ const pkgs = new Map();
685
+ for (const rawLine of stdout.split("\n")) {
686
+ const line = rawLine.trim();
687
+ if (!line.startsWith("packageName="))
688
+ continue;
689
+ const pkg = line.slice("packageName=".length).trim();
690
+ if (!pkg)
691
+ continue;
692
+ // `system` is best-effort here — `query-activities` doesn't
693
+ // distinguish, so we mark unknown as `false` and let
694
+ // `includeSystem` consumers re-query if they actually care.
695
+ if (!pkgs.has(pkg)) {
696
+ pkgs.set(pkg, { packageId: pkg, system: false });
697
+ }
698
+ }
699
+ return [...pkgs.values()].sort((a, b) => a.packageId.localeCompare(b.packageId));
700
+ }
701
+ // `pm list packages -3` lists user-installed (third-party) packages.
702
+ // `-s` lists system packages. Default to third-party only — system
703
+ // apps are 200+ entries on Android and rarely actionable.
704
+ const args = opts.includeSystem
705
+ ? sh `pm list packages -f`
706
+ : sh `pm list packages -3 -f`;
707
+ const { stdout } = await this.adbShell(args);
708
+ const pkgs = new Map();
709
+ for (const line of stdout.split("\n")) {
710
+ // Format: `package:/data/app/com.foo-1/base.apk=com.foo`
711
+ const m = line.trim().match(/^package:.*=([\w.\-_]+)\s*$/);
712
+ if (m && m[1]) {
713
+ pkgs.set(m[1], { packageId: m[1], system: !!opts.includeSystem });
714
+ }
715
+ }
716
+ return [...pkgs.values()].sort((a, b) => a.packageId.localeCompare(b.packageId));
717
+ }
718
+ async terminateApp(pkg) {
719
+ if (!looksLikePackage(pkg)) {
720
+ throw new DeviceError(`terminate_app: '${pkg}' is not a valid Android package id`, { subtype: "invalid_args", retriable: false });
721
+ }
722
+ await this.adbShell(sh `am force-stop ${pkg}`);
723
+ }
724
+ async listElements(opts = {}) {
725
+ const dump = await this.uiDump();
726
+ if (!dump.supported || !dump.content)
727
+ return [];
728
+ const nodes = searchUiXmlNodes(dump.content, opts.query ?? "");
729
+ return nodes.map((n) => {
730
+ const m = n.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
731
+ const [x1, y1, x2, y2] = m
732
+ ? [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
733
+ : [0, 0, 0, 0];
734
+ return {
735
+ text: n.text || undefined,
736
+ contentDesc: n.contentDesc || undefined,
737
+ resourceId: n.resourceId || undefined,
738
+ className: n.className || undefined,
739
+ center: n.center,
740
+ rect: {
741
+ x: x1,
742
+ y: y1,
743
+ width: Math.max(0, x2 - x1),
744
+ height: Math.max(0, y2 - y1),
745
+ },
746
+ clickable: n.clickable,
747
+ };
748
+ });
749
+ }
750
+ async getOrientation() {
751
+ const { stdout } = await this.adbShell(sh `settings get system user_rotation`);
752
+ const code = Number.parseInt(stdout.trim(), 10);
753
+ return rotationCodeToOrientation(code);
754
+ }
755
+ async setOrientation(o) {
756
+ const code = orientationToRotationCode(o);
757
+ // Disable accelerometer-driven rotation first so user_rotation sticks.
758
+ await this.adbShell(sh `settings put system accelerometer_rotation 0`);
759
+ await this.adbShell(sh `settings put system user_rotation ${String(code)}`);
760
+ }
761
+ /* ------------------------------------------------------------------ */
450
762
  /* Shell / files */
451
763
  /* ------------------------------------------------------------------ */
452
764
  async executeCommand(body, opts = {}) {
@@ -525,6 +837,259 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
525
837
  }
526
838
  }
527
839
  /* ------------------------------------------------------------------ */
840
+ /* 0.4.1: mobile-mcp parity round 2 */
841
+ /* ------------------------------------------------------------------ */
842
+ async uninstallApp(pkgOrName) {
843
+ const pkg = looksLikePackage(pkgOrName)
844
+ ? pkgOrName
845
+ : (getAppPackage(pkgOrName) ?? pkgOrName);
846
+ if (!looksLikePackage(pkg)) {
847
+ throw new DeviceError(`uninstall_app: '${pkgOrName}' is not a valid Android package id`, { subtype: "invalid_args", retriable: false });
848
+ }
849
+ try {
850
+ // `adb uninstall <pkg>` (host-side) is the canonical pair to
851
+ // `adb install -r`. Going through `pm uninstall` over `adb shell`
852
+ // is the alternative but requires UID 0 / root for system packages
853
+ // and silently no-ops without it.
854
+ await this.runner("adb", this.adbArgs(["uninstall", pkg]), {
855
+ encoding: "utf8",
856
+ timeoutMs: this.installTimeoutMs,
857
+ });
858
+ }
859
+ catch (e) {
860
+ throw wrapAdbFailure("uninstall", ["uninstall", pkg], e, this.installTimeoutMs);
861
+ }
862
+ }
863
+ /**
864
+ * Parse `dumpsys dropbox --print` and return one entry per recorded
865
+ * crash / ANR / WTF. The list is best-effort — the dumpsys output
866
+ * format has shifted slightly across Android releases, so we tolerate
867
+ * unknown lines instead of rejecting them.
868
+ */
869
+ async listCrashes() {
870
+ const { stdout } = await this.adbShell(sh `dumpsys dropbox --print`);
871
+ return parseDropboxEntries(stdout);
872
+ }
873
+ /**
874
+ * Re-run `dumpsys dropbox --print` and return the body of the entry
875
+ * whose `id` matches. Stateless so the result stays correct even if
876
+ * `listCrashes` was called from a different MCP session.
877
+ */
878
+ async getCrash(id) {
879
+ if (!id) {
880
+ throw new DeviceError(`get_crash: id is required`, {
881
+ subtype: "invalid_args",
882
+ retriable: false,
883
+ });
884
+ }
885
+ const { stdout } = await this.adbShell(sh `dumpsys dropbox --print`);
886
+ const body = extractDropboxBody(stdout, id);
887
+ if (!body) {
888
+ throw new DeviceError(`get_crash: no crash entry with id '${id}'`, { subtype: "invalid_args", retriable: false });
889
+ }
890
+ return body;
891
+ }
892
+ /**
893
+ * Spawn `adb shell screenrecord` for a single recording. The device
894
+ * enforces a hard `--time-limit` (default 5 min, capped at 300s by
895
+ * Android itself); the host sends SIGINT on `stopScreenRecord` and
896
+ * falls back to SIGKILL after `recordKillGraceMs` if the child
897
+ * hasn't exited.
898
+ *
899
+ * The same-pid `screenrecord` invocation also lets us pipe the output
900
+ * to a `/sdcard/<id>.mp4` so `adb pull` after stop can fetch the
901
+ * exact file without guessing names.
902
+ *
903
+ * `opts.timeLimitS` (mobile-mcp parity, r3): caller-supplied cap,
904
+ * clamped into `[1, 300]` and passed to `screenrecord --time-limit`.
905
+ * `opts.localPath`: caller-supplied host-side destination registered
906
+ * at start time. Already passed through `assertSafeOutputPathWithExt`
907
+ * — backend treats as trusted.
908
+ */
909
+ async startScreenRecord(opts = {}) {
910
+ const id = `beeos-rec-${Date.now()}-${randomUUID().slice(0, 8)}`;
911
+ const remotePath = `/sdcard/${id}.mp4`;
912
+ // Clamp into the 1-300s range Android `screenrecord` enforces. We
913
+ // do the clamp inside the backend (not just at the tool boundary)
914
+ // so direct backend callers — tests, future server APIs — get the
915
+ // same safety net.
916
+ const requested = opts.timeLimitS ?? this.recordTimeLimitS;
917
+ const limit = Math.min(300, Math.max(1, Math.floor(requested)));
918
+ const args = this.adbArgs([
919
+ "shell",
920
+ // Keep one single quoted shell command — adb shell joins argv
921
+ // with spaces and re-evaluates through `sh -c`, so multiple
922
+ // tokens here would be silently re-tokenised on the device side.
923
+ sh `screenrecord --time-limit ${limit} ${remotePath}`,
924
+ ]);
925
+ const child = this.processSpawner("adb", args, {
926
+ stdio: ["ignore", "pipe", "pipe"],
927
+ });
928
+ if (!child.pid) {
929
+ throw new DeviceError(`screen_record_start: failed to spawn adb`, {
930
+ subtype: "environment",
931
+ retriable: true,
932
+ });
933
+ }
934
+ const exitPromise = new Promise((resolve) => {
935
+ // We deliberately resolve on either `close` or `exit` — different
936
+ // stdio shapes on Linux vs macOS sequence them differently.
937
+ const done = () => resolve();
938
+ child.once("close", done);
939
+ child.once("exit", done);
940
+ child.once("error", done);
941
+ });
942
+ const entry = {
943
+ id,
944
+ startedAt: Date.now(),
945
+ remotePath,
946
+ child,
947
+ exitPromise,
948
+ timeLimitS: limit,
949
+ localPath: opts.localPath,
950
+ };
951
+ this.recordings.set(id, entry);
952
+ // Detach stderr so the listener doesn't keep the process alive
953
+ // unnecessarily but we still drain it to avoid a back-pressure
954
+ // deadlock.
955
+ child.stdout?.resume();
956
+ child.stderr?.resume();
957
+ return {
958
+ id,
959
+ startedAt: entry.startedAt,
960
+ timeLimitS: entry.timeLimitS,
961
+ remotePath: entry.remotePath,
962
+ ...(entry.localPath !== undefined ? { localPath: entry.localPath } : {}),
963
+ };
964
+ }
965
+ async stopScreenRecord(id, opts = {}) {
966
+ const entry = this.recordings.get(id);
967
+ if (!entry) {
968
+ throw new DeviceError(`screen_record_stop: no recording with id '${id}'`, { subtype: "invalid_args", retriable: false });
969
+ }
970
+ this.recordings.delete(id);
971
+ // Polite stop first — adb shell forwards SIGINT to the device-side
972
+ // `screenrecord`, which flushes the MP4 trailer. Then escalate to
973
+ // SIGKILL after a grace period so we never wedge a recording on a
974
+ // misbehaving device.
975
+ const tryKill = (sig) => {
976
+ try {
977
+ entry.child.kill(sig);
978
+ }
979
+ catch {
980
+ /* swallow — child already exited */
981
+ }
982
+ };
983
+ tryKill("SIGINT");
984
+ const killTimer = setTimeout(() => tryKill("SIGKILL"), this.recordKillGraceMs);
985
+ try {
986
+ await entry.exitPromise;
987
+ }
988
+ finally {
989
+ clearTimeout(killTimer);
990
+ }
991
+ // Determine the host-side destination. Priority order:
992
+ // 1. `opts.path` from this stop call (overrides the start-time
993
+ // registration so a caller can re-target a recording at the
994
+ // last moment without losing the kill+pull machinery).
995
+ // 2. `entry.localPath` registered at start time (mobile-mcp
996
+ // parity, r3) — the start-time path is the canonical mobile-
997
+ // mcp design and the one we expose in MCP tool descriptions.
998
+ // 3. Fresh per-recording tmp dir under `os.tmpdir()`.
999
+ // All caller-supplied paths have already passed through
1000
+ // `assertSafeOutputPathWithExt` at the tool boundary; the backend
1001
+ // does not re-validate.
1002
+ const localPath = opts.path
1003
+ ? opts.path
1004
+ : (entry.localPath ??
1005
+ (await (async () => {
1006
+ const dir = await mkdtemp(path.join(os.tmpdir(), "beeos-screenrec-"));
1007
+ return path.join(dir, `${id}.mp4`);
1008
+ })()));
1009
+ try {
1010
+ await adbPull(this.runner, this.serial, entry.remotePath, {
1011
+ timeoutMs: this.fileTimeoutMs,
1012
+ }).then(async (buf) => {
1013
+ // adbPull returns the bytes — write them to our chosen path.
1014
+ // We do this in a tiny helper here rather than going through
1015
+ // `adb pull <remote> <local>` directly so callers asking for a
1016
+ // specific destination get the exact file there.
1017
+ const { writeFile: wf } = await import("node:fs/promises");
1018
+ await wf(localPath, buf);
1019
+ });
1020
+ }
1021
+ catch (e) {
1022
+ // Best-effort cleanup of the device-side artifact even if the
1023
+ // host pull fails — leaving 5-min MP4s on `/sdcard` is rude.
1024
+ this.adbShell(sh `rm -f ${entry.remotePath}`).catch(() => undefined);
1025
+ throw e;
1026
+ }
1027
+ // Best-effort cleanup of the device-side file once we have the
1028
+ // local copy.
1029
+ this.adbShell(sh `rm -f ${entry.remotePath}`).catch(() => undefined);
1030
+ const { stat } = await import("node:fs/promises");
1031
+ const fstat = await stat(localPath);
1032
+ return {
1033
+ path: localPath,
1034
+ bytes: fstat.size,
1035
+ durationMs: Date.now() - entry.startedAt,
1036
+ };
1037
+ }
1038
+ /**
1039
+ * Capture a screenshot via `adb pull` of `/sdcard/screenshot.png`.
1040
+ * Bypasses the JSON-RPC body cap by writing the bytes straight to a
1041
+ * host filesystem path the caller chose. Useful for 4K Android
1042
+ * captures where base64-over-HTTP would otherwise blow past 16 MB.
1043
+ */
1044
+ async saveScreenshot(localPath, opts = {}) {
1045
+ const remote = `/sdcard/beeos-${Date.now()}-${randomUUID().slice(0, 8)}.png`;
1046
+ // `screencap -p` writes to a path; `-d <id>` selects display.
1047
+ const cmd = opts.displayId !== undefined
1048
+ ? sh `screencap -p -d ${String(opts.displayId)} ${remote}`
1049
+ : sh `screencap -p ${remote}`;
1050
+ await this.adbShell(cmd);
1051
+ try {
1052
+ const buf = await adbPull(this.runner, this.serial, remote, {
1053
+ timeoutMs: this.fileTimeoutMs,
1054
+ });
1055
+ const { writeFile: wf } = await import("node:fs/promises");
1056
+ await wf(localPath, buf);
1057
+ const [w, h] = parsePngDimensions(buf);
1058
+ return {
1059
+ path: localPath,
1060
+ bytes: buf.length,
1061
+ width: w,
1062
+ height: h,
1063
+ format: "png",
1064
+ };
1065
+ }
1066
+ finally {
1067
+ this.adbShell(sh `rm -f ${remote}`).catch(() => undefined);
1068
+ }
1069
+ }
1070
+ /**
1071
+ * Count the number of displays the device exposes. Reads
1072
+ * `dumpsys SurfaceFlinger --display-id` (Android 10+) and falls back
1073
+ * to "1" on older builds where the flag isn't recognised.
1074
+ */
1075
+ async getDisplayCount() {
1076
+ try {
1077
+ const { stdout } = await this.adbShell(sh `dumpsys SurfaceFlinger --display-id`);
1078
+ // Output shape (Android 10+):
1079
+ // Display 4630946866250191 (HWC display 0): port=0 ...
1080
+ // Display 4630946866250192 (HWC display 1): port=1 ...
1081
+ const lines = stdout
1082
+ .split("\n")
1083
+ .filter((l) => /^Display\s+\d+/.test(l.trim()));
1084
+ if (lines.length > 0)
1085
+ return lines.length;
1086
+ }
1087
+ catch {
1088
+ /* fall through */
1089
+ }
1090
+ return 1;
1091
+ }
1092
+ /* ------------------------------------------------------------------ */
528
1093
  /* Internal helpers */
529
1094
  /* ------------------------------------------------------------------ */
530
1095
  async getCurrentApp() {
@@ -560,9 +1125,16 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
560
1125
  throw wrapAdbFailure("shell", ["shell", command], e, this.shellTimeoutMs);
561
1126
  }
562
1127
  }
563
- async captureRawPng() {
1128
+ async captureRawPng(displayId) {
1129
+ // Multi-display capture: `screencap -d <id>` first appears on
1130
+ // Android 10+ (API 29). On older builds the flag is silently
1131
+ // ignored and we capture the primary display — that's still a
1132
+ // sensible fallback so we don't gate this behind an SDK check.
1133
+ const cmd = displayId !== undefined
1134
+ ? ["exec-out", "screencap", "-p", "-d", String(displayId)]
1135
+ : ["exec-out", "screencap", "-p"];
564
1136
  try {
565
- const { stdout } = await this.runner("adb", this.adbArgs(["exec-out", "screencap", "-p"]), {
1137
+ const { stdout } = await this.runner("adb", this.adbArgs(cmd), {
566
1138
  encoding: "buffer",
567
1139
  maxBufferBytes: 32 * 1024 * 1024,
568
1140
  timeoutMs: this.screenshotTimeoutMs,
@@ -570,7 +1142,7 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
570
1142
  return Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
571
1143
  }
572
1144
  catch (e) {
573
- throw wrapAdbFailure("screencap", ["exec-out", "screencap", "-p"], e, this.screenshotTimeoutMs);
1145
+ throw wrapAdbFailure("screencap", cmd, e, this.screenshotTimeoutMs);
574
1146
  }
575
1147
  }
576
1148
  /**
@@ -639,6 +1211,37 @@ function parseGetpropDump(out) {
639
1211
  }
640
1212
  return map;
641
1213
  }
1214
+ function rotationCodeToOrientation(code) {
1215
+ switch (code) {
1216
+ case 0:
1217
+ return "portrait";
1218
+ case 1:
1219
+ return "landscape";
1220
+ case 2:
1221
+ return "reverse-portrait";
1222
+ case 3:
1223
+ return "reverse-landscape";
1224
+ default:
1225
+ return "portrait";
1226
+ }
1227
+ }
1228
+ function orientationToRotationCode(o) {
1229
+ switch (o) {
1230
+ case "portrait":
1231
+ return 0;
1232
+ case "landscape":
1233
+ return 1;
1234
+ case "reverse-portrait":
1235
+ return 2;
1236
+ case "reverse-landscape":
1237
+ return 3;
1238
+ default:
1239
+ throw new DeviceError(`unknown orientation '${o}'`, {
1240
+ subtype: "invalid_args",
1241
+ retriable: false,
1242
+ });
1243
+ }
1244
+ }
642
1245
  /**
643
1246
  * Local POSIX-quote helper used ONLY by `executeCommand`'s `cwd` prefix.
644
1247
  * The general-purpose `sh\`...\`` template lives in `util/adb-shell.ts`
@@ -705,13 +1308,15 @@ export function wrapAdbFailure(verb, args, cause, timeoutMs) {
705
1308
  ? "adb_shell_failed"
706
1309
  : verb === "install"
707
1310
  ? "adb_install_failed"
708
- : verb === "screencap"
709
- ? "screenshot_failed"
710
- : verb === "devices"
711
- ? "adb_failed"
712
- : verb === "exec-out"
1311
+ : verb === "uninstall"
1312
+ ? "adb_uninstall_failed"
1313
+ : verb === "screencap"
1314
+ ? "screenshot_failed"
1315
+ : verb === "devices"
713
1316
  ? "adb_failed"
714
- : "adb_failed";
1317
+ : verb === "exec-out"
1318
+ ? "adb_failed"
1319
+ : "adb_failed";
715
1320
  // Reference unused args param for symmetry with previous signature.
716
1321
  void args;
717
1322
  return new DeviceError(`adb ${verb} failed: ${summary}`, {
@@ -720,4 +1325,135 @@ export function wrapAdbFailure(verb, args, cause, timeoutMs) {
720
1325
  cause: err,
721
1326
  });
722
1327
  }
1328
+ /* ----------------------------------------------------------------------- */
1329
+ /* 0.4.1 helpers — open_url whitelist + dropbox crash parser */
1330
+ /* ----------------------------------------------------------------------- */
1331
+ /**
1332
+ * Default URL scheme allow-list for `open_url`. Mirrors mobile-mcp's
1333
+ * default — `http` / `https` only. Operators who need to launch
1334
+ * `intent://` / `file://` / custom schemes set
1335
+ * `BEEOS_DEVICE_ALLOW_UNSAFE_URLS=1` to opt out (mobile-mcp uses
1336
+ * `MOBILEMCP_ALLOW_UNSAFE_URLS=1`; we accept both for compatibility).
1337
+ */
1338
+ const SAFE_URL_SCHEMES = new Set(["http", "https"]);
1339
+ export function assertSafeUrl(url) {
1340
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
1341
+ throw new DeviceError(`open_url: '${url}' is not a fully-qualified URL`, {
1342
+ subtype: "invalid_args",
1343
+ retriable: false,
1344
+ });
1345
+ }
1346
+ // Allow opt-out via either env name so a mobile-mcp prompt that sets
1347
+ // the upstream variable keeps working.
1348
+ if (process.env.BEEOS_DEVICE_ALLOW_UNSAFE_URLS === "1" ||
1349
+ process.env.MOBILEMCP_ALLOW_UNSAFE_URLS === "1") {
1350
+ return;
1351
+ }
1352
+ const colon = url.indexOf(":");
1353
+ const scheme = url.slice(0, colon).toLowerCase();
1354
+ if (!SAFE_URL_SCHEMES.has(scheme)) {
1355
+ throw new DeviceError(`open_url: scheme '${scheme}' is not in the default allow-list ` +
1356
+ `(http/https). Set BEEOS_DEVICE_ALLOW_UNSAFE_URLS=1 to permit ` +
1357
+ `intent:/file:/content:/custom URLs.`, { subtype: "invalid_args", retriable: false });
1358
+ }
1359
+ }
1360
+ /**
1361
+ * Parse the textual output of `dumpsys dropbox --print` into one
1362
+ * `CrashSummary` per crash / ANR / WTF entry.
1363
+ *
1364
+ * Output shape (Android 7+):
1365
+ *
1366
+ * 2024-08-15 12:34:56 system_app_crash (text, 1234 bytes):
1367
+ * Process: com.foo.bar
1368
+ * ...stack trace lines...
1369
+ * 2024-08-15 12:35:00 data_app_anr (text, 5678 bytes):
1370
+ * ...
1371
+ *
1372
+ * We extract the leading `<timestamp> <tag> (text, N bytes):` row and
1373
+ * include the first non-blank body line as a `headline` for at-a-glance
1374
+ * triage. The id encodes both the tag and the timestamp so `getCrash`
1375
+ * can re-locate the same row on the next `dumpsys` invocation without
1376
+ * holding state.
1377
+ */
1378
+ export function parseDropboxEntries(out) {
1379
+ const headerRe = /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+([A-Za-z0-9_]+)(?:\s*\(text,\s*(\d+)\s*bytes\))?:?\s*$/;
1380
+ const TAG_RE = /(crash|anr|wtf|kernel_panic|tombstone)/i;
1381
+ const lines = out.split(/\r?\n/);
1382
+ const entries = [];
1383
+ for (let i = 0; i < lines.length; i++) {
1384
+ const line = lines[i] ?? "";
1385
+ const m = headerRe.exec(line.trim());
1386
+ if (!m)
1387
+ continue;
1388
+ const tag = m[2];
1389
+ if (!TAG_RE.test(tag))
1390
+ continue;
1391
+ const timestamp = m[1];
1392
+ const bytes = m[3] ? Number(m[3]) : undefined;
1393
+ // Pull a one-line headline from the immediate body — usually the
1394
+ // `Process:` row for crashes and `Cmd line:` for ANRs.
1395
+ let headline;
1396
+ let app;
1397
+ for (let j = i + 1; j < Math.min(lines.length, i + 8); j++) {
1398
+ const body = (lines[j] ?? "").trim();
1399
+ if (!body)
1400
+ continue;
1401
+ if (headerRe.test(body))
1402
+ break;
1403
+ if (!headline)
1404
+ headline = body.length > 240 ? body.slice(0, 240) + "…" : body;
1405
+ const procMatch = /^(?:Process|Cmd ?line):\s*(\S+)/.exec(body);
1406
+ if (procMatch) {
1407
+ app = procMatch[1].split(/[\s,]/)[0];
1408
+ break;
1409
+ }
1410
+ }
1411
+ const type = TAG_RE.exec(tag)?.[1]?.toLowerCase() ?? "crash";
1412
+ entries.push({
1413
+ id: `${tag}:${timestamp}`,
1414
+ type,
1415
+ timestamp,
1416
+ app,
1417
+ headline,
1418
+ bytes,
1419
+ });
1420
+ }
1421
+ // Newest first — dumpsys output is already chronological so we reverse.
1422
+ return entries.reverse();
1423
+ }
1424
+ /**
1425
+ * Pull the body of a single dumpsys-dropbox entry out of the `--print`
1426
+ * blob, indexed by `<tag>:<timestamp>` (the same id `parseDropboxEntries`
1427
+ * returns). The body is everything between the matching header and the
1428
+ * next entry header.
1429
+ */
1430
+ export function extractDropboxBody(out, id) {
1431
+ const colon = id.indexOf(":");
1432
+ if (colon < 0)
1433
+ return undefined;
1434
+ const tag = id.slice(0, colon);
1435
+ const timestamp = id.slice(colon + 1);
1436
+ const headerRe = /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+([A-Za-z0-9_]+)(?:\s*\(text,\s*\d+\s*bytes\))?:?\s*$/;
1437
+ const lines = out.split(/\r?\n/);
1438
+ let startIdx = -1;
1439
+ for (let i = 0; i < lines.length; i++) {
1440
+ const m = headerRe.exec(lines[i]?.trim() ?? "");
1441
+ if (!m)
1442
+ continue;
1443
+ if (m[1] === timestamp && m[2] === tag) {
1444
+ startIdx = i + 1;
1445
+ break;
1446
+ }
1447
+ }
1448
+ if (startIdx === -1)
1449
+ return undefined;
1450
+ let endIdx = lines.length;
1451
+ for (let j = startIdx; j < lines.length; j++) {
1452
+ if (headerRe.test(lines[j]?.trim() ?? "")) {
1453
+ endIdx = j;
1454
+ break;
1455
+ }
1456
+ }
1457
+ return lines.slice(startIdx, endIdx).join("\n").trim();
1458
+ }
723
1459
  //# sourceMappingURL=android-adb.js.map