mobile-debug-mcp 0.4.0 → 0.6.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.
@@ -0,0 +1,114 @@
1
+ import { execFile, spawn } from "child_process";
2
+ export const XCRUN = process.env.XCRUN_PATH || "xcrun";
3
+ export const IDB = "idb";
4
+ // Validate bundle ID to prevent any potential injection or invalid characters
5
+ export function validateBundleId(bundleId) {
6
+ if (!bundleId)
7
+ return;
8
+ // Allow alphanumeric, dots, hyphens, and underscores.
9
+ if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
10
+ throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`);
11
+ }
12
+ }
13
+ export function execCommand(args, deviceId = "booted") {
14
+ return new Promise((resolve, reject) => {
15
+ // Use spawn for better stream control and consistency with Android implementation
16
+ const child = spawn(XCRUN, args);
17
+ let stdout = '';
18
+ let stderr = '';
19
+ if (child.stdout) {
20
+ child.stdout.on('data', (data) => {
21
+ stdout += data.toString();
22
+ });
23
+ }
24
+ if (child.stderr) {
25
+ child.stderr.on('data', (data) => {
26
+ stderr += data.toString();
27
+ });
28
+ }
29
+ const timeoutMs = args.includes('log') ? 10000 : 5000; // 10s for logs, 5s for others
30
+ const timeout = setTimeout(() => {
31
+ child.kill();
32
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`));
33
+ }, timeoutMs);
34
+ child.on('close', (code) => {
35
+ clearTimeout(timeout);
36
+ if (code !== 0) {
37
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
38
+ }
39
+ else {
40
+ resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } });
41
+ }
42
+ });
43
+ child.on('error', (err) => {
44
+ clearTimeout(timeout);
45
+ reject(err);
46
+ });
47
+ });
48
+ }
49
+ function parseRuntimeName(runtime) {
50
+ // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
51
+ try {
52
+ const parts = runtime.split('.');
53
+ const lastPart = parts[parts.length - 1]; // e.g. "iOS-17-0"
54
+ // Split by hyphen to separate OS from version numbers
55
+ // e.g. "iOS-17-0" -> ["iOS", "17", "0"]
56
+ const segments = lastPart.split('-');
57
+ if (segments.length > 1) {
58
+ const os = segments[0]; // "iOS"
59
+ const version = segments.slice(1).join('.'); // "17.0"
60
+ return `${os} ${version}`;
61
+ }
62
+ return lastPart;
63
+ }
64
+ catch {
65
+ return runtime;
66
+ }
67
+ }
68
+ export async function getIOSDeviceMetadata(deviceId = "booted") {
69
+ return new Promise((resolve) => {
70
+ // If deviceId is provided (and not "booted"), we could try to list just that device.
71
+ // But listing all booted devices is usually fine to find the one we want or just one.
72
+ // Let's stick to listing all and filtering if needed, or just return basic info if we can't find it.
73
+ execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
74
+ // Default fallback
75
+ const fallback = {
76
+ platform: "ios",
77
+ id: deviceId,
78
+ osVersion: "Unknown",
79
+ model: "Simulator",
80
+ simulator: true,
81
+ };
82
+ if (err || !stdout) {
83
+ resolve(fallback);
84
+ return;
85
+ }
86
+ try {
87
+ const data = JSON.parse(stdout);
88
+ const devicesMap = data.devices || {};
89
+ // Find the device
90
+ for (const runtime in devicesMap) {
91
+ const devices = devicesMap[runtime];
92
+ if (Array.isArray(devices)) {
93
+ for (const device of devices) {
94
+ if (deviceId === "booted" || device.udid === deviceId) {
95
+ resolve({
96
+ platform: "ios",
97
+ id: device.udid,
98
+ osVersion: parseRuntimeName(runtime),
99
+ model: device.name,
100
+ simulator: true,
101
+ });
102
+ return;
103
+ }
104
+ }
105
+ }
106
+ }
107
+ resolve(fallback);
108
+ }
109
+ catch (error) {
110
+ resolve(fallback);
111
+ }
112
+ });
113
+ });
114
+ }
package/dist/ios.js CHANGED
@@ -1,21 +1,344 @@
1
- import { exec } from "child_process";
2
- export function startIOSApp(bundleId) {
1
+ import { execFile, spawn } from "child_process";
2
+ import { promises as fs } from "fs";
3
+ const XCRUN = process.env.XCRUN_PATH || "xcrun";
4
+ const IDB = "idb";
5
+ // Validate bundle ID to prevent any potential injection or invalid characters
6
+ function validateBundleId(bundleId) {
7
+ if (!bundleId)
8
+ return;
9
+ // Allow alphanumeric, dots, hyphens, and underscores.
10
+ if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
11
+ throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`);
12
+ }
13
+ }
14
+ function execCommand(args, deviceId = "booted") {
3
15
  return new Promise((resolve, reject) => {
4
- exec(`xcrun simctl launch booted ${bundleId}`, (err, stdout, stderr) => {
5
- if (err)
6
- reject(stderr);
7
- else
8
- resolve(stdout);
16
+ // Use spawn for better stream control and consistency with Android implementation
17
+ const child = spawn(XCRUN, args);
18
+ let stdout = '';
19
+ let stderr = '';
20
+ if (child.stdout) {
21
+ child.stdout.on('data', (data) => {
22
+ stdout += data.toString();
23
+ });
24
+ }
25
+ if (child.stderr) {
26
+ child.stderr.on('data', (data) => {
27
+ stderr += data.toString();
28
+ });
29
+ }
30
+ const timeoutMs = args.includes('log') ? 10000 : 5000; // 10s for logs, 5s for others
31
+ const timeout = setTimeout(() => {
32
+ child.kill();
33
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`));
34
+ }, timeoutMs);
35
+ child.on('close', (code) => {
36
+ clearTimeout(timeout);
37
+ if (code !== 0) {
38
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
39
+ }
40
+ else {
41
+ resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } });
42
+ }
43
+ });
44
+ child.on('error', (err) => {
45
+ clearTimeout(timeout);
46
+ reject(err);
9
47
  });
