mobile-debug-mcp 0.10.0 → 0.12.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 (67) hide show
  1. package/README.md +20 -5
  2. package/dist/android/diagnostics.js +24 -0
  3. package/dist/android/interact.js +1 -145
  4. package/dist/android/manage.js +162 -0
  5. package/dist/android/observe.js +133 -88
  6. package/dist/android/run.js +187 -0
  7. package/dist/android/utils.js +137 -147
  8. package/dist/ios/interact.js +4 -175
  9. package/dist/ios/manage.js +169 -0
  10. package/dist/ios/observe.js +129 -13
  11. package/dist/ios/run.js +200 -0
  12. package/dist/ios/utils.js +138 -124
  13. package/dist/server.js +45 -17
  14. package/dist/tools/interact.js +21 -71
  15. package/dist/tools/manage.js +180 -0
  16. package/dist/tools/observe.js +23 -69
  17. package/dist/tools/run.js +180 -0
  18. package/dist/utils/diagnostics.js +25 -0
  19. package/docs/CHANGELOG.md +14 -0
  20. package/eslint.config.js +22 -1
  21. package/package.json +8 -5
  22. package/scripts/check-idb.js +83 -0
  23. package/scripts/check-idb.ts +73 -0
  24. package/scripts/idb-helper.ts +76 -0
  25. package/scripts/install-idb.js +88 -0
  26. package/scripts/install-idb.ts +90 -0
  27. package/scripts/run-ios-smoke.ts +34 -0
  28. package/scripts/run-ios-ui-tree-tap.ts +33 -0
  29. package/src/android/diagnostics.ts +23 -0
  30. package/src/android/interact.ts +2 -155
  31. package/src/android/manage.ts +157 -0
  32. package/src/android/observe.ts +129 -97
  33. package/src/android/utils.ts +147 -149
  34. package/src/ios/interact.ts +5 -181
  35. package/src/ios/manage.ts +164 -0
  36. package/src/ios/observe.ts +130 -14
  37. package/src/ios/utils.ts +127 -128
  38. package/src/server.ts +47 -17
  39. package/src/tools/interact.ts +23 -62
  40. package/src/tools/manage.ts +171 -0
  41. package/src/tools/observe.ts +24 -74
  42. package/src/types.ts +9 -0
  43. package/src/utils/diagnostics.ts +36 -0
  44. package/test/device/README.md +49 -0
  45. package/test/device/index.ts +27 -0
  46. package/test/device/manage/run-build-install-ios.ts +82 -0
  47. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  48. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  49. package/test/{integration → device/observe}/logstream-real.ts +5 -4
  50. package/test/{integration → device/utils}/test-dist.ts +2 -2
  51. package/test/unit/index.ts +10 -6
  52. package/test/unit/manage/build.test.ts +83 -0
  53. package/test/unit/manage/build_and_install.test.ts +134 -0
  54. package/test/unit/manage/diagnostics.test.ts +85 -0
  55. package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
  56. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  57. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
  58. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  59. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  60. package/tsconfig.json +2 -1
  61. package/test/integration/index.ts +0 -8
  62. package/test/integration/test-dist.mjs +0 -41
  63. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  64. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  65. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  66. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  67. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
