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.
- package/README.md +20 -5
- package/dist/android/diagnostics.js +24 -0
- package/dist/android/interact.js +1 -145
- package/dist/android/manage.js +162 -0
- package/dist/android/observe.js +133 -88
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +137 -147
- package/dist/ios/interact.js +4 -175
- package/dist/ios/manage.js +169 -0
- package/dist/ios/observe.js +129 -13
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +138 -124
- package/dist/server.js +45 -17
- package/dist/tools/interact.js +21 -71
- package/dist/tools/manage.js +180 -0
- package/dist/tools/observe.js +23 -69
- package/dist/tools/run.js +180 -0
- package/dist/utils/diagnostics.js +25 -0
- package/docs/CHANGELOG.md +14 -0
- package/eslint.config.js +22 -1
- package/package.json +8 -5
- package/scripts/check-idb.js +83 -0
- package/scripts/check-idb.ts +73 -0
- package/scripts/idb-helper.ts +76 -0
- package/scripts/install-idb.js +88 -0
- package/scripts/install-idb.ts +90 -0
- package/scripts/run-ios-smoke.ts +34 -0
- package/scripts/run-ios-ui-tree-tap.ts +33 -0
- package/src/android/diagnostics.ts +23 -0
- package/src/android/interact.ts +2 -155
- package/src/android/manage.ts +157 -0
- package/src/android/observe.ts +129 -97
- package/src/android/utils.ts +147 -149
- package/src/ios/interact.ts +5 -181
- package/src/ios/manage.ts +164 -0
- package/src/ios/observe.ts +130 -14
- package/src/ios/utils.ts +127 -128
- package/src/server.ts +47 -17
- package/src/tools/interact.ts +23 -62
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +24 -74
- package/src/types.ts +9 -0
- package/src/utils/diagnostics.ts +36 -0
- package/test/device/README.md +49 -0
- package/test/device/index.ts +27 -0
- package/test/device/manage/run-build-install-ios.ts +82 -0
- package/test/{integration → device/manage}/run-install-android.ts +4 -4
- package/test/{integration → device/manage}/run-install-ios.ts +4 -4
- package/test/{integration → device/observe}/logstream-real.ts +5 -4
- package/test/{integration → device/utils}/test-dist.ts +2 -2
- package/test/unit/index.ts +10 -6
- package/test/unit/manage/build.test.ts +83 -0
- package/test/unit/manage/build_and_install.test.ts +134 -0
- package/test/unit/manage/diagnostics.test.ts +85 -0
- package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
- package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
- package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
- package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
- package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
- package/tsconfig.json +2 -1
- package/test/integration/index.ts +0 -8
- package/test/integration/test-dist.mjs +0 -41
- /package/test/{integration → device/interact}/run-real-test.ts +0 -0
- /package/test/{integration → device/interact}/smoke-test.ts +0 -0
- /package/test/{integration → device/manage}/install.integration.ts +0 -0
- /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
- /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
|
+
}
|
package/src/ios/observe.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
5
|
-
|
|
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(
|
|
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
|
|
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: ${
|
|
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"),
|
|
88
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
}
|