10
48
  });
11
49
  }
12
- export function getIOSLogs() {
13
- return new Promise((resolve, reject) => {
14
- exec(`xcrun simctl spawn booted log show --style syslog --last 1m`, (err, stdout, stderr) => {
15
- if (err)
16
- reject(stderr);
17
- else
18
- resolve(stdout);
50
+ function parseRuntimeName(runtime) {
51
+ // Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
52
+ try {
53
+ const parts = runtime.split('.');
54
+ const lastPart = parts[parts.length - 1];
55
+ return lastPart.replace(/-/g, ' ').replace('iOS ', 'iOS '); // Keep iOS prefix
56
+ }
57
+ catch {
58
+ return runtime;
59
+ }
60
+ }
61
+ export async function getIOSDeviceMetadata(deviceId = "booted") {
62
+ return new Promise((resolve) => {
63
+ // If deviceId is provided (and not "booted"), we could try to list just that device.
64
+ // But listing all booted devices is usually fine to find the one we want or just one.
65
+ // Let's stick to listing all and filtering if needed, or just return basic info if we can't find it.
66
+ execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
67
+ // Default fallback
68
+ const fallback = {
69
+ platform: "ios",
70
+ id: deviceId,
71
+ osVersion: "Unknown",
72
+ model: "Simulator",
73
+ simulator: true,
74
+ };
75
+ if (err || !stdout) {
76
+ resolve(fallback);
77
+ return;
78
+ }
79
+ try {
80
+ const data = JSON.parse(stdout);
81
+ const devicesMap = data.devices || {};
82
+ // Find the device
83
+ for (const runtime in devicesMap) {
84
+ const devices = devicesMap[runtime];
85
+ if (Array.isArray(devices)) {
86
+ for (const device of devices) {
87
+ if (deviceId === "booted" || device.udid === deviceId) {
88
+ resolve({
89
+ platform: "ios",
90
+ id: device.udid,
91
+ osVersion: parseRuntimeName(runtime),
92
+ model: device.name,
93
+ simulator: true,
94
+ });
95
+ return;
96
+ }
97
+ }
98
+ }
99
+ }
100
+ resolve(fallback);
101
+ }
102
+ catch (error) {
103
+ resolve(fallback);
104
+ }
105
+ });
106
+ });
107
+ }
108
+ export async function startIOSApp(bundleId, deviceId = "booted") {
109
+ validateBundleId(bundleId);
110
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
111
+ const device = await getIOSDeviceMetadata(deviceId);
112
+ // Simulate launch time and appStarted for demonstration
113
+ return {
114
+ device,
115
+ appStarted: !!result.output,
116
+ launchTimeMs: 1000,
117
+ };
118
+ }
119
+ export async function terminateIOSApp(bundleId, deviceId = "booted") {
120
+ validateBundleId(bundleId);
121
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
122
+ const device = await getIOSDeviceMetadata(deviceId);
123
+ return {
124
+ device,
125
+ appTerminated: true
126
+ };
127
+ }
128
+ export async function restartIOSApp(bundleId, deviceId = "booted") {
129
+ // terminateIOSApp already validates bundleId
130
+ await terminateIOSApp(bundleId, deviceId);
131
+ const startResult = await startIOSApp(bundleId, deviceId);
132
+ return {
133
+ device: startResult.device,
134
+ appRestarted: startResult.appStarted,
135
+ launchTimeMs: startResult.launchTimeMs
136
+ };
137
+ }
138
+ export async function resetIOSAppData(bundleId, deviceId = "booted") {
139
+ validateBundleId(bundleId);
140
+ await terminateIOSApp(bundleId, deviceId);
141
+ const device = await getIOSDeviceMetadata(deviceId);
142
+ // Get data container path
143
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
144
+ const dataPath = containerResult.output.trim();
145
+ if (!dataPath) {
146
+ throw new Error(`Could not find data container for ${bundleId}`);
147
+ }
148
+ // Clear contents of Library and Documents
149
+ try {
150
+ const libraryPath = `${dataPath}/Library`;
151
+ const documentsPath = `${dataPath}/Documents`;
152
+ const tmpPath = `${dataPath}/tmp`;
153
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
154
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
155
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
156
+ // Re-create empty directories as they are expected by apps
157
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
158
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
159
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
160
+ return {
161
+ device,
162
+ dataCleared: true
163
+ };
164
+ }
165
+ catch (err) {
166
+ throw new Error(`Failed to clear data for ${bundleId}: ${err instanceof Error ? err.message : String(err)}`);
167
+ }
168
+ }
169
+ export async function getIOSLogs(appId, deviceId = "booted") {
170
+ // If appId is provided, use predicate filtering
171
+ // Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
172
+ // but we do need to construct the predicate correctly for log show.
173
+ const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m'];
174
+ if (appId) {
175
+ validateBundleId(appId);
176
+ args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
177
+ }
178
+ const result = await execCommand(args, deviceId);
179
+ const device = await getIOSDeviceMetadata(deviceId);
180
+ const logs = result.output ? result.output.split('\n') : [];
181
+ return {
182
+ device,
183
+ logs,
184
+ logCount: logs.length,
185
+ };
186
+ }
187
+ export async function captureIOSScreenshot(deviceId = "booted") {
188
+ const device = await getIOSDeviceMetadata(deviceId);
189
+ const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`;
190
+ try {
191
+ // 1. Capture screenshot to temp file
192
+ await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
193
+ // 2. Read file as base64
194
+ const buffer = await fs.readFile(tmpFile);
195
+ const base64 = buffer.toString('base64');
196
+ // 3. Clean up
197
+ await fs.rm(tmpFile).catch(() => { });
198
+ return {
199
+ device,
200
+ screenshot: base64,
201
+ // Default resolution since we can't easily parse it without extra libs
202
+ // Clients will read the real dimensions from the PNG header anyway
203
+ resolution: { width: 0, height: 0 },
204
+ };
205
+ }
206
+ catch (err) {
207
+ // Ensure cleanup happens even on error
208
+ await fs.rm(tmpFile).catch(() => { });
209
+ throw new Error(`Failed to capture screenshot: ${err instanceof Error ? err.message : String(err)}`);
210
+ }
211
+ }
212
+ function parseIDBFrame(frame) {
213
+ if (!frame)
214
+ return [0, 0, 0, 0];
215
+ const x = Number(frame.x || 0);
216
+ const y = Number(frame.y || 0);
217
+ const w = Number(frame.width || frame.w || 0);
218
+ const h = Number(frame.height || frame.h || 0);
219
+ return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
220
+ }
221
+ function traverseIDBNode(node, elements, parentIndex = -1) {
222
+ if (!node)
223
+ return -1;
224
+ // Prefer standard keys, fallback to alternatives
225
+ const type = node.AXElementType || node.type || "unknown";
226
+ const label = node.AXLabel || node.label || null;
227
+ const value = node.AXValue || null;
228
+ const frame = node.AXFrame || node.frame;
229
+ const element = {
230
+ text: label,
231
+ contentDescription: value, // iOS uses Value/Label differently than Android but this maps roughly
232
+ type: type,
233
+ resourceId: node.AXUniqueId || null,
234
+ clickable: (node.AXTraits || []).includes("UIAccessibilityTraitButton") || type === "Button",
235
+ enabled: true, // idb usually returns enabled elements
236
+ visible: true,
237
+ bounds: parseIDBFrame(frame),
238
+ };
239
+ if (parentIndex !== -1) {
240
+ element.parentId = parentIndex;
241
+ }
242
+ elements.push(element);
243
+ const currentIndex = elements.length - 1;
244
+ const childrenIndices = [];
245
+ if (node.children && Array.isArray(node.children)) {
246
+ for (const child of node.children) {
247
+ const childIndex = traverseIDBNode(child, elements, currentIndex);
248
+ if (childIndex !== -1) {
249
+ childrenIndices.push(childIndex);
250
+ }
251
+ }
252
+ }
253
+ if (childrenIndices.length > 0) {
254
+ elements[currentIndex].children = childrenIndices;
255
+ }
256
+ return currentIndex;
257
+ }
258
+ // Check if IDB is installed
259
+ async function isIDBInstalled() {
260
+ return new Promise((resolve) => {
261
+ const child = spawn(IDB, ['--version']);
262
+ child.on('error', () => resolve(false));
263
+ child.on('close', (code) => resolve(code === 0));
264
+ });
265
+ }
266
+ export async function getIOSUITree(deviceId = "booted") {
267
+ const device = await getIOSDeviceMetadata(deviceId);
268
+ // idb is required
269
+ const idbExists = await isIDBInstalled();
270
+ if (!idbExists) {
271
+ return {
272
+ device,
273
+ screen: "",
274
+ resolution: { width: 0, height: 0 },
275
+ elements: [],
276
+ error: "iOS UI tree retrieval requires 'idb' (iOS Device Bridge). Please install it via Homebrew: `brew tap facebook/fb && brew install idb-companion` and `pip3 install fb-idb`."
277
+ };
278
+ }
279
+ return new Promise((resolve) => {
280
+ // idb ui describe --udid <uuid> --json
281
+ // If deviceId is 'booted', try to resolve it to a UDID because idb often needs explicit target
282
+ const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
283
+ const args = ['ui', 'describe', '--json'];
284
+ if (targetUdid) {
285
+ args.push('--udid', targetUdid);
286
+ }
287
+ const child = spawn(IDB, args);
288
+ let stdout = '';
289
+ let stderr = '';
290
+ child.stdout.on('data', (data) => stdout += data.toString());
291
+ child.stderr.on('data', (data) => stderr += data.toString());
292
+ child.on('error', (err) => {
293
+ resolve({
294
+ device,
295
+ screen: "",
296
+ resolution: { width: 0, height: 0 },
297
+ elements: [],
298
+ error: `Failed to execute idb: ${err.message}`
299
+ });
300
+ });
301
+ child.on('close', (code) => {
302
+ if (code !== 0) {
303
+ resolve({
304
+ device,
305
+ screen: "",
306
+ resolution: { width: 0, height: 0 },
307
+ elements: [],
308
+ error: `idb failed (code ${code}): ${stderr.trim()}`
309
+ });
310
+ return;
311
+ }
312
+ try {
313
+ const json = JSON.parse(stdout);
314
+ const elements = [];
315
+ // idb return object usually has 'children' at root or is the root
316
+ const root = json;
317
+ traverseIDBNode(root, elements);
318
+ // Infer resolution from root element if possible (usually the Window/Application frame)
319
+ let width = 0;
320
+ let height = 0;
321
+ if (elements.length > 0) {
322
+ const rootBounds = elements[0].bounds;
323
+ width = rootBounds[2] - rootBounds[0];
324
+ height = rootBounds[3] - rootBounds[1];
325
+ }
326
+ resolve({
327
+ device,
328
+ screen: "",
329
+ resolution: { width, height },
330
+ elements
331
+ });
332
+ }
333
+ catch (e) {
334
+ resolve({
335
+ device,
336
+ screen: "",
337
+ resolution: { width: 0, height: 0 },
338
+ elements: [],
339
+ error: `Failed to parse idb output: ${e instanceof Error ? e.message : String(e)}`
340
+ });
341
+ }
19
342
  });
20
343
  });
21
344
  }