mobile-debug-mcp 0.6.0 → 0.8.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.
@@ -1,6 +1,114 @@
1
1
  import { promises as fs } from "fs";
2
- import { execCommand, getIOSDeviceMetadata, validateBundleId } from "./utils.js";
2
+ import { spawn } from "child_process";
3
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js";
4
+ import { iOSObserve } from "./observe.js";
3
5
  export class iOSInteract {
6
+ observe = new iOSObserve();
7
+ async waitForElement(text, timeout, deviceId = "booted") {
8
+ const device = await getIOSDeviceMetadata(deviceId);
9
+ const startTime = Date.now();
10
+ while (Date.now() - startTime < timeout) {
11
+ try {
12
+ const tree = await this.observe.getUITree(deviceId);
13
+ if (tree.error) {
14
+ return { device, found: false, error: tree.error };
15
+ }
16
+ const element = tree.elements.find(e => e.text === text);
17
+ if (element) {
18
+ return { device, found: true, element };
19
+ }
20
+ }
21
+ catch (e) {
22
+ // Ignore errors during polling and retry
23
+ console.error("Error polling UI tree:", e);
24
+ }
25
+ const elapsed = Date.now() - startTime;
26
+ const remaining = timeout - elapsed;
27
+ if (remaining <= 0)
28
+ break;
29
+ await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
30
+ }
31
+ return { device, found: false };
32
+ }
33
+ async tap(x, y, deviceId = "booted") {
34
+ const device = await getIOSDeviceMetadata(deviceId);
35
+ // Check for idb
36
+ const child = spawn(IDB, ['--version']);
37
+ const idbExists = await new Promise((resolve) => {
38
+ child.on('error', () => resolve(false));
39
+ child.on('close', (code) => resolve(code === 0));
40
+ });
41
+ if (!idbExists) {
42
+ return {
43
+ device,
44
+ success: false,
45
+ x,
46
+ y,
47
+ error: "iOS tap requires 'idb' (iOS Device Bridge)."
48
+ };
49
+ }
50
+ try {
51
+ const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
52
+ const args = ['ui', 'tap', x.toString(), y.toString()];
53
+ if (targetUdid) {
54
+ args.push('--udid', targetUdid);
55
+ }
56
+ await new Promise((resolve, reject) => {
57
+ const proc = spawn(IDB, args);
58
+ let stderr = '';
59
+ proc.stderr.on('data', d => stderr += d.toString());
60
+ proc.on('close', code => {
61
+ if (code === 0)
62
+ resolve();
63
+ else
64
+ reject(new Error(`idb ui tap failed: ${stderr}`));
65
+ });
66
+ proc.on('error', err => reject(err));
67
+ });
68
+ return { device, success: true, x, y };
69
+ }
70
+ catch (e) {
71
+ return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
72
+ }
73
+ }
74
+ async installApp(appPath, deviceId = "booted") {
75
+ // Try simulator install first
76
+ const device = await getIOSDeviceMetadata(deviceId);
77
+ try {
78
+ const res = await execCommand(['simctl', 'install', deviceId, appPath], deviceId);
79
+ return { device, installed: true, output: res.output };
80
+ }
81
+ catch (e) {
82
+ // If simctl fails and idb is available, try idb install for physical devices
83
+ try {
84
+ const child = spawn(IDB, ['--version']);
85
+ const idbExists = await new Promise((resolve) => {
86
+ child.on('error', () => resolve(false));
87
+ child.on('close', (code) => resolve(code === 0));
88
+ });
89
+ if (idbExists) {
90
+ // Use idb to install (works for physical devices and simulators)
91
+ await new Promise((resolve, reject) => {
92
+ const proc = spawn(IDB, ['install', appPath, '--udid', device.id]);
93
+ let stderr = '';
94
+ proc.stderr.on('data', d => stderr += d.toString());
95
+ proc.on('close', code => {
96
+ if (code === 0)
97
+ resolve();
98
+ else
99
+ reject(new Error(stderr || `idb install failed with code ${code}`));
100
+ });
101
+ proc.on('error', err => reject(err));
102
+ });
103
+ return { device, installed: true };
104
+ }
105
+ }
106
+ catch (inner) {
107
+ // fallthrough
108
+ }
109
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
110
+ }
111
+ }
4
112
  async startApp(bundleId, deviceId = "booted") {
5
113
  validateBundleId(bundleId);
6
114
  const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
package/dist/ios/utils.js CHANGED
@@ -112,3 +112,157 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
112
112
  });
