@beeos-ai/device-mcp-server 0.3.0 → 0.4.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.
- package/dist/backends/android-adb.d.ts +149 -6
- package/dist/backends/android-adb.js +854 -42
- package/dist/backends/android-adb.js.map +1 -1
- package/dist/backends/base.d.ts +243 -7
- package/dist/backends/base.js +81 -2
- package/dist/backends/base.js.map +1 -1
- package/dist/backends/desktop.d.ts +3 -2
- package/dist/backends/desktop.js +9 -3
- package/dist/backends/desktop.js.map +1 -1
- package/dist/backends/linux.js +3 -0
- package/dist/backends/linux.js.map +1 -1
- package/dist/backends/mac.d.ts +11 -2
- package/dist/backends/mac.js +39 -1
- package/dist/backends/mac.js.map +1 -1
- package/dist/backends/stubs/windows.js +3 -0
- package/dist/backends/stubs/windows.js.map +1 -1
- package/dist/cli.d.ts +40 -26
- package/dist/cli.js +118 -84
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/dist/server/app.d.ts +60 -17
- package/dist/server/app.js +182 -138
- package/dist/server/app.js.map +1 -1
- package/dist/server/mcp-server.d.ts +25 -0
- package/dist/server/mcp-server.js +33 -0
- package/dist/server/mcp-server.js.map +1 -0
- package/dist/server/registry.d.ts +111 -0
- package/dist/server/registry.js +191 -0
- package/dist/server/registry.js.map +1 -0
- package/dist/server/stdio.d.ts +29 -0
- package/dist/server/stdio.js +35 -0
- package/dist/server/stdio.js.map +1 -0
- package/dist/server/tool-registry.d.ts +60 -35
- package/dist/server/tool-registry.js +911 -434
- package/dist/server/tool-registry.js.map +1 -1
- package/dist/util/adb-files.d.ts +25 -1
- package/dist/util/adb-files.js +95 -0
- package/dist/util/adb-files.js.map +1 -1
- package/dist/util/android-apps.js +209 -9
- package/dist/util/android-apps.js.map +1 -1
- package/dist/util/locale.d.ts +16 -0
- package/dist/util/locale.js +31 -0
- package/dist/util/locale.js.map +1 -0
- package/dist/util/logger.d.ts +27 -0
- package/dist/util/logger.js +27 -0
- package/dist/util/logger.js.map +1 -0
- package/dist/util/output-path.d.ts +60 -0
- package/dist/util/output-path.js +123 -0
- package/dist/util/output-path.js.map +1 -0
- package/dist/util/package-name.d.ts +26 -0
- package/dist/util/package-name.js +41 -0
- package/dist/util/package-name.js.map +1 -0
- package/package.json +5 -3
- package/dist/server/action-mapping.d.ts +0 -21
- package/dist/server/action-mapping.js +0 -153
- 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.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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 =
|
|
72
|
-
|
|
73
|
-
|
|
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,83 @@ const DELAYS = {
|
|
|
93
110
|
keyboardRestore: 300,
|
|
94
111
|
};
|
|
95
112
|
/* ----------------------------------------------------------------------- */
|
|
113
|
+
/* Known browser packages — browser-specific launch fallback */
|
|
114
|
+
/* ----------------------------------------------------------------------- */
|
|
115
|
+
const BROWSER_PACKAGES = new Set([
|
|
116
|
+
"com.android.chrome",
|
|
117
|
+
"com.android.browser",
|
|
118
|
+
"org.mozilla.firefox",
|
|
119
|
+
"org.mozilla.firefox_beta",
|
|
120
|
+
"org.mozilla.fenix",
|
|
121
|
+
"com.microsoft.emmx",
|
|
122
|
+
"com.opera.browser",
|
|
123
|
+
"com.opera.mini.native",
|
|
124
|
+
"com.UCMobile.intl",
|
|
125
|
+
"com.UCMobile",
|
|
126
|
+
"com.quark.browser",
|
|
127
|
+
"com.baidu.searchbox",
|
|
128
|
+
"com.brave.browser",
|
|
129
|
+
"com.duckduckgo.mobile.android",
|
|
130
|
+
"com.vivaldi.browser",
|
|
131
|
+
"com.sec.android.app.sbrowser",
|
|
132
|
+
"com.huawei.browser",
|
|
133
|
+
"com.mi.globalbrowser",
|
|
134
|
+
"com.heytap.browser",
|
|
135
|
+
"com.vivo.browser",
|
|
136
|
+
]);
|
|
137
|
+
/* ----------------------------------------------------------------------- */
|
|
138
|
+
/* uiautomator dump retry — 0.4.1 */
|
|
139
|
+
/* ----------------------------------------------------------------------- */
|
|
140
|
+
/**
|
|
141
|
+
* `uiautomator dump` racing the foreground accessibility window can return
|
|
142
|
+
* `ERROR: null root node returned by UiTestAutomationBridge` (real-device
|
|
143
|
+
* regression on devices with frequent fullscreen transitions). Mobile-mcp
|
|
144
|
+
* documents the same flake. Retry the dump up to this many times — the
|
|
145
|
+
* window-state usually settles within 2-3 attempts.
|
|
146
|
+
*/
|
|
147
|
+
const UIDUMP_MAX_RETRIES = 10;
|
|
148
|
+
const UIDUMP_RETRY_INTERVAL_MS = 200;
|
|
149
|
+
const UIDUMP_RETRY_SUBSTRINGS = [
|
|
150
|
+
"null root node returned by UiTestAutomationBridge",
|
|
151
|
+
// toybox `cat` reports this when uiautomator failed to write the file.
|
|
152
|
+
"No such file or directory",
|
|
153
|
+
];
|
|
154
|
+
/* ----------------------------------------------------------------------- */
|
|
155
|
+
/* press_button keymap — 0.4.1 */
|
|
156
|
+
/* ----------------------------------------------------------------------- */
|
|
157
|
+
/**
|
|
158
|
+
* Friendly button name → Android `KEYCODE_*`. Names are case-insensitive.
|
|
159
|
+
*
|
|
160
|
+
* The vocabulary mirrors mobile-mcp `mobile_press_button` so prompts that
|
|
161
|
+
* already work against mobile-mcp port over without rewording. The
|
|
162
|
+
* registry exposes `Object.keys(PRESS_BUTTON_KEYMAP)` so the MCP tool's
|
|
163
|
+
* `enum` is auto-derived rather than maintained in two places.
|
|
164
|
+
*/
|
|
165
|
+
export const PRESS_BUTTON_KEYMAP = {
|
|
166
|
+
BACK: "KEYCODE_BACK",
|
|
167
|
+
HOME: "KEYCODE_HOME",
|
|
168
|
+
POWER: "KEYCODE_POWER",
|
|
169
|
+
MENU: "KEYCODE_MENU",
|
|
170
|
+
CAMERA: "KEYCODE_CAMERA",
|
|
171
|
+
ENTER: "KEYCODE_ENTER",
|
|
172
|
+
// Volume + media — Android TV agents rely on these.
|
|
173
|
+
VOLUME_UP: "KEYCODE_VOLUME_UP",
|
|
174
|
+
VOLUME_DOWN: "KEYCODE_VOLUME_DOWN",
|
|
175
|
+
VOLUME_MUTE: "KEYCODE_VOLUME_MUTE",
|
|
176
|
+
MUTE: "KEYCODE_MUTE",
|
|
177
|
+
MEDIA_PLAY_PAUSE: "KEYCODE_MEDIA_PLAY_PAUSE",
|
|
178
|
+
MEDIA_PLAY: "KEYCODE_MEDIA_PLAY",
|
|
179
|
+
MEDIA_PAUSE: "KEYCODE_MEDIA_PAUSE",
|
|
180
|
+
MEDIA_NEXT: "KEYCODE_MEDIA_NEXT",
|
|
181
|
+
MEDIA_PREVIOUS: "KEYCODE_MEDIA_PREVIOUS",
|
|
182
|
+
// DPAD — Android TV / car-head-unit / accessibility navigation.
|
|
183
|
+
DPAD_UP: "KEYCODE_DPAD_UP",
|
|
184
|
+
DPAD_DOWN: "KEYCODE_DPAD_DOWN",
|
|
185
|
+
DPAD_LEFT: "KEYCODE_DPAD_LEFT",
|
|
186
|
+
DPAD_RIGHT: "KEYCODE_DPAD_RIGHT",
|
|
187
|
+
DPAD_CENTER: "KEYCODE_DPAD_CENTER",
|
|
188
|
+
};
|
|
189
|
+
/* ----------------------------------------------------------------------- */
|
|
96
190
|
/* Default spawn-backed runner */
|
|
97
191
|
/* ----------------------------------------------------------------------- */
|
|
98
192
|
function defaultRunner(file, args, opts = {}) {
|
|
@@ -188,6 +282,14 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
188
282
|
screenshotTimeoutMs;
|
|
189
283
|
installTimeoutMs;
|
|
190
284
|
fileTimeoutMs;
|
|
285
|
+
processSpawner;
|
|
286
|
+
recordKillGraceMs;
|
|
287
|
+
recordTimeLimitS;
|
|
288
|
+
recordings = new Map();
|
|
289
|
+
/** Cached `android.software.leanback` system feature — drives `info().isTv`. */
|
|
290
|
+
isLeanback = false;
|
|
291
|
+
/** True after `probeLeanback()` has resolved (success OR failure). */
|
|
292
|
+
leanbackProbed = false;
|
|
191
293
|
constructor(opts = {}) {
|
|
192
294
|
super();
|
|
193
295
|
this.serial = opts.serial;
|
|
@@ -201,6 +303,13 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
201
303
|
opts.screenshotTimeoutMs ?? DEFAULT_SCREENSHOT_TIMEOUT_MS;
|
|
202
304
|
this.installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
|
|
203
305
|
this.fileTimeoutMs = opts.fileTimeoutMs ?? DEFAULT_FILE_TIMEOUT_MS;
|
|
306
|
+
// `spawn` itself is overloaded; wrap it so the resolved overload
|
|
307
|
+
// matches `AdbProcessSpawner`'s `(file, args, opts)` shape.
|
|
308
|
+
this.processSpawner =
|
|
309
|
+
opts.processSpawner ??
|
|
310
|
+
((file, args, spawnOpts) => spawn(file, args, spawnOpts ?? {}));
|
|
311
|
+
this.recordKillGraceMs = opts.recordKillGraceMs ?? 5_000;
|
|
312
|
+
this.recordTimeLimitS = opts.recordTimeLimitS ?? 300;
|
|
204
313
|
}
|
|
205
314
|
async connect() {
|
|
206
315
|
if (this.skipConnectValidation)
|
|
@@ -213,6 +322,31 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
213
322
|
});
|
|
214
323
|
}
|
|
215
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Detect Android TV / leanback so `info().isTv` can hint at LLM
|
|
327
|
+
* prompts that should default to DPAD navigation. Cached for the
|
|
328
|
+
* lifetime of the backend — running `pm has-feature` once per device
|
|
329
|
+
* is enough; the answer doesn't change at runtime. Lazy on purpose —
|
|
330
|
+
* `connect()` with `skipConnectValidation: true` (the registry path)
|
|
331
|
+
* MUST NOT issue any adb commands, so we defer this probe to the
|
|
332
|
+
* first `info()` call.
|
|
333
|
+
*/
|
|
334
|
+
async ensureLeanbackProbed() {
|
|
335
|
+
if (this.leanbackProbed)
|
|
336
|
+
return;
|
|
337
|
+
this.leanbackProbed = true;
|
|
338
|
+
try {
|
|
339
|
+
const { stdout } = await this.adbShell(sh `pm has-feature android.software.leanback`);
|
|
340
|
+
const text = stdout.trim().toLowerCase();
|
|
341
|
+
// Newer Android prints `true` / `false`; older builds emit the
|
|
342
|
+
// raw feature line `feature:android.software.leanback`. Accept
|
|
343
|
+
// either positive shape.
|
|
344
|
+
this.isLeanback = text === "true" || /leanback/.test(text);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
this.isLeanback = false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
216
350
|
async disconnect() {
|
|
217
351
|
// No-op — adb daemon stays alive between commands.
|
|
218
352
|
}
|
|
@@ -220,11 +354,15 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
220
354
|
/* Info / screen */
|
|
221
355
|
/* ------------------------------------------------------------------ */
|
|
222
356
|
async info() {
|
|
357
|
+
// Lazy leanback probe — see comment on `ensureLeanbackProbed`. Run
|
|
358
|
+
// it in parallel with the other info shell-outs so the call adds no
|
|
359
|
+
// latency to the first `info()` invocation.
|
|
223
360
|
const [size, density, propDump, currentApp] = await Promise.all([
|
|
224
361
|
this.runShell("wm size").catch(() => ""),
|
|
225
362
|
this.runShell("wm density").catch(() => ""),
|
|
226
363
|
this.runShell("getprop").catch(() => ""),
|
|
227
364
|
this.getCurrentApp().catch(() => "System Home"),
|
|
365
|
+
this.ensureLeanbackProbed(),
|
|
228
366
|
]);
|
|
229
367
|
const sizeMatch = size.match(/Physical size:\s*(\d+)x(\d+)/i);
|
|
230
368
|
const width = sizeMatch ? Number(sizeMatch[1]) : 1080;
|
|
@@ -266,37 +404,82 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
266
404
|
abi,
|
|
267
405
|
manufacturer,
|
|
268
406
|
brand,
|
|
407
|
+
// 0.4.1: leanback feature is the canonical AOSP marker for TV /
|
|
408
|
+
// car-head-unit form factors. Surface as a string in `metadata`
|
|
409
|
+
// (DeviceInfo.metadata is `Record<string, string>` per
|
|
410
|
+
// common/device-info.ts) so LLM prompts can default to DPAD
|
|
411
|
+
// navigation without parsing extra fields.
|
|
412
|
+
isTv: this.isLeanback ? "true" : "false",
|
|
269
413
|
},
|
|
270
414
|
};
|
|
271
415
|
}
|
|
272
|
-
async screenshot() {
|
|
416
|
+
async screenshot(opts = {}) {
|
|
273
417
|
// Capture the raw screencap PNG. The 0.2.3 default is to forward
|
|
274
418
|
// these bytes to the agent unchanged so `width/height` always match
|
|
275
419
|
// `data`. Callers that need a smaller payload set
|
|
276
420
|
// `GUI_AGENT_SS_MAX_EDGE` to opt back into the legacy resize path.
|
|
277
|
-
|
|
421
|
+
//
|
|
422
|
+
// 0.4.1: per-call overrides. The MCP tool surfaces `maxEdge`,
|
|
423
|
+
// `quality`, and `format` so an LLM running on a 4K device can
|
|
424
|
+
// shrink a single screenshot without restarting the server.
|
|
425
|
+
// - `opts.maxEdge` overrides the env default. `0` means "no
|
|
426
|
+
// resize", matching the env semantics.
|
|
427
|
+
// - `opts.quality` overrides JPEG quality; clamped 1-100 by zod.
|
|
428
|
+
// - `opts.format = "jpeg"` forces re-encode even when the image
|
|
429
|
+
// is already under the resize threshold (for deterministic
|
|
430
|
+
// payload sizing). `"png"` forces raw passthrough.
|
|
431
|
+
const raw = await this.captureRawPng(opts.displayId);
|
|
278
432
|
const [rawW, rawH] = parsePngDimensions(raw);
|
|
279
|
-
|
|
433
|
+
const effectiveMaxEdge = (() => {
|
|
434
|
+
if (opts.maxEdge !== undefined) {
|
|
435
|
+
return opts.maxEdge <= 0 ? Number.POSITIVE_INFINITY : opts.maxEdge;
|
|
436
|
+
}
|
|
437
|
+
return SS_MAX_EDGE;
|
|
438
|
+
})();
|
|
439
|
+
const effectiveQuality = (() => {
|
|
440
|
+
const q = opts.quality;
|
|
441
|
+
if (q !== undefined)
|
|
442
|
+
return Math.max(1, Math.min(100, Math.round(q)));
|
|
443
|
+
return SS_JPEG_QUALITY;
|
|
444
|
+
})();
|
|
445
|
+
const formatHint = opts.format;
|
|
446
|
+
const exceedsCap = Number.isFinite(effectiveMaxEdge) &&
|
|
447
|
+
Math.max(rawW, rawH) > effectiveMaxEdge;
|
|
448
|
+
const wantJpeg = formatHint === "jpeg";
|
|
449
|
+
const wantPngPassthrough = formatHint === "png" && !exceedsCap;
|
|
450
|
+
// Fast path: caller asked for raw PNG (or default behaviour with
|
|
451
|
+
// a small enough capture).
|
|
452
|
+
if (!exceedsCap && !wantJpeg) {
|
|
280
453
|
return { data: raw, width: rawW, height: rawH, format: "png" };
|
|
281
454
|
}
|
|
282
|
-
//
|
|
455
|
+
// Caller explicitly asked for `format: "png"` but the capture is
|
|
456
|
+
// larger than the cap → we still resize, just keep the PNG codec.
|
|
457
|
+
if (wantPngPassthrough) {
|
|
458
|
+
return { data: raw, width: rawW, height: rawH, format: "png" };
|
|
459
|
+
}
|
|
460
|
+
// Resize-on-capture path: re-encode and recompute dimensions
|
|
283
461
|
// so the wire layer reports the actual encoded payload, not the
|
|
284
462
|
// original device-side resolution.
|
|
285
463
|
try {
|
|
286
464
|
const pipeline = sharp(raw, { failOn: "none" });
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
465
|
+
if (Number.isFinite(effectiveMaxEdge)) {
|
|
466
|
+
if (rawW >= rawH)
|
|
467
|
+
pipeline.resize({ width: effectiveMaxEdge });
|
|
468
|
+
else
|
|
469
|
+
pipeline.resize({ height: effectiveMaxEdge });
|
|
470
|
+
}
|
|
471
|
+
const usePng = formatHint === "png";
|
|
472
|
+
const encoded = usePng
|
|
473
|
+
? await pipeline.png().toBuffer()
|
|
474
|
+
: await pipeline
|
|
475
|
+
.jpeg({ quality: effectiveQuality, mozjpeg: true })
|
|
476
|
+
.toBuffer();
|
|
477
|
+
const meta = await sharp(encoded).metadata();
|
|
295
478
|
return {
|
|
296
|
-
data,
|
|
479
|
+
data: encoded,
|
|
297
480
|
width: meta.width ?? rawW,
|
|
298
481
|
height: meta.height ?? rawH,
|
|
299
|
-
format: "jpeg",
|
|
482
|
+
format: usePng ? "png" : "jpeg",
|
|
300
483
|
};
|
|
301
484
|
}
|
|
302
485
|
catch {
|
|
@@ -309,12 +492,50 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
309
492
|
return { w: m ? Number(m[1]) : 1080, h: m ? Number(m[2]) : 1920 };
|
|
310
493
|
}
|
|
311
494
|
async uiDump() {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
495
|
+
// Mobile-mcp parity: real devices occasionally surface
|
|
496
|
+
// `null root node returned by UiTestAutomationBridge` while a
|
|
497
|
+
// fullscreen transition is in flight. Retry up to 10× with a
|
|
498
|
+
// 200 ms cool-off — the window state always settles within a
|
|
499
|
+
// few attempts in practice.
|
|
500
|
+
let lastErr;
|
|
501
|
+
for (let attempt = 0; attempt < UIDUMP_MAX_RETRIES; attempt++) {
|
|
502
|
+
try {
|
|
503
|
+
const { stdout: dumpStdout, stderr: dumpStderr } = await this.adbShell(sh `uiautomator dump /sdcard/ui_dump.xml`);
|
|
504
|
+
// `uiautomator dump` reports flake on stdout AND stderr depending
|
|
505
|
+
// on Android version. Check both before assuming success.
|
|
506
|
+
const dumpBlob = `${dumpStdout}\n${dumpStderr}`;
|
|
507
|
+
if (UIDUMP_RETRY_SUBSTRINGS.some((s) => dumpBlob.includes(s))) {
|
|
508
|
+
await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const { stdout } = await this.adbShell(sh `cat /sdcard/ui_dump.xml`);
|
|
512
|
+
if (!stdout || stdout.includes("ERROR")) {
|
|
513
|
+
// Empty / error output — flake; retry.
|
|
514
|
+
await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (UIDUMP_RETRY_SUBSTRINGS.some((s) => stdout.includes(s))) {
|
|
518
|
+
await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
return { supported: true, content: stdout.trim() };
|
|
522
|
+
}
|
|
523
|
+
catch (e) {
|
|
524
|
+
lastErr = e;
|
|
525
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
526
|
+
if (UIDUMP_RETRY_SUBSTRINGS.some((s) => msg.includes(s))) {
|
|
527
|
+
await this.sleep(UIDUMP_RETRY_INTERVAL_MS);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
// Non-flake error — surface immediately so callers see the
|
|
531
|
+
// real cause (timeout, adb gone, permission, …).
|
|
532
|
+
throw e;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (lastErr) {
|
|
536
|
+
throw lastErr;
|
|
316
537
|
}
|
|
317
|
-
return { supported: true
|
|
538
|
+
return { supported: true };
|
|
318
539
|
}
|
|
319
540
|
/* ------------------------------------------------------------------ */
|
|
320
541
|
/* Pointer / gesture */
|
|
@@ -345,15 +566,18 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
345
566
|
async drag(x1, y1, x2, y2, durationMs) {
|
|
346
567
|
return this.swipe(x1, y1, x2, y2, durationMs);
|
|
347
568
|
}
|
|
348
|
-
async scroll(x, y, direction,
|
|
569
|
+
async scroll(x, y, direction, amount) {
|
|
349
570
|
let { w, h } = { w: 1080, h: 1920 };
|
|
350
571
|
if (!x && !y) {
|
|
351
572
|
({ w, h } = await this.screenSize().catch(() => ({ w: 1080, h: 1920 })));
|
|
352
573
|
x = Math.round(w / 2);
|
|
353
574
|
y = Math.round(h / 2);
|
|
354
575
|
}
|
|
355
|
-
|
|
356
|
-
|
|
576
|
+
// 0.4.1: `amount` is now a real multiplier (mobile-mcp parity). Each
|
|
577
|
+
// unit ≈ 300 px of swipe distance regardless of direction; defaults
|
|
578
|
+
// to 1 when caller omits it. Previous behaviour silently ignored
|
|
579
|
+
// `amount` and shipped a fixed 300 px / 600 px asymmetric distance.
|
|
580
|
+
const dist = Math.max(50, Math.round(300 * (amount ?? 1)));
|
|
357
581
|
const dx = direction === "left" ? dist : direction === "right" ? -dist : 0;
|
|
358
582
|
const dy = direction === "up" ? dist : direction === "down" ? -dist : 0;
|
|
359
583
|
await this.swipe(x, y, x + dx, y + dy);
|
|
@@ -422,7 +646,8 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
422
646
|
/* ------------------------------------------------------------------ */
|
|
423
647
|
/* App lifecycle */
|
|
424
648
|
/* ------------------------------------------------------------------ */
|
|
425
|
-
async launchApp(pkgOrName,
|
|
649
|
+
async launchApp(pkgOrName, opts) {
|
|
650
|
+
const { activity, locale } = normalizeLaunchAppOptions(opts);
|
|
426
651
|
const pkg = looksLikePackage(pkgOrName)
|
|
427
652
|
? pkgOrName
|
|
428
653
|
: (getAppPackage(pkgOrName) ?? pkgOrName);
|
|
@@ -437,15 +662,162 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
437
662
|
const component = activity.includes("/") ? activity : `${pkg}/${activity}`;
|
|
438
663
|
await this.adbShell(sh `am start -n ${component}`);
|
|
439
664
|
}
|
|
665
|
+
else if (BROWSER_PACKAGES.has(pkg)) {
|
|
666
|
+
// Browser apps: 3-level fallback so the launch survives devices where
|
|
667
|
+
// `monkey` flakes (Redroid) or the requested browser isn't installed.
|
|
668
|
+
// 1. VIEW intent → OS picks the default browser (cheapest, most reliable)
|
|
669
|
+
// 2. monkey on the requested pkg (original behaviour)
|
|
670
|
+
// 3. monkey on Chrome as last-resort fallback
|
|
671
|
+
await this.launchBrowserApp(pkg);
|
|
672
|
+
}
|
|
440
673
|
else {
|
|
441
674
|
// No activity hint → fall back to the launcher intent. `monkey` is
|
|
442
675
|
// intentionally used here (not `am start`) because some Redroid
|
|
443
676
|
// images have flaky `am` behaviour for LAUNCHER intents and `monkey`
|
|
444
677
|
// 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`);
|
|
678
|
+
const { stdout } = await this.adbShell(sh `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`);
|
|
679
|
+
// `monkey` exits 0 even when it cannot find a launchable activity —
|
|
680
|
+
// it just prints "** No activities found to run, monkey aborted" to
|
|
681
|
+
// stdout. Without this check the launch silently "succeeds" and the
|
|
682
|
+
// agent waits on a screen that never changed (the TikTok no-launch
|
|
683
|
+
// incident). Surface it as a hard failure with the real reason so the
|
|
684
|
+
// caller learns the package has no LAUNCHER entry / isn't installed.
|
|
685
|
+
if (/Error|No activities found/i.test(stdout)) {
|
|
686
|
+
throw new DeviceError(`launch_app: '${pkg}' has no launchable activity ` +
|
|
687
|
+
`(monkey: ${stdout.trim() || "no output"})`, { subtype: "launch_failed", retriable: false });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// 0.4.1: per-app locale (mobile-mcp parity). Best-effort — Android 12
|
|
691
|
+
// and below do not have `cmd locale`, so a non-zero exit just means
|
|
692
|
+
// "the OS predates per-app locales". We swallow that silently and
|
|
693
|
+
// log so operators can spot the platform mismatch.
|
|
694
|
+
if (locale && locale.length > 0) {
|
|
695
|
+
try {
|
|
696
|
+
await this.adbShell(sh `cmd locale set-app-locales ${pkg} --locales ${locale}`);
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
// best-effort — do NOT fail the launch; Android 12- silently
|
|
700
|
+
// returns a non-zero exit on `cmd locale set-app-locales`.
|
|
701
|
+
// Surface as a debug-level warning so noisy CI doesn't drown
|
|
702
|
+
// every multi-region test run in stderr.
|
|
703
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
704
|
+
// Use the runner-level log at warn so callers can opt-in via
|
|
705
|
+
// LOG_LEVEL=warn — this never blocks the launch.
|
|
706
|
+
// (No structured logger handle here; rely on stderr.)
|
|
707
|
+
process.stderr.write(`[android] cmd locale set-app-locales failed for ${pkg} (${locale}): ${msg}\n`);
|
|
708
|
+
}
|
|
446
709
|
}
|
|
447
710
|
await this.sleep(DELAYS.launch);
|
|
448
711
|
}
|
|
712
|
+
async launchBrowserApp(pkg) {
|
|
713
|
+
const launched = (await this.tryBrowserLaunch(`am start -a android.intent.action.VIEW -d "https://example.com"`)) ||
|
|
714
|
+
(await this.tryBrowserLaunch(sh `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`)) ||
|
|
715
|
+
(await this.tryBrowserLaunch(`monkey -p com.android.chrome -c android.intent.category.LAUNCHER 1`));
|
|
716
|
+
if (!launched) {
|
|
717
|
+
throw new DeviceError(`launch_app: failed to open browser '${pkg}' — all fallbacks exhausted`, { subtype: "launch_failed", retriable: true });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
async tryBrowserLaunch(command) {
|
|
721
|
+
try {
|
|
722
|
+
const { stdout } = await this.adbShell(command);
|
|
723
|
+
return !/Error|No activities found/i.test(stdout);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/* ------------------------------------------------------------------ */
|
|
730
|
+
/* 0.4.0: cross-OS open_url + mobile-mcp parity */
|
|
731
|
+
/* ------------------------------------------------------------------ */
|
|
732
|
+
async openUrl(url) {
|
|
733
|
+
assertSafeUrl(url);
|
|
734
|
+
await this.adbShell(sh `am start -a android.intent.action.VIEW -d ${url}`);
|
|
735
|
+
}
|
|
736
|
+
async listApps(opts = {}) {
|
|
737
|
+
// 0.4.1 (mobile-mcp parity): `launchableOnly: true` filters to apps
|
|
738
|
+
// that declare a `MAIN`/`LAUNCHER` activity — drops 200+ headless
|
|
739
|
+
// system services that pollute `pm list packages`. Implemented via
|
|
740
|
+
// `cmd package query-activities`, which prints one block per
|
|
741
|
+
// matching activity with a `packageName=...` line we dedupe on.
|
|
742
|
+
if (opts.launchableOnly) {
|
|
743
|
+
const { stdout } = await this.adbShell(sh `cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.LAUNCHER`);
|
|
744
|
+
const pkgs = new Map();
|
|
745
|
+
for (const rawLine of stdout.split("\n")) {
|
|
746
|
+
const line = rawLine.trim();
|
|
747
|
+
if (!line.startsWith("packageName="))
|
|
748
|
+
continue;
|
|
749
|
+
const pkg = line.slice("packageName=".length).trim();
|
|
750
|
+
if (!pkg)
|
|
751
|
+
continue;
|
|
752
|
+
// `system` is best-effort here — `query-activities` doesn't
|
|
753
|
+
// distinguish, so we mark unknown as `false` and let
|
|
754
|
+
// `includeSystem` consumers re-query if they actually care.
|
|
755
|
+
if (!pkgs.has(pkg)) {
|
|
756
|
+
pkgs.set(pkg, { packageId: pkg, system: false });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return [...pkgs.values()].sort((a, b) => a.packageId.localeCompare(b.packageId));
|
|
760
|
+
}
|
|
761
|
+
// `pm list packages -3` lists user-installed (third-party) packages.
|
|
762
|
+
// `-s` lists system packages. Default to third-party only — system
|
|
763
|
+
// apps are 200+ entries on Android and rarely actionable.
|
|
764
|
+
const args = opts.includeSystem
|
|
765
|
+
? sh `pm list packages -f`
|
|
766
|
+
: sh `pm list packages -3 -f`;
|
|
767
|
+
const { stdout } = await this.adbShell(args);
|
|
768
|
+
const pkgs = new Map();
|
|
769
|
+
for (const line of stdout.split("\n")) {
|
|
770
|
+
// Format: `package:/data/app/com.foo-1/base.apk=com.foo`
|
|
771
|
+
const m = line.trim().match(/^package:.*=([\w.\-_]+)\s*$/);
|
|
772
|
+
if (m && m[1]) {
|
|
773
|
+
pkgs.set(m[1], { packageId: m[1], system: !!opts.includeSystem });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return [...pkgs.values()].sort((a, b) => a.packageId.localeCompare(b.packageId));
|
|
777
|
+
}
|
|
778
|
+
async terminateApp(pkg) {
|
|
779
|
+
if (!looksLikePackage(pkg)) {
|
|
780
|
+
throw new DeviceError(`terminate_app: '${pkg}' is not a valid Android package id`, { subtype: "invalid_args", retriable: false });
|
|
781
|
+
}
|
|
782
|
+
await this.adbShell(sh `am force-stop ${pkg}`);
|
|
783
|
+
}
|
|
784
|
+
async listElements(opts = {}) {
|
|
785
|
+
const dump = await this.uiDump();
|
|
786
|
+
if (!dump.supported || !dump.content)
|
|
787
|
+
return [];
|
|
788
|
+
const nodes = searchUiXmlNodes(dump.content, opts.query ?? "");
|
|
789
|
+
return nodes.map((n) => {
|
|
790
|
+
const m = n.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
791
|
+
const [x1, y1, x2, y2] = m
|
|
792
|
+
? [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
|
|
793
|
+
: [0, 0, 0, 0];
|
|
794
|
+
return {
|
|
795
|
+
text: n.text || undefined,
|
|
796
|
+
contentDesc: n.contentDesc || undefined,
|
|
797
|
+
resourceId: n.resourceId || undefined,
|
|
798
|
+
className: n.className || undefined,
|
|
799
|
+
center: n.center,
|
|
800
|
+
rect: {
|
|
801
|
+
x: x1,
|
|
802
|
+
y: y1,
|
|
803
|
+
width: Math.max(0, x2 - x1),
|
|
804
|
+
height: Math.max(0, y2 - y1),
|
|
805
|
+
},
|
|
806
|
+
clickable: n.clickable,
|
|
807
|
+
};
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
async getOrientation() {
|
|
811
|
+
const { stdout } = await this.adbShell(sh `settings get system user_rotation`);
|
|
812
|
+
const code = Number.parseInt(stdout.trim(), 10);
|
|
813
|
+
return rotationCodeToOrientation(code);
|
|
814
|
+
}
|
|
815
|
+
async setOrientation(o) {
|
|
816
|
+
const code = orientationToRotationCode(o);
|
|
817
|
+
// Disable accelerometer-driven rotation first so user_rotation sticks.
|
|
818
|
+
await this.adbShell(sh `settings put system accelerometer_rotation 0`);
|
|
819
|
+
await this.adbShell(sh `settings put system user_rotation ${String(code)}`);
|
|
820
|
+
}
|
|
449
821
|
/* ------------------------------------------------------------------ */
|
|
450
822
|
/* Shell / files */
|
|
451
823
|
/* ------------------------------------------------------------------ */
|
|
@@ -525,6 +897,259 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
525
897
|
}
|
|
526
898
|
}
|
|
527
899
|
/* ------------------------------------------------------------------ */
|
|
900
|
+
/* 0.4.1: mobile-mcp parity round 2 */
|
|
901
|
+
/* ------------------------------------------------------------------ */
|
|
902
|
+
async uninstallApp(pkgOrName) {
|
|
903
|
+
const pkg = looksLikePackage(pkgOrName)
|
|
904
|
+
? pkgOrName
|
|
905
|
+
: (getAppPackage(pkgOrName) ?? pkgOrName);
|
|
906
|
+
if (!looksLikePackage(pkg)) {
|
|
907
|
+
throw new DeviceError(`uninstall_app: '${pkgOrName}' is not a valid Android package id`, { subtype: "invalid_args", retriable: false });
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
// `adb uninstall <pkg>` (host-side) is the canonical pair to
|
|
911
|
+
// `adb install -r`. Going through `pm uninstall` over `adb shell`
|
|
912
|
+
// is the alternative but requires UID 0 / root for system packages
|
|
913
|
+
// and silently no-ops without it.
|
|
914
|
+
await this.runner("adb", this.adbArgs(["uninstall", pkg]), {
|
|
915
|
+
encoding: "utf8",
|
|
916
|
+
timeoutMs: this.installTimeoutMs,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
throw wrapAdbFailure("uninstall", ["uninstall", pkg], e, this.installTimeoutMs);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Parse `dumpsys dropbox --print` and return one entry per recorded
|
|
925
|
+
* crash / ANR / WTF. The list is best-effort — the dumpsys output
|
|
926
|
+
* format has shifted slightly across Android releases, so we tolerate
|
|
927
|
+
* unknown lines instead of rejecting them.
|
|
928
|
+
*/
|
|
929
|
+
async listCrashes() {
|
|
930
|
+
const { stdout } = await this.adbShell(sh `dumpsys dropbox --print`);
|
|
931
|
+
return parseDropboxEntries(stdout);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Re-run `dumpsys dropbox --print` and return the body of the entry
|
|
935
|
+
* whose `id` matches. Stateless so the result stays correct even if
|
|
936
|
+
* `listCrashes` was called from a different MCP session.
|
|
937
|
+
*/
|
|
938
|
+
async getCrash(id) {
|
|
939
|
+
if (!id) {
|
|
940
|
+
throw new DeviceError(`get_crash: id is required`, {
|
|
941
|
+
subtype: "invalid_args",
|
|
942
|
+
retriable: false,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
const { stdout } = await this.adbShell(sh `dumpsys dropbox --print`);
|
|
946
|
+
const body = extractDropboxBody(stdout, id);
|
|
947
|
+
if (!body) {
|
|
948
|
+
throw new DeviceError(`get_crash: no crash entry with id '${id}'`, { subtype: "invalid_args", retriable: false });
|
|
949
|
+
}
|
|
950
|
+
return body;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Spawn `adb shell screenrecord` for a single recording. The device
|
|
954
|
+
* enforces a hard `--time-limit` (default 5 min, capped at 300s by
|
|
955
|
+
* Android itself); the host sends SIGINT on `stopScreenRecord` and
|
|
956
|
+
* falls back to SIGKILL after `recordKillGraceMs` if the child
|
|
957
|
+
* hasn't exited.
|
|
958
|
+
*
|
|
959
|
+
* The same-pid `screenrecord` invocation also lets us pipe the output
|
|
960
|
+
* to a `/sdcard/<id>.mp4` so `adb pull` after stop can fetch the
|
|
961
|
+
* exact file without guessing names.
|
|
962
|
+
*
|
|
963
|
+
* `opts.timeLimitS` (mobile-mcp parity, r3): caller-supplied cap,
|
|
964
|
+
* clamped into `[1, 300]` and passed to `screenrecord --time-limit`.
|
|
965
|
+
* `opts.localPath`: caller-supplied host-side destination registered
|
|
966
|
+
* at start time. Already passed through `assertSafeOutputPathWithExt`
|
|
967
|
+
* — backend treats as trusted.
|
|
968
|
+
*/
|
|
969
|
+
async startScreenRecord(opts = {}) {
|
|
970
|
+
const id = `beeos-rec-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
971
|
+
const remotePath = `/sdcard/${id}.mp4`;
|
|
972
|
+
// Clamp into the 1-300s range Android `screenrecord` enforces. We
|
|
973
|
+
// do the clamp inside the backend (not just at the tool boundary)
|
|
974
|
+
// so direct backend callers — tests, future server APIs — get the
|
|
975
|
+
// same safety net.
|
|
976
|
+
const requested = opts.timeLimitS ?? this.recordTimeLimitS;
|
|
977
|
+
const limit = Math.min(300, Math.max(1, Math.floor(requested)));
|
|
978
|
+
const args = this.adbArgs([
|
|
979
|
+
"shell",
|
|
980
|
+
// Keep one single quoted shell command — adb shell joins argv
|
|
981
|
+
// with spaces and re-evaluates through `sh -c`, so multiple
|
|
982
|
+
// tokens here would be silently re-tokenised on the device side.
|
|
983
|
+
sh `screenrecord --time-limit ${limit} ${remotePath}`,
|
|
984
|
+
]);
|
|
985
|
+
const child = this.processSpawner("adb", args, {
|
|
986
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
987
|
+
});
|
|
988
|
+
if (!child.pid) {
|
|
989
|
+
throw new DeviceError(`screen_record_start: failed to spawn adb`, {
|
|
990
|
+
subtype: "environment",
|
|
991
|
+
retriable: true,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
const exitPromise = new Promise((resolve) => {
|
|
995
|
+
// We deliberately resolve on either `close` or `exit` — different
|
|
996
|
+
// stdio shapes on Linux vs macOS sequence them differently.
|
|
997
|
+
const done = () => resolve();
|
|
998
|
+
child.once("close", done);
|
|
999
|
+
child.once("exit", done);
|
|
1000
|
+
child.once("error", done);
|
|
1001
|
+
});
|
|
1002
|
+
const entry = {
|
|
1003
|
+
id,
|
|
1004
|
+
startedAt: Date.now(),
|
|
1005
|
+
remotePath,
|
|
1006
|
+
child,
|
|
1007
|
+
exitPromise,
|
|
1008
|
+
timeLimitS: limit,
|
|
1009
|
+
localPath: opts.localPath,
|
|
1010
|
+
};
|
|
1011
|
+
this.recordings.set(id, entry);
|
|
1012
|
+
// Detach stderr so the listener doesn't keep the process alive
|
|
1013
|
+
// unnecessarily but we still drain it to avoid a back-pressure
|
|
1014
|
+
// deadlock.
|
|
1015
|
+
child.stdout?.resume();
|
|
1016
|
+
child.stderr?.resume();
|
|
1017
|
+
return {
|
|
1018
|
+
id,
|
|
1019
|
+
startedAt: entry.startedAt,
|
|
1020
|
+
timeLimitS: entry.timeLimitS,
|
|
1021
|
+
remotePath: entry.remotePath,
|
|
1022
|
+
...(entry.localPath !== undefined ? { localPath: entry.localPath } : {}),
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
async stopScreenRecord(id, opts = {}) {
|
|
1026
|
+
const entry = this.recordings.get(id);
|
|
1027
|
+
if (!entry) {
|
|
1028
|
+
throw new DeviceError(`screen_record_stop: no recording with id '${id}'`, { subtype: "invalid_args", retriable: false });
|
|
1029
|
+
}
|
|
1030
|
+
this.recordings.delete(id);
|
|
1031
|
+
// Polite stop first — adb shell forwards SIGINT to the device-side
|
|
1032
|
+
// `screenrecord`, which flushes the MP4 trailer. Then escalate to
|
|
1033
|
+
// SIGKILL after a grace period so we never wedge a recording on a
|
|
1034
|
+
// misbehaving device.
|
|
1035
|
+
const tryKill = (sig) => {
|
|
1036
|
+
try {
|
|
1037
|
+
entry.child.kill(sig);
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
/* swallow — child already exited */
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
tryKill("SIGINT");
|
|
1044
|
+
const killTimer = setTimeout(() => tryKill("SIGKILL"), this.recordKillGraceMs);
|
|
1045
|
+
try {
|
|
1046
|
+
await entry.exitPromise;
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
clearTimeout(killTimer);
|
|
1050
|
+
}
|
|
1051
|
+
// Determine the host-side destination. Priority order:
|
|
1052
|
+
// 1. `opts.path` from this stop call (overrides the start-time
|
|
1053
|
+
// registration so a caller can re-target a recording at the
|
|
1054
|
+
// last moment without losing the kill+pull machinery).
|
|
1055
|
+
// 2. `entry.localPath` registered at start time (mobile-mcp
|
|
1056
|
+
// parity, r3) — the start-time path is the canonical mobile-
|
|
1057
|
+
// mcp design and the one we expose in MCP tool descriptions.
|
|
1058
|
+
// 3. Fresh per-recording tmp dir under `os.tmpdir()`.
|
|
1059
|
+
// All caller-supplied paths have already passed through
|
|
1060
|
+
// `assertSafeOutputPathWithExt` at the tool boundary; the backend
|
|
1061
|
+
// does not re-validate.
|
|
1062
|
+
const localPath = opts.path
|
|
1063
|
+
? opts.path
|
|
1064
|
+
: (entry.localPath ??
|
|
1065
|
+
(await (async () => {
|
|
1066
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "beeos-screenrec-"));
|
|
1067
|
+
return path.join(dir, `${id}.mp4`);
|
|
1068
|
+
})()));
|
|
1069
|
+
try {
|
|
1070
|
+
await adbPull(this.runner, this.serial, entry.remotePath, {
|
|
1071
|
+
timeoutMs: this.fileTimeoutMs,
|
|
1072
|
+
}).then(async (buf) => {
|
|
1073
|
+
// adbPull returns the bytes — write them to our chosen path.
|
|
1074
|
+
// We do this in a tiny helper here rather than going through
|
|
1075
|
+
// `adb pull <remote> <local>` directly so callers asking for a
|
|
1076
|
+
// specific destination get the exact file there.
|
|
1077
|
+
const { writeFile: wf } = await import("node:fs/promises");
|
|
1078
|
+
await wf(localPath, buf);
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
catch (e) {
|
|
1082
|
+
// Best-effort cleanup of the device-side artifact even if the
|
|
1083
|
+
// host pull fails — leaving 5-min MP4s on `/sdcard` is rude.
|
|
1084
|
+
this.adbShell(sh `rm -f ${entry.remotePath}`).catch(() => undefined);
|
|
1085
|
+
throw e;
|
|
1086
|
+
}
|
|
1087
|
+
// Best-effort cleanup of the device-side file once we have the
|
|
1088
|
+
// local copy.
|
|
1089
|
+
this.adbShell(sh `rm -f ${entry.remotePath}`).catch(() => undefined);
|
|
1090
|
+
const { stat } = await import("node:fs/promises");
|
|
1091
|
+
const fstat = await stat(localPath);
|
|
1092
|
+
return {
|
|
1093
|
+
path: localPath,
|
|
1094
|
+
bytes: fstat.size,
|
|
1095
|
+
durationMs: Date.now() - entry.startedAt,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Capture a screenshot via `adb pull` of `/sdcard/screenshot.png`.
|
|
1100
|
+
* Bypasses the JSON-RPC body cap by writing the bytes straight to a
|
|
1101
|
+
* host filesystem path the caller chose. Useful for 4K Android
|
|
1102
|
+
* captures where base64-over-HTTP would otherwise blow past 16 MB.
|
|
1103
|
+
*/
|
|
1104
|
+
async saveScreenshot(localPath, opts = {}) {
|
|
1105
|
+
const remote = `/sdcard/beeos-${Date.now()}-${randomUUID().slice(0, 8)}.png`;
|
|
1106
|
+
// `screencap -p` writes to a path; `-d <id>` selects display.
|
|
1107
|
+
const cmd = opts.displayId !== undefined
|
|
1108
|
+
? sh `screencap -p -d ${String(opts.displayId)} ${remote}`
|
|
1109
|
+
: sh `screencap -p ${remote}`;
|
|
1110
|
+
await this.adbShell(cmd);
|
|
1111
|
+
try {
|
|
1112
|
+
const buf = await adbPull(this.runner, this.serial, remote, {
|
|
1113
|
+
timeoutMs: this.fileTimeoutMs,
|
|
1114
|
+
});
|
|
1115
|
+
const { writeFile: wf } = await import("node:fs/promises");
|
|
1116
|
+
await wf(localPath, buf);
|
|
1117
|
+
const [w, h] = parsePngDimensions(buf);
|
|
1118
|
+
return {
|
|
1119
|
+
path: localPath,
|
|
1120
|
+
bytes: buf.length,
|
|
1121
|
+
width: w,
|
|
1122
|
+
height: h,
|
|
1123
|
+
format: "png",
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
finally {
|
|
1127
|
+
this.adbShell(sh `rm -f ${remote}`).catch(() => undefined);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Count the number of displays the device exposes. Reads
|
|
1132
|
+
* `dumpsys SurfaceFlinger --display-id` (Android 10+) and falls back
|
|
1133
|
+
* to "1" on older builds where the flag isn't recognised.
|
|
1134
|
+
*/
|
|
1135
|
+
async getDisplayCount() {
|
|
1136
|
+
try {
|
|
1137
|
+
const { stdout } = await this.adbShell(sh `dumpsys SurfaceFlinger --display-id`);
|
|
1138
|
+
// Output shape (Android 10+):
|
|
1139
|
+
// Display 4630946866250191 (HWC display 0): port=0 ...
|
|
1140
|
+
// Display 4630946866250192 (HWC display 1): port=1 ...
|
|
1141
|
+
const lines = stdout
|
|
1142
|
+
.split("\n")
|
|
1143
|
+
.filter((l) => /^Display\s+\d+/.test(l.trim()));
|
|
1144
|
+
if (lines.length > 0)
|
|
1145
|
+
return lines.length;
|
|
1146
|
+
}
|
|
1147
|
+
catch {
|
|
1148
|
+
/* fall through */
|
|
1149
|
+
}
|
|
1150
|
+
return 1;
|
|
1151
|
+
}
|
|
1152
|
+
/* ------------------------------------------------------------------ */
|
|
528
1153
|
/* Internal helpers */
|
|
529
1154
|
/* ------------------------------------------------------------------ */
|
|
530
1155
|
async getCurrentApp() {
|
|
@@ -560,9 +1185,16 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
560
1185
|
throw wrapAdbFailure("shell", ["shell", command], e, this.shellTimeoutMs);
|
|
561
1186
|
}
|
|
562
1187
|
}
|
|
563
|
-
async captureRawPng() {
|
|
1188
|
+
async captureRawPng(displayId) {
|
|
1189
|
+
// Multi-display capture: `screencap -d <id>` first appears on
|
|
1190
|
+
// Android 10+ (API 29). On older builds the flag is silently
|
|
1191
|
+
// ignored and we capture the primary display — that's still a
|
|
1192
|
+
// sensible fallback so we don't gate this behind an SDK check.
|
|
1193
|
+
const cmd = displayId !== undefined
|
|
1194
|
+
? ["exec-out", "screencap", "-p", "-d", String(displayId)]
|
|
1195
|
+
: ["exec-out", "screencap", "-p"];
|
|
564
1196
|
try {
|
|
565
|
-
const { stdout } = await this.runner("adb", this.adbArgs(
|
|
1197
|
+
const { stdout } = await this.runner("adb", this.adbArgs(cmd), {
|
|
566
1198
|
encoding: "buffer",
|
|
567
1199
|
maxBufferBytes: 32 * 1024 * 1024,
|
|
568
1200
|
timeoutMs: this.screenshotTimeoutMs,
|
|
@@ -570,7 +1202,7 @@ export class AndroidAdbBackend extends BaseSandboxBackend {
|
|
|
570
1202
|
return Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
|
|
571
1203
|
}
|
|
572
1204
|
catch (e) {
|
|
573
|
-
throw wrapAdbFailure("screencap",
|
|
1205
|
+
throw wrapAdbFailure("screencap", cmd, e, this.screenshotTimeoutMs);
|
|
574
1206
|
}
|
|
575
1207
|
}
|
|
576
1208
|
/**
|
|
@@ -639,6 +1271,37 @@ function parseGetpropDump(out) {
|
|
|
639
1271
|
}
|
|
640
1272
|
return map;
|
|
641
1273
|
}
|
|
1274
|
+
function rotationCodeToOrientation(code) {
|
|
1275
|
+
switch (code) {
|
|
1276
|
+
case 0:
|
|
1277
|
+
return "portrait";
|
|
1278
|
+
case 1:
|
|
1279
|
+
return "landscape";
|
|
1280
|
+
case 2:
|
|
1281
|
+
return "reverse-portrait";
|
|
1282
|
+
case 3:
|
|
1283
|
+
return "reverse-landscape";
|
|
1284
|
+
default:
|
|
1285
|
+
return "portrait";
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
function orientationToRotationCode(o) {
|
|
1289
|
+
switch (o) {
|
|
1290
|
+
case "portrait":
|
|
1291
|
+
return 0;
|
|
1292
|
+
case "landscape":
|
|
1293
|
+
return 1;
|
|
1294
|
+
case "reverse-portrait":
|
|
1295
|
+
return 2;
|
|
1296
|
+
case "reverse-landscape":
|
|
1297
|
+
return 3;
|
|
1298
|
+
default:
|
|
1299
|
+
throw new DeviceError(`unknown orientation '${o}'`, {
|
|
1300
|
+
subtype: "invalid_args",
|
|
1301
|
+
retriable: false,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
642
1305
|
/**
|
|
643
1306
|
* Local POSIX-quote helper used ONLY by `executeCommand`'s `cwd` prefix.
|
|
644
1307
|
* The general-purpose `sh\`...\`` template lives in `util/adb-shell.ts`
|
|
@@ -700,18 +1363,36 @@ export function wrapAdbFailure(verb, args, cause, timeoutMs) {
|
|
|
700
1363
|
cause: err,
|
|
701
1364
|
});
|
|
702
1365
|
}
|
|
703
|
-
|
|
1366
|
+
// Observability: the actionable reason often lives in STDOUT, not stderr —
|
|
1367
|
+
// e.g. `monkey` prints "** No activities found to run, monkey aborted" to
|
|
1368
|
+
// stdout (and frequently exits non-zero with only its verbose arg echo on
|
|
1369
|
+
// the wire). Folding both streams + the exit code into the summary means the
|
|
1370
|
+
// agent (and the message-bus error log) sees WHY the command failed instead
|
|
1371
|
+
// of an opaque arg dump.
|
|
1372
|
+
const stderrTxt = err.stderr?.trim() ?? "";
|
|
1373
|
+
const stdoutTxt = typeof err.stdout === "string"
|
|
1374
|
+
? err.stdout.trim()
|
|
1375
|
+
: Buffer.isBuffer(err.stdout)
|
|
1376
|
+
? err.stdout.toString("utf8").trim()
|
|
1377
|
+
: "";
|
|
1378
|
+
const detail = [stderrTxt, stdoutTxt].filter(Boolean).join(" | ");
|
|
1379
|
+
let summary = detail || err.message || "unknown error";
|
|
1380
|
+
if (typeof err.code === "number") {
|
|
1381
|
+
summary = `${summary} (exit ${err.code})`;
|
|
1382
|
+
}
|
|
704
1383
|
const subtype = verb === "shell"
|
|
705
1384
|
? "adb_shell_failed"
|
|
706
1385
|
: verb === "install"
|
|
707
1386
|
? "adb_install_failed"
|
|
708
|
-
: verb === "
|
|
709
|
-
? "
|
|
710
|
-
: verb === "
|
|
711
|
-
? "
|
|
712
|
-
: verb === "
|
|
1387
|
+
: verb === "uninstall"
|
|
1388
|
+
? "adb_uninstall_failed"
|
|
1389
|
+
: verb === "screencap"
|
|
1390
|
+
? "screenshot_failed"
|
|
1391
|
+
: verb === "devices"
|
|
713
1392
|
? "adb_failed"
|
|
714
|
-
: "
|
|
1393
|
+
: verb === "exec-out"
|
|
1394
|
+
? "adb_failed"
|
|
1395
|
+
: "adb_failed";
|
|
715
1396
|
// Reference unused args param for symmetry with previous signature.
|
|
716
1397
|
void args;
|
|
717
1398
|
return new DeviceError(`adb ${verb} failed: ${summary}`, {
|
|
@@ -720,4 +1401,135 @@ export function wrapAdbFailure(verb, args, cause, timeoutMs) {
|
|
|
720
1401
|
cause: err,
|
|
721
1402
|
});
|
|
722
1403
|
}
|
|
1404
|
+
/* ----------------------------------------------------------------------- */
|
|
1405
|
+
/* 0.4.1 helpers — open_url whitelist + dropbox crash parser */
|
|
1406
|
+
/* ----------------------------------------------------------------------- */
|
|
1407
|
+
/**
|
|
1408
|
+
* Default URL scheme allow-list for `open_url`. Mirrors mobile-mcp's
|
|
1409
|
+
* default — `http` / `https` only. Operators who need to launch
|
|
1410
|
+
* `intent://` / `file://` / custom schemes set
|
|
1411
|
+
* `BEEOS_DEVICE_ALLOW_UNSAFE_URLS=1` to opt out (mobile-mcp uses
|
|
1412
|
+
* `MOBILEMCP_ALLOW_UNSAFE_URLS=1`; we accept both for compatibility).
|
|
1413
|
+
*/
|
|
1414
|
+
const SAFE_URL_SCHEMES = new Set(["http", "https"]);
|
|
1415
|
+
export function assertSafeUrl(url) {
|
|
1416
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
|
1417
|
+
throw new DeviceError(`open_url: '${url}' is not a fully-qualified URL`, {
|
|
1418
|
+
subtype: "invalid_args",
|
|
1419
|
+
retriable: false,
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
// Allow opt-out via either env name so a mobile-mcp prompt that sets
|
|
1423
|
+
// the upstream variable keeps working.
|
|
1424
|
+
if (process.env.BEEOS_DEVICE_ALLOW_UNSAFE_URLS === "1" ||
|
|
1425
|
+
process.env.MOBILEMCP_ALLOW_UNSAFE_URLS === "1") {
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const colon = url.indexOf(":");
|
|
1429
|
+
const scheme = url.slice(0, colon).toLowerCase();
|
|
1430
|
+
if (!SAFE_URL_SCHEMES.has(scheme)) {
|
|
1431
|
+
throw new DeviceError(`open_url: scheme '${scheme}' is not in the default allow-list ` +
|
|
1432
|
+
`(http/https). Set BEEOS_DEVICE_ALLOW_UNSAFE_URLS=1 to permit ` +
|
|
1433
|
+
`intent:/file:/content:/custom URLs.`, { subtype: "invalid_args", retriable: false });
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Parse the textual output of `dumpsys dropbox --print` into one
|
|
1438
|
+
* `CrashSummary` per crash / ANR / WTF entry.
|
|
1439
|
+
*
|
|
1440
|
+
* Output shape (Android 7+):
|
|
1441
|
+
*
|
|
1442
|
+
* 2024-08-15 12:34:56 system_app_crash (text, 1234 bytes):
|
|
1443
|
+
* Process: com.foo.bar
|
|
1444
|
+
* ...stack trace lines...
|
|
1445
|
+
* 2024-08-15 12:35:00 data_app_anr (text, 5678 bytes):
|
|
1446
|
+
* ...
|
|
1447
|
+
*
|
|
1448
|
+
* We extract the leading `<timestamp> <tag> (text, N bytes):` row and
|
|
1449
|
+
* include the first non-blank body line as a `headline` for at-a-glance
|
|
1450
|
+
* triage. The id encodes both the tag and the timestamp so `getCrash`
|
|
1451
|
+
* can re-locate the same row on the next `dumpsys` invocation without
|
|
1452
|
+
* holding state.
|
|
1453
|
+
*/
|
|
1454
|
+
export function parseDropboxEntries(out) {
|
|
1455
|
+
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*$/;
|
|
1456
|
+
const TAG_RE = /(crash|anr|wtf|kernel_panic|tombstone)/i;
|
|
1457
|
+
const lines = out.split(/\r?\n/);
|
|
1458
|
+
const entries = [];
|
|
1459
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1460
|
+
const line = lines[i] ?? "";
|
|
1461
|
+
const m = headerRe.exec(line.trim());
|
|
1462
|
+
if (!m)
|
|
1463
|
+
continue;
|
|
1464
|
+
const tag = m[2];
|
|
1465
|
+
if (!TAG_RE.test(tag))
|
|
1466
|
+
continue;
|
|
1467
|
+
const timestamp = m[1];
|
|
1468
|
+
const bytes = m[3] ? Number(m[3]) : undefined;
|
|
1469
|
+
// Pull a one-line headline from the immediate body — usually the
|
|
1470
|
+
// `Process:` row for crashes and `Cmd line:` for ANRs.
|
|
1471
|
+
let headline;
|
|
1472
|
+
let app;
|
|
1473
|
+
for (let j = i + 1; j < Math.min(lines.length, i + 8); j++) {
|
|
1474
|
+
const body = (lines[j] ?? "").trim();
|
|
1475
|
+
if (!body)
|
|
1476
|
+
continue;
|
|
1477
|
+
if (headerRe.test(body))
|
|
1478
|
+
break;
|
|
1479
|
+
if (!headline)
|
|
1480
|
+
headline = body.length > 240 ? body.slice(0, 240) + "…" : body;
|
|
1481
|
+
const procMatch = /^(?:Process|Cmd ?line):\s*(\S+)/.exec(body);
|
|
1482
|
+
if (procMatch) {
|
|
1483
|
+
app = procMatch[1].split(/[\s,]/)[0];
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const type = TAG_RE.exec(tag)?.[1]?.toLowerCase() ?? "crash";
|
|
1488
|
+
entries.push({
|
|
1489
|
+
id: `${tag}:${timestamp}`,
|
|
1490
|
+
type,
|
|
1491
|
+
timestamp,
|
|
1492
|
+
app,
|
|
1493
|
+
headline,
|
|
1494
|
+
bytes,
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
// Newest first — dumpsys output is already chronological so we reverse.
|
|
1498
|
+
return entries.reverse();
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Pull the body of a single dumpsys-dropbox entry out of the `--print`
|
|
1502
|
+
* blob, indexed by `<tag>:<timestamp>` (the same id `parseDropboxEntries`
|
|
1503
|
+
* returns). The body is everything between the matching header and the
|
|
1504
|
+
* next entry header.
|
|
1505
|
+
*/
|
|
1506
|
+
export function extractDropboxBody(out, id) {
|
|
1507
|
+
const colon = id.indexOf(":");
|
|
1508
|
+
if (colon < 0)
|
|
1509
|
+
return undefined;
|
|
1510
|
+
const tag = id.slice(0, colon);
|
|
1511
|
+
const timestamp = id.slice(colon + 1);
|
|
1512
|
+
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*$/;
|
|
1513
|
+
const lines = out.split(/\r?\n/);
|
|
1514
|
+
let startIdx = -1;
|
|
1515
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1516
|
+
const m = headerRe.exec(lines[i]?.trim() ?? "");
|
|
1517
|
+
if (!m)
|
|
1518
|
+
continue;
|
|
1519
|
+
if (m[1] === timestamp && m[2] === tag) {
|
|
1520
|
+
startIdx = i + 1;
|
|
1521
|
+
break;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (startIdx === -1)
|
|
1525
|
+
return undefined;
|
|
1526
|
+
let endIdx = lines.length;
|
|
1527
|
+
for (let j = startIdx; j < lines.length; j++) {
|
|
1528
|
+
if (headerRe.test(lines[j]?.trim() ?? "")) {
|
|
1529
|
+
endIdx = j;
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return lines.slice(startIdx, endIdx).join("\n").trim();
|
|
1534
|
+
}
|
|
723
1535
|
//# sourceMappingURL=android-adb.js.map
|