@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +110 -21
  3. package/dist/android/NOTICE.md +65 -0
  4. package/dist/android/device-mcp-android-snapshot-helper-0.4.0.apk +0 -0
  5. package/dist/android/device-mcp-android-snapshot-helper-0.4.0.apk.idsig +0 -0
  6. package/dist/android/device-mcp-android-snapshot-helper-0.4.0.manifest.json +10 -0
  7. package/dist/backends/adb-backend.cjs +326 -17
  8. package/dist/backends/adb-backend.cjs.map +1 -1
  9. package/dist/backends/adb-backend.d.cts +1 -0
  10. package/dist/backends/adb-backend.d.cts.map +1 -1
  11. package/dist/backends/adb-backend.d.mts +1 -0
  12. package/dist/backends/adb-backend.d.mts.map +1 -1
  13. package/dist/backends/adb-backend.mjs +327 -18
  14. package/dist/backends/adb-backend.mjs.map +1 -1
  15. package/dist/backends/android-instrumentation/adb.cjs +28 -0
  16. package/dist/backends/android-instrumentation/adb.cjs.map +1 -0
  17. package/dist/backends/android-instrumentation/adb.d.cts +24 -0
  18. package/dist/backends/android-instrumentation/adb.d.cts.map +1 -0
  19. package/dist/backends/android-instrumentation/adb.d.mts +24 -0
  20. package/dist/backends/android-instrumentation/adb.d.mts.map +1 -0
  21. package/dist/backends/android-instrumentation/adb.mjs +25 -0
  22. package/dist/backends/android-instrumentation/adb.mjs.map +1 -0
  23. package/dist/backends/android-instrumentation/artifact.cjs +139 -0
  24. package/dist/backends/android-instrumentation/artifact.cjs.map +1 -0
  25. package/dist/backends/android-instrumentation/artifact.d.cts +28 -0
  26. package/dist/backends/android-instrumentation/artifact.d.cts.map +1 -0
  27. package/dist/backends/android-instrumentation/artifact.d.mts +28 -0
  28. package/dist/backends/android-instrumentation/artifact.d.mts.map +1 -0
  29. package/dist/backends/android-instrumentation/artifact.mjs +154 -0
  30. package/dist/backends/android-instrumentation/artifact.mjs.map +1 -0
  31. package/dist/backends/android-instrumentation/errors.cjs +79 -0
  32. package/dist/backends/android-instrumentation/errors.cjs.map +1 -0
  33. package/dist/backends/android-instrumentation/errors.d.cts +68 -0
  34. package/dist/backends/android-instrumentation/errors.d.cts.map +1 -0
  35. package/dist/backends/android-instrumentation/errors.d.mts +68 -0
  36. package/dist/backends/android-instrumentation/errors.d.mts.map +1 -0
  37. package/dist/backends/android-instrumentation/errors.mjs +73 -0
  38. package/dist/backends/android-instrumentation/errors.mjs.map +1 -0
  39. package/dist/backends/android-instrumentation/installer.cjs +235 -0
  40. package/dist/backends/android-instrumentation/installer.cjs.map +1 -0
  41. package/dist/backends/android-instrumentation/installer.d.cts +123 -0
  42. package/dist/backends/android-instrumentation/installer.d.cts.map +1 -0
  43. package/dist/backends/android-instrumentation/installer.d.mts +123 -0
  44. package/dist/backends/android-instrumentation/installer.d.mts.map +1 -0
  45. package/dist/backends/android-instrumentation/installer.mjs +225 -0
  46. package/dist/backends/android-instrumentation/installer.mjs.map +1 -0
  47. package/dist/backends/android-instrumentation/signer.cjs +435 -0
  48. package/dist/backends/android-instrumentation/signer.cjs.map +1 -0
  49. package/dist/backends/android-instrumentation/signer.d.cts +40 -0
  50. package/dist/backends/android-instrumentation/signer.d.cts.map +1 -0
  51. package/dist/backends/android-instrumentation/signer.d.mts +40 -0
  52. package/dist/backends/android-instrumentation/signer.d.mts.map +1 -0
  53. package/dist/backends/android-instrumentation/signer.mjs +431 -0
  54. package/dist/backends/android-instrumentation/signer.mjs.map +1 -0
  55. package/dist/backends/android-instrumentation/snapshot.cjs +159 -0
  56. package/dist/backends/android-instrumentation/snapshot.cjs.map +1 -0
  57. package/dist/backends/android-instrumentation/snapshot.d.cts +67 -0
  58. package/dist/backends/android-instrumentation/snapshot.d.cts.map +1 -0
  59. package/dist/backends/android-instrumentation/snapshot.d.mts +67 -0
  60. package/dist/backends/android-instrumentation/snapshot.d.mts.map +1 -0
  61. package/dist/backends/android-instrumentation/snapshot.mjs +154 -0
  62. package/dist/backends/android-instrumentation/snapshot.mjs.map +1 -0
  63. package/dist/backends/appium-backend.cjs +19 -1
  64. package/dist/backends/appium-backend.cjs.map +1 -1
  65. package/dist/backends/appium-backend.d.cts +1 -0
  66. package/dist/backends/appium-backend.d.cts.map +1 -1
  67. package/dist/backends/appium-backend.d.mts +1 -0
  68. package/dist/backends/appium-backend.d.mts.map +1 -1
  69. package/dist/backends/appium-backend.mjs +19 -1
  70. package/dist/backends/appium-backend.mjs.map +1 -1
  71. package/dist/backends/idb-backend.cjs +128 -31
  72. package/dist/backends/idb-backend.cjs.map +1 -1
  73. package/dist/backends/idb-backend.d.cts +1 -0
  74. package/dist/backends/idb-backend.d.cts.map +1 -1
  75. package/dist/backends/idb-backend.d.mts +1 -0
  76. package/dist/backends/idb-backend.d.mts.map +1 -1
  77. package/dist/backends/idb-backend.mjs +129 -32
  78. package/dist/backends/idb-backend.mjs.map +1 -1
  79. package/dist/backends/index.cjs +3 -0
  80. package/dist/backends/index.cjs.map +1 -1
  81. package/dist/backends/index.d.cts.map +1 -1
  82. package/dist/backends/index.d.mts.map +1 -1
  83. package/dist/backends/index.mjs +3 -0
  84. package/dist/backends/index.mjs.map +1 -1
  85. package/dist/backends/types.cjs.map +1 -1
  86. package/dist/backends/types.d.cts +6 -0
  87. package/dist/backends/types.d.cts.map +1 -1
  88. package/dist/backends/types.d.mts +6 -0
  89. package/dist/backends/types.d.mts.map +1 -1
  90. package/dist/backends/types.mjs.map +1 -1
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.d.cts +1 -1
  93. package/dist/index.d.cts.map +1 -1
  94. package/dist/index.d.mts +1 -1
  95. package/dist/index.d.mts.map +1 -1
  96. package/dist/index.mjs.map +1 -1
  97. package/dist/tools/swipe.cjs +3 -1
  98. package/dist/tools/swipe.cjs.map +1 -1
  99. package/dist/tools/swipe.d.cts.map +1 -1
  100. package/dist/tools/swipe.d.mts.map +1 -1
  101. package/dist/tools/swipe.mjs +3 -1
  102. package/dist/tools/swipe.mjs.map +1 -1
  103. package/dist/utils/output-path.cjs +13 -0
  104. package/dist/utils/output-path.cjs.map +1 -1
  105. package/dist/utils/output-path.d.cts +10 -0
  106. package/dist/utils/output-path.d.cts.map +1 -1
  107. package/dist/utils/output-path.d.mts +10 -0
  108. package/dist/utils/output-path.d.mts.map +1 -1
  109. package/dist/utils/output-path.mjs +12 -0
  110. package/dist/utils/output-path.mjs.map +1 -1
  111. package/dist/utils/platform.cjs +65 -7
  112. package/dist/utils/platform.cjs.map +1 -1
  113. package/dist/utils/platform.d.cts +33 -0
  114. package/dist/utils/platform.d.cts.map +1 -1
  115. package/dist/utils/platform.d.mts +33 -0
  116. package/dist/utils/platform.d.mts.map +1 -1
  117. package/dist/utils/platform.mjs +63 -7
  118. package/dist/utils/platform.mjs.map +1 -1
  119. 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.3.2...HEAD
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-companion && pip3 install fb-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) | Appium (W3C WebDriver) |
178
- | ------------------------- | ------------------------------------ | -------------------- | ----------------------------- |
179
- | `device_snapshot` | `idb ui describe-all` | `uiautomator dump` | `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` |
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
+ ```
@@ -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
- await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'screencap', '-p', '/sdcard/screenshot.png']);
166
- await (0, exec_js_1.execStrict)('adb', [
167
- '-s',
168
- __classPrivateFieldGet(this, _AdbBackend_serial, "f"),
169
- 'pull',
170
- '/sdcard/screenshot.png',
171
- localPath,
172
- ]);
173
- await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'rm', '-f', '/sdcard/screenshot.png']);
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 remotePath = '/sdcard/window_dump.xml';
368
- await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'uiautomator', 'dump', remotePath]);
369
- const xml = await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'cat', remotePath]);
370
- await __classPrivateFieldGet(this, _AdbBackend_instances, "m", _AdbBackend_adb).call(this, ['shell', 'rm', '-f', remotePath]);
371
- return xml;
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 = [];