mobile-debug-mcp 0.14.0 → 0.15.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 (98) 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 +37 -0
  7. package/dist/interact/ios.js +120 -0
  8. package/dist/interact/shared/fingerprint.js +72 -0
  9. package/dist/interact/shared/scroll_to_element.js +98 -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 +21 -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 +429 -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/ios/utils.js +301 -0
  44. package/dist/utils/resolve-device.js +2 -2
  45. package/docs/CHANGELOG.md +4 -0
  46. package/docs/tools/observe.md +24 -0
  47. package/package.json +1 -1
  48. package/src/{android/interact.ts → interact/android.ts} +3 -3
  49. package/src/{tools/interact.ts → interact/index.ts} +4 -3
  50. package/src/{ios/interact.ts → interact/ios.ts} +3 -3
  51. package/src/interact/shared/fingerprint.ts +73 -0
  52. package/src/{tools → interact/shared}/scroll_to_element.ts +1 -1
  53. package/src/{android/manage.ts → manage/android.ts} +2 -2
  54. package/src/{tools/manage.ts → manage/index.ts} +7 -4
  55. package/src/{ios/manage.ts → manage/ios.ts} +1 -1
  56. package/src/{android/observe.ts → observe/android.ts} +14 -26
  57. package/src/observe/index.ts +92 -0
  58. package/src/{ios/observe.ts → observe/ios.ts} +17 -35
  59. package/src/server.ts +23 -6
  60. package/src/{android → utils/android}/utils.ts +2 -2
  61. package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
  62. package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
  63. package/src/utils/diagnostics.ts +1 -1
  64. package/src/{ios → utils/ios}/utils.ts +2 -2
  65. package/src/utils/resolve-device.ts +2 -2
  66. package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
  67. package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
  68. package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
  69. package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
  70. package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
  71. package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
  72. package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
  73. package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
  74. package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
  75. package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
  76. package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
  77. package/test/observe/device/run-screen-fingerprint.ts +36 -0
  78. package/test/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
  79. package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
  80. package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
  81. package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
  82. package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
  83. package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
  84. package/test/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
  85. package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
  86. package/test/unit/index.ts +12 -11
  87. package/src/tools/observe.ts +0 -82
  88. package/test/device/README.md +0 -49
  89. package/test/device/index.ts +0 -27
  90. package/test/device/utils/test-dist.ts +0 -41
  91. package/test/unit/utils/detect-java.test.ts +0 -22
  92. /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
  93. /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
  94. /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
  95. /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
  96. /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
  97. /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
  98. /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
@@ -0,0 +1,73 @@
1
+ import crypto from 'crypto'
2
+ import { GetUITreeResponse, GetCurrentScreenResponse, UIElement } from '../../types.js'
3
+
4
+ const ANDROID_STRUCTURAL_TYPES = ['Window','Application','View','ViewGroup','LinearLayout','FrameLayout','RelativeLayout','ScrollView','RecyclerView','TextView','ImageView']
5
+ const IOS_STRUCTURAL_TYPES = ['Window','Application','View','ViewController','UITableView','UICollectionView','UILabel','UIImageView','UIView','UIWindow','UIStackView','UITextView','UITableViewCell']
6
+
7
+ function isDynamicText(t?: string): boolean {
8
+ if (!t) return false
9
+ const txt = t.trim()
10
+ if (!txt) return false
11
+ if (/\b\d{1,2}:\d{2}\b/.test(txt)) return true
12
+ if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt)) return true
13
+ if (/^\d+(?:\.\d+)?%$/.test(txt)) return true
14
+ if (/^\d+$/.test(txt)) return true
15
+ if (/^[\d,]{1,10}$/.test(txt)) return true
16
+ return false
17
+ }
18
+
19
+ function normalizeElement(e: UIElement) {
20
+ return {
21
+ type: (e.type || '').toString(),
22
+ resourceId: (e.resourceId || '').toString(),
23
+ text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
24
+ contentDesc: (e.contentDescription || '').toString(),
25
+ bounds: Array.isArray(e.bounds) ? e.bounds.slice(0,4).map((n:any)=>Number(n)||0) : [0,0,0,0]
26
+ }
27
+ }
28
+
29
+ export function computeScreenFingerprint(tree: GetUITreeResponse, current: GetCurrentScreenResponse | null, platform: 'android' | 'ios', limit: number = 50): { fingerprint: string | null; activity?: string; error?: string } {
30
+ try {
31
+ if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
32
+
33
+ const activity = current && (current.activity || (current as any).shortActivity) ? (current.activity || (current as any).shortActivity) : ''
34
+
35
+ const candidates: UIElement[] = (tree.elements || []).filter(e => {
36
+ if (!e) return false
37
+ if (!e.visible) return false
38
+ const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0
39
+ const hasResource = !!e.resourceId
40
+ const interactable = !!e.clickable || !!e.enabled
41
+ const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES
42
+ const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '')
43
+ return interactable || structurallySignificant
44
+ }) as UIElement[]
45
+
46
+ const normalized = candidates.map(normalizeElement)
47
+
48
+ const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0))
49
+
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) return ay - by
54
+ const ax = (a.bounds && a.bounds[0]) || 0
55
+ const bx = (b.bounds && b.bounds[0]) || 0
56
+ return ax - bx
57
+ })
58
+
59
+ const limited = filteredNormalized.slice(0, Math.max(0, limit))
60
+
61
+ const payload = {
62
+ activity: platform === 'android' ? (activity || '') : '',
63
+ resolution: (tree as any).resolution || { width: 0, height: 0 },
64
+ elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
65
+ }
66
+
67
+ const combined = JSON.stringify(payload)
68
+ const hash = crypto.createHash('sha256').update(combined).digest('hex')
69
+ return { fingerprint: hash, activity: activity }
70
+ } catch (e) {
71
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
72
+ }
73
+ }
@@ -1,4 +1,4 @@
1
- import { UIElement, GetUITreeResponse, SwipeResponse } from '../types.js'
1
+ import { UIElement, GetUITreeResponse, SwipeResponse } from '../../types.js'
2
2
 
