mobile-debug-mcp 0.31.0 → 0.32.1
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/README.md +1 -0
- package/dist/interact/android.js +13 -8
- package/dist/interact/index.js +33 -22
- package/dist/interact/ios.js +5 -2
- package/dist/manage/android.js +20 -10
- package/dist/manage/ios.js +19 -10
- package/dist/observe/android-tree-bridge.js +192 -0
- package/dist/observe/android.js +53 -14
- package/dist/observe/index.js +22 -5
- package/dist/observe/ios.js +50 -29
- package/dist/observe/performance-metrics.js +124 -0
- package/dist/observe/snapshot-metadata.js +94 -15
- package/dist/server/common.js +14 -7
- package/dist/server/journey-schema.js +60 -0
- package/dist/server/tool-definitions.js +59 -6
- package/dist/server/tool-handlers.js +381 -56
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +18 -5
- package/dist/utils/ios/utils.js +5 -2
- package/dist/utils/operation-budget.js +25 -0
- package/dist/utils/resolve-device.js +12 -1
- package/dist/utils/timing.js +19 -0
- package/dist/utils/ui/index.js +2 -0
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +9 -0
- package/docs/benchmarks/interaction-latency/README.md +26 -0
- package/docs/benchmarks/interaction-latency/final.json +1810 -0
- package/docs/benchmarks/interaction-latency/final.md +17 -0
- package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
- package/docs/benchmarks/ui-tree-performance/README.md +51 -0
- package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
- package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
- package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
- package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
- package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
- package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
- package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
- package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
- package/docs/tools/interact.md +30 -0
- package/docs/tools/observe.md +1 -0
- package/package.json +1 -1
- package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
- package/src/interact/android.ts +13 -8
- package/src/interact/index.ts +32 -23
- package/src/interact/ios.ts +5 -2
- package/src/manage/android.ts +19 -10
- package/src/manage/ios.ts +18 -10
- package/src/observe/android-tree-bridge.ts +170 -0
- package/src/observe/android.ts +48 -17
- package/src/observe/index.ts +21 -5
- package/src/observe/ios.ts +49 -36
- package/src/observe/performance-metrics.ts +125 -0
- package/src/observe/snapshot-metadata.ts +94 -15
- package/src/server/common.ts +13 -6
- package/src/server/journey-schema.ts +63 -0
- package/src/server/tool-definitions.ts +60 -7
- package/src/server/tool-handlers.ts +349 -54
- package/src/server-core.ts +1 -1
- package/src/types.ts +31 -2
- package/src/utils/exec.ts +13 -5
- package/src/utils/ios/utils.ts +5 -2
- package/src/utils/operation-budget.ts +22 -0
- package/src/utils/resolve-device.ts +13 -1
- package/src/utils/timing.ts +15 -0
- package/src/utils/ui/index.ts +2 -0
- package/test/device/automated/latency/benchmark.ts +136 -0
- package/test/device/automated/latency/report.ts +6 -0
- package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
- package/test/fixtures/latency-journey-app/README.md +28 -0
- package/test/fixtures/latency-journey-app/build.ts +26 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
- package/test/fixtures/ui-tree-bridge/build.ts +34 -0
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
- package/test/unit/manage/restart_budget.test.ts +37 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
- package/test/unit/observe/performance_metrics.test.ts +40 -0
- package/test/unit/observe/snapshot_metadata.test.ts +20 -1
- package/test/unit/observe/state_delta.test.ts +15 -0
- package/test/unit/server/benchmark_report.test.ts +4 -0
- package/test/unit/server/contract.test.ts +1 -1
- package/test/unit/server/interaction_modes.test.ts +60 -0
- package/test/unit/server/operation_budget.test.ts +45 -0
- package/test/unit/server/response_shapes.test.ts +3 -3
- package/test/unit/server/run_journey.test.ts +120 -0
- package/test/unit/server/tap_selector.test.ts +68 -0
- /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
- /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
- /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
- /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
- /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
- /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
- /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
- /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
- /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
- /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
- /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
- /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
- /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
- /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
- /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# Mobile Debug Tools
|
|
2
|
+
[](https://www.npmjs.com/package/mobile-debug-mcp)
|
|
2
3
|
|
|
3
4
|
A minimal, secure MCP server for AI-assisted mobile development. Build, install, interact and inspect Android/iOS apps from an MCP-compatible client.
|
|
4
5
|
|
package/dist/interact/android.js
CHANGED
|
@@ -1,49 +1,54 @@
|
|
|
1
|
+
import { markDispatchStarted } from '../utils/operation-budget.js';
|
|
1
2
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js";
|
|
2
3
|
import { AndroidObserve } from "../observe/index.js";
|
|
3
4
|
import { scrollToElementShared } from "../utils/ui/index.js";
|
|
4
5
|
export class AndroidInteract {
|
|
5
6
|
observe = new AndroidObserve();
|
|
6
|
-
async tap(x, y, deviceId) {
|
|
7
|
+
async tap(x, y, deviceId, timeoutMs) {
|
|
7
8
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
8
9
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
9
10
|
try {
|
|
10
|
-
|
|
11
|
+
markDispatchStarted();
|
|
12
|
+
await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs });
|
|
11
13
|
return { device: deviceInfo, success: true, x, y };
|
|
12
14
|
}
|
|
13
15
|
catch (e) {
|
|
14
16
|
return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
|
-
async swipe(x1, y1, x2, y2, duration, deviceId) {
|
|
19
|
+
async swipe(x1, y1, x2, y2, duration, deviceId, timeoutMs) {
|
|
18
20
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
19
21
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
20
22
|
try {
|
|
21
|
-
|
|
23
|
+
markDispatchStarted();
|
|
24
|
+
await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs });
|
|
22
25
|
return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration };
|
|
23
26
|
}
|
|
24
27
|
catch (e) {
|
|
25
28
|
return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
|
-
async typeText(text, deviceId) {
|
|
31
|
+
async typeText(text, deviceId, timeoutMs) {
|
|
29
32
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
30
33
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
31
34
|
try {
|
|
32
35
|
// Encode spaces as %s to ensure proper input handling by adb shell input text
|
|
33
36
|
const encodedText = text.replace(/\s/g, '%s');
|
|
34
37
|
// Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
|
|
35
|
-
|
|
38
|
+
markDispatchStarted();
|
|
39
|
+
await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs });
|
|
36
40
|
return { device: deviceInfo, success: true, text };
|
|
37
41
|
}
|
|
38
42
|
catch (e) {
|
|
39
43
|
return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) };
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
async pressBack(deviceId) {
|
|
46
|
+
async pressBack(deviceId, timeoutMs) {
|
|
43
47
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
44
48
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
45
49
|
try {
|
|
46
|
-
|
|
50
|
+
markDispatchStarted();
|
|
51
|
+
await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs });
|
|
47
52
|
return { device: deviceInfo, success: true };
|
|
48
53
|
}
|
|
49
54
|
catch (e) {
|
package/dist/interact/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget } from '../utils/operation-budget.js';
|
|
1
2
|
import { createHash } from 'crypto';
|
|
2
3
|
import { AndroidInteract } from './android.js';
|
|
3
4
|
import { iOSInteract } from './ios.js';
|
|
@@ -950,19 +951,19 @@ export class ToolsInteract {
|
|
|
950
951
|
const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
|
|
951
952
|
return { interact: interact, resolved, platform: effectivePlatform };
|
|
952
953
|
}
|
|
953
|
-
static async tapHandler({ platform, x, y, deviceId }) {
|
|
954
|
+
static async tapHandler({ platform, x, y, deviceId, timeoutMs }) {
|
|
954
955
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
955
|
-
return await interact.tap(x, y, resolved.id);
|
|
956
|
+
return await interact.tap(x, y, resolved.id, timeoutMs);
|
|
956
957
|
}
|
|
957
|
-
static async tapElementHandler({ elementId }) {
|
|
958
|
+
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }) {
|
|
958
959
|
const actionType = 'tap_element';
|
|
959
960
|
const selector = { elementId };
|
|
960
961
|
const resolved = ToolsInteract._resolvedUiElements.get(elementId);
|
|
961
|
-
if (!resolved) {
|
|
962
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
|
|
962
963
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null);
|
|
963
964
|
}
|
|
964
|
-
const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
965
|
-
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
|
|
965
|
+
const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
|
966
|
+
const tree = freshTree ?? await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
|
|
966
967
|
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform;
|
|
967
968
|
const treeDeviceId = tree?.device?.id || resolved.deviceId;
|
|
968
969
|
const elements = Array.isArray(tree?.elements) ? tree.elements : [];
|
|
@@ -981,12 +982,12 @@ export class ToolsInteract {
|
|
|
981
982
|
}
|
|
982
983
|
const x = Math.floor((bounds[0] + bounds[2]) / 2);
|
|
983
984
|
const y = Math.floor((bounds[1] + bounds[3]) / 2);
|
|
984
|
-
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId });
|
|
985
|
+
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId, timeoutMs });
|
|
985
986
|
if (!tapResult.success) {
|
|
986
|
-
const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
987
|
-
return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure);
|
|
987
|
+
const fingerprintAfterFailure = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
|
988
|
+
return { ...ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure), error: tapResult.error };
|
|
988
989
|
}
|
|
989
|
-
const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
990
|
+
const fingerprintAfter = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
|
990
991
|
return buildActionExecutionResult({
|
|
991
992
|
actionType,
|
|
992
993
|
device: tree?.device,
|
|
@@ -1756,17 +1757,17 @@ export class ToolsInteract {
|
|
|
1756
1757
|
const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
|
|
1757
1758
|
return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter);
|
|
1758
1759
|
}
|
|
1759
|
-
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
|
|
1760
|
+
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId, timeoutMs }) {
|
|
1760
1761
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
1761
|
-
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
|
|
1762
|
+
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id, timeoutMs);
|
|
1762
1763
|
}
|
|
1763
|
-
static async typeTextHandler({ text, deviceId }) {
|
|
1764
|
+
static async typeTextHandler({ text, deviceId, timeoutMs }) {
|
|
1764
1765
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
1765
|
-
return await new AndroidInteract().typeText(text, resolved.id);
|
|
1766
|
+
return await new AndroidInteract().typeText(text, resolved.id, timeoutMs);
|
|
1766
1767
|
}
|
|
1767
|
-
static async pressBackHandler({ deviceId }) {
|
|
1768
|
+
static async pressBackHandler({ deviceId, timeoutMs }) {
|
|
1768
1769
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
1769
|
-
return await new AndroidInteract().pressBack(resolved.id);
|
|
1770
|
+
return await new AndroidInteract().pressBack(resolved.id, timeoutMs);
|
|
1770
1771
|
}
|
|
1771
1772
|
static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }) {
|
|
1772
1773
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
@@ -2067,7 +2068,7 @@ export class ToolsInteract {
|
|
|
2067
2068
|
};
|
|
2068
2069
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution };
|
|
2069
2070
|
}
|
|
2070
|
-
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
|
|
2071
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }) {
|
|
2071
2072
|
const overallStart = Date.now();
|
|
2072
2073
|
const requestedIndex = typeof match?.index === 'number' ? match.index : null;
|
|
2073
2074
|
const requested = {
|
|
@@ -2109,7 +2110,7 @@ export class ToolsInteract {
|
|
|
2109
2110
|
let lastConditionSatisfied = false;
|
|
2110
2111
|
let matchedAt = null;
|
|
2111
2112
|
let stableMatchCount = 0;
|
|
2112
|
-
const stableObservationCount = 2;
|
|
2113
|
+
const stableObservationCount = singleObservation ? 1 : 2;
|
|
2113
2114
|
const snapshotStaleThresholdMs = 500;
|
|
2114
2115
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
2115
2116
|
const normalize = ToolsInteract._normalize;
|
|
@@ -2122,7 +2123,8 @@ export class ToolsInteract {
|
|
|
2122
2123
|
attempts++;
|
|
2123
2124
|
const attemptStart = Date.now();
|
|
2124
2125
|
const deadline = attemptStart + (timeout_ms || 0);
|
|
2125
|
-
while (Date.now() <= deadline) {
|
|
2126
|
+
while (Date.now() <= deadline || (singleAttempt && totalPollCount === 0)) {
|
|
2127
|
+
remainingBudget();
|
|
2126
2128
|
totalPollCount++;
|
|
2127
2129
|
try {
|
|
2128
2130
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
@@ -2172,6 +2174,8 @@ export class ToolsInteract {
|
|
|
2172
2174
|
}
|
|
2173
2175
|
// Evaluate condition
|
|
2174
2176
|
const matchedCount = matches.length;
|
|
2177
|
+
if (rejectAmbiguous && matchedCount > 1 && match?.index === undefined)
|
|
2178
|
+
return { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } };
|
|
2175
2179
|
const pickIndex = (typeof match?.index === 'number') ? match.index : undefined;
|
|
2176
2180
|
let chosen = null;
|
|
2177
2181
|
if (matches.length > 0) {
|
|
@@ -2237,6 +2241,7 @@ export class ToolsInteract {
|
|
|
2237
2241
|
const outEl = lastMatchedElement;
|
|
2238
2242
|
return {
|
|
2239
2243
|
status: 'success',
|
|
2244
|
+
...(singleObservation ? { _tree: tree } : {}),
|
|
2240
2245
|
matched: matchedCount,
|
|
2241
2246
|
element: outEl,
|
|
2242
2247
|
metrics: { latency_ms, poll_count: totalPollCount, attempts },
|
|
@@ -2256,9 +2261,13 @@ export class ToolsInteract {
|
|
|
2256
2261
|
}
|
|
2257
2262
|
}
|
|
2258
2263
|
catch (e) {
|
|
2264
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
|
|
2265
|
+
throw e;
|
|
2259
2266
|
// Non-fatal per-poll error; record and continue
|
|
2260
2267
|
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
|
|
2261
2268
|
}
|
|
2269
|
+
if (singleAttempt)
|
|
2270
|
+
break;
|
|
2262
2271
|
// Sleep until next poll
|
|
2263
2272
|
await new Promise(r => setTimeout(r, effectivePoll || 50));
|
|
2264
2273
|
}
|
|
@@ -2289,6 +2298,8 @@ export class ToolsInteract {
|
|
|
2289
2298
|
}
|
|
2290
2299
|
}
|
|
2291
2300
|
catch (err) {
|
|
2301
|
+
if (err instanceof Error && err.message === 'ACTION_TIMEOUT')
|
|
2302
|
+
throw err;
|
|
2292
2303
|
const elapsed = Date.now() - overallStart;
|
|
2293
2304
|
return {
|
|
2294
2305
|
status: 'timeout',
|
|
@@ -2665,15 +2676,15 @@ export class ToolsInteract {
|
|
|
2665
2676
|
})
|
|
2666
2677
|
};
|
|
2667
2678
|
}
|
|
2668
|
-
static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId, stabilization_window_ms = 1000, stable_observation_count = 2, snapshot_stale_threshold_ms = 500, poll_interval_ms = 150 }) {
|
|
2679
|
+
static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId, stabilization_window_ms = 1000, stable_observation_count = 2, snapshot_stale_threshold_ms = 500, poll_interval_ms = 150, exact_budget = false }) {
|
|
2669
2680
|
const compareBoolean = (value) => typeof value === 'boolean' ? value : null;
|
|
2670
2681
|
const compareString = (value) => typeof value === 'string' ? value : null;
|
|
2671
2682
|
const compareNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
2672
2683
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
2673
2684
|
const start = Date.now();
|
|
2674
|
-
const deadline = start + Math.max(500, stabilization_window_ms);
|
|
2685
|
+
const deadline = start + (exact_budget ? stabilization_window_ms : Math.max(500, stabilization_window_ms));
|
|
2675
2686
|
const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2));
|
|
2676
|
-
const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200));
|
|
2687
|
+
const pollDelay = exact_budget ? Math.max(50, Math.min(poll_interval_ms || 150, 1000)) : Math.max(100, Math.min(poll_interval_ms || 150, 200));
|
|
2677
2688
|
const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800));
|
|
2678
2689
|
let attempts = 0;
|
|
2679
2690
|
let stableCount = 0;
|
package/dist/interact/ios.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js';
|
|
1
2
|
import { spawn } from "child_process";
|
|
2
3
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
|
|
3
4
|
import { iOSObserve } from "../observe/index.js";
|
|
@@ -24,7 +25,8 @@ export class iOSInteract {
|
|
|
24
25
|
args.push('--udid', targetUdid);
|
|
25
26
|
}
|
|
26
27
|
await new Promise((resolve, reject) => {
|
|
27
|
-
|
|
28
|
+
markDispatchStarted();
|
|
29
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
28
30
|
let stderr = '';
|
|
29
31
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
30
32
|
proc.on('close', code => {
|
|
@@ -63,7 +65,8 @@ export class iOSInteract {
|
|
|
63
65
|
args.push('--udid', targetUdid);
|
|
64
66
|
}
|
|
65
67
|
await new Promise((resolve, reject) => {
|
|
66
|
-
|
|
68
|
+
markDispatchStarted();
|
|
69
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
67
70
|
let stderr = '';
|
|
68
71
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
69
72
|
proc.on('close', code => {
|
package/dist/manage/android.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
1
2
|
import { promises as fs } from 'fs';
|
|
2
3
|
import { spawn } from 'child_process';
|
|
3
4
|
import path from 'path';
|
|
@@ -145,16 +146,18 @@ export class AndroidManage {
|
|
|
145
146
|
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
|
-
async startApp(appId, deviceId) {
|
|
149
|
+
async startApp(appId, deviceId, timeoutMs) {
|
|
150
|
+
const started = performance.now();
|
|
149
151
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
150
152
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
151
153
|
try {
|
|
152
|
-
|
|
154
|
+
markDispatchStarted();
|
|
155
|
+
const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs });
|
|
153
156
|
const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null);
|
|
154
157
|
return {
|
|
155
158
|
device: deviceInfo,
|
|
156
159
|
appStarted: true,
|
|
157
|
-
launchTimeMs:
|
|
160
|
+
launchTimeMs: performance.now() - started,
|
|
158
161
|
output,
|
|
159
162
|
observedApp: {
|
|
160
163
|
appId,
|
|
@@ -166,25 +169,31 @@ export class AndroidManage {
|
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
171
|
catch (e) {
|
|
169
|
-
const
|
|
170
|
-
return { device: deviceInfo, appStarted: false, launchTimeMs:
|
|
172
|
+
const diagnostics = timeoutMs === undefined ? execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId) : undefined;
|
|
173
|
+
return { device: deviceInfo, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
176
|
async terminateApp(appId, deviceId) {
|
|
174
177
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
175
178
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
176
179
|
try {
|
|
180
|
+
markDispatchStarted();
|
|
177
181
|
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
178
182
|
return { device: deviceInfo, appTerminated: true };
|
|
179
183
|
}
|
|
180
184
|
catch (e) {
|
|
181
|
-
const
|
|
182
|
-
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics:
|
|
185
|
+
const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
|
|
186
|
+
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
|
|
183
187
|
}
|
|
184
188
|
}
|
|
185
|
-
async restartApp(appId, deviceId) {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
189
|
+
async restartApp(appId, deviceId, bounded = false, timeoutMs = 15000) {
|
|
190
|
+
const started = performance.now();
|
|
191
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(appId, deviceId)) : await this.terminateApp(appId, deviceId);
|
|
192
|
+
const remaining = timeoutMs - (performance.now() - started);
|
|
193
|
+
if (bounded && remaining <= 0)
|
|
194
|
+
throw new Error('ACTION_TIMEOUT');
|
|
195
|
+
const launchBudget = remainingBudget(remaining);
|
|
196
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(appId, deviceId, remainingBudget(launchBudget))) : await this.startApp(appId, deviceId);
|
|
188
197
|
return {
|
|
189
198
|
device: startResult.device,
|
|
190
199
|
appRestarted: startResult.appStarted,
|
|
@@ -201,6 +210,7 @@ export class AndroidManage {
|
|
|
201
210
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
202
211
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
203
212
|
try {
|
|
213
|
+
markDispatchStarted();
|
|
204
214
|
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
205
215
|
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
206
216
|
}
|
package/dist/manage/ios.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
1
2
|
import { promises as fs } from "fs";
|
|
2
3
|
import { spawn, spawnSync } from "child_process";
|
|
3
4
|
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "../utils/ios/utils.js";
|
|
@@ -287,17 +288,19 @@ export class iOSManage {
|
|
|
287
288
|
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
288
289
|
}
|
|
289
290
|
}
|
|
290
|
-
async startApp(bundleId, deviceId = "booted") {
|
|
291
|
+
async startApp(bundleId, deviceId = "booted", observe = true) {
|
|
292
|
+
const started = performance.now();
|
|
291
293
|
validateBundleId(bundleId);
|
|
292
294
|
try {
|
|
295
|
+
markDispatchStarted();
|
|
293
296
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
294
297
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
295
|
-
const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null);
|
|
298
|
+
const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null;
|
|
296
299
|
const pidMatch = result.output.match(/:\s*(\d+)\s*$/);
|
|
297
300
|
return {
|
|
298
301
|
device,
|
|
299
302
|
appStarted: !!result.output,
|
|
300
|
-
launchTimeMs:
|
|
303
|
+
launchTimeMs: performance.now() - started,
|
|
301
304
|
output: result.output,
|
|
302
305
|
observedApp: {
|
|
303
306
|
appId: bundleId,
|
|
@@ -308,27 +311,33 @@ export class iOSManage {
|
|
|
308
311
|
};
|
|
309
312
|
}
|
|
310
313
|
catch (e) {
|
|
311
|
-
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
312
314
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
313
|
-
|
|
315
|
+
const diagnostics = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
316
|
+
return { device, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
|
|
314
317
|
}
|
|
315
318
|
}
|
|
316
319
|
async terminateApp(bundleId, deviceId = "booted") {
|
|
317
320
|
validateBundleId(bundleId);
|
|
318
321
|
try {
|
|
322
|
+
markDispatchStarted();
|
|
319
323
|
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
320
324
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
321
325
|
return { device, appTerminated: true };
|
|
322
326
|
}
|
|
323
327
|
catch (e) {
|
|
324
|
-
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
328
|
+
const diag = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
325
329
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
326
|
-
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
330
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diag ? { diagnostics: diag } : {}) };
|
|
327
331
|
}
|
|
328
332
|
}
|
|
329
|
-
async restartApp(bundleId, deviceId = "booted") {
|
|
330
|
-
const
|
|
331
|
-
const
|
|
333
|
+
async restartApp(bundleId, deviceId = "booted", observe = true, bounded = false, timeoutMs = 15000) {
|
|
334
|
+
const started = performance.now();
|
|
335
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(bundleId, deviceId)) : await this.terminateApp(bundleId, deviceId);
|
|
336
|
+
const remaining = timeoutMs - (performance.now() - started);
|
|
337
|
+
if (bounded && remaining <= 0)
|
|
338
|
+
throw new Error('ACTION_TIMEOUT');
|
|
339
|
+
const launchBudget = remainingBudget(remaining);
|
|
340
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(bundleId, deviceId, observe)) : await this.startApp(bundleId, deviceId, observe);
|
|
332
341
|
return {
|
|
333
342
|
device: startResult.device,
|
|
334
343
|
appRestarted: startResult.appStarted,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execAdb, getAdbCmd } from '../utils/android/utils.js';
|
|
6
|
+
import { remainingBudget } from '../utils/operation-budget.js';
|
|
7
|
+
const packageName = 'dev.mobiledebugmcp.treebridge';
|
|
8
|
+
const component = `${packageName}/.TreeInstrumentation`;
|
|
9
|
+
const devicePort = 39019;
|
|
10
|
+
const active = new Map();
|
|
11
|
+
const starting = new Map();
|
|
12
|
+
const stopping = new Map();
|
|
13
|
+
export function androidTreeBridgeEnabled() {
|
|
14
|
+
return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK);
|
|
15
|
+
}
|
|
16
|
+
function configuredApk() {
|
|
17
|
+
const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK;
|
|
18
|
+
if (!configured)
|
|
19
|
+
return null;
|
|
20
|
+
const apk = path.resolve(configured);
|
|
21
|
+
if (!existsSync(apk))
|
|
22
|
+
throw new Error(`Android UI tree bridge APK does not exist: ${apk}`);
|
|
23
|
+
return apk;
|
|
24
|
+
}
|
|
25
|
+
function unbudgetedAdb(deviceId, args) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve());
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function request(bridge, command, timeoutMs) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
let output = '';
|
|
33
|
+
const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`));
|
|
34
|
+
socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)));
|
|
35
|
+
socket.on('data', (chunk) => {
|
|
36
|
+
output += chunk.toString();
|
|
37
|
+
if (command === 'STOP' && output.startsWith('OK\n')) {
|
|
38
|
+
resolve(output);
|
|
39
|
+
socket.destroy();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
socket.on('error', reject);
|
|
43
|
+
socket.on('end', () => resolve(output));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function waitForChildClose(bridge, timeoutMs) {
|
|
47
|
+
if (bridge.childClosed)
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const onClose = () => { clearTimeout(timer); resolve(); };
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
bridge.child.off('close', onClose);
|
|
53
|
+
reject(new Error('Android UI tree bridge child did not exit'));
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
bridge.child.once('close', onClose);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function stop(bridge) {
|
|
59
|
+
const pending = stopping.get(bridge.deviceId);
|
|
60
|
+
if (pending)
|
|
61
|
+
return pending;
|
|
62
|
+
bridge.closed = true;
|
|
63
|
+
const cleanup = (async () => {
|
|
64
|
+
await request(bridge, 'STOP', 1000).catch(() => '');
|
|
65
|
+
let forceStopError;
|
|
66
|
+
try {
|
|
67
|
+
await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
forceStopError = error;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
await waitForChildClose(bridge, 1000);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
bridge.child.kill('SIGTERM');
|
|
77
|
+
try {
|
|
78
|
+
await waitForChildClose(bridge, 1000);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
bridge.child.kill('SIGKILL');
|
|
82
|
+
await waitForChildClose(bridge, 2000).catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let forwardError;
|
|
86
|
+
try {
|
|
87
|
+
await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
forwardError = error;
|
|
91
|
+
}
|
|
92
|
+
if (forceStopError || !bridge.childClosed || forwardError) {
|
|
93
|
+
const reason = forceStopError ?? (!bridge.childClosed ? 'instrumentation child did not close' : forwardError);
|
|
94
|
+
throw new Error(`BRIDGE_CLEANUP_UNCONFIRMED: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
95
|
+
}
|
|
96
|
+
if (active.get(bridge.deviceId) === bridge)
|
|
97
|
+
active.delete(bridge.deviceId);
|
|
98
|
+
})();
|
|
99
|
+
stopping.set(bridge.deviceId, cleanup);
|
|
100
|
+
try {
|
|
101
|
+
await cleanup;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
stopping.delete(bridge.deviceId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function launch(deviceId, apk) {
|
|
108
|
+
await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName]);
|
|
109
|
+
try {
|
|
110
|
+
await execAdb(['install', '-r', apk], deviceId);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE'))
|
|
114
|
+
throw error;
|
|
115
|
+
await execAdb(['uninstall', packageName], deviceId);
|
|
116
|
+
await execAdb(['install', '-r', apk], deviceId);
|
|
117
|
+
}
|
|
118
|
+
const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId));
|
|
119
|
+
if (!Number.isInteger(hostPort) || hostPort <= 0)
|
|
120
|
+
throw new Error('Android UI tree bridge could not obtain a host port');
|
|
121
|
+
const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' });
|
|
122
|
+
const bridge = { deviceId, hostPort, child, closed: false, childClosed: false };
|
|
123
|
+
child.once('close', () => { bridge.childClosed = true; });
|
|
124
|
+
child.once('error', () => { });
|
|
125
|
+
active.set(deviceId, bridge);
|
|
126
|
+
try {
|
|
127
|
+
const deadline = Date.now() + Math.min(remainingBudget(3000), 3000);
|
|
128
|
+
while (Date.now() < deadline) {
|
|
129
|
+
if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null)
|
|
130
|
+
throw new Error('Android UI tree bridge exited during startup');
|
|
131
|
+
try {
|
|
132
|
+
if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
|
|
133
|
+
return bridge;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch { /* The instrumentation process may still be starting. */ }
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
138
|
+
}
|
|
139
|
+
throw new Error('Android UI tree bridge did not become ready');
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
await stop(bridge);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function ensure(deviceId, apk) {
|
|
147
|
+
const pending = starting.get(deviceId);
|
|
148
|
+
if (pending)
|
|
149
|
+
return pending;
|
|
150
|
+
const ready = active.get(deviceId);
|
|
151
|
+
if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null)
|
|
152
|
+
return ready;
|
|
153
|
+
if (ready)
|
|
154
|
+
await stop(ready);
|
|
155
|
+
const promise = launch(deviceId, apk);
|
|
156
|
+
starting.set(deviceId, promise);
|
|
157
|
+
try {
|
|
158
|
+
return await promise;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
starting.delete(deviceId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
|
|
165
|
+
export async function readAndroidTreeFromBridge(deviceId) {
|
|
166
|
+
const apk = configuredApk();
|
|
167
|
+
if (!apk)
|
|
168
|
+
return null;
|
|
169
|
+
let bridge;
|
|
170
|
+
try {
|
|
171
|
+
bridge = await ensure(deviceId, apk);
|
|
172
|
+
const output = await request(bridge, 'TREE', remainingBudget(10000));
|
|
173
|
+
if (!output.startsWith('OK\n') || !output.includes('<hierarchy'))
|
|
174
|
+
throw new Error('Android UI tree bridge returned invalid hierarchy output');
|
|
175
|
+
return output.slice(3);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))
|
|
179
|
+
throw error;
|
|
180
|
+
if (bridge)
|
|
181
|
+
await stop(bridge);
|
|
182
|
+
if (error instanceof Error && error.message === 'ACTION_TIMEOUT')
|
|
183
|
+
throw error;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
process.once('exit', () => {
|
|
188
|
+
for (const bridge of active.values()) {
|
|
189
|
+
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' });
|
|
190
|
+
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' });
|
|
191
|
+
}
|
|
192
|
+
});
|