mobile-debug-mcp 0.14.0 → 0.16.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 (103) hide show
  1. package/dist/android/interact.js +2 -2
  2. package/dist/android/observe.js +13 -0
  3. package/dist/cli/ios/run-ios-smoke.js +2 -2
  4. package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
  5. package/dist/interact/android.js +91 -0
  6. package/dist/interact/index.js +76 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +1 -0
  9. package/dist/interact/shared/scroll_to_element.js +1 -0
  10. package/dist/ios/interact.js +2 -2
  11. package/dist/ios/observe.js +12 -0
  12. package/dist/manage/android.js +162 -0
  13. package/dist/manage/index.js +364 -0
  14. package/dist/manage/ios.js +353 -0
  15. package/dist/observe/android.js +351 -0
  16. package/dist/observe/fingerprint.js +1 -0
  17. package/dist/observe/index.js +85 -0
  18. package/dist/observe/ios.js +320 -0
  19. package/dist/observe/test/device/logstream-real.js +34 -0
  20. package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
  21. package/dist/observe/test/device/run-scroll-test-android.js +22 -0
  22. package/dist/observe/test/device/test-ui-tree.js +67 -0
  23. package/dist/observe/test/device/wait_for_element_real.js +69 -0
  24. package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
  25. package/dist/observe/test/unit/logparse.test.js +39 -0
  26. package/dist/observe/test/unit/logstream.test.js +41 -0
  27. package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
  28. package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
  29. package/dist/server.js +41 -5
  30. package/dist/shared/fingerprint.js +72 -0
  31. package/dist/shared/scroll_to_element.js +98 -0
  32. package/dist/tools/interact.js +2 -2
  33. package/dist/tools/manage.js +2 -2
  34. package/dist/tools/observe.js +45 -43
  35. package/dist/utils/android/utils.js +373 -0
  36. package/dist/utils/cli/idb/check-idb.js +84 -0
  37. package/dist/utils/cli/idb/idb-helper.js +91 -0
  38. package/dist/utils/cli/idb/install-idb.js +82 -0
  39. package/dist/utils/cli/ios/preflight-ios.js +155 -0
  40. package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
  41. package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
  42. package/dist/utils/diagnostics.js +1 -1
  43. package/dist/utils/exec.js +34 -0
  44. package/dist/utils/ios/utils.js +301 -0
  45. package/dist/utils/resolve-device.js +2 -2
  46. package/dist/utils/ui/index.js +169 -0
  47. package/docs/CHANGELOG.md +8 -0
  48. package/docs/tools/interact.md +29 -0
  49. package/docs/tools/observe.md +24 -0
  50. package/package.json +1 -1
  51. package/src/{android/interact.ts → interact/android.ts} +3 -3
  52. package/src/{tools/interact.ts → interact/index.ts} +47 -3
  53. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  54. package/src/{android/manage.ts → manage/android.ts} +2 -2
  55. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  56. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  57. package/src/{android/observe.ts → observe/android.ts} +14 -26
  58. package/src/observe/index.ts +92 -0
  59. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  60. package/src/server.ts +45 -6
  61. package/src/types.ts +1 -0
  62. package/src/{android → utils/android}/utils.ts +12 -79
  63. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  64. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  65. package/src/utils/diagnostics.ts +1 -1
  66. package/src/utils/exec.ts +33 -0
  67. package/src/{ios → utils/ios}/utils.ts +2 -2
  68. package/src/utils/resolve-device.ts +2 -2
  69. package/src/{tools/scroll_to_element.ts → utils/ui/index.ts} +73 -2
  70. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  71. package/test/interact/unit/wait_for_screen_change.test.ts +32 -0
  72. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  73. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  74. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  76. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  77. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  78. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  79. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  80. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  81. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  82. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  83. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  84. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  85. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  86. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  87. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  88. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  89. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  90. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  91. package/test/unit/index.ts +13 -11
  92. package/src/tools/observe.ts +0 -82
  93. package/test/device/README.md +0 -49
  94. package/test/device/index.ts +0 -27
  95. package/test/device/utils/test-dist.ts +0 -41
  96. package/test/unit/utils/detect-java.test.ts +0 -22
  97. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  98. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  99. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  100. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  101. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  102. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  103. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,169 @@