3
3
  export interface ScrollSelector { text?: string; resourceId?: string; contentDesc?: string; className?: string }
4
4
 
@@ -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 "../interact/shared/fingerprint.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
+ }
@@ -1,12 +1,11 @@
1
1
  import { spawn } from "child_process"
2
2
  import { promises as fs } from "fs"
3
3
  import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo } from "../types.js"
4
- import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js"
4
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
5
5
  import { createWriteStream, promises as fsPromises } from 'fs'
6
6
  import path from 'path'
7
- import { parseLogLine } from '../android/utils.js'
8
-
9
- // --- Helper Functions Specific to Observe ---
7
+ import { parseLogLine } from '../utils/android/utils.js'
8
+ import { computeScreenFingerprint } from '../interact/shared/fingerprint.js'
10
9
 
11
10
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
12
11
 
@@ -25,7 +24,6 @@ interface IDBElement {
25
24
 
26
25
  function parseIDBFrame(frame: any): [number, number, number, number] {
27
26
  if (!frame) return [0, 0, 0, 0];
28
- // Handle string frames like "{{0, 0}, {402, 874}}"
29
27
  if (typeof frame === 'string') {
30
28
  const nums = frame.match(/-?\d+(?:\.\d+)?/g);
31
29
  if (!nums || nums.length < 4) return [0, 0, 0, 0];
@@ -53,29 +51,25 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
53
51
 
54
52
  let currentIndex = -1;
55
53
 
56
- // Prefer standard keys, fallback to alternatives
57
54
  const type = node.AXElementType || node.type || "unknown";
58
55
  const label = node.AXLabel || node.label || null;
59
56
  const value = node.AXValue || null;
60
57
  const frame = node.AXFrame || node.frame;
61
58
  const traits = node.AXTraits || [];
62
59
 
63
- const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell"; // Cells are often clickable
60
+ const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
64
61
 
65
- // Filtering Logic:
66
- // Keep if clickable OR has visible text/label OR has value
67
- // Also keep 'Window' or 'Application' types as they define the root structure often, though usually depth 0
68
62
  const isUseful = clickable || (label && label.length > 0) || (value && value.length > 0) || type === "Application" || type === "Window";
69
63
 
70
64
  if (isUseful) {
71
65
  const bounds = parseIDBFrame(frame);
72
66
  const element: UIElement = {
73
67
  text: label,
74
- contentDescription: value,
68
+ contentDescription: value,
75
69
  type: type,
76
70
  resourceId: node.AXUniqueId || null,
77
71
  clickable: clickable,
78
- enabled: true, // idb usually returns enabled elements
72
+ enabled: true,
79
73
  visible: true,
80
74
  bounds: bounds,
81
75
  center: getCenter(bounds),
@@ -90,8 +84,6 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
90
84
  currentIndex = elements.length - 1;
91
85
  }
92
86
 
93
- // If current node was skipped, children inherit parentIndex and depth (flattening)
94
- // If current node was added, children use currentIndex and depth + 1
95
87
  const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
96
88
  const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
97
89
 
@@ -113,12 +105,8 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
113
105
  return currentIndex;
114
106
  }
115
107
 
116
-
117
-
118
- // iOS live log stream support (moved from ios/utils to observe)
119
108
  const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
120
109
 
121
- // Test helpers
122
110
  export function _setIOSActiveLogStream(sessionId: string, file: string) {
123
111
  iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
124
112
  }
@@ -133,9 +121,6 @@ export class iOSObserve {
133
121
  }
134
122
 
135
123
  async getLogs(appId?: string, deviceId: string = "booted"): Promise<GetLogsResponse> {
136
- // If appId is provided, use predicate filtering
137
- // Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
138
- // but we do need to construct the predicate correctly for log show.
139
124
  const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
140
125
  if (appId) {
141
126
  validateBundleId(appId)
@@ -157,25 +142,19 @@ export class iOSObserve {
157
142
  const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
158
143
 
159
144
  try {
160
- // 1. Capture screenshot to temp file
161
145
  await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
162
146
 
163
- // 2. Read file as base64
164
147
  const buffer = await fs.readFile(tmpFile)
165
148
  const base64 = buffer.toString('base64')
166
149
 
167
- // 3. Clean up
168
150
  await fs.rm(tmpFile).catch(() => {})
169
151
 
170
152
  return {
171
153
  device,
172
154
  screenshot: base64,
173
- // Default resolution since we can't easily parse it without extra libs
174
- // Clients will read the real dimensions from the PNG header anyway
175
155
  resolution: { width: 0, height: 0 },
176
156
  }
177
157
  } catch (e) {
178
- // Ensure cleanup happens even on error
179
158
  await fs.rm(tmpFile).catch(() => {})
180
159
  throw new Error(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`)
181
160
  }
@@ -184,7 +163,6 @@ export class iOSObserve {
184
163
  async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
185
164
  const device = await getIOSDeviceMetadata(deviceId);
186
165
 
187
- // idb is required
188
166
  const idbExists = await isIDBInstalled();
189
167
  if (!idbExists) {
190
168
  return {
@@ -196,10 +174,8 @@ export class iOSObserve {
196
174
  };
197
175
  }
198
176
 
199
- // Resolve UDID if needed
200
177
  const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
201
178
 
202
- // Retry Logic
203
179
  let jsonContent: any = null;
204
180
  let attempts = 0;
205
181
  const maxAttempts = 3;
@@ -207,7 +183,6 @@ export class iOSObserve {
207
183
  while (attempts < maxAttempts) {
208
184
  attempts++;
209
185
  try {
210
- // Stabilization delay
211
186
  await delay(300 + (attempts * 100));
212
187
 
213
188
  const args = ['ui', 'describe-all', '--json'];
@@ -255,7 +230,6 @@ export class iOSObserve {
255
230
 
256
231
  try {
257
232
  const elements: UIElement[] = [];
258
- // idb describe-all returns either a root object or an array of root nodes
259
233
  if (Array.isArray(jsonContent)) {
260
234
  for (const node of jsonContent) {
261
235
  traverseIDBNode(node, elements);
@@ -264,7 +238,6 @@ export class iOSObserve {
264
238
  traverseIDBNode(jsonContent, elements);
265
239
  }
266
240
 
267
- // Infer resolution from root element if possible (usually the Window/Application frame)
268
241
  let width = 0;
269
242
  let height = 0;
270
243
  if (elements.length > 0) {
@@ -290,7 +263,17 @@ export class iOSObserve {
290
263
  }
291
264
  }
292
265
 
293
- // --- Log stream methods ---
266
+ async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
267
+ try {
268
+ const tree = await this.getUITree(deviceId)
269
+ if (!tree || tree.error) return { fingerprint: null, error: tree && (tree as any).error }
270
+
271
+ return computeScreenFingerprint(tree, null, 'ios', 50)
272
+ } catch (e) {
273
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
274
+ }
275
+ }
276
+
294
277
  async startLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
295
278
  try {
296
279
  const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`
@@ -303,7 +286,6 @@ export class iOSObserve {
303
286
  const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate]
304
287
  const proc = spawn(getXcrunCmd(), args)
305
288
 
306
- // Prepare output file
307
289
  const tmpDir = process.env.TMPDIR || '/tmp'
308
290
  const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`)
309
291
  const stream = createWriteStream(file, { flags: 'a' })
package/src/server.ts CHANGED
@@ -14,11 +14,11 @@ import {
14
14
  InstallAppResponse
15
15
  } from "./types.js"
16
16
 
17
- import { ToolsManage } from './tools/manage.js'
18
- import { ToolsInteract } from './tools/interact.js'
19
- import { ToolsObserve } from './tools/observe.js'
20
- import { AndroidManage } from './android/manage.js'
21
- import { iOSManage } from './ios/manage.js'
17
+ import { ToolsManage } from './manage/index.js'
18
+ import { ToolsInteract } from './interact/index.js'
19
+ import { ToolsObserve } from './observe/index.js'
20
+ import { AndroidManage } from './manage/index.js'
21
+ import { iOSManage } from './manage/index.js'
22
22
 
23
23
 
24
24
  const server = new Server(
@@ -283,6 +283,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
283
283
  }
284
284
  }
285
285
  },
286
+ {
287
+ name: "get_screen_fingerprint",
288
+ description: "Generate a stable fingerprint representing the current visible screen (activity + visible UI elements).",
289
+ inputSchema: {
290
+ type: "object",
291
+ properties: {
292
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
293
+ deviceId: { type: "string", description: "Optional device id/udid to target" }
294
+ }
295
+ }
296
+ },
286
297
  {
287
298
  name: "wait_for_element",
288
299
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -579,6 +590,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
579
590
  return wrapResponse(res)
580
591
  }
581
592
 
593
+ if (name === "get_screen_fingerprint") {
594
+ const { platform, deviceId } = (args || {}) as any
595
+ const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId })
596
+ return wrapResponse(res)
597
+ }
598
+
582
599
  if (name === "wait_for_element") {
583
600
  const { platform, text, timeout, deviceId } = (args || {}) as any
584
601
  const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId })
@@ -644,7 +661,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
644
661
  const transport = new StdioServerTransport()
645
662
 
646
663
  async function main() {
647
- await server.connect(transport)
664
+ await (server as any).connect(transport)
648
665
  }
649
666
 
650
667
  main().catch((error) => {
@@ -1,8 +1,8 @@
1
1
  import { spawn } from 'child_process'
2
- import { DeviceInfo, UIElement } from "../types.js"
2
+ import { DeviceInfo, UIElement } from "../../types.js"
3
3
  import { promises as fsPromises, existsSync } from 'fs'
4
4
  import path from 'path'
5
- import { detectJavaHome } from '../utils/java.js'
5
+ import { detectJavaHome } from '../java.js'
6
6
 
7
7
  export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
8
8
 
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../../ios/observe.js';
2
- import { iOSManage } from '../../ios/manage.js';
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSManage } from '../../../manage/index.js';
3
3
 
4
4
  async function main() {
5
5
  const appId = process.argv[2] || 'com.apple.springboard';
@@ -1,5 +1,5 @@
1
- import { iOSObserve } from '../../ios/observe.js';
2
- import { iOSInteract } from '../../ios/interact.js';
1
+ import { iOSObserve } from '../../../observe/index.js';
2
+ import { iOSInteract } from '../../../interact/index.js';
3
3
 
4
4
  async function main() {
5
5
  const deviceId = 'booted';
@@ -18,7 +18,7 @@ async function main() {
18
18
  process.exit(3);
19
19
  }
20
20
 
21
- const clickable = tree.elements.find(e => e.clickable) || tree.elements[0];
21
+ const clickable = tree.elements.find((e: any) => e.clickable) || tree.elements[0];
22
22
  console.log('Using element:', clickable.text || '(no text)', 'clickable=', clickable.clickable, 'center=', clickable.center);
23
23
  const [x,y] = clickable.center || [0,0];
24
24
 
@@ -37,7 +37,7 @@ export class DiagnosticError extends Error {
37
37
 
38
38
  // Exec ADB with diagnostics — moved from src/android/diagnostics.ts
39
39
  import { spawnSync } from 'child_process'
40
- import { getAdbCmd } from '../android/utils.js'
40
+ import { getAdbCmd } from './android/utils.js'
41
41
 
42
42
  export function execAdbWithDiagnostics(args: string[], deviceId?: string) {
43
43
  const adbArgs = deviceId ? ['-s', deviceId, ...args] : args
@@ -1,8 +1,8 @@
1
1
  import { execFile, spawn, execSync, spawnSync } from "child_process"
2
- import { DeviceInfo } from "../types.js"
2
+ import { DeviceInfo } from "../../types.js"
3
3
  import { promises as fsPromises } from 'fs'
4
4
  import path from 'path'
5
- import { makeEnvSnapshot } from '../utils/diagnostics.js'
5
+ import { makeEnvSnapshot } from '../diagnostics.js'
6
6
 
7
7
  export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun' }
8
8
 
@@ -1,6 +1,6 @@
1
1
  import { DeviceInfo } from "../types.js"
2
- import { listAndroidDevices } from "../android/utils.js"
3
- import { listIOSDevices } from "../ios/utils.js"
2
+ import { listAndroidDevices } from "./android/utils.js"
3
+ import { listIOSDevices } from "./ios/utils.js"
4
4
 
5
5
  export interface ResolveOptions {
6
6
  platform: "android" | "ios"