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