@beeos-ai/device-mcp-server 0.3.0 → 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.
- package/dist/backends/android-adb.d.ts +147 -6
- package/dist/backends/android-adb.js +776 -40
- 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/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,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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
356
|
-
|
|
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,
|
|
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(
|
|
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",
|
|
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 === "
|
|
709
|
-
? "
|
|
710
|
-
: verb === "
|
|
711
|
-
? "
|
|
712
|
-
: verb === "
|
|
1311
|
+
: verb === "uninstall"
|
|
1312
|
+
? "adb_uninstall_failed"
|
|
1313
|
+
: verb === "screencap"
|
|
1314
|
+
? "screenshot_failed"
|
|
1315
|
+
: verb === "devices"
|
|
713
1316
|
? "adb_failed"
|
|
714
|
-
: "
|
|
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
|