1
+ import crypto from 'crypto';
2
+ const ANDROID_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewGroup', 'LinearLayout', 'FrameLayout', 'RelativeLayout', 'ScrollView', 'RecyclerView', 'TextView', 'ImageView'];
3
+ const IOS_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewController', 'UITableView', 'UICollectionView', 'UILabel', 'UIImageView', 'UIView', 'UIWindow', 'UIStackView', 'UITextView', 'UITableViewCell'];
4
+ function isDynamicText(t) {
5
+ if (!t)
6
+ return false;
7
+ const txt = t.trim();
8
+ if (!txt)
9
+ return false;
10
+ if (/\b\d{1,2}:\d{2}\b/.test(txt))
11
+ return true;
12
+ if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt))
13
+ return true;
14
+ if (/^\d+(?:\.\d+)?%$/.test(txt))
15
+ return true;
16
+ if (/^\d+$/.test(txt))
17
+ return true;
18
+ if (/^[\d,]{1,10}$/.test(txt))
19
+ return true;
20
+ return false;
21
+ }
22
+ function normalizeElement(e) {
23
+ return {
24
+ type: (e.type || '').toString(),
25
+ resourceId: (e.resourceId || '').toString(),
26
+ text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
27
+ contentDesc: (e.contentDescription || '').toString(),
28
+ bounds: Array.isArray(e.bounds) ? e.bounds.slice(0, 4).map((n) => Number(n) || 0) : [0, 0, 0, 0]
29
+ };
30
+ }
31
+ export function computeScreenFingerprint(tree, current, platform, limit = 50) {
32
+ try {
33
+ if (!tree || tree.error)
34
+ return { fingerprint: null, error: tree.error };
35
+ const activity = current && (current.activity || current.shortActivity) ? (current.activity || current.shortActivity) : '';
36
+ const candidates = (tree.elements || []).filter(e => {
37
+ if (!e)
38
+ return false;
39
+ if (!e.visible)
40
+ return false;
41
+ const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0;
42
+ const hasResource = !!e.resourceId;
43
+ const interactable = !!e.clickable || !!e.enabled;
44
+ const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES;
45
+ const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '');
46
+ return interactable || structurallySignificant;
47
+ });
48
+ const normalized = candidates.map(normalizeElement);
49
+ const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0));
50
+ filteredNormalized.sort((a, b) => {
51
+ const ay = (a.bounds && a.bounds[1]) || 0;
52
+ const by = (b.bounds && b.bounds[1]) || 0;
53
+ if (ay !== by)
54
+ return ay - by;
55
+ const ax = (a.bounds && a.bounds[0]) || 0;
56
+ const bx = (b.bounds && b.bounds[0]) || 0;
57
+ return ax - bx;
58
+ });
59
+ const limited = filteredNormalized.slice(0, Math.max(0, limit));
60
+ const payload = {
61
+ activity: platform === 'android' ? (activity || '') : '',
62
+ resolution: tree.resolution || { width: 0, height: 0 },
63
+ elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
64
+ };
65
+ const combined = JSON.stringify(payload);
66
+ const hash = crypto.createHash('sha256').update(combined).digest('hex');
67
+ return { fingerprint: hash, activity: activity };
68
+ }
69
+ catch (e) {
70
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
71
+ }
72
+ }
73
+ export async function scrollToElementShared(opts) {
74
+ const { selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId, fetchTree, swipe, stabilizationDelayMs = 350 } = opts;
75
+ const matchElement = (el) => {
76
+ if (!el)
77
+ return false;
78
+ if (selector.text !== undefined && selector.text !== el.text)
79
+ return false;
80
+ if (selector.resourceId !== undefined && selector.resourceId !== el.resourceId)
81
+ return false;
82
+ if (selector.contentDesc !== undefined && selector.contentDesc !== el.contentDescription)
83
+ return false;
84
+ if (selector.className !== undefined && selector.className !== el.type)
85
+ return false;
86
+ return true;
87
+ };
88
+ const isVisible = (el, resolution) => {
89
+ if (!el)
90
+ return false;
91
+ if (el.visible === false)
92
+ return false;
93
+ if (!el.bounds || !resolution || !resolution.width || !resolution.height)
94
+ return (el.visible === undefined ? true : !!el.visible);
95
+ const [left, top, right, bottom] = el.bounds;
96
+ const withinY = bottom > 0 && top < resolution.height;
97
+ const withinX = right > 0 && left < resolution.width;
98
+ return withinX && withinY;
99
+ };
100
+ const findVisibleMatch = (elements, resolution) => {
101
+ if (!Array.isArray(elements))
102
+ return null;
103
+ for (const e of elements) {
104
+ if (matchElement(e) && isVisible(e, resolution))
105
+ return e;
106
+ }
107
+ return null;
108
+ };
109
+ // Initial check
110
+ let tree = await fetchTree();
111
+ if (tree.error)
112
+ return { success: false, reason: tree.error, scrollsPerformed: 0 };
113
+ let found = findVisibleMatch(tree.elements, tree.resolution);
114
+ if (found) {
115
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed: 0 };
116
+ }
117
+ const fingerprintOf = (t) => {
118
+ try {
119
+ return JSON.stringify((t.elements || []).map((e) => ({ text: e.text, resourceId: e.resourceId, bounds: e.bounds })));
120
+ }
121
+ catch {
122
+ return '';
123
+ }
124
+ };
125
+ let prevFingerprint = fingerprintOf(tree);
126
+ const width = (tree.resolution && tree.resolution.width) ? tree.resolution.width : 0;
127
+ const height = (tree.resolution && tree.resolution.height) ? tree.resolution.height : 0;
128
+ const centerX = Math.round(width / 2) || 50;
129
+ const clampPct = (v) => Math.max(0.05, Math.min(0.95, v));
130
+ const computeCoords = () => {
131
+ const defaultStart = direction === 'down' ? 0.8 : 0.2;
132
+ const startPct = clampPct(defaultStart);
133
+ const endPct = clampPct(defaultStart + (direction === 'down' ? -scrollAmount : scrollAmount));
134
+ const x1 = centerX;
135
+ const x2 = centerX;
136
+ const y1 = Math.round((height || 100) * startPct);
137
+ const y2 = Math.round((height || 100) * endPct);
138
+ return { x1, y1, x2, y2 };
139
+ };
140
+ const duration = 300;
141
+ let scrollsPerformed = 0;
142
+ for (let i = 0; i < maxScrolls; i++) {
143
+ const { x1, y1, x2, y2 } = computeCoords();
144
+ try {
145
+ await swipe(x1, y1, x2, y2, duration, deviceId);
146
+ }
147
+ catch (e) {
148
+ try {
149
+ console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`);
150
+ }
151
+ catch { }
152
+ }
153
+ scrollsPerformed++;
154
+ await new Promise(resolve => setTimeout(resolve, stabilizationDelayMs));
155
+ tree = await fetchTree();
156
+ if (tree.error)
157
+ return { success: false, reason: tree.error, scrollsPerformed: scrollsPerformed };
158
+ found = findVisibleMatch(tree.elements, tree.resolution);
159
+ if (found) {
160
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed };
161
+ }
162
+ const fp = fingerprintOf(tree);
163
+ if (fp === prevFingerprint) {
164
+ return { success: false, reason: 'UI unchanged after scroll; likely end of list', scrollsPerformed: scrollsPerformed };
165
+ }
166
+ prevFingerprint = fp;
167
+ }
168
+ return { success: false, reason: 'Element not found after scrolling', scrollsPerformed: scrollsPerformed };
169
+ }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.16.0]
6
+ - Added `wait_for_screen_change` interact tool: polls the platform-specific `get_screen_fingerprint` until it differs from a provided `previousFingerprint`, with configurable `timeoutMs` and `pollIntervalMs` and an optional stability confirmation poll to avoid reacting to transient UI flickers. Implemented at the interact layer and delegates fingerprinting to the observe implementations (Android/iOS).
7
+ - Added unit tests covering immediate change, transient null fingerprints, stability confirmation and timeout behavior: `test/interact/unit/wait_for_screen_change.test.ts`.
8
+
9
+ ## [0.15.0]
10
+ - Reorganised repository for cohesion: merged tool handlers into feature entrypoints (src/observe, src/interact, src/manage) and moved platform helpers and CLI tooling into src/utils/{android,ios,cli}.
11
+ - Added computeScreenFingerprint utility used by observe/interact to normalise UI element significance across platforms (fingerprint shared between Android and iOS implementations).
12
+
5
13
  ## [0.14.0]
6
14
  - Added `scroll_to_element` tool: platform-aware helper that scrolls until a UI element matching a selector is visible. Supports Android and iOS with configurable options: direction, maxScrolls, and scrollAmount. Includes unit tests and device runners under `test/device/` for manual E2E validation.
7
15
  - Moved scroll logic into platform-specific implementations (`src/android/interact.ts`, `src/ios/interact.ts`) and delegated from `src/tools/interact.ts` to centralise platform behaviour.
@@ -72,3 +72,32 @@ Notes:
72
72
  - Android swipe uses `adb shell input swipe` with screen percentage coordinates. iOS swipe uses `idb ui swipe` command; note `idb` swipe does not accept a duration argument.
73
73
  - Unit tests are located at `test/unit/observe/scroll_to_element.test.ts` and device runners at `test/device/observe/`.
74
74
 
75
+ ---
76
+
77
+ ## wait_for_screen_change
78
+
79
+ Description:
80
+ - Waits until the current screen fingerprint differs from the provided `previousFingerprint`. Useful after taps, navigation, or other interactions that should change the visible UI.
81
+
82
+ Input example:
83
+ ```
84
+ { "platform": "android", "previousFingerprint": "<hex-fingerprint>", "timeoutMs": 5000, "pollIntervalMs": 300, "deviceId": "emulator-5554" }
85
+ ```
86
+
87
+ Success response example:
88
+ ```
89
+ { "success": true, "newFingerprint": "<hex-fingerprint>", "elapsedMs": 420 }
90
+ ```
91
+
92
+ Failure (timeout) example:
93
+ ```
94
+ { "success": false, "reason": "timeout", "lastFingerprint": "<hex-fingerprint>", "elapsedMs": 5000 }
95
+ ```
96
+
97
+ Notes:
98
+ - Always compares to the original `previousFingerprint` (baseline is not updated during polling).
99
+ - Treats `null` fingerprints as transient; continues polling rather than returning success.
100
+ - Includes a stability confirmation: after detecting a different fingerprint it waits one additional poll interval and confirms the fingerprint is stable before returning success to avoid reacting to transient flickers or animation frames.
101
+ - Default `timeoutMs` is 5000ms and default `pollIntervalMs` is 300ms; callers may override these.
102
+ - Implemented as an interact-level tool and delegates platform-specific fingerprint calculation to the observe layer (`get_screen_fingerprint`).
103
+
@@ -76,6 +76,30 @@ Response:
76
76
 
77
77
  ---
78
78
 
79
+ ## get_screen_fingerprint
80
+ Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
81
+
82
+ Input (optional):
83
+
84
+ ```
85
+ { "platform": "android", "deviceId": "emulator-5554" }
86
+ ```
87
+
88
+ Response:
89
+
90
+ ```json
91
+ { "fingerprint": "<sha256_hex>", "activity": "com.example.app.MainActivity" }
92
+ ```
93
+
94
+ Notes:
95
+ - Uses get_ui_tree and (on Android) get_current_screen as inputs.
96
+ - Normalises visible, interactable or structurally significant elements (class/type, resourceId, text, contentDesc).
97
+ - Trims and lowercases text, filters out likely dynamic values (timestamps, counters).
98
+ - Sorts deterministically (top-to-bottom, left-to-right) and limits elements to 50.
99
+ - Returns fingerprint: null and an error message if the UI tree or activity cannot be retrieved.
100
+
101
+ ---
102
+
79
103
  ## start_log_stream / read_log_stream / stop_log_stream
80
104
  Start a background adb logcat stream and retrieve parsed NDJSON entries.
81
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
- import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
3
- import { AndroidObserve } from "./observe.js"
4
- import { scrollToElementShared } from "../tools/scroll_to_element.js"
2
+ import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
3
+ import { AndroidObserve } from "../observe/index.js"
4
+ import { scrollToElementShared } from "../utils/ui/index.js"
5
5
 
6
6
 
7
7
  export class AndroidInteract {
@@ -1,6 +1,9 @@
1
+ import { AndroidInteract } from './android.js';
2
+ import { iOSInteract } from './ios.js';
3
+ export { AndroidInteract, iOSInteract };
4
+
1
5
  import { resolveTargetDevice } from '../utils/resolve-device.js'
2
- import { AndroidInteract } from '../android/interact.js'
3
- import { iOSInteract } from '../ios/interact.js'
6
+ import { ToolsObserve } from '../observe/index.js'
4
7
 
5
8
  export class ToolsInteract {
6
9
 
@@ -42,5 +45,46 @@ export class ToolsInteract {
42
45
  return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id)
43
46
  }
44
47
 
45
- }
48
+ static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
49
+ const start = Date.now()
50
+ let lastFingerprint: string | null = null
51
+
52
+ while (Date.now() - start < timeoutMs) {
53
+ try {
54
+ const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId })
55
+ const fp = (res as any)?.fingerprint ?? null
56
+ if (fp === null || fp === undefined) {
57
+ lastFingerprint = null
58
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
59
+ continue
60
+ }
61
+
62
+ lastFingerprint = fp
63
+
64
+ if (fp !== previousFingerprint) {
65
+ // Stability confirmation
66
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
67
+ try {
68
+ const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId })
69
+ const confirmFp = (confirmRes as any)?.fingerprint ?? null
70
+ if (confirmFp === fp) {
71
+ return { success: true, newFingerprint: fp, elapsedMs: Date.now() - start }
72
+ }
73
+ lastFingerprint = confirmFp
74
+ continue
75
+ } catch {
76
+ // ignore and continue polling
77
+ continue
78
+ }
79
+ }
80
+ } catch {
81
+ // ignore transient errors
82
+ }
46
83
 
84
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
85
+ }
86
+
87
+ return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
88
+ }
89
+
90
+ }
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "child_process"
2
2
  import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
3
- import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js"
4
- import { iOSObserve } from "./observe.js"
5
- import { scrollToElementShared } from "../tools/scroll_to_element.js"
3
+ import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
4
+ import { iOSObserve } from "../observe/index.js"
5
+ import { scrollToElementShared } from "../utils/ui/index.js"
6
6
 
7
7
  export class iOSInteract {
8
8
  private observe = new iOSObserve();
@@ -2,7 +2,7 @@ import { promises as fs } from 'fs'
2
2
  import { spawn } from 'child_process'
3
3
  import path from 'path'
4
4
  import { existsSync } from 'fs'
5
- import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js'
5
+ import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js'
6
6
  import { execAdbWithDiagnostics } from '../utils/diagnostics.js'
7
7
  import { detectJavaHome } from '../utils/java.js'
8
8
  import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
@@ -12,7 +12,7 @@ export class AndroidManage {
12
12
  void _variant
13
13
  try {
14
14
  // Always use the shared prepareGradle utility for consistent env/setup
15
- const { execCmd, gradleArgs, spawnOpts } = await (await import('./utils.js')).prepareGradle(projectPath)
15
+ const { execCmd, gradleArgs, spawnOpts } = await (await import('../utils/android/utils.js')).prepareGradle(projectPath)
16
16
  await new Promise<void>((resolve, reject) => {
17
17
  const proc = spawn(execCmd, gradleArgs, spawnOpts)
18
18
  let stderr = ''
@@ -1,13 +1,16 @@
1
1
  import { promises as fs } from 'fs'
2
2
  import path from 'path'
3
3
  import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js'
4
- import { AndroidManage } from '../android/manage.js'
5
- import { iOSManage } from '../ios/manage.js'
6
- import { findApk } from '../android/utils.js'
7
- import { findAppBundle } from '../ios/utils.js'
4
+ import { AndroidManage } from './android.js'
5
+ import { iOSManage } from './ios.js'
6
+ import { findApk } from '../utils/android/utils.js'
7
+ import { findAppBundle } from '../utils/ios/utils.js'
8
8
  import { execSync } from 'child_process'
9
9
  import type { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
10
10
 
11
+ export { AndroidManage } from './android.js';
12
+ export { iOSManage } from './ios.js';
13
+
11
14
  export async function detectProjectPlatform(projectPath: string): Promise<'ios'|'android'|'ambiguous'|'unknown'> {
12
15
  // Recursively scan up to a limited depth for platform markers to avoid mis-detection
13
16
  async function scan(dir: string, depth = 3): Promise<{ ios: boolean, android: boolean }>{
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "fs"
2
2
  import { spawn, spawnSync } from "child_process"
3
3
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
4
- import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js"
4
+ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "../utils/ios/utils.js"
5
5
  import path from "path"
6
6
 
7
7
  export class iOSManage {
@@ -1,15 +1,14 @@
1
1
  import { spawn } from "child_process"
2
2
  import { XMLParser } from "fast-xml-parser"
3
3
  import { GetLogsResponse, CaptureAndroidScreenResponse, GetUITreeResponse, GetCurrentScreenResponse, UIElement, DeviceInfo } from "../types.js"
4
- import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js"
4
+ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "../utils/android/utils.js"
5
5
  import { createWriteStream } from "fs"
6
6
  import { promises as fsPromises } from "fs"
7
7
  import path from "path"
8
+ import { computeScreenFingerprint } from "../utils/ui/index.js"
8
9
 
9
10
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
10
11
 
11
-
12
-
13
12
  export class AndroidObserve {
14
13
  async getDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
15
14
  return getAndroidDeviceMetadata(appId, deviceId);
@@ -98,24 +97,16 @@ export class AndroidObserve {
98
97
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
99
98
 
100
99
  try {
101
- // We'll skip PID lookup for now to avoid potential hangs with 'pidof' on some emulators
102
- // and rely on robust string matching against the log line.
103
-
104
- // Get logs
105
100
  const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId)
106
101
  const allLogs = stdout.split('\n')
107
102
 
108
103
  let filteredLogs = allLogs
109
104
  if (appId) {
110
- // Filter by checking if the line contains the appId string.
111
105
  const matchingLogs = allLogs.filter(line => line.includes(appId))
112
106
 
113
107
  if (matchingLogs.length > 0) {
114
108
  filteredLogs = matchingLogs
115
109
  } else {
116
- // Fallback: if no logs match the appId, return the raw logs (last N lines)
117
- // This matches the behavior of the "working" version provided by the user,
118
- // ensuring they at least see system activity if the app is silent or crashing early.
119
110
  filteredLogs = allLogs
120
111
  }
121
112
  }
@@ -132,10 +123,7 @@ export class AndroidObserve {
132
123
  const deviceInfo: DeviceInfo = getDeviceInfo(deviceId || 'default', metadata)
133
124
 
134
125
  return new Promise((resolve, reject) => {
135
- // Need to construct ADB args manually since spawn handles it
136
126
  const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
137
-
138
- // Using spawn for screencap as well to ensure consistent process handling
139
127
  const child = spawn(getAdbCmd(), args)
140
128
 
141
129
  const chunks: Buffer[] = []
@@ -164,7 +152,6 @@ export class AndroidObserve {
164
152
  const screenshotBuffer = Buffer.concat(chunks)
165
153
  const screenshotBase64 = screenshotBuffer.toString('base64')
166
154
 
167
- // Get resolution
168
155
  execAdb(['shell', 'wm', 'size'], deviceId)
169
156
  .then(sizeStdout => {
170
157
  let width = 0
@@ -201,13 +188,8 @@ export class AndroidObserve {
201
188
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
202
189
 
203
190
  try {
204
- // Dumpsys activity can be slow on some devices, so we increase timeout to 10s
205
191
  const output = await execAdb(['shell', 'dumpsys', 'activity', 'activities'], deviceId, { timeout: 10000 })
206
-
207
- // Find the line with mResumedActivity or ResumedActivity (some versions might differ)
208
192
  const lines = output.split('\n');
209
- // Prioritize mResumedActivity, then ResumedActivity.
210
- // Use strict regex match to ensure it starts with the key, avoiding false positives like 'mLastResumedActivity'.
211
193
  let resumedLine = lines.find(line => /^\s*mResumedActivity:/.test(line));
212
194
 
213
195
  if (!resumedLine) {
@@ -224,17 +206,12 @@ export class AndroidObserve {
224
206
  }
225
207
  }
226
208
 
227
- // Regex to parse the line: ActivityRecord{... package/activity ...}
228
- // Matches: ActivityRecord{<hex> <user> <package>/<activity> ...}
229
- // We want to capture the component "package/activity" which is separated by space from other tokens.
230
- // We use greedy match ([^ \{}]+) for activity to ensure we get the full name until a space or closing brace.
231
209
  const match = resumedLine.match(/ActivityRecord\{[^ ]*(?:\s+[^ ]+)*\s+([^\/ ]+)\/([^ \{}]+)[^}]*\}/);
232
210
 
233
211
  if (match) {
234
212
  const packageName = match[1];
235
213
  let activityName = match[2];
236
214
 
237
- // Handle relative activity names (e.g. .LoginActivity)
238
215
  if (activityName.startsWith('.')) {
239
216
  activityName = packageName + activityName;
240
217
  }
@@ -268,6 +245,18 @@ export class AndroidObserve {
268
245
  }
269
246
  }
270
247
 
248
+ async getScreenFingerprint(deviceId?: string): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
249
+ try {
250
+ const tree = await this.getUITree(deviceId)
251
+ if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
252
+
253
+ const current = await this.getCurrentScreen(deviceId).catch(() => null)
254
+ return computeScreenFingerprint(tree, current, 'android', 50)
255
+ } catch (e) {
256
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
257
+ }
258
+ }
259
+
271
260
  async startLogStream(packageName: string, level: 'error' | 'warn' | 'info' | 'debug' = 'error', deviceId?: string, sessionId: string = 'default') {
272
261
  try {
273
262
  const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '')
@@ -328,7 +317,6 @@ export class AndroidObserve {
328
317
  }
329
318
 
330
319
  async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string) {
331
- // Prefer active stream if present, otherwise fall back to a well-known NDJSON file for the session
332
320
  const entry = activeLogStreams.get(sessionId)
333
321
  let file: string | undefined
334
322
  if (entry && entry.file) file = entry.file
@@ -0,0 +1,92 @@
1
+ import { resolveTargetDevice } from '../utils/resolve-device.js'
2
+ import { AndroidObserve } from './android.js'
3
+ import { iOSObserve } from './ios.js'
4
+
5
+ export { AndroidObserve } from './android.js'
6
+ export { iOSObserve } from './ios.js'
7
+
8
+ export class ToolsObserve {
9
+ // Resolve a target device and return the appropriate observe instance and resolved info.
10
+ private static async resolveObserve(platform?: 'android' | 'ios', deviceId?: string, appId?: string) {
11
+ if (platform === 'android') {
12
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId })
13
+ return { observe: new AndroidObserve(), resolved }
14
+ }
15
+ if (platform === 'ios') {
16
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId })
17
+ return { observe: new iOSObserve(), resolved }
18
+ }
19
+
20
+ // No platform specified: try android then ios
21
+ try {
22
+ const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId })
23
+ return { observe: new AndroidObserve(), resolved }
24
+ } catch {
25
+ const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId })
26
+ return { observe: new iOSObserve(), resolved }
27
+ }
28
+ }
29
+
30
+ static async getUITreeHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
31
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
32
+ return await observe.getUITree(resolved.id)
33
+ }
34
+
35
+ static async getCurrentScreenHandler({ deviceId }: { deviceId?: string }) {
36
+ const { observe, resolved } = await ToolsObserve.resolveObserve('android', deviceId)
37
+ // getCurrentScreen is Android-specific
38
+ return await (observe as AndroidObserve).getCurrentScreen(resolved.id)
39
+ }
40
+
41
+ static async getLogsHandler({ platform, appId, deviceId, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, lines?: number }) {
42
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
43
+ if (observe instanceof AndroidObserve) {
44
+ const response = await observe.getLogs(appId, lines ?? 200, resolved.id)
45
+ const logs = Array.isArray(response.logs) ? response.logs : []
46
+ const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'))
47
+ return { device: response.device, logs, crashLines }
48
+ } else {
49
+ const resp = await (observe as iOSObserve).getLogs(appId, resolved.id)
50
+ const logs = Array.isArray(resp.logs) ? resp.logs : []
51
+ const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'))
52
+ return { device: resp.device, logs, crashLines }
53
+ }
54
+ }
55
+
56
+ static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }: { platform?: 'android' | 'ios', packageName: string, level?: 'error' | 'warn' | 'info' | 'debug', sessionId?: string, deviceId?: string }) {
57
+ const sid = sessionId || 'default'
58
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, packageName)
59
+ if (observe instanceof AndroidObserve) {
60
+ return await observe.startLogStream(packageName, level || 'error', resolved.id, sid)
61
+ } else {
62
+ return await (observe as iOSObserve).startLogStream(packageName, resolved.id, sid)
63
+ }
64
+ }
65
+
66
+ static async readLogStreamHandler({ platform, sessionId, limit, since }: { platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
67
+ const sid = sessionId || 'default'
68
+ const { observe } = await ToolsObserve.resolveObserve(platform)
69
+ return await (observe as any).readLogStream(sid, limit ?? 100, since)
70
+ }
71
+
72
+ static async stopLogStreamHandler({ platform, sessionId }: { platform?: 'android' | 'ios', sessionId?: string }) {
73
+ const sid = sessionId || 'default'
74
+ const { observe } = await ToolsObserve.resolveObserve(platform)
75
+ return await (observe as any).stopLogStream(sid)
76
+ }
77
+
78
+ static async captureScreenshotHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
79
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
80
+ if (observe instanceof AndroidObserve) {
81
+ return await observe.captureScreen(resolved.id)
82
+ } else {
83
+ return await (observe as iOSObserve).captureScreenshot(resolved.id)
84
+ }
85
+ }
86
+
87
+ static async getScreenFingerprintHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string } = {}) {
88
+ const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
89
+ // Both observes implement getScreenFingerprint
90
+ return await (observe as any).getScreenFingerprint(resolved.id)
91
+ }
92
+ }