mobile-debug-mcp 0.11.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 +18 -5
- package/dist/android/diagnostics.js +24 -0
- package/dist/android/manage.js +33 -8
- package/dist/android/observe.js +4 -4
- package/dist/android/utils.js +3 -3
- package/dist/ios/interact.js +4 -8
- package/dist/ios/manage.js +50 -26
- package/dist/ios/observe.js +24 -15
- package/dist/ios/utils.js +121 -8
- package/dist/server.js +18 -0
- package/dist/utils/diagnostics.js +25 -0
- package/docs/CHANGELOG.md +10 -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/manage.ts +30 -8
- package/src/android/observe.ts +4 -4
- package/src/android/utils.ts +3 -3
- package/src/ios/interact.ts +4 -8
- package/src/ios/manage.ts +69 -48
- package/src/ios/observe.ts +24 -16
- package/src/ios/utils.ts +109 -8
- package/src/server.ts +19 -0
- 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/utils}/test-dist.ts +2 -2
- package/test/unit/index.ts +10 -6
- package/test/unit/{build.test.ts → manage/build.test.ts} +16 -17
- package/test/unit/{build_and_install.test.ts → manage/build_and_install.test.ts} +20 -18
- package/test/unit/manage/diagnostics.test.ts +85 -0
- package/test/unit/{install.test.ts → manage/install.test.ts} +26 -17
- package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
- package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +2 -2
- 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}/logstream-real.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
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
# Mobile
|
|
1
|
+
# Mobile Dev Agent Tools
|
|
2
2
|
|
|
3
3
|
A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
|
|
4
4
|
|
|
5
|
-
> **Note:** iOS support is currently
|
|
5
|
+
> **Note:** iOS support is currently only tested on simulator. Please use with caution and report any issues.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
- Node.js >= 18
|
|
10
|
-
- Android SDK (adb) for Android support
|
|
10
|
+
- [Android SDK](https://developer.android.com/studio) (adb) for Android support
|
|
11
11
|
- Xcode command-line tools for iOS support
|
|
12
|
-
-
|
|
12
|
+
- [idb](https://github.com/facebook/idb) for iOS device support
|
|
13
13
|
|
|
14
14
|
## Configuration example
|
|
15
15
|
|
|
@@ -19,12 +19,16 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
|
|
|
19
19
|
"mobile-debug": {
|
|
20
20
|
"command": "npx",
|
|
21
21
|
"args": ["--yes","mobile-debug-mcp","server"],
|
|
22
|
-
"env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun" }
|
|
22
|
+
"env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun", "IDB_PATH": "/path/to/idb" }
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
```
|
|
27
27
|
## Usage
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
After a crash tell the agent the following:
|
|
31
|
+
|
|
28
32
|
I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
|
|
29
33
|
|
|
30
34
|
## Docs
|
|
@@ -35,3 +39,12 @@ I have a crash on the app, can you diagnose it, fix and validate using the mcp t
|
|
|
35
39
|
## License
|
|
36
40
|
|
|
37
41
|
MIT
|
|
42
|
+
|
|
43
|
+
## IDB/ADB healthcheck and diagnostics
|
|
44
|
+
|
|
45
|
+
The agent provides healthcheck and optional auto-install scripts for iOS (idb) and Android (adb).
|
|
46
|
+
|
|
47
|
+
- Run `npm run healthcheck` to verify idb is available. Set `MCP_AUTO_INSTALL_IDB=true` to allow the installer to run in CI or non-interactive environments.
|
|
48
|
+
- Override detection with `IDB_PATH` or `ADB_PATH` environment variables.
|
|
49
|
+
- Tools now return structured diagnostics on failures: { exitCode, stdout, stderr, command, args, envSnapshot, suggestedFixes } which helps agents decide corrective actions.
|
|
50
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { getAdbCmd } from './utils.js';
|
|
3
|
+
import { makeEnvSnapshot } from '../utils/diagnostics.js';
|
|
4
|
+
export function execAdbWithDiagnostics(args, deviceId) {
|
|
5
|
+
const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
|
|
6
|
+
const timeout = 120000;
|
|
7
|
+
const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout });
|
|
8
|
+
const runResult = {
|
|
9
|
+
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
10
|
+
stdout: res.stdout || '',
|
|
11
|
+
stderr: res.stderr || '',
|
|
12
|
+
envSnapshot: makeEnvSnapshot(['PATH', 'ADB_PATH', 'HOME', 'JAVA_HOME']),
|
|
13
|
+
command: getAdbCmd(),
|
|
14
|
+
args: adbArgs,
|
|
15
|
+
suggestedFixes: []
|
|
16
|
+
};
|
|
17
|
+
if (res.status !== 0) {
|
|
18
|
+
if ((runResult.stderr || '').includes('device not found'))
|
|
19
|
+
runResult.suggestedFixes.push('Ensure device is connected and adb is authorized (adb devices)');
|
|
20
|
+
if ((runResult.stderr || '').includes('No such file or directory'))
|
|
21
|
+
runResult.suggestedFixes.push('Verify ADB_PATH or that adb is installed');
|
|
22
|
+
}
|
|
23
|
+
return { runResult };
|
|
24
|
+
}
|
package/dist/android/manage.js
CHANGED
|
@@ -3,6 +3,7 @@ import { spawn } from 'child_process';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { existsSync } from 'fs';
|
|
5
5
|
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js';
|
|
6
|
+
import { execAdbWithDiagnostics } from './diagnostics.js';
|
|
6
7
|
import { detectJavaHome } from '../utils/java.js';
|
|
7
8
|
export class AndroidManage {
|
|
8
9
|
async build(projectPath, _variant) {
|
|
@@ -34,8 +35,8 @@ export class AndroidManage {
|
|
|
34
35
|
async installApp(apkPath, deviceId) {
|
|
35
36
|
const metadata = await getAndroidDeviceMetadata('', deviceId);
|
|
36
37
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
38
|
+
let apkToInstall = apkPath;
|
|
37
39
|
try {
|
|
38
|
-
let apkToInstall = apkPath;
|
|
39
40
|
const stat = await fs.stat(apkPath).catch(() => null);
|
|
40
41
|
if (stat && stat.isDirectory()) {
|
|
41
42
|
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
@@ -104,20 +105,38 @@ export class AndroidManage {
|
|
|
104
105
|
return { device: deviceInfo, installed: true, output: pmOut };
|
|
105
106
|
}
|
|
106
107
|
catch (e) {
|
|
107
|
-
|
|
108
|
+
// gather diagnostics for attempted adb operations
|
|
109
|
+
const basename = path.basename(apkToInstall);
|
|
110
|
+
const remotePath = `/data/local/tmp/${basename}`;
|
|
111
|
+
const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId);
|
|
112
|
+
const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId);
|
|
113
|
+
const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId);
|
|
114
|
+
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
117
|
async startApp(appId, deviceId) {
|
|
111
118
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
112
119
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
try {
|
|
121
|
+
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
|
|
122
|
+
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
|
|
126
|
+
return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
127
|
+
}
|
|
115
128
|
}
|
|
116
129
|
async terminateApp(appId, deviceId) {
|
|
117
130
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
118
131
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
try {
|
|
133
|
+
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
134
|
+
return { device: deviceInfo, appTerminated: true };
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
|
|
138
|
+
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
139
|
+
}
|
|
121
140
|
}
|
|
122
141
|
async restartApp(appId, deviceId) {
|
|
123
142
|
await this.terminateApp(appId, deviceId);
|
|
@@ -131,7 +150,13 @@ export class AndroidManage {
|
|
|
131
150
|
async resetAppData(appId, deviceId) {
|
|
132
151
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
133
152
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
try {
|
|
154
|
+
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
155
|
+
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId);
|
|
159
|
+
return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
160
|
+
}
|
|
136
161
|
}
|
|
137
162
|
}
|
package/dist/android/observe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { XMLParser } from "fast-xml-parser";
|
|
3
|
-
import {
|
|
3
|
+
import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js";
|
|
4
4
|
import { createWriteStream } from "fs";
|
|
5
5
|
import { promises as fsPromises } from "fs";
|
|
6
6
|
import path from "path";
|
|
@@ -67,7 +67,7 @@ export class AndroidObserve {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
catch (e) {
|
|
70
|
-
const errorMessage = `Failed to get UI tree. ADB Path: '${
|
|
70
|
+
const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
71
71
|
console.error(errorMessage);
|
|
72
72
|
return {
|
|
73
73
|
device: deviceInfo,
|
|
@@ -115,7 +115,7 @@ export class AndroidObserve {
|
|
|
115
115
|
// Need to construct ADB args manually since spawn handles it
|
|
116
116
|
const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
|
|
117
117
|
// Using spawn for screencap as well to ensure consistent process handling
|
|
118
|
-
const child = spawn(
|
|
118
|
+
const child = spawn(getAdbCmd(), args);
|
|
119
119
|
const chunks = [];
|
|
120
120
|
let stderr = '';
|
|
121
121
|
child.stdout.on('data', (chunk) => {
|
|
@@ -245,7 +245,7 @@ export class AndroidObserve {
|
|
|
245
245
|
activeLogStreams.delete(sessionId);
|
|
246
246
|
}
|
|
247
247
|
const args = ['logcat', `--pid=${pid}`, filter];
|
|
248
|
-
const proc = spawn(
|
|
248
|
+
const proc = spawn(getAdbCmd(), args);
|
|
249
249
|
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
250
250
|
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
|
|
251
251
|
const stream = createWriteStream(file, { flags: 'a' });
|
package/dist/android/utils.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import { promises as fsPromises, existsSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { detectJavaHome } from '../utils/java.js';
|
|
5
|
-
export
|
|
5
|
+
export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
|
|
6
6
|
/**
|
|
7
7
|
* Prepare Gradle execution options for building an Android project.
|
|
8
8
|
* Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
|
|
@@ -72,7 +72,7 @@ export function execAdb(args, deviceId, options = {}) {
|
|
|
72
72
|
// Extract timeout from options if present, otherwise pass options to spawn
|
|
73
73
|
const { timeout: customTimeout, ...spawnOptions } = options;
|
|
74
74
|
// Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
|
|
75
|
-
const child = spawn(
|
|
75
|
+
const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
|
|
76
76
|
let stdout = '';
|
|
77
77
|
let stderr = '';
|
|
78
78
|
if (child.stdout) {
|
|
@@ -112,7 +112,7 @@ export function spawnAdb(args, deviceId, options = {}) {
|
|
|
112
112
|
const adbArgs = getAdbArgs(args, deviceId);
|
|
113
113
|
return new Promise((resolve, reject) => {
|
|
114
114
|
const { timeout: customTimeout, ...spawnOptions } = options;
|
|
115
|
-
const child = spawn(
|
|
115
|
+
const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
|
|
116
116
|
let stdout = '';
|
|
117
117
|
let stderr = '';
|
|
118
118
|
if (child.stdout)
|
package/dist/ios/interact.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { getIOSDeviceMetadata,
|
|
2
|
+
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js";
|
|
3
3
|
import { iOSObserve } from "./observe.js";
|
|
4
4
|
export class iOSInteract {
|
|
5
5
|
observe = new iOSObserve();
|
|
@@ -31,12 +31,8 @@ export class iOSInteract {
|
|
|
31
31
|
}
|
|
32
32
|
async tap(x, y, deviceId = "booted") {
|
|
33
33
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
const idbExists = await new Promise((resolve) => {
|
|
37
|
-
child.on('error', () => resolve(false));
|
|
38
|
-
child.on('close', (code) => resolve(code === 0));
|
|
39
|
-
});
|
|
34
|
+
// Use shared helper to detect idb
|
|
35
|
+
const idbExists = await isIDBInstalled();
|
|
40
36
|
if (!idbExists) {
|
|
41
37
|
return {
|
|
42
38
|
device,
|
|
@@ -53,7 +49,7 @@ export class iOSInteract {
|
|
|
53
49
|
args.push('--udid', targetUdid);
|
|
54
50
|
}
|
|
55
51
|
await new Promise((resolve, reject) => {
|
|
56
|
-
const proc = spawn(
|
|
52
|
+
const proc = spawn(getIdbCmd(), args);
|
|
57
53
|
let stderr = '';
|
|
58
54
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
59
55
|
proc.on('close', code => {
|
package/dist/ios/manage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "fs";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId,
|
|
3
|
+
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js";
|
|
4
4
|
import path from "path";
|
|
5
5
|
export class iOSManage {
|
|
6
6
|
async build(projectPath, _variant) {
|
|
@@ -23,7 +23,8 @@ export class iOSManage {
|
|
|
23
23
|
buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
|
|
24
24
|
}
|
|
25
25
|
await new Promise((resolve, reject) => {
|
|
26
|
-
const
|
|
26
|
+
const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
|
|
27
|
+
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
|
|
27
28
|
let stderr = '';
|
|
28
29
|
proc.stderr?.on('data', d => stderr += d.toString());
|
|
29
30
|
proc.on('close', code => {
|
|
@@ -71,15 +72,18 @@ export class iOSManage {
|
|
|
71
72
|
return { device, installed: true, output: res.output };
|
|
72
73
|
}
|
|
73
74
|
catch (e) {
|
|
75
|
+
// Gather diagnostics for simctl failure
|
|
76
|
+
const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId);
|
|
74
77
|
try {
|
|
75
|
-
const child = spawn(
|
|
78
|
+
const child = spawn(getIdbCmd(), ['--version']);
|
|
76
79
|
const idbExists = await new Promise((resolve) => {
|
|
77
80
|
child.on('error', () => resolve(false));
|
|
78
81
|
child.on('close', (code) => resolve(code === 0));
|
|
79
82
|
});
|
|
80
83
|
if (idbExists) {
|
|
84
|
+
// attempt idb install via spawn but include diagnostics
|
|
81
85
|
await new Promise((resolve, reject) => {
|
|
82
|
-
const proc = spawn(
|
|
86
|
+
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
|
|
83
87
|
let stderr = '';
|
|
84
88
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
85
89
|
proc.on('close', code => {
|
|
@@ -94,7 +98,7 @@ export class iOSManage {
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
catch { }
|
|
97
|
-
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
101
|
+
return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
98
102
|
}
|
|
99
103
|
}
|
|
100
104
|
catch (e) {
|
|
@@ -103,15 +107,29 @@ export class iOSManage {
|
|
|
103
107
|
}
|
|
104
108
|
async startApp(bundleId, deviceId = "booted") {
|
|
105
109
|
validateBundleId(bundleId);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
try {
|
|
111
|
+
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
112
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
113
|
+
return { device, appStarted: !!result.output, launchTimeMs: 1000 };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
117
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
118
|
+
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
119
|
+
}
|
|
109
120
|
}
|
|
110
121
|
async terminateApp(bundleId, deviceId = "booted") {
|
|
111
122
|
validateBundleId(bundleId);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
try {
|
|
124
|
+
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
125
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
126
|
+
return { device, appTerminated: true };
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
130
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
131
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
132
|
+
}
|
|
115
133
|
}
|
|
116
134
|
async restartApp(bundleId, deviceId = "booted") {
|
|
117
135
|
await this.terminateApp(bundleId, deviceId);
|
|
@@ -122,24 +140,30 @@ export class iOSManage {
|
|
|
122
140
|
validateBundleId(bundleId);
|
|
123
141
|
await this.terminateApp(bundleId, deviceId);
|
|
124
142
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
125
|
-
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
126
|
-
const dataPath = containerResult.output.trim();
|
|
127
|
-
if (!dataPath)
|
|
128
|
-
throw new Error(`Could not find data container for ${bundleId}`);
|
|
129
143
|
try {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
145
|
+
const dataPath = containerResult.output.trim();
|
|
146
|
+
if (!dataPath)
|
|
147
|
+
throw new Error(`Could not find data container for ${bundleId}`);
|
|
148
|
+
try {
|
|
149
|
+
const libraryPath = `${dataPath}/Library`;
|
|
150
|
+
const documentsPath = `${dataPath}/Documents`;
|
|
151
|
+
const tmpPath = `${dataPath}/tmp`;
|
|
152
|
+
await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
|
|
153
|
+
await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
|
|
154
|
+
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
|
|
155
|
+
await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
|
|
156
|
+
await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
|
|
157
|
+
await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
|
|
158
|
+
return { device, dataCleared: true };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
162
|
+
}
|
|
140
163
|
}
|
|
141
164
|
catch (e) {
|
|
142
|
-
|
|
165
|
+
const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
166
|
+
return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
143
167
|
}
|
|
144
168
|
}
|
|
145
169
|
}
|
package/dist/ios/observe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId,
|
|
3
|
+
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js";
|
|
4
4
|
import { createWriteStream, promises as fsPromises } from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { parseLogLine } from '../android/utils.js';
|
|
@@ -9,6 +9,17 @@ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
9
9
|
function parseIDBFrame(frame) {
|
|
10
10
|
if (!frame)
|
|
11
11
|
return [0, 0, 0, 0];
|
|
12
|
+
// Handle string frames like "{{0, 0}, {402, 874}}"
|
|
13
|
+
if (typeof frame === 'string') {
|
|
14
|
+
const nums = frame.match(/-?\d+(?:\.\d+)?/g);
|
|
15
|
+
if (!nums || nums.length < 4)
|
|
16
|
+
return [0, 0, 0, 0];
|
|
17
|
+
const x = Number(nums[0]);
|
|
18
|
+
const y = Number(nums[1]);
|
|
19
|
+
const w = Number(nums[2]);
|
|
20
|
+
const h = Number(nums[3]);
|
|
21
|
+
return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
|
|
22
|
+
}
|
|
12
23
|
const x = Number(frame.x || 0);
|
|
13
24
|
const y = Number(frame.y || 0);
|
|
14
25
|
const w = Number(frame.width || frame.w || 0);
|
|
@@ -72,15 +83,6 @@ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
|
|
|
72
83
|
}
|
|
73
84
|
return currentIndex;
|
|
74
85
|
}
|
|
75
|
-
// Check if IDB is installed
|
|
76
|
-
async function isIDBInstalled() {
|
|
77
|
-
return new Promise((resolve) => {
|
|
78
|
-
// Check if 'idb' is in path by trying to run it
|
|
79
|
-
const child = spawn(IDB, ['--version']);
|
|
80
|
-
child.on('error', () => resolve(false));
|
|
81
|
-
child.on('close', (code) => resolve(code === 0));
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
86
|
// iOS live log stream support (moved from ios/utils to observe)
|
|
85
87
|
const iosActiveLogStreams = new Map();
|
|
86
88
|
// Test helpers
|
|
@@ -161,12 +163,12 @@ export class iOSObserve {
|
|
|
161
163
|
try {
|
|
162
164
|
// Stabilization delay
|
|
163
165
|
await delay(300 + (attempts * 100));
|
|
164
|
-
const args = ['ui', 'describe', '--json'];
|
|
166
|
+
const args = ['ui', 'describe-all', '--json'];
|
|
165
167
|
if (targetUdid) {
|
|
166
168
|
args.push('--udid', targetUdid);
|
|
167
169
|
}
|
|
168
170
|
const output = await new Promise((resolve, reject) => {
|
|
169
|
-
const child = spawn(
|
|
171
|
+
const child = spawn(getIdbCmd(), args);
|
|
170
172
|
let stdout = '';
|
|
171
173
|
let stderr = '';
|
|
172
174
|
child.stdout.on('data', (data) => stdout += data.toString());
|
|
@@ -201,8 +203,15 @@ export class iOSObserve {
|
|
|
201
203
|
}
|
|
202
204
|
try {
|
|
203
205
|
const elements = [];
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
// idb describe-all returns either a root object or an array of root nodes
|
|
207
|
+
if (Array.isArray(jsonContent)) {
|
|
208
|
+
for (const node of jsonContent) {
|
|
209
|
+
traverseIDBNode(node, elements);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
traverseIDBNode(jsonContent, elements);
|
|
214
|
+
}
|
|
206
215
|
// Infer resolution from root element if possible (usually the Window/Application frame)
|
|
207
216
|
let width = 0;
|
|
208
217
|
let height = 0;
|
|
@@ -240,7 +249,7 @@ export class iOSObserve {
|
|
|
240
249
|
iosActiveLogStreams.delete(sessionId);
|
|
241
250
|
}
|
|
242
251
|
const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
|
|
243
|
-
const proc = spawn(
|
|
252
|
+
const proc = spawn(getXcrunCmd(), args);
|
|
244
253
|
// Prepare output file
|
|
245
254
|
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
246
255
|
const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
|
package/dist/ios/utils.js
CHANGED
|
@@ -1,8 +1,90 @@
|
|
|
1
|
-
import { execFile, spawn } from "child_process";
|
|
1
|
+
import { execFile, spawn, execSync, spawnSync } from "child_process";
|
|
2
2
|
import { promises as fsPromises } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
|
|
5
|
-
export
|
|
4
|
+
import { makeEnvSnapshot } from '../utils/diagnostics.js';
|
|
5
|
+
export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun'; }
|
|
6
|
+
export function getConfiguredIdbPath() {
|
|
7
|
+
if (process.env.MCP_IDB_PATH)
|
|
8
|
+
return process.env.MCP_IDB_PATH;
|
|
9
|
+
if (process.env.IDB_PATH)
|
|
10
|
+
return process.env.IDB_PATH;
|
|
11
|
+
const cfgPaths = [
|
|
12
|
+
process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
|
|
13
|
+
`${process.cwd()}/mcp.config.json`
|
|
14
|
+
];
|
|
15
|
+
try {
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
for (const p of cfgPaths) {
|
|
18
|
+
if (!p)
|
|
19
|
+
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)
|
|
26
|
+
return json.idbPath;
|
|
27
|
+
if (json.IDB_PATH)
|
|
28
|
+
return json.IDB_PATH;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function getIdbCmd() {
|
|
39
|
+
const cfg = getConfiguredIdbPath();
|
|
40
|
+
if (cfg)
|
|
41
|
+
return cfg;
|
|
42
|
+
if (process.env.IDB_PATH)
|
|
43
|
+
return process.env.IDB_PATH;
|
|
44
|
+
try {
|
|
45
|
+
const p = execSync('which idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
46
|
+
if (p)
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
try {
|
|
51
|
+
const p2 = execSync('command -v idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
52
|
+
if (p2)
|
|
53
|
+
return p2;
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
// check common user locations
|
|
57
|
+
const common = [
|
|
58
|
+
`${process.env.HOME}/Library/Python/3.9/bin/idb`,
|
|
59
|
+
`${process.env.HOME}/Library/Python/3.10/bin/idb`,
|
|
60
|
+
'/opt/homebrew/bin/idb',
|
|
61
|
+
'/usr/local/bin/idb',
|
|
62
|
+
];
|
|
63
|
+
for (const c of common) {
|
|
64
|
+
try {
|
|
65
|
+
execSync(`test -x ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
66
|
+
return c;
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
}
|
|
70
|
+
return 'idb';
|
|
71
|
+
}
|
|
72
|
+
export async function isIDBInstalled() {
|
|
73
|
+
const cmd = getIdbCmd();
|
|
74
|
+
try {
|
|
75
|
+
execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
try {
|
|
80
|
+
execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
6
88
|
// Validate bundle ID to prevent any potential injection or invalid characters
|
|
7
89
|
export function validateBundleId(bundleId) {
|
|
8
90
|
if (!bundleId)
|
|
@@ -15,7 +97,7 @@ export function validateBundleId(bundleId) {
|
|
|
15
97
|
export function execCommand(args, deviceId = "booted") {
|
|
16
98
|
return new Promise((resolve, reject) => {
|
|
17
99
|
// Use spawn for better stream control and consistency with Android implementation
|
|
18
|
-
const child = spawn(
|
|
100
|
+
const child = spawn(getXcrunCmd(), args);
|
|
19
101
|
let stdout = '';
|
|
20
102
|
let stderr = '';
|
|
21
103
|
if (child.stdout) {
|
|
@@ -28,10 +110,12 @@ export function execCommand(args, deviceId = "booted") {
|
|
|
28
110
|
stderr += data.toString();
|
|
29
111
|
});
|
|
30
112
|
}
|
|
31
|
-
const
|
|
113
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
|
|
114
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
|
|
115
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
|
|
32
116
|
const timeout = setTimeout(() => {
|
|
33
117
|
child.kill();
|
|
34
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms: ${
|
|
118
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
|
|
35
119
|
}, timeoutMs);
|
|
36
120
|
child.on('close', (code) => {
|
|
37
121
|
clearTimeout(timeout);
|
|
@@ -48,6 +132,35 @@ export function execCommand(args, deviceId = "booted") {
|
|
|
48
132
|
});
|
|
49
133
|
});
|
|
50
134
|
}
|
|
135
|
+
export function execCommandWithDiagnostics(args, deviceId = "booted") {
|
|
136
|
+
// Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
|
|
137
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
|
|
138
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
|
|
139
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT;
|
|
140
|
+
const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs });
|
|
141
|
+
const runResult = {
|
|
142
|
+
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
143
|
+
stdout: res.stdout || '',
|
|
144
|
+
stderr: res.stderr || '',
|
|
145
|
+
envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
|
|
146
|
+
command: getXcrunCmd(),
|
|
147
|
+
args,
|
|
148
|
+
deviceId
|
|
149
|
+
};
|
|
150
|
+
if (res.status !== 0) {
|
|
151
|
+
// include suggested fixes for common errors
|
|
152
|
+
const suggested = [];
|
|
153
|
+
if ((runResult.stderr || '').includes('xcodebuild: error')) {
|
|
154
|
+
suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.');
|
|
155
|
+
}
|
|
156
|
+
if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
|
|
157
|
+
suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.');
|
|
158
|
+
}
|
|
159
|
+
// Return diagnostics object
|
|
160
|
+
return { runResult: { ...runResult, suggestedFixes: suggested } };
|
|
161
|
+
}
|
|
162
|
+
return { runResult: { ...runResult, suggestedFixes: [] } };
|
|
163
|
+
}
|
|
51
164
|
function parseRuntimeName(runtime) {
|
|
52
165
|
// Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
|
|
53
166
|
try {
|
|
@@ -84,7 +197,7 @@ export async function findAppBundle(dir) {
|
|
|
84
197
|
export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
85
198
|
return new Promise((resolve) => {
|
|
86
199
|
// If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
|
|
87
|
-
execFile(
|
|
200
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
88
201
|
const fallback = {
|
|
89
202
|
platform: "ios",
|
|
90
203
|
id: deviceId,
|
|
@@ -126,7 +239,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
|
126
239
|
}
|
|
127
240
|
export async function listIOSDevices(appId) {
|
|
128
241
|
return new Promise((resolve) => {
|
|
129
|
-
execFile(
|
|
242
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
130
243
|
if (err || !stdout)
|
|
131
244
|
return resolve([]);
|
|
132
245
|
try {
|