113
113
  });
114
114
  }
115
+ export async function listIOSDevices(appId) {
116
+ return new Promise((resolve) => {
117
+ execFile(XCRUN, ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
118
+ if (err || !stdout)
119
+ return resolve([]);
120
+ try {
121
+ const data = JSON.parse(stdout);
122
+ const devicesMap = data.devices || {};
123
+ const out = [];
124
+ const checks = [];
125
+ for (const runtime in devicesMap) {
126
+ const devices = devicesMap[runtime];
127
+ if (Array.isArray(devices)) {
128
+ for (const device of devices) {
129
+ const info = {
130
+ platform: 'ios',
131
+ id: device.udid,
132
+ osVersion: parseRuntimeName(runtime),
133
+ model: device.name,
134
+ simulator: true
135
+ };
136
+ if (appId) {
137
+ // check if installed
138
+ const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
139
+ .then(() => { info.appInstalled = true; })
140
+ .catch(() => { info.appInstalled = false; })
141
+ .then(() => { out.push(info); });
142
+ checks.push(p);
143
+ }
144
+ else {
145
+ out.push(info);
146
+ }
147
+ }
148
+ }
149
+ }
150
+ Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
151
+ }
152
+ catch (e) {
153
+ resolve([]);
154
+ }
155
+ });
156
+ });
157
+ }
158
+ // --- iOS live log stream support ---
159
+ import { createWriteStream, promises as fsPromises } from 'fs';
160
+ import path from 'path';
161
+ import { parseLogLine } from '../android/utils.js';
162
+ const iosActiveLogStreams = new Map();
163
+ // Test helpers
164
+ export function _setIOSActiveLogStream(sessionId, file) {
165
+ iosActiveLogStreams.set(sessionId, { proc: {}, file });
166
+ }
167
+ export function _clearIOSActiveLogStream(sessionId) {
168
+ iosActiveLogStreams.delete(sessionId);
169
+ }
170
+ export async function startIOSLogStream(bundleId, level = 'error', deviceId = 'booted', sessionId = 'default') {
171
+ try {
172
+ // Build predicate to filter by process or subsystem
173
+ const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`;
174
+ // Prevent multiple streams per session
175
+ if (iosActiveLogStreams.has(sessionId)) {
176
+ try {
177
+ iosActiveLogStreams.get(sessionId).proc.kill();
178
+ }
179
+ catch (e) { }
180
+ iosActiveLogStreams.delete(sessionId);
181
+ }
182
+ // Start simctl log stream: xcrun simctl spawn <device> log stream --style syslog --predicate '<predicate>'
183
+ const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
184
+ const proc = spawn(XCRUN, args);
185
+ // Prepare output file
186
+ const tmpDir = process.env.TMPDIR || '/tmp';
187
+ const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
188
+ const stream = createWriteStream(file, { flags: 'a' });
189
+ proc.stdout.on('data', (chunk) => {
190
+ const text = chunk.toString();
191
+ const lines = text.split(/\r?\n/).filter(Boolean);
192
+ for (const l of lines) {
193
+ // Try to parse with shared parser; parser may be optimized for Android but extracts exceptions and message
194
+ const entry = parseLogLine(l);
195
+ stream.write(JSON.stringify(entry) + '\n');
196
+ }
197
+ });
198
+ proc.stderr.on('data', (chunk) => {
199
+ const text = chunk.toString();
200
+ const lines = text.split(/\r?\n/).filter(Boolean);
201
+ for (const l of lines) {
202
+ const entry = { timestamp: '', level: 'E', tag: 'xcrun', message: l };
203
+ stream.write(JSON.stringify(entry) + '\n');
204
+ }
205
+ });
206
+ proc.on('close', (code) => {
207
+ stream.end();
208
+ iosActiveLogStreams.delete(sessionId);
209
+ });
210
+ iosActiveLogStreams.set(sessionId, { proc, file });
211
+ return { success: true, stream_started: true };
212
+ }
213
+ catch (err) {
214
+ return { success: false, error: 'log_stream_start_failed' };
215
+ }
216
+ }
217
+ export async function stopIOSLogStream(sessionId = 'default') {
218
+ const entry = iosActiveLogStreams.get(sessionId);
219
+ if (!entry)
220
+ return { success: true };
221
+ try {
222
+ entry.proc.kill();
223
+ }
224
+ catch (e) { }
225
+ iosActiveLogStreams.delete(sessionId);
226
+ return { success: true };
227
+ }
228
+ export async function readIOSLogStreamLines(sessionId = 'default', limit = 100, since) {
229
+ const entry = iosActiveLogStreams.get(sessionId);
230
+ if (!entry)
231
+ return { entries: [] };
232
+ try {
233
+ const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
234
+ if (!data)
235
+ return { entries: [], crash_summary: { crash_detected: false } };
236
+ const lines = data.split(/\r?\n/).filter(Boolean);
237
+ const parsed = lines.map(l => {
238
+ try {
239
+ return JSON.parse(l);
240
+ }
241
+ catch {
242
+ return { message: l, _iso: null, crash: false };
243
+ }
244
+ });
245
+ // Minimal since filtering if provided
246
+ let filtered = parsed;
247
+ if (since) {
248
+ let sinceMs = null;
249
+ if (/^\d+$/.test(since))
250
+ sinceMs = Number(since);
251
+ else {
252
+ const sDate = new Date(since);
253
+ if (!isNaN(sDate.getTime()))
254
+ sinceMs = sDate.getTime();
255
+ }
256
+ if (sinceMs !== null) {
257
+ filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
258
+ }
259
+ }
260
+ const entries = filtered.slice(-Math.max(0, limit));
261
+ const crashEntry = entries.find(e => e.crash);
262
+ const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
263
+ return { entries, crash_summary };
264
+ }
265
+ catch (e) {
266
+ return { entries: [], crash_summary: { crash_detected: false } };
267
+ }
268
+ }
@@ -0,0 +1,70 @@
1
+ import { listAndroidDevices } from "./android/utils.js";
2
+ import { listIOSDevices } from "./ios/utils.js";
3
+ function parseNumericVersion(v) {
4
+ if (!v)
5
+ return 0;
6
+ // extract first number groups like 17.0 -> 17.0 or Android 12 -> 12
7
+ const m = v.match(/(\d+)(?:[\.\-](\d+))?/);
8
+ if (!m)
9
+ return 0;
10
+ const major = parseInt(m[1], 10) || 0;
11
+ const minor = parseInt(m[2] || "0", 10) || 0;
12
+ return major + minor / 100;
13
+ }
14
+ export async function listDevices(platform, appId) {
15
+ if (!platform || platform === "android") {
16
+ const android = await listAndroidDevices(appId);
17
+ if (platform === "android")
18
+ return android;
19
+ // if no platform specified, merge with ios below
20
+ const ios = await listIOSDevices(appId);
21
+ return [...android, ...ios];
22
+ }
23
+ return listIOSDevices(appId);
24
+ }
25
+ export async function resolveTargetDevice(opts) {
26
+ const { platform, appId, prefer, deviceId } = opts;
27
+ const devices = await listDevices(platform, appId);
28
+ if (deviceId) {
29
+ const found = devices.find(d => d.id === deviceId);
30
+ if (!found)
31
+ throw new Error(`Device '${deviceId}' not found for platform ${platform}`);
32
+ return found;
33
+ }
34
+ let candidates = devices.slice();
35
+ // Apply prefer filter
36
+ if (prefer === "physical")
37
+ candidates = candidates.filter(d => !d.simulator);
38
+ if (prefer === "emulator")
39
+ candidates = candidates.filter(d => d.simulator);
40
+ // If appId provided, prefer devices with appInstalled
41
+ if (appId) {
42
+ const installed = candidates.filter(d => d.appInstalled);
43
+ if (installed.length > 0)
44
+ candidates = installed;
45
+ }
46
+ if (candidates.length === 1)
47
+ return candidates[0];
48
+ if (candidates.length > 1) {
49
+ // Prefer physical over emulator unless prefer=emulator
50
+ if (!prefer) {
51
+ const physical = candidates.filter(d => !d.simulator);
52
+ if (physical.length === 1)
53
+ return physical[0];
54
+ if (physical.length > 1)
55
+ candidates = physical;
56
+ }
57
+ // Pick highest OS version
58
+ candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion));
59
+ // If top is unique (numeric differs), return it
60
+ if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
61
+ return candidates[0];
62
+ }
63
+ // Ambiguous: throw an error with candidate list so caller (agent) can present choices
64
+ const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }));
65
+ const err = new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`);
66
+ err.devices = list;
67
+ throw err;
68
+ }
69
+ throw new Error(`No devices found for platform ${platform}`);
70
+ }