@metamask/device-mcp 0.3.2 → 0.4.0
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/CHANGELOG.md +18 -1
- package/README.md +110 -21
- package/dist/android/NOTICE.md +65 -0
- package/dist/android/device-mcp-android-snapshot-helper-0.4.0.apk +0 -0
- package/dist/android/device-mcp-android-snapshot-helper-0.4.0.apk.idsig +0 -0
- package/dist/android/device-mcp-android-snapshot-helper-0.4.0.manifest.json +10 -0
- package/dist/backends/adb-backend.cjs +326 -17
- package/dist/backends/adb-backend.cjs.map +1 -1
- package/dist/backends/adb-backend.d.cts +1 -0
- package/dist/backends/adb-backend.d.cts.map +1 -1
- package/dist/backends/adb-backend.d.mts +1 -0
- package/dist/backends/adb-backend.d.mts.map +1 -1
- package/dist/backends/adb-backend.mjs +327 -18
- package/dist/backends/adb-backend.mjs.map +1 -1
- package/dist/backends/android-instrumentation/adb.cjs +28 -0
- package/dist/backends/android-instrumentation/adb.cjs.map +1 -0
- package/dist/backends/android-instrumentation/adb.d.cts +24 -0
- package/dist/backends/android-instrumentation/adb.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/adb.d.mts +24 -0
- package/dist/backends/android-instrumentation/adb.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/adb.mjs +25 -0
- package/dist/backends/android-instrumentation/adb.mjs.map +1 -0
- package/dist/backends/android-instrumentation/artifact.cjs +139 -0
- package/dist/backends/android-instrumentation/artifact.cjs.map +1 -0
- package/dist/backends/android-instrumentation/artifact.d.cts +28 -0
- package/dist/backends/android-instrumentation/artifact.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/artifact.d.mts +28 -0
- package/dist/backends/android-instrumentation/artifact.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/artifact.mjs +154 -0
- package/dist/backends/android-instrumentation/artifact.mjs.map +1 -0
- package/dist/backends/android-instrumentation/errors.cjs +79 -0
- package/dist/backends/android-instrumentation/errors.cjs.map +1 -0
- package/dist/backends/android-instrumentation/errors.d.cts +68 -0
- package/dist/backends/android-instrumentation/errors.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/errors.d.mts +68 -0
- package/dist/backends/android-instrumentation/errors.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/errors.mjs +73 -0
- package/dist/backends/android-instrumentation/errors.mjs.map +1 -0
- package/dist/backends/android-instrumentation/installer.cjs +235 -0
- package/dist/backends/android-instrumentation/installer.cjs.map +1 -0
- package/dist/backends/android-instrumentation/installer.d.cts +123 -0
- package/dist/backends/android-instrumentation/installer.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/installer.d.mts +123 -0
- package/dist/backends/android-instrumentation/installer.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/installer.mjs +225 -0
- package/dist/backends/android-instrumentation/installer.mjs.map +1 -0
- package/dist/backends/android-instrumentation/signer.cjs +435 -0
- package/dist/backends/android-instrumentation/signer.cjs.map +1 -0
- package/dist/backends/android-instrumentation/signer.d.cts +40 -0
- package/dist/backends/android-instrumentation/signer.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/signer.d.mts +40 -0
- package/dist/backends/android-instrumentation/signer.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/signer.mjs +431 -0
- package/dist/backends/android-instrumentation/signer.mjs.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.cjs +159 -0
- package/dist/backends/android-instrumentation/snapshot.cjs.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.d.cts +67 -0
- package/dist/backends/android-instrumentation/snapshot.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.d.mts +67 -0
- package/dist/backends/android-instrumentation/snapshot.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.mjs +154 -0
- package/dist/backends/android-instrumentation/snapshot.mjs.map +1 -0
- package/dist/backends/appium-backend.cjs +19 -1
- package/dist/backends/appium-backend.cjs.map +1 -1
- package/dist/backends/appium-backend.d.cts +1 -0
- package/dist/backends/appium-backend.d.cts.map +1 -1
- package/dist/backends/appium-backend.d.mts +1 -0
- package/dist/backends/appium-backend.d.mts.map +1 -1
- package/dist/backends/appium-backend.mjs +19 -1
- package/dist/backends/appium-backend.mjs.map +1 -1
- package/dist/backends/idb-backend.cjs +128 -31
- package/dist/backends/idb-backend.cjs.map +1 -1
- package/dist/backends/idb-backend.d.cts +1 -0
- package/dist/backends/idb-backend.d.cts.map +1 -1
- package/dist/backends/idb-backend.d.mts +1 -0
- package/dist/backends/idb-backend.d.mts.map +1 -1
- package/dist/backends/idb-backend.mjs +129 -32
- package/dist/backends/idb-backend.mjs.map +1 -1
- package/dist/backends/index.cjs +3 -0
- package/dist/backends/index.cjs.map +1 -1
- package/dist/backends/index.d.cts.map +1 -1
- package/dist/backends/index.d.mts.map +1 -1
- package/dist/backends/index.mjs +3 -0
- package/dist/backends/index.mjs.map +1 -1
- package/dist/backends/types.cjs.map +1 -1
- package/dist/backends/types.d.cts +6 -0
- package/dist/backends/types.d.cts.map +1 -1
- package/dist/backends/types.d.mts +6 -0
- package/dist/backends/types.d.mts.map +1 -1
- package/dist/backends/types.mjs.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/tools/swipe.cjs +3 -1
- package/dist/tools/swipe.cjs.map +1 -1
- package/dist/tools/swipe.d.cts.map +1 -1
- package/dist/tools/swipe.d.mts.map +1 -1
- package/dist/tools/swipe.mjs +3 -1
- package/dist/tools/swipe.mjs.map +1 -1
- package/dist/utils/output-path.cjs +13 -0
- package/dist/utils/output-path.cjs.map +1 -1
- package/dist/utils/output-path.d.cts +10 -0
- package/dist/utils/output-path.d.cts.map +1 -1
- package/dist/utils/output-path.d.mts +10 -0
- package/dist/utils/output-path.d.mts.map +1 -1
- package/dist/utils/output-path.mjs +12 -0
- package/dist/utils/output-path.mjs.map +1 -1
- package/dist/utils/platform.cjs +65 -7
- package/dist/utils/platform.cjs.map +1 -1
- package/dist/utils/platform.d.cts +33 -0
- package/dist/utils/platform.d.cts.map +1 -1
- package/dist/utils/platform.d.mts +33 -0
- package/dist/utils/platform.d.mts.map +1 -1
- package/dist/utils/platform.mjs +63 -7
- package/dist/utils/platform.mjs.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- fix: make idb gestures scroll and report correct device geometry ([#27](https://github.com/MetaMask/device-mcp/pull/27))
|
|
15
|
+
- fix: make simulator element extraction work across iOS 17-26 ([#25](https://github.com/MetaMask/device-mcp/pull/25))
|
|
16
|
+
- fix: code scanner error for java files ([#26](https://github.com/MetaMask/device-mcp/pull/26))
|
|
17
|
+
- fix: UI automator staleness issue ([#23](https://github.com/MetaMask/device-mcp/pull/23))
|
|
18
|
+
|
|
19
|
+
## [0.3.3]
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- feat: add public `kind` discriminator to DeviceBackend ([#21](https://github.com/MetaMask/device-mcp/pull/21))
|
|
24
|
+
|
|
10
25
|
## [0.3.2]
|
|
11
26
|
|
|
12
27
|
### Changed
|
|
@@ -55,7 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
55
70
|
- `.device-session` file for attaching to existing Appium sessions or creating new ones
|
|
56
71
|
- 16 MCP tools: `device_snapshot`, `device_screenshot`, `device_info`, `device_tap_element`, `device_tap_coordinates`, `device_type`, `device_swipe`, `device_long_press`, `device_wait_for`, `device_app_state`, `device_open_app`, `device_close_app`, `device_press_button`, `device_dismiss_keyboard`, `device_dismiss_alert`, `device_logs`
|
|
57
72
|
|
|
58
|
-
[Unreleased]: https://github.com/MetaMask/device-mcp/compare/v0.
|
|
73
|
+
[Unreleased]: https://github.com/MetaMask/device-mcp/compare/v0.4.0...HEAD
|
|
74
|
+
[0.4.0]: https://github.com/MetaMask/device-mcp/compare/v0.3.3...v0.4.0
|
|
75
|
+
[0.3.3]: https://github.com/MetaMask/device-mcp/compare/v0.3.2...v0.3.3
|
|
59
76
|
[0.3.2]: https://github.com/MetaMask/device-mcp/compare/v0.3.1...v0.3.2
|
|
60
77
|
[0.3.1]: https://github.com/MetaMask/device-mcp/compare/v0.3.0...v0.3.1
|
|
61
78
|
[0.3.0]: https://github.com/MetaMask/device-mcp/compare/v0.2.0...v0.3.0
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Provides device interaction tools for LLM agents to inspect UI state, interact w
|
|
|
15
15
|
## Requirements
|
|
16
16
|
|
|
17
17
|
- **Node.js** `^20 || ^22 || >=24`
|
|
18
|
-
- **iOS local**: Xcode Command Line Tools (for `xcrun simctl`) + [IDB](https://fbidb.io/) for UI interaction (`brew tap facebook/fb && brew install idb
|
|
18
|
+
- **iOS local**: Xcode Command Line Tools (for `xcrun simctl`) + [IDB](https://fbidb.io/) for UI interaction. Install the unified client (`brew tap facebook/fb && brew install idb`), which bundles the CLI and simulator companion. On iOS 17+ simulators, element extraction requires this modern `idb` (it uses `idb ui describe-all --api axbridge`); the legacy python `fb-idb` client does not support `--api` and will not read the UI hierarchy on newer runtimes.
|
|
19
19
|
- **Android local**: ADB (Android SDK platform-tools) — auto-discovered from `$ANDROID_HOME`, `$ANDROID_SDK_ROOT`, or `~/Library/Android/sdk`
|
|
20
20
|
- **Remote/BrowserStack**: No local tools needed — connects via Appium W3C WebDriver HTTP
|
|
21
21
|
- **Hermes CDP** (debugging the React Native JS runtime): uses the global `WebSocket` API. Node 22+ works out of the box; **Node 20 requires launching with `NODE_OPTIONS="--experimental-websocket"`**.
|
|
@@ -174,26 +174,115 @@ Elements are identified by accessibility attributes — not internal refs. Match
|
|
|
174
174
|
|
|
175
175
|
### Backend Implementation
|
|
176
176
|
|
|
177
|
-
| Tool | iOS (IDB) | Android (ADB)
|
|
178
|
-
| ------------------------- | ------------------------------------ |
|
|
179
|
-
| `device_snapshot` | `idb ui describe-all` | `uiautomator dump`
|
|
180
|
-
| `device_screenshot` | `idb screenshot` | `screencap` + `pull`
|
|
181
|
-
| `device_info` | `idb describe` | `getprop`
|
|
182
|
-
| `device_tap_element` | find + `idb ui tap` | find + `input tap`
|
|
183
|
-
| `device_tap_coordinates` | `idb ui tap x y` | `input tap x y`
|
|
184
|
-
| `device_type` | `idb ui text` | `input text`
|
|
185
|
-
| `device_swipe` | `idb ui swipe` | `input swipe`
|
|
186
|
-
| `device_long_press` | `idb ui tap --duration` | `input swipe` (hold)
|
|
187
|
-
| `device_wait_for` | poll snapshot | poll snapshot
|
|
188
|
-
| `device_list_devices` | `xcrun simctl list` | `adb devices`
|
|
189
|
-
| `device_select_device` | select by UDID | select by serial
|
|
190
|
-
| `device_app_state` | `idb list-apps` / `simctl listapps` | `dumpsys activity`
|
|
191
|
-
| `device_open_app` | `idb launch` / `simctl launch` | `monkey -p`
|
|
192
|
-
| `device_close_app` | `idb terminate` / `simctl terminate` | `am force-stop`
|
|
193
|
-
| `device_press_button` | `idb ui key` | `input keyevent`
|
|
194
|
-
| `device_dismiss_keyboard` | `idb ui key RETURN` | `input keyevent 111`
|
|
195
|
-
| `device_dismiss_alert` | find button + tap | find button + tap
|
|
196
|
-
| `device_logs` | `idb log` | `logcat`
|
|
177
|
+
| Tool | iOS (IDB) | Android (ADB) | Appium (W3C WebDriver) |
|
|
178
|
+
| ------------------------- | ------------------------------------ | ------------------------------------------- | ----------------------------- |
|
|
179
|
+
| `device_snapshot` | `idb ui describe-all` | `uiautomator dump` + instrumentation helper | `mobile: source` |
|
|
180
|
+
| `device_screenshot` | `idb screenshot` | `screencap` + `pull` | `mobile: getScreenshot` |
|
|
181
|
+
| `device_info` | `idb describe` | `getprop` | session capabilities |
|
|
182
|
+
| `device_tap_element` | find + `idb ui tap` | find + `input tap` | find + W3C Actions |
|
|
183
|
+
| `device_tap_coordinates` | `idb ui tap x y` | `input tap x y` | W3C Actions |
|
|
184
|
+
| `device_type` | `idb ui text` | `input text` | `findElement` + `sendKeys` |
|
|
185
|
+
| `device_swipe` | `idb ui swipe` | `input swipe` | W3C Actions |
|
|
186
|
+
| `device_long_press` | `idb ui tap --duration` | `input swipe` (hold) | W3C Actions (pause) |
|
|
187
|
+
| `device_wait_for` | poll snapshot | poll snapshot | poll snapshot |
|
|
188
|
+
| `device_list_devices` | `xcrun simctl list` | `adb devices` | N/A |
|
|
189
|
+
| `device_select_device` | select by UDID | select by serial | N/A |
|
|
190
|
+
| `device_app_state` | `idb list-apps` / `simctl listapps` | `dumpsys activity` | `mobile: queryAppState` |
|
|
191
|
+
| `device_open_app` | `idb launch` / `simctl launch` | `monkey -p` | `mobile: activateApp` |
|
|
192
|
+
| `device_close_app` | `idb terminate` / `simctl terminate` | `am force-stop` | `mobile: terminateApp` |
|
|
193
|
+
| `device_press_button` | `idb ui key` | `input keyevent` | `mobile: pressButton/Key` |
|
|
194
|
+
| `device_dismiss_keyboard` | `idb ui key RETURN` | `input keyevent 111` | `mobile: hideKeyboard` |
|
|
195
|
+
| `device_dismiss_alert` | find button + tap | find button + tap | `mobile: accept/dismissAlert` |
|
|
196
|
+
| `device_logs` | `idb log` | `logcat` | `mobile: getLog` |
|
|
197
|
+
|
|
198
|
+
#### Android snapshot on continuously-redrawing screens
|
|
199
|
+
|
|
200
|
+
`uiautomator dump` calls `UiAutomation.waitForIdle` internally, which never
|
|
201
|
+
returns on a screen that emits a continuous accessibility-event stream (for
|
|
202
|
+
example a React Native screen with polling or an animating skeleton loader). On
|
|
203
|
+
those screens the stock dump fails with `ERROR: could not get idle state.`
|
|
204
|
+
|
|
205
|
+
To handle this, the ADB backend ships a small self-instrumenting helper APK
|
|
206
|
+
(`dist/android/device-mcp-android-snapshot-helper-*.apk`, built during
|
|
207
|
+
`prepack`). The helper captures the accessibility hierarchy **without** waiting
|
|
208
|
+
for idle: it skips `waitForIdle` and instead retries a cheap capture until the
|
|
209
|
+
foreground window's root is present, then streams the hierarchy back over
|
|
210
|
+
`am instrument` as chunked base64. It is installed on demand the first time it
|
|
211
|
+
is needed and reused across snapshots in the session.
|
|
212
|
+
|
|
213
|
+
The strategy is controlled by `DEVICE_MCP_ADB_SNAPSHOT`:
|
|
214
|
+
|
|
215
|
+
- **`auto`** (default) — a single fast `uiautomator dump` first (wins instantly
|
|
216
|
+
on idle screens), then the instrumentation helper if that dump does not
|
|
217
|
+
produce a hierarchy, then the remaining dump retries as a last resort.
|
|
218
|
+
- **`instrument`** — use the instrumentation helper only.
|
|
219
|
+
- **`dump`** — use stock `uiautomator dump` only (the original behavior; no APK
|
|
220
|
+
is installed).
|
|
221
|
+
|
|
222
|
+
The helper is a `testOnly` APK installed with `adb install -t`, so it only
|
|
223
|
+
installs on developer emulators/attached devices, never on locked-down or
|
|
224
|
+
managed profiles.
|
|
225
|
+
|
|
226
|
+
##### Trust: the helper's signing certificate is verified before use
|
|
227
|
+
|
|
228
|
+
Package names and version codes are attacker-controlled metadata, so before the
|
|
229
|
+
backend runs `am instrument` it verifies that the installed helper is actually
|
|
230
|
+
**ours**. It pulls the installed APK(s) and cryptographically verifies their APK
|
|
231
|
+
Signature Scheme v2/v3 signature (a pure-JS check — no Android SDK required at
|
|
232
|
+
runtime), then compares the signer certificate SHA-256 against the value pinned
|
|
233
|
+
in the bundled build manifest. If a different app is squatting the helper's
|
|
234
|
+
package name, the signer will not match: the snapshot **fails closed** with a
|
|
235
|
+
trust error and does **not** silently fall back to `uiautomator dump`. Generic,
|
|
236
|
+
non-trust failures (a churny screen, an `am` hiccup) still fall back to dump in
|
|
237
|
+
`auto` mode. Use `DEVICE_MCP_ADB_SNAPSHOT=instrument` for a fully fail-closed
|
|
238
|
+
mode, or `DEVICE_MCP_ADB_SNAPSHOT=dump` to skip the helper entirely.
|
|
239
|
+
|
|
240
|
+
##### Building the Android snapshot helper locally
|
|
241
|
+
|
|
242
|
+
The helper APK is normally built for you by `prepack` and (for releases) in CI.
|
|
243
|
+
You only need this if you are developing the helper or want the helper-based
|
|
244
|
+
snapshot path to work against a local build.
|
|
245
|
+
|
|
246
|
+
Requirements: JDK 17, Android SDK with `platforms/android-36` and
|
|
247
|
+
`build-tools;36.0.0` (auto-discovered from `$ANDROID_HOME` / `$ANDROID_SDK_ROOT`
|
|
248
|
+
/ `~/Library/Android/sdk`).
|
|
249
|
+
|
|
250
|
+
The helper is signed with a shared key so the on-device trust check accepts your
|
|
251
|
+
local build. Obtain the keystore from the team vault, place it **outside** the
|
|
252
|
+
repo, point the build at it, and build:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
export DEVICE_MCP_HELPER_KEYSTORE=~/.device-mcp/helper.keystore
|
|
256
|
+
export DEVICE_MCP_HELPER_KEYSTORE_PASSWORD=<from vault>
|
|
257
|
+
export DEVICE_MCP_HELPER_KEY_ALIAS=device-mcp-helper
|
|
258
|
+
yarn build:android-helper
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The APK and its `.manifest.json` land in `dist/android/`. The MCP server
|
|
262
|
+
installs the APK on demand and verifies its signing certificate before use.
|
|
263
|
+
|
|
264
|
+
**Don't have the keystore?** You can still develop everything except the
|
|
265
|
+
helper-signed path: the build script auto-generates a throwaway key when
|
|
266
|
+
`DEVICE_MCP_HELPER_KEYSTORE` is unset. A throwaway-signed APK will **not** match
|
|
267
|
+
the pinned signer, so the helper path rejects it by design — run snapshots via
|
|
268
|
+
`DEVICE_MCP_ADB_SNAPSHOT=dump`.
|
|
269
|
+
|
|
270
|
+
> Never commit a keystore. `*.keystore`, `*.jks`, `*.p12`, and `~/.device-mcp/`
|
|
271
|
+
> are gitignored.
|
|
272
|
+
|
|
273
|
+
##### Maintainers: one-time signing key generation
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
keytool -genkeypair -v -keystore device-mcp-helper.keystore \
|
|
277
|
+
-alias device-mcp-helper -keyalg RSA -keysize 2048 -validity 10000 \
|
|
278
|
+
-dname "CN=Device MCP Snapshot Helper, OU=device-mcp, O=MetaMask, C=US"
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Store the keystore in the team vault for developers. For CI, add repository
|
|
282
|
+
secrets `ANDROID_HELPER_KEYSTORE_B64` (`base64 -i device-mcp-helper.keystore`),
|
|
283
|
+
`ANDROID_HELPER_KEYSTORE_PASSWORD`, and `ANDROID_HELPER_KEY_ALIAS`. Then pin the
|
|
284
|
+
certificate SHA-256 that the build emits (`signerSha256` in the manifest) as the
|
|
285
|
+
runtime trust anchor.
|
|
197
286
|
|
|
198
287
|
## Hermes CDP
|
|
199
288
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## agent-device (Callstack) — MIT
|
|
4
|
+
|
|
5
|
+
Portions of the Android snapshot-helper host-side code are derived from the
|
|
6
|
+
`agent-device` project by Callstack.
|
|
7
|
+
|
|
8
|
+
- Upstream: https://github.com/callstackincubator/agent-device
|
|
9
|
+
- License: MIT — Copyright (c) 2026 Callstack
|
|
10
|
+
- Full license text: see the end of this file.
|
|
11
|
+
|
|
12
|
+
### What is derived
|
|
13
|
+
|
|
14
|
+
The structure and approach of the following host-side TypeScript modules were
|
|
15
|
+
adapted from `agent-device`:
|
|
16
|
+
|
|
17
|
+
- `src/backends/android-instrumentation/artifact.ts` — manifest parsing and
|
|
18
|
+
artifact SHA-256 verification.
|
|
19
|
+
- `src/backends/android-instrumentation/errors.ts` — the `SnapshotHelperError`
|
|
20
|
+
shape (the `UntrustedHelperError` trust class is our addition).
|
|
21
|
+
- `src/backends/android-instrumentation/adb.ts` — the device-scoped ADB
|
|
22
|
+
executor abstraction.
|
|
23
|
+
- `src/backends/android-instrumentation/installer.ts` — the versionCode-based
|
|
24
|
+
install policy and signer-verification install flow.
|
|
25
|
+
|
|
26
|
+
### What is NOT derived
|
|
27
|
+
|
|
28
|
+
The following are original to this project and are not taken from
|
|
29
|
+
`agent-device`:
|
|
30
|
+
|
|
31
|
+
- The instrumentation helper APK and its Java source
|
|
32
|
+
(`android/snapshot-helper/`), including the `foregroundWindowObserved`
|
|
33
|
+
capture fix. This project builds its own APK from source; it does not
|
|
34
|
+
redistribute Callstack's prebuilt binary or use their package name.
|
|
35
|
+
- `src/backends/android-instrumentation/signer.ts` — a pure-JS APK Signature
|
|
36
|
+
Scheme v2/v3 signature verifier implemented from the public AOSP
|
|
37
|
+
specification.
|
|
38
|
+
- `src/backends/android-instrumentation/snapshot.ts` — the chunked
|
|
39
|
+
instrumentation output parser.
|
|
40
|
+
|
|
41
|
+
### agent-device MIT license
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
MIT License
|
|
45
|
+
|
|
46
|
+
Copyright (c) 2026 Callstack
|
|
47
|
+
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
49
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
50
|
+
in the Software without restriction, including without limitation the rights
|
|
51
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
52
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
53
|
+
furnished to do so, subject to the following conditions:
|
|
54
|
+
|
|
55
|
+
The above copyright notice and this permission notice shall be included in all
|
|
56
|
+
copies or substantial portions of the Software.
|
|
57
|
+
|
|
58
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
59
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
60
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
61
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
62
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
63
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
64
|
+
SOFTWARE.
|
|
65
|
+
```
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.4.0",
|
|
3
|
+
"versionCode": 4000,
|
|
4
|
+
"packageName": "io.metamask.devicemcp.snapshothelper",
|
|
5
|
+
"instrumentationRunner": "io.metamask.devicemcp.snapshothelper/.SnapshotInstrumentation",
|
|
6
|
+
"assetName": "device-mcp-android-snapshot-helper-0.4.0.apk",
|
|
7
|
+
"sha256": "2cda32e70114c978ba9aafec74679d3ae9b137d7fd5f4312475858a380bf180e",
|
|
8
|
+
"signerSha256": "cefcaea256de",
|
|
9
|
+
"minSdk": 23
|
|
10
|
+
}
|
|
@@ -10,25 +10,124 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
10
10
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
11
11
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
12
12
|
};
|
|
13
|
-
var _AdbBackend_instances, _AdbBackend_serial, _AdbBackend_recordingProcess, _AdbBackend_recordingPath, _AdbBackend_recordingRemotePath, _AdbBackend_adb, _AdbBackend_dumpUiHierarchy;
|
|
13
|
+
var _AdbBackend_instances, _AdbBackend_serial, _AdbBackend_recordingProcess, _AdbBackend_recordingPath, _AdbBackend_recordingRemotePath, _AdbBackend_helperInstalled, _AdbBackend_captureQueue, _AdbBackend_adb, _AdbBackend_serializeCapture, _AdbBackend_dumpUiHierarchy, _AdbBackend_dumpViaInstrumentation, _AdbBackend_runInstrumentation, _AdbBackend_tryDumpViaUiautomator, _AdbBackend_dumpViaUiautomator, _AdbBackend_composeDumpFailure;
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.AdbBackend = void 0;
|
|
16
16
|
exports.parseAndroidHierarchy = parseAndroidHierarchy;
|
|
17
17
|
exports.parseNodeAttributes = parseNodeAttributes;
|
|
18
18
|
const node_child_process_1 = require("node:child_process");
|
|
19
|
+
const node_crypto_1 = require("node:crypto");
|
|
19
20
|
const promises_1 = require("node:fs/promises");
|
|
21
|
+
const errors_js_1 = require("./android-instrumentation/errors.cjs");
|
|
22
|
+
const installer_js_1 = require("./android-instrumentation/installer.cjs");
|
|
23
|
+
const snapshot_js_1 = require("./android-instrumentation/snapshot.cjs");
|
|
20
24
|
const alert_labels_js_1 = require("../utils/alert-labels.cjs");
|
|
21
25
|
const element_js_1 = require("../utils/element.cjs");
|
|
22
26
|
const exec_js_1 = require("../utils/exec.cjs");
|
|
23
27
|
const output_path_js_1 = require("../utils/output-path.cjs");
|
|
28
|
+
// `uiautomator dump` exits 0 and prints its success banner even when it fails, so
|
|
29
|
+
// the XML payload is the only trustworthy signal.
|
|
30
|
+
const DUMP_ATTEMPTS = 3;
|
|
31
|
+
const DUMP_RETRY_DELAY_MS = 750;
|
|
32
|
+
// Healthy dumps take ~150ms-2s, and even a failing one returns in ~2s.
|
|
33
|
+
const DUMP_TIMEOUT_MS = 15000;
|
|
34
|
+
// Caps all attempts plus backoff, so worst case does not scale with retries.
|
|
35
|
+
const DUMP_DEADLINE_MS = 25000;
|
|
36
|
+
// Writable by the shell user without scoped-storage restrictions.
|
|
37
|
+
const DUMP_REMOTE_DIR = '/data/local/tmp';
|
|
38
|
+
// The instrumentation helper skips uiautomator's idle wait, so it must be
|
|
39
|
+
// bounded by its own timeout rather than relying on "eventually idle".
|
|
40
|
+
const INSTRUMENT_TIMEOUT_MS = 8000;
|
|
41
|
+
// `am instrument` streams the whole hierarchy as base64 status records; a deep
|
|
42
|
+
// tree can produce a few hundred KB, so raise the exec buffer above the default
|
|
43
|
+
// 10MB guard to keep an overflow from masquerading as a helper crash.
|
|
44
|
+
const INSTRUMENT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
45
|
+
// In `auto` mode, try a single fast uiautomator dump first (it wins instantly
|
|
46
|
+
// on idle screens) before paying for the instrumentation path.
|
|
47
|
+
const AUTO_PROBE_DUMP_ATTEMPTS = 1;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the snapshot mode from the environment.
|
|
50
|
+
*
|
|
51
|
+
* @returns The configured mode, defaulting to `auto` for unset/unknown values.
|
|
52
|
+
*/
|
|
53
|
+
function resolveSnapshotMode() {
|
|
54
|
+
const raw = process.env.DEVICE_MCP_ADB_SNAPSHOT?.trim().toLowerCase();
|
|
55
|
+
if (raw === 'instrument' || raw === 'dump' || raw === 'auto') {
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
return 'auto';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Check that a dump produced complete hierarchy XML.
|
|
62
|
+
*
|
|
63
|
+
* `<hierarchy` alone sits at the start of the file, so requiring the closing tag
|
|
64
|
+
* too is what rejects a truncated write before it reaches the tolerant regex
|
|
65
|
+
* parser. An empty screen still emits a complete root, keeping "no elements"
|
|
66
|
+
* distinguishable from a failed capture.
|
|
67
|
+
*
|
|
68
|
+
* @param xml - Raw stdout captured from the dumped file.
|
|
69
|
+
* @returns True when the payload is complete hierarchy XML.
|
|
70
|
+
*/
|
|
71
|
+
function isValidHierarchyXml(xml) {
|
|
72
|
+
return xml.includes('<hierarchy') && xml.includes('</hierarchy>');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build a single-line diagnostic from a dump's output streams.
|
|
76
|
+
*
|
|
77
|
+
* @param stderr - Standard error captured from the dump.
|
|
78
|
+
* @param stdout - Standard output captured from the dump.
|
|
79
|
+
* @returns A description of why the dump was rejected.
|
|
80
|
+
*/
|
|
81
|
+
function summarizeDumpOutput(stderr, stdout) {
|
|
82
|
+
const detail = [stderr, stdout]
|
|
83
|
+
.map((stream) => stream.trim())
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join(' | ');
|
|
86
|
+
return detail
|
|
87
|
+
? `uiautomator produced no usable hierarchy (${detail})`
|
|
88
|
+
: 'uiautomator produced no usable hierarchy';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Pick remediation advice that matches how the dump actually failed, so an
|
|
92
|
+
* unreachable device is not misreported as an animation problem.
|
|
93
|
+
*
|
|
94
|
+
* @param failures - The collected per-attempt failure details.
|
|
95
|
+
* @returns Actionable guidance for the caller.
|
|
96
|
+
*/
|
|
97
|
+
function describeDumpRemediation(failures) {
|
|
98
|
+
const detail = failures.toLowerCase();
|
|
99
|
+
if (detail.includes('not found') || detail.includes('device offline')) {
|
|
100
|
+
return ('The device was unreachable during the dump. Confirm it is still connected ' +
|
|
101
|
+
'with `adb devices` and that the serial is correct.');
|
|
102
|
+
}
|
|
103
|
+
return ('The most common cause is that uiautomator never observed an idle window ' +
|
|
104
|
+
'("ERROR: could not get idle state."), which means something on screen is ' +
|
|
105
|
+
'animating or redrawing continuously.\n' +
|
|
106
|
+
'Things to check:\n' +
|
|
107
|
+
' - Disable animations: adb shell settings put global window_animation_scale 0 ' +
|
|
108
|
+
'(also transition_animation_scale and animator_duration_scale).\n' +
|
|
109
|
+
' - Animation scales do NOT stop JS-driven React Native animations ' +
|
|
110
|
+
'(Animated.loop with useNativeDriver: false), indeterminate ProgressBars, ' +
|
|
111
|
+
'shimmer/skeleton loaders, or video/SurfaceView playback. Look for one of those ' +
|
|
112
|
+
'on the current screen and use device_screenshot to confirm what is on it.');
|
|
113
|
+
}
|
|
24
114
|
class AdbBackend {
|
|
25
115
|
constructor(serial) {
|
|
26
116
|
_AdbBackend_instances.add(this);
|
|
117
|
+
this.kind = 'adb';
|
|
27
118
|
this.platform = 'android';
|
|
28
119
|
_AdbBackend_serial.set(this, void 0);
|
|
29
120
|
_AdbBackend_recordingProcess.set(this, null);
|
|
30
121
|
_AdbBackend_recordingPath.set(this, null);
|
|
31
122
|
_AdbBackend_recordingRemotePath.set(this, '/sdcard/device-mcp-recording.mp4');
|
|
123
|
+
// Optimistic per-process cache: once the helper is confirmed installed we
|
|
124
|
+
// skip the `pm list packages` probe on every snapshot. A mid-session
|
|
125
|
+
// uninstall/reboot is recovered reactively when `am instrument` reports the
|
|
126
|
+
// instrumentation is missing.
|
|
127
|
+
_AdbBackend_helperInstalled.set(this, false);
|
|
128
|
+
// Serializes snapshot captures so overlapping `am instrument` runs (e.g. a
|
|
129
|
+
// tap that triggers a nested snapshot) do not race on the single helper.
|
|
130
|
+
_AdbBackend_captureQueue.set(this, Promise.resolve());
|
|
32
131
|
__classPrivateFieldSet(this, _AdbBackend_serial, serial, "f");
|
|
33
132
|
}
|
|
34
133
|
async ensureConnected() {
|
|
@@ -55,7 +154,7 @@ class AdbBackend {
|
|
|
55
154
|
};
|
|
56
155
|
}
|
|
57
156
|
async snapshot() {
|
|
58
|
-
const raw = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_dumpUiHierarchy).call(this);
|
|
157
|
+
const raw = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_serializeCapture).call(this, async () => __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_dumpUiHierarchy).call(this));
|
|
59
158
|
const hierarchy = parseAndroidHierarchy(raw);
|
|
60
159
|
return {
|
|
61
160
|
platform: 'android',
|
|
@@ -162,15 +261,22 @@ class AdbBackend {
|
|
|
162
261
|
}
|
|
163
262
|
async screenshot(outputPath, options) {
|
|
164
263
|
const localPath = (0, output_path_js_1.resolveArtifactPath)(outputPath, 'screenshot');
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
'
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
264
|
+
// Unique per capture so a failed screencap can never be pulled as a stale
|
|
265
|
+
// image from an earlier call.
|
|
266
|
+
const remotePath = `${DUMP_REMOTE_DIR}/device-mcp-screenshot-${(0, node_crypto_1.randomUUID)()}.png`;
|
|
267
|
+
try {
|
|
268
|
+
await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'screencap', '-p', remotePath]);
|
|
269
|
+
await (0, exec_js_1.execStrict)('adb', [
|
|
270
|
+
'-s',
|
|
271
|
+
__classPrivateFieldGet(this, _AdbBackend_serial, "f"),
|
|
272
|
+
'pull',
|
|
273
|
+
remotePath,
|
|
274
|
+
localPath,
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'rm', '-f', remotePath]).catch(() => undefined);
|
|
279
|
+
}
|
|
174
280
|
(0, output_path_js_1.hardenArtifactFile)(localPath);
|
|
175
281
|
if (options?.encode === false) {
|
|
176
282
|
return { data: undefined, format: 'png', path: localPath };
|
|
@@ -361,14 +467,217 @@ class AdbBackend {
|
|
|
361
467
|
}
|
|
362
468
|
}
|
|
363
469
|
exports.AdbBackend = AdbBackend;
|
|
364
|
-
_AdbBackend_serial = new WeakMap(), _AdbBackend_recordingProcess = new WeakMap(), _AdbBackend_recordingPath = new WeakMap(), _AdbBackend_recordingRemotePath = new WeakMap(), _AdbBackend_instances = new WeakSet(), _AdbBackend_adb = async function _AdbBackend_adb(args) {
|
|
470
|
+
_AdbBackend_serial = new WeakMap(), _AdbBackend_recordingProcess = new WeakMap(), _AdbBackend_recordingPath = new WeakMap(), _AdbBackend_recordingRemotePath = new WeakMap(), _AdbBackend_helperInstalled = new WeakMap(), _AdbBackend_captureQueue = new WeakMap(), _AdbBackend_instances = new WeakSet(), _AdbBackend_adb = async function _AdbBackend_adb(args) {
|
|
365
471
|
return (0, exec_js_1.execStrict)('adb', ['-s', __classPrivateFieldGet(this, _AdbBackend_serial, "f"), ...args]);
|
|
472
|
+
}, _AdbBackend_serializeCapture =
|
|
473
|
+
/**
|
|
474
|
+
* Run a capture after any in-flight capture completes, so concurrent
|
|
475
|
+
* snapshots never overlap an `am instrument` run on the shared helper.
|
|
476
|
+
*
|
|
477
|
+
* @param capture - The capture operation to serialize.
|
|
478
|
+
* @returns The capture result.
|
|
479
|
+
*/
|
|
480
|
+
async function _AdbBackend_serializeCapture(capture) {
|
|
481
|
+
const run = __classPrivateFieldGet(this, _AdbBackend_captureQueue, "f").then(capture, capture);
|
|
482
|
+
__classPrivateFieldSet(this, _AdbBackend_captureQueue, run.then(() => undefined, () => undefined), "f");
|
|
483
|
+
return run;
|
|
366
484
|
}, _AdbBackend_dumpUiHierarchy = async function _AdbBackend_dumpUiHierarchy() {
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
485
|
+
const mode = resolveSnapshotMode();
|
|
486
|
+
const failures = [];
|
|
487
|
+
const deadline = Date.now() + DUMP_DEADLINE_MS;
|
|
488
|
+
if (mode === 'dump') {
|
|
489
|
+
const xml = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_tryDumpViaUiautomator).call(this, deadline, DUMP_ATTEMPTS, failures);
|
|
490
|
+
if (xml !== null) {
|
|
491
|
+
return xml;
|
|
492
|
+
}
|
|
493
|
+
throw new Error(__classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_composeDumpFailure).call(this, failures));
|
|
494
|
+
}
|
|
495
|
+
if (mode === 'instrument') {
|
|
496
|
+
const xml = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_dumpViaInstrumentation).call(this, deadline, failures);
|
|
497
|
+
if (xml !== null) {
|
|
498
|
+
return xml;
|
|
499
|
+
}
|
|
500
|
+
throw new Error(__classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_composeDumpFailure).call(this, failures));
|
|
501
|
+
}
|
|
502
|
+
// auto: a single fast dump wins instantly on idle screens; otherwise the
|
|
503
|
+
// instrumentation helper handles churn; the remaining dump retries are the
|
|
504
|
+
// last resort so a helper-side problem never leaves us worse than before.
|
|
505
|
+
const quick = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_tryDumpViaUiautomator).call(this, deadline, AUTO_PROBE_DUMP_ATTEMPTS, failures);
|
|
506
|
+
if (quick !== null) {
|
|
507
|
+
return quick;
|
|
508
|
+
}
|
|
509
|
+
const instrumented = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_dumpViaInstrumentation).call(this, deadline, failures);
|
|
510
|
+
if (instrumented !== null) {
|
|
511
|
+
return instrumented;
|
|
512
|
+
}
|
|
513
|
+
const fallback = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_tryDumpViaUiautomator).call(this, deadline, DUMP_ATTEMPTS, failures);
|
|
514
|
+
if (fallback !== null) {
|
|
515
|
+
return fallback;
|
|
516
|
+
}
|
|
517
|
+
throw new Error(__classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_composeDumpFailure).call(this, failures));
|
|
518
|
+
}, _AdbBackend_dumpViaInstrumentation =
|
|
519
|
+
/**
|
|
520
|
+
* Capture the hierarchy with the instrumentation helper, installing it first
|
|
521
|
+
* when needed.
|
|
522
|
+
*
|
|
523
|
+
* @param deadline - Absolute time (ms) the overall snapshot must finish by.
|
|
524
|
+
* @param failures - Accumulator for diagnostic messages across dump paths.
|
|
525
|
+
* @returns The hierarchy XML, or null when the helper path could not produce
|
|
526
|
+
* a valid capture (the caller then falls back to uiautomator dump).
|
|
527
|
+
*/
|
|
528
|
+
async function _AdbBackend_dumpViaInstrumentation(deadline, failures) {
|
|
529
|
+
try {
|
|
530
|
+
const xml = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_runInstrumentation).call(this, deadline);
|
|
531
|
+
if (isValidHierarchyXml(xml)) {
|
|
532
|
+
return xml;
|
|
533
|
+
}
|
|
534
|
+
failures.push('instrument: output was not complete hierarchy XML');
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
// A signer-trust failure means a possibly-malicious app squats the
|
|
538
|
+
// helper's package name. Fail closed: propagate, never fall back to dump.
|
|
539
|
+
if ((0, errors_js_1.isUntrustedHelperError)(error)) {
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
543
|
+
// A mid-session uninstall/reboot invalidates the optimistic cache. Retry
|
|
544
|
+
// once through the ensure-install path before giving up on this path.
|
|
545
|
+
if (__classPrivateFieldGet(this, _AdbBackend_helperInstalled, "f") &&
|
|
546
|
+
message.includes(installer_js_1.INSTRUMENTATION_NOT_FOUND_SIGNATURE)) {
|
|
547
|
+
__classPrivateFieldSet(this, _AdbBackend_helperInstalled, false, "f");
|
|
548
|
+
try {
|
|
549
|
+
const xml = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_runInstrumentation).call(this, deadline);
|
|
550
|
+
if (isValidHierarchyXml(xml)) {
|
|
551
|
+
return xml;
|
|
552
|
+
}
|
|
553
|
+
failures.push('instrument: output was not complete hierarchy XML after reinstall');
|
|
554
|
+
}
|
|
555
|
+
catch (retryError) {
|
|
556
|
+
if ((0, errors_js_1.isUntrustedHelperError)(retryError)) {
|
|
557
|
+
throw retryError;
|
|
558
|
+
}
|
|
559
|
+
failures.push(`instrument: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
failures.push(`instrument: ${message}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}, _AdbBackend_runInstrumentation =
|
|
568
|
+
/**
|
|
569
|
+
* Ensure the helper is installed, then run it and reassemble its output.
|
|
570
|
+
*
|
|
571
|
+
* @param deadline - Absolute time (ms) the overall snapshot must finish by.
|
|
572
|
+
* @returns The reassembled hierarchy XML.
|
|
573
|
+
*/
|
|
574
|
+
async function _AdbBackend_runInstrumentation(deadline) {
|
|
575
|
+
if (!__classPrivateFieldGet(this, _AdbBackend_helperInstalled, "f")) {
|
|
576
|
+
// Install/upgrade the helper by versionCode. Safe to cache per process:
|
|
577
|
+
// the install is idempotent and NOT a trust decision.
|
|
578
|
+
await (0, installer_js_1.ensureHelperInstalled)(__classPrivateFieldGet(this, _AdbBackend_serial, "f"));
|
|
579
|
+
__classPrivateFieldSet(this, _AdbBackend_helperInstalled, true, "f");
|
|
580
|
+
}
|
|
581
|
+
// Trust invariant: re-verify the installed signer BEFORE EVERY am instrument,
|
|
582
|
+
// never cached. Package name / versionCode are attacker-controlled, so an
|
|
583
|
+
// actor who can install packages could swap a previously-trusted helper for
|
|
584
|
+
// a malicious same-package instrumentation between snapshots. Caching this as
|
|
585
|
+
// a boolean would degrade the signature pin to a one-time check; verifying
|
|
586
|
+
// per use (pm path + pull + hash) keeps it a per-use invariant. Throws
|
|
587
|
+
// UntrustedHelperError on a signer mismatch (caller fails closed).
|
|
588
|
+
await (0, installer_js_1.assertInstalledHelperTrusted)(__classPrivateFieldGet(this, _AdbBackend_serial, "f"));
|
|
589
|
+
const remainingMs = deadline - Date.now();
|
|
590
|
+
const timeoutMs = Math.max(1000, Math.min(INSTRUMENT_TIMEOUT_MS, remainingMs));
|
|
591
|
+
const { stdout } = await (0, exec_js_1.exec)('adb', [
|
|
592
|
+
'-s',
|
|
593
|
+
__classPrivateFieldGet(this, _AdbBackend_serial, "f"),
|
|
594
|
+
'shell',
|
|
595
|
+
'am',
|
|
596
|
+
'instrument',
|
|
597
|
+
'-w',
|
|
598
|
+
'-e',
|
|
599
|
+
'waitForIdleTimeoutMs',
|
|
600
|
+
'0',
|
|
601
|
+
'-e',
|
|
602
|
+
'timeoutMs',
|
|
603
|
+
String(timeoutMs),
|
|
604
|
+
snapshot_js_1.HELPER_INSTRUMENTATION,
|
|
605
|
+
],
|
|
606
|
+
// Bound the exec above the helper's own timeout, and raise the buffer so
|
|
607
|
+
// a large tree is not misreported as a crash.
|
|
608
|
+
{ timeoutMs: timeoutMs + 5000, maxBuffer: INSTRUMENT_MAX_BUFFER });
|
|
609
|
+
return (0, snapshot_js_1.reassembleInstrumentationXml)(stdout);
|
|
610
|
+
}, _AdbBackend_tryDumpViaUiautomator =
|
|
611
|
+
/**
|
|
612
|
+
* Run the uiautomator dump retry loop, swallowing failure into null.
|
|
613
|
+
*
|
|
614
|
+
* @param deadline - Absolute time (ms) the overall snapshot must finish by.
|
|
615
|
+
* @param maxAttempts - How many dump attempts to make.
|
|
616
|
+
* @param failures - Accumulator for diagnostic messages across dump paths.
|
|
617
|
+
* @returns The hierarchy XML, or null when every attempt failed.
|
|
618
|
+
*/
|
|
619
|
+
async function _AdbBackend_tryDumpViaUiautomator(deadline, maxAttempts, failures) {
|
|
620
|
+
try {
|
|
621
|
+
return await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_dumpViaUiautomator).call(this, deadline, maxAttempts, failures);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
// Failures are already recorded in the accumulator.
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
}, _AdbBackend_dumpViaUiautomator =
|
|
628
|
+
/**
|
|
629
|
+
* The stock `uiautomator dump` capture with bounded retries.
|
|
630
|
+
*
|
|
631
|
+
* `uiautomator dump` exits 0 and prints its success banner even when it fails,
|
|
632
|
+
* so the XML payload is the only trustworthy signal.
|
|
633
|
+
*
|
|
634
|
+
* @param deadline - Absolute time (ms) the overall snapshot must finish by.
|
|
635
|
+
* @param maxAttempts - How many dump attempts to make.
|
|
636
|
+
* @param failures - Accumulator for diagnostic messages across dump paths.
|
|
637
|
+
* @returns The hierarchy XML.
|
|
638
|
+
* @throws If every attempt failed to produce complete hierarchy XML.
|
|
639
|
+
*/
|
|
640
|
+
async function _AdbBackend_dumpViaUiautomator(deadline, maxAttempts, failures) {
|
|
641
|
+
const startFailures = failures.length;
|
|
642
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
643
|
+
const remainingMs = deadline - Date.now();
|
|
644
|
+
if (remainingMs <= 0) {
|
|
645
|
+
failures.push(`dump attempt ${attempt}: skipped, ${DUMP_DEADLINE_MS}ms budget exhausted`);
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
// Unique per attempt: on a shared path a failed dump leaves the previous
|
|
649
|
+
// file behind, so `cat` silently returns an earlier screen's hierarchy.
|
|
650
|
+
const remotePath = `${DUMP_REMOTE_DIR}/device-mcp-dump-${(0, node_crypto_1.randomUUID)()}.xml`;
|
|
651
|
+
try {
|
|
652
|
+
const dump = await (0, exec_js_1.exec)('adb', ['-s', __classPrivateFieldGet(this, _AdbBackend_serial, "f"), 'shell', 'uiautomator', 'dump', remotePath],
|
|
653
|
+
// Never outlive the overall deadline.
|
|
654
|
+
{ timeoutMs: Math.min(DUMP_TIMEOUT_MS, remainingMs) });
|
|
655
|
+
const xml = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'cat', remotePath]);
|
|
656
|
+
if (isValidHierarchyXml(xml)) {
|
|
657
|
+
return xml;
|
|
658
|
+
}
|
|
659
|
+
failures.push(`dump attempt ${attempt}: ${summarizeDumpOutput(dump.stderr, dump.stdout)}`);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
failures.push(`dump attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`);
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
// Never let cleanup mask the dump outcome.
|
|
666
|
+
await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'rm', '-f', remotePath]).catch(() => undefined);
|
|
667
|
+
}
|
|
668
|
+
const backoffMs = DUMP_RETRY_DELAY_MS * attempt;
|
|
669
|
+
if (attempt < maxAttempts && Date.now() + backoffMs < deadline) {
|
|
670
|
+
await new Promise((resolve) => {
|
|
671
|
+
setTimeout(resolve, backoffMs);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const attemptCount = failures.length - startFailures;
|
|
676
|
+
throw new Error(`uiautomator failed to capture the UI hierarchy after ${attemptCount} attempts.`);
|
|
677
|
+
}, _AdbBackend_composeDumpFailure = function _AdbBackend_composeDumpFailure(failures) {
|
|
678
|
+
const detail = failures.join('\n');
|
|
679
|
+
const remediation = describeDumpRemediation(detail);
|
|
680
|
+
return `Failed to capture the UI hierarchy.\n${detail}\n${remediation}`;
|
|
372
681
|
};
|
|
373
682
|
function collectAndroidAlertTexts(elements) {
|
|
374
683
|
const texts = [];
|