@@ -0,0 +1,164 @@
1
+ import { promises as fs } from "fs"
2
+ import { spawn } from "child_process"
3
+ import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
4
+ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js"
5
+ import path from "path"
6
+
7
+ export class iOSManage {
8
+ async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
9
+ void _variant
10
+ try {
11
+ const files = await fs.readdir(projectPath).catch(() => [])
12
+ const workspace = files.find(f => f.endsWith('.xcworkspace'))
13
+ const proj = files.find(f => f.endsWith('.xcodeproj'))
14
+ if (!workspace && !proj) return { error: 'No Xcode project or workspace found' }
15
+
16
+ let buildArgs: string[]
17
+ if (workspace) {
18
+ const workspacePath = path.join(projectPath, workspace)
19
+ const scheme = workspace.replace(/\.xcworkspace$/, '')
20
+ buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
21
+ } else {
22
+ const projectPathFull = path.join(projectPath, proj!)
23
+ const scheme = proj!.replace(/\.xcodeproj$/, '')
24
+ buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build']
25
+ }
26
+
27
+ await new Promise<void>((resolve, reject) => {
28
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
29
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath })
30
+ let stderr = ''
31
+ proc.stderr?.on('data', d => stderr += d.toString())
32
+ proc.on('close', code => {
33
+ if (code === 0) resolve()
34
+ else reject(new Error(stderr || `xcodebuild failed with code ${code}`))
35
+ })
36
+ proc.on('error', err => reject(err))
37
+ })
38
+
39
+ const built = await findAppBundle(projectPath)
40
+ if (!built) return { error: 'Could not find .app after build' }
41
+ return { artifactPath: built }
42
+ } catch (e) {
43
+ return { error: e instanceof Error ? e.message : String(e) }
44
+ }
45
+ }
46
+
47
+ async installApp(appPath: string, deviceId: string = "booted"): Promise<InstallAppResponse> {
48
+ const device = await getIOSDeviceMetadata(deviceId)
49
+
50
+ try {
51
+ let toInstall = appPath
52
+
53
+ const stat = await fs.stat(appPath).catch(() => null)
54
+ if (stat && stat.isDirectory()) {
55
+ if (appPath.endsWith('.app')) {
56
+ toInstall = appPath
57
+ } else {
58
+ const found = await findAppBundle(appPath)
59
+ if (found) {
60
+ toInstall = found
61
+ } else {
62
+ // Reuse the existing build() implementation to avoid duplicating the xcodebuild logic
63
+ const buildRes = await this.build(appPath)
64
+ if ((buildRes as any).error) throw new Error((buildRes as any).error)
65
+ toInstall = (buildRes as any).artifactPath
66
+ }
67
+ }
68
+ }
69
+
70
+ try {
71
+ const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId)
72
+ return { device, installed: true, output: res.output }
73
+ } catch (e) {
74
+ // Gather diagnostics for simctl failure
75
+ const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId)
76
+ try {
77
+ const child = spawn(getIdbCmd(), ['--version'])
78
+ const idbExists = await new Promise<boolean>((resolve) => {
79
+ child.on('error', () => resolve(false));
80
+ child.on('close', (code) => resolve(code === 0));
81
+ });
82
+ if (idbExists) {
83
+ // attempt idb install via spawn but include diagnostics
84
+ await new Promise<void>((resolve, reject) => {
85
+ const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
86
+ let stderr = '';
87
+ proc.stderr.on('data', d => stderr += d.toString());
88
+ proc.on('close', code => {
89
+ if (code === 0) resolve();
90
+ else reject(new Error(stderr || `idb install failed with code ${code}`));
91
+ });
92
+ proc.on('error', err => reject(err));
93
+ });
94
+ return { device, installed: true }
95
+ }
96
+ } catch {}
97
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
98
+ }
99
+ } catch (e) {
100
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
101
+ }
102
+ }
103
+
104
+ async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
105
+ validateBundleId(bundleId)
106
+ try {
107
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
108
+ const device = await getIOSDeviceMetadata(deviceId)
109
+ return { device, appStarted: !!result.output, launchTimeMs: 1000 }
110
+ } catch (e:any) {
111
+ const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
112
+ const device = await getIOSDeviceMetadata(deviceId)
113
+ return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
114
+ }
115
+ }
116
+
117
+ async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
118
+ validateBundleId(bundleId)
119
+ try {
120
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
121
+ const device = await getIOSDeviceMetadata(deviceId)
122
+ return { device, appTerminated: true }
123
+ } catch (e:any) {
124
+ const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
125
+ const device = await getIOSDeviceMetadata(deviceId)
126
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
127
+ }
128
+ }
129
+
130
+ async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
131
+ await this.terminateApp(bundleId, deviceId)
132
+ const startResult = await this.startApp(bundleId, deviceId)
133
+ return { device: startResult.device, appRestarted: startResult.appStarted, launchTimeMs: startResult.launchTimeMs }
134
+ }
135
+
136
+ async resetAppData(bundleId: string, deviceId: string = "booted"): Promise<ResetAppDataResponse> {
137
+ validateBundleId(bundleId)
138
+ await this.terminateApp(bundleId, deviceId)
139
+ const device = await getIOSDeviceMetadata(deviceId)
140
+ try {
141
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
142
+ const dataPath = containerResult.output.trim()
143
+ if (!dataPath) throw new Error(`Could not find data container for ${bundleId}`)
144
+
145
+ try {
146
+ const libraryPath = `${dataPath}/Library`
147
+ const documentsPath = `${dataPath}/Documents`
148
+ const tmpPath = `${dataPath}/tmp`
149
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => {})
150
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => {})
151
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
152
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => {})
153
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => {})
154
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => {})
155
+ return { device, dataCleared: true }
156
+ } catch (e) {
157
+ throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`)
158
+ }
159
+ } catch (e:any) {
160
+ const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
161
+ return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
162
+ }
163
+ }
164
+ }
@@ -1,7 +1,10 @@
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, IDB } from "./utils.js"
4
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js"
5
+ import { createWriteStream, promises as fsPromises } from 'fs'
6
+ import path from 'path'
7
+ import { parseLogLine } from '../android/utils.js'
5
8
 
6
9
  // --- Helper Functions Specific to Observe ---
7
10
 
@@ -22,6 +25,17 @@ interface IDBElement {
22
25
 
23
26
  function parseIDBFrame(frame: any): [number, number, number, number] {
24
27
  if (!frame) return [0, 0, 0, 0];
28
+ // Handle string frames like "{{0, 0}, {402, 874}}"
29
+ if (typeof frame === 'string') {
30
+ const nums = frame.match(/-?\d+(?:\.\d+)?/g);
31
+ if (!nums || nums.length < 4) return [0, 0, 0, 0];
32
+ const x = Number(nums[0]);
33
+ const y = Number(nums[1]);
34
+ const w = Number(nums[2]);
35
+ const h = Number(nums[3]);
36
+ return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
37
+ }
38
+
25
39
  const x = Number(frame.x || 0);
26
40
  const y = Number(frame.y || 0);
27
41
  const w = Number(frame.width || frame.w || 0);
@@ -99,14 +113,18 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
99
113
  return currentIndex;
100
114
  }
101
115
 
102
- // Check if IDB is installed
103
- async function isIDBInstalled(): Promise<boolean> {
104
- return new Promise((resolve) => {
105
- // Check if 'idb' is in path by trying to run it
106
- const child = spawn(IDB, ['--version']);
107
- child.on('error', () => resolve(false));
108
- child.on('close', (code) => resolve(code === 0));
109
- });
116
+
117
+
118
+ // iOS live log stream support (moved from ios/utils to observe)
119
+ const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
120
+
121
+ // Test helpers
122
+ export function _setIOSActiveLogStream(sessionId: string, file: string) {
123
+ iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
124
+ }
125
+
126
+ export function _clearIOSActiveLogStream(sessionId: string) {
127
+ iosActiveLogStreams.delete(sessionId)
110
128
  }
111
129
 
112
130
  export class iOSObserve {
@@ -192,13 +210,13 @@ export class iOSObserve {
192
210
  // Stabilization delay
193
211
  await delay(300 + (attempts * 100));
194
212
 
195
- const args = ['ui', 'describe', '--json'];
213
+ const args = ['ui', 'describe-all', '--json'];
196
214
  if (targetUdid) {
197
215
  args.push('--udid', targetUdid);
198
216
  }
199
217
 
200
218
  const output = await new Promise<string>((resolve, reject) => {
201
- const child = spawn(IDB, args);
219
+ const child = spawn(getIdbCmd(), args);
202
220
  let stdout = '';
203
221
  let stderr = '';
204
222
 
@@ -237,9 +255,14 @@ export class iOSObserve {
237
255
 
238
256
  try {
239
257
  const elements: UIElement[] = [];
240
- const root = jsonContent;
241
-
242
- traverseIDBNode(root, elements);
258
+ // idb describe-all returns either a root object or an array of root nodes
259
+ if (Array.isArray(jsonContent)) {
260
+ for (const node of jsonContent) {
261
+ traverseIDBNode(node, elements);
262
+ }
263
+ } else {
264
+ traverseIDBNode(jsonContent, elements);
265
+ }
243
266
 
244
267
  // Infer resolution from root element if possible (usually the Window/Application frame)
245
268
  let width = 0;
@@ -266,4 +289,97 @@ export class iOSObserve {
266
289
  };
267
290
  }
268
291
  }
292
+
293
+ // --- Log stream methods ---
294
+ async startLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
295
+ try {
296
+ const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`
297
+
298
+ if (iosActiveLogStreams.has(sessionId)) {
299
+ try { iosActiveLogStreams.get(sessionId)!.proc.kill() } catch {}
300
+ iosActiveLogStreams.delete(sessionId)
301
+ }
302
+
303
+ const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate]
304
+ const proc = spawn(getXcrunCmd(), args)
305
+
306
+ // Prepare output file
307
+ const tmpDir = process.env.TMPDIR || '/tmp'
308
+ const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`)
309
+ const stream = createWriteStream(file, { flags: 'a' })
310
+
311
+ proc.stdout.on('data', (chunk) => {
312
+ const text = chunk.toString()
313
+ const lines = text.split(/\r?\n/).filter(Boolean)
314
+ for (const l of lines) {
315
+ const entry = parseLogLine(l)
316
+ stream.write(JSON.stringify(entry) + '\n')
317
+ }
318
+ })
319
+
320
+ proc.stderr.on('data', (chunk) => {
321
+ const text = chunk.toString()
322
+ const lines = text.split(/\r?\n/).filter(Boolean)
323
+ for (const l of lines) {
324
+ const entry = { timestamp: '', level: 'E', tag: 'xcrun', message: l }
325
+ stream.write(JSON.stringify(entry) + '\n')
326
+ }
327
+ })
328
+
329
+ proc.on('close', () => {
330
+ stream.end()
331
+ iosActiveLogStreams.delete(sessionId)
332
+ })
333
+
334
+ iosActiveLogStreams.set(sessionId, { proc, file })
335
+ return { success: true, stream_started: true }
336
+ } catch {
337
+ return { success: false, error: 'log_stream_start_failed' }
338
+ }
339
+ }
340
+
341
+ async stopLogStream(sessionId: string = 'default'): Promise<{ success: boolean }> {
342
+ const entry = iosActiveLogStreams.get(sessionId)
343
+ if (!entry) return { success: true }
344
+ try { entry.proc.kill() } catch {}
345
+ iosActiveLogStreams.delete(sessionId)
346
+ return { success: true }
347
+ }
348
+
349
+ async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
350
+ const entry = iosActiveLogStreams.get(sessionId)
351
+ if (!entry) return { entries: [] }
352
+ try {
353
+ const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '')
354
+ if (!data) return { entries: [], crash_summary: { crash_detected: false } }
355
+ const lines = data.split(/\r?\n/).filter(Boolean)
356
+ const parsed = lines.map(l => {
357
+ try {
358
+ return JSON.parse(l)
359
+ } catch {
360
+ return { message: l, _iso: null, crash: false }
361
+ }
362
+ })
363
+
364
+ let filtered = parsed
365
+ if (since) {
366
+ let sinceMs: number | null = null
367
+ if (/^\d+$/.test(since)) sinceMs = Number(since)
368
+ else {
369
+ const sDate = new Date(since)
370
+ if (!isNaN(sDate.getTime())) sinceMs = sDate.getTime()
371
+ }
372
+ if (sinceMs !== null) {
373
+ filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs))
374
+ }
375
+ }
376
+
377
+ const entries = filtered.slice(-Math.max(0, limit))
378
+ const crashEntry = entries.find(e => e.crash)
379
+ const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false }
380
+ return { entries, crash_summary }
381
+ } catch {
382
+ return { entries: [], crash_summary: { crash_detected: false } }
383
+ }
384
+ }
269
385
  }
package/src/ios/utils.ts CHANGED
@@ -1,8 +1,76 @@
1
- import { execFile, spawn } from "child_process"
1
+ import { execFile, spawn, execSync, spawnSync } from "child_process"
2
2
  import { DeviceInfo } from "../types.js"
3
+ import { promises as fsPromises } from 'fs'
4
+ import path from 'path'
5
+ import { makeEnvSnapshot } from '../utils/diagnostics.js'
6
+
7
+ export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun' }
8
+
9
+ export function getConfiguredIdbPath(): string | undefined {
10
+ if (process.env.MCP_IDB_PATH) return process.env.MCP_IDB_PATH
11
+ if (process.env.IDB_PATH) return process.env.IDB_PATH
12
+ const cfgPaths = [
13
+ process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
14
+ `${process.cwd()}/mcp.config.json`
15
+ ]
16
+ try {
17
+ const fs = require('fs')
18
+ for (const p of cfgPaths) {
19
+ if (!p) continue
20
+ try {
21
+ if (fs.existsSync(p)) {
22
+ const raw = fs.readFileSync(p, 'utf8')
23
+ const json = JSON.parse(raw)
24
+ if (json) {
25
+ if (json.idbPath) return json.idbPath
26
+ if (json.IDB_PATH) return json.IDB_PATH
27
+ }
28
+ }
29
+ } catch {}
30
+ }
31
+ } catch {}
32
+ return undefined
33
+ }
3
34
 
4
- export const XCRUN = process.env.XCRUN_PATH || "xcrun"
5
- export const IDB = "idb"
35
+ export function getIdbCmd() {
36
+ const cfg = getConfiguredIdbPath()
37
+ if (cfg) return cfg
38
+ if (process.env.IDB_PATH) return process.env.IDB_PATH
39
+ try {
40
+ const p = execSync('which idb', { stdio: ['ignore','pipe','ignore'] }).toString().trim()
41
+ if (p) return p
42
+ } catch {}
43
+ try {
44
+ const p2 = execSync('command -v idb', { stdio: ['ignore','pipe','ignore'] }).toString().trim()
45
+ if (p2) return p2
46
+ } catch {}
47
+ // check common user locations
48
+ const common = [
49
+ `${process.env.HOME}/Library/Python/3.9/bin/idb`,
50
+ `${process.env.HOME}/Library/Python/3.10/bin/idb`,
51
+ '/opt/homebrew/bin/idb',
52
+ '/usr/local/bin/idb',
53
+ ]
54
+ for (const c of common) {
55
+ try { execSync(`test -x ${c}`, { stdio: ['ignore','pipe','ignore'] }); return c } catch {}
56
+ }
57
+ return 'idb'
58
+ }
59
+
60
+ export async function isIDBInstalled(): Promise<boolean> {
61
+ const cmd = getIdbCmd()
62
+ try {
63
+ execSync(`command -v ${cmd}`, { stdio: ['ignore','pipe','ignore'] })
64
+ return true
65
+ } catch {
66
+ try {
67
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','ignore'], timeout: 2000 })
68
+ return true
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+ }
6
74
 
7
75
  export interface IOSResult {
8
76
  output: string
@@ -21,7 +89,7 @@ export function validateBundleId(bundleId: string) {
21
89
  export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
22
90
  return new Promise((resolve, reject) => {
23
91
  // Use spawn for better stream control and consistency with Android implementation
24
- const child = spawn(XCRUN, args)
92
+ const child = spawn(getXcrunCmd(), args)
25
93
 
26
94
  let stdout = ''
27
95
  let stderr = ''
@@ -38,10 +106,12 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
38
106
  })
39
107
  }
40
108
 
41
- const timeoutMs = args.includes('log') ? 10000 : 5000 // 10s for logs, 5s for others
109
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
110
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
111
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT // choose appropriate timeout
42
112
  const timeout = setTimeout(() => {
43
113
  child.kill()
44
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`))
114
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`))
45
115
  }, timeoutMs)
46
116
 
47
117
  child.on('close', (code) => {
@@ -60,6 +130,39 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
60
130
  })
61
131
  }
62
132
 
133
+ export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
134
+ // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
135
+ const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
136
+ const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
137
+ const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT
138
+ const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs }) as any
139
+ const runResult = {
140
+ exitCode: typeof res.status === 'number' ? res.status : null,
141
+ stdout: res.stdout || '',
142
+ stderr: res.stderr || '',
143
+ envSnapshot: makeEnvSnapshot(['PATH','IDB_PATH','JAVA_HOME','HOME']),
144
+ command: getXcrunCmd(),
145
+ args,
146
+ deviceId
147
+ }
148
+
149
+ if (res.status !== 0) {
150
+ // include suggested fixes for common errors
151
+ const suggested: string[] = []
152
+ if ((runResult.stderr || '').includes('xcodebuild: error')) {
153
+ suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.')
154
+ }
155
+ if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
156
+ suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.')
157
+ }
158
+
159
+ // Return diagnostics object
160
+ return { runResult: { ...runResult, suggestedFixes: suggested } }
161
+ }
162
+
163
+ return { runResult: { ...runResult, suggestedFixes: [] } }
164
+ }
165
+
63
166
  function parseRuntimeName(runtime: string): string {
64
167
  // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
65
168
  try {
@@ -82,13 +185,22 @@ function parseRuntimeName(runtime: string): string {
82
185
  }
83
186
  }
84
187
 
188
+ export async function findAppBundle(dir: string): Promise<string | undefined> {
189
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => [])
190
+ for (const e of entries) {
191
+ const full = path.join(dir, e.name)
192
+ if (e.isDirectory()) {
193
+ if (full.endsWith('.app')) return full
194
+ const found = await findAppBundle(full)
195
+ if (found) return found
196
+ }
197
+ }
198
+ return undefined
199
+ }
85
200
  export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
86
201
  return new Promise((resolve) => {
87
- // If deviceId is provided (and not "booted"), we could try to list just that device.
88
- // But listing all booted devices is usually fine to find the one we want or just one.
89
- // Let's stick to listing all and filtering if needed, or just return basic info if we can't find it.
90
- execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
91
- // Default fallback
202
+ // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
203
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
92
204
  const fallback: DeviceInfo = {
93
205
  platform: "ios",
94
206
  id: deviceId,
@@ -105,14 +217,13 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
105
217
  try {
106
218
  const data = JSON.parse(stdout)
107
219
  const devicesMap = data.devices || {}
108
-
109
- // Find the device
220
+
110
221
  for (const runtime in devicesMap) {
111
222
  const devices = devicesMap[runtime]
112
223
  if (Array.isArray(devices)) {
113
224
  for (const device of devices) {
114
225
  if (deviceId === "booted" || device.udid === deviceId) {
115
- resolve({
226
+ resolve({
116
227
  platform: "ios",
117
228
  id: device.udid,
118
229
  osVersion: parseRuntimeName(runtime),
@@ -124,6 +235,7 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
124
235
  }
125
236
  }
126
237
  }
238
+
127
239
  resolve(fallback)
128
240
  } catch {
129
241
  resolve(fallback)
@@ -134,7 +246,7 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
134
246
 
135
247
  export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
136
248
  return new Promise((resolve) => {
137
- execFile(XCRUN, ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
249
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
138
250
  if (err || !stdout) return resolve([])
139
251
  try {
140
252
  const data = JSON.parse(stdout)
@@ -175,116 +287,3 @@ export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
175
287
  })
176
288
  })
177
289
  }
178
-
179
- // --- iOS live log stream support ---
180
- import { createWriteStream, promises as fsPromises } from 'fs'
181
- import path from 'path'
182
- import { parseLogLine } from '../android/utils.js'
183
-
184
- const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
185
-
186
- // Test helpers
187
- export function _setIOSActiveLogStream(sessionId: string, file: string) {
188
- iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
189
- }
190
-
191
- export function _clearIOSActiveLogStream(sessionId: string) {
192
- iosActiveLogStreams.delete(sessionId)
193
- }
194
-
195
- export async function startIOSLogStream(bundleId: string, deviceId: string = 'booted', sessionId: string = 'default') : Promise<{ success: boolean; stream_started?: boolean; error?: string }> {
196
- try {
197
- // Build predicate to filter by process or subsystem
198
- const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`
199
-
200
- // Prevent multiple streams per session
201
- if (iosActiveLogStreams.has(sessionId)) {
202
- try { iosActiveLogStreams.get(sessionId)!.proc.kill() } catch {}
203
- iosActiveLogStreams.delete(sessionId)
204
- }
205
-
206
- // Start simctl log stream: xcrun simctl spawn <device> log stream --style syslog --predicate '<predicate>'
207
- const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate]
208
- const proc = spawn(XCRUN, args)
209
-
210
- // Prepare output file
211
- const tmpDir = process.env.TMPDIR || '/tmp'
212
- const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`)
213
- const stream = createWriteStream(file, { flags: 'a' })
214
-
215
- proc.stdout.on('data', (chunk) => {
216
- const text = chunk.toString()
217
- const lines = text.split(/\r?\n/).filter(Boolean)
218
- for (const l of lines) {
219
- // Try to parse with shared parser; parser may be optimized for Android but extracts exceptions and message
220
- const entry = parseLogLine(l)
221
- stream.write(JSON.stringify(entry) + '\n')
222
- }
223
- })
224
-
225
- proc.stderr.on('data', (chunk) => {
226
- const text = chunk.toString()
227
- const lines = text.split(/\r?\n/).filter(Boolean)
228
- for (const l of lines) {
229
- const entry = { timestamp: '', level: 'E', tag: 'xcrun', message: l }
230
- stream.write(JSON.stringify(entry) + '\n')
231
- }
232
- })
233
-
234
- proc.on('close', () => {
235
- stream.end()
236
- iosActiveLogStreams.delete(sessionId)
237
- })
238
-
239
- iosActiveLogStreams.set(sessionId, { proc, file })
240
- return { success: true, stream_started: true }
241
- } catch {
242
- return { success: false, error: 'log_stream_start_failed' }
243
- }
244
- }
245
-
246
- export async function stopIOSLogStream(sessionId: string = 'default'): Promise<{ success: boolean }> {
247
- const entry = iosActiveLogStreams.get(sessionId)
248
- if (!entry) return { success: true }
249
- try { entry.proc.kill() } catch {}
250
- iosActiveLogStreams.delete(sessionId)
251
- return { success: true }
252
- }
253
-
254
- export async function readIOSLogStreamLines(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
255
- const entry = iosActiveLogStreams.get(sessionId)
256
- if (!entry) return { entries: [] }
257
- try {
258
- const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '')
259
- if (!data) return { entries: [], crash_summary: { crash_detected: false } }
260
- const lines = data.split(/\r?\n/).filter(Boolean)
261
- const parsed = lines.map(l => {
262
- try {
263
- return JSON.parse(l)
264
- } catch {
265
- return { message: l, _iso: null, crash: false }
266
- }
267
- })
268
-
269
- // Minimal since filtering if provided
270
- let filtered = parsed
271
- if (since) {
272
- let sinceMs: number | null = null
273
- if (/^\d+$/.test(since)) sinceMs = Number(since)
274
- else {
275
- const sDate = new Date(since)
276
- if (!isNaN(sDate.getTime())) sinceMs = sDate.getTime()
277
- }
278
- if (sinceMs !== null) {
279
- filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs))
280
- }
281
- }
282
-
283
- const entries = filtered.slice(-Math.max(0, limit))
284
- const crashEntry = entries.find(e => e.crash)
285
- const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false }
286
- return { entries, crash_summary }
287
- } catch {
288
- return { entries: [], crash_summary: { crash_detected: false } }
289
- }
290
- }