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.
package/dist/android.js CHANGED
@@ -1,44 +1,316 @@
1
- import { exec } from "child_process";
1
+ import { spawn } from "child_process";
2
+ import { XMLParser } from "fast-xml-parser";
2
3
  const ADB = process.env.ADB_PATH || "adb";
3
- export function startAndroidApp(pkg) {
4
+ // Helper to construct ADB args with optional device ID
5
+ function getAdbArgs(args, deviceId) {
6
+ if (deviceId) {
7
+ return ['-s', deviceId, ...args];
8
+ }
9
+ return args;
10
+ }
11
+ function execAdb(args, deviceId, options = {}) {
12
+ const adbArgs = getAdbArgs(args, deviceId);
4
13
  return new Promise((resolve, reject) => {
5
- exec(`${ADB} shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`, (err, stdout, stderr) => {
6
- if (err)
7
- reject(stderr);
8
- else
9
- resolve(stdout);
14
+ // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
15
+ const child = spawn(ADB, adbArgs, options);
16
+ let stdout = '';
17
+ let stderr = '';
18
+ if (child.stdout) {
19
+ child.stdout.on('data', (data) => {
20
+ stdout += data.toString();
21
+ });
22
+ }
23
+ if (child.stderr) {
24
+ child.stderr.on('data', (data) => {
25
+ stderr += data.toString();
26
+ });
27
+ }
28
+ let timeoutMs = 2000;
29
+ if (args.includes('logcat')) {
30
+ timeoutMs = 10000;
31
+ }
32
+ else if (args.includes('uiautomator') && args.includes('dump')) {
33
+ timeoutMs = 20000; // UI dump can be slow
34
+ }
35
+ const timeout = setTimeout(() => {
36
+ child.kill();
37
+ reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
38
+ }, timeoutMs);
39
+ child.on('close', (code) => {
40
+ clearTimeout(timeout);
41
+ if (code !== 0) {
42
+ // If there's an actual error (non-zero exit code), reject
43
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
44
+ }
45
+ else {
46
+ // If exit code is 0, resolve with stdout
47
+ resolve(stdout.trim());
48
+ }
49
+ });
50
+ child.on('error', (err) => {
51
+ clearTimeout(timeout);
52
+ reject(err);
10
53
  });
11
54
  });
12
55
  }
13
- export function getAndroidLogs(pkg, lines = 200) {
14
- return new Promise((resolve, reject) => {
15
- exec(`${ADB} shell pidof -s ${pkg}`, (pidErr, pidStdout, pidStderr) => {
16
- if (pidErr || !pidStdout.trim()) {
17
- reject(pidStderr || "App process not running");
18
- return;
19
- }
20
- const pid = pidStdout.trim();
21
- exec(`${ADB} logcat -d --pid=${pid} -t ${lines} -v threadtime`, (err, stdout, stderr) => {
22
- if (err)
23
- reject(stderr || err.message);
24
- else
25
- resolve(stdout);
56
+ function parseBounds(bounds) {
57
+ const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
58
+ if (match) {
59
+ return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
60
+ }
61
+ return [0, 0, 0, 0];
62
+ }
63
+ async function getScreenResolution(deviceId) {
64
+ try {
65
+ const output = await execAdb(['shell', 'wm', 'size'], deviceId);
66
+ const match = output.match(/Physical size: (\d+)x(\d+)/);
67
+ if (match) {
68
+ return { width: parseInt(match[1]), height: parseInt(match[2]) };
69
+ }
70
+ }
71
+ catch (e) {
72
+ // ignore
73
+ }
74
+ return { width: 0, height: 0 };
75
+ }
76
+ function traverseNode(node, elements, parentIndex = -1) {
77
+ if (!node)
78
+ return -1;
79
+ let currentIndex = -1;
80
+ // Check if it's a valid node with attributes we care about
81
+ if (node['@_class']) {
82
+ const element = {
83
+ text: node['@_text'] || null,
84
+ contentDescription: node['@_content-desc'] || null,
85
+ type: node['@_class'] || 'unknown',
86
+ resourceId: node['@_resource-id'] || null,
87
+ clickable: node['@_clickable'] === 'true',
88
+ enabled: node['@_enabled'] === 'true',
89
+ visible: true, // uiautomator dump typically includes visible elements
90
+ bounds: parseBounds(node['@_bounds'] || '[0,0][0,0]')
91
+ };
92
+ if (parentIndex !== -1) {
93
+ element.parentId = parentIndex;
94
+ }
95
+ elements.push(element);
96
+ currentIndex = elements.length - 1;
97
+ }
98
+ // If current node was skipped (no class), children inherit parentIndex
99
+ // If current node was added, children use currentIndex
100
+ const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
101
+ const childrenIndices = [];
102
+ // Traverse children
103
+ if (node.node) {
104
+ if (Array.isArray(node.node)) {
105
+ node.node.forEach((child) => {
106
+ const childIndex = traverseNode(child, elements, nextParentIndex);
107
+ if (childIndex !== -1)
108
+ childrenIndices.push(childIndex);
26
109
  });
110
+ }
111
+ else {
112
+ const childIndex = traverseNode(node.node, elements, nextParentIndex);
113
+ if (childIndex !== -1)
114
+ childrenIndices.push(childIndex);
115
+ }
116
+ }
117
+ // Update current element with children if it was added
118
+ if (currentIndex !== -1 && childrenIndices.length > 0) {
119
+ elements[currentIndex].children = childrenIndices;
120
+ }
121
+ return currentIndex;
122
+ }
123
+ export async function getAndroidUITree(deviceId) {
124
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
125
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
126
+ try {
127
+ // Get screen resolution first
128
+ const resolution = await getScreenResolution(deviceId);
129
+ if (resolution.width === 0 && resolution.height === 0) {
130
+ throw new Error("Failed to get screen resolution. Is the device connected and authorized?");
131
+ }
132
+ // Dump UI hierarchy
133
+ // We suppress stderr because uiautomator dump sometimes outputs "UI hierchary dumped to: /sdcard/ui.xml" to stdout/stderr
134
+ await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
135
+ // Read the file
136
+ const xmlContent = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
137
+ if (!xmlContent || xmlContent.trim().length === 0) {
138
+ throw new Error("Failed to read UI XML. File is empty.");
139
+ }
140
+ if (xmlContent.includes("ERROR:")) {
141
+ throw new Error(`UI Automator dump failed: ${xmlContent}`);
142
+ }
143
+ const parser = new XMLParser({
144
+ ignoreAttributes: false,
145
+ attributeNamePrefix: "@_"
27
146
  });
28
- });
147
+ const result = parser.parse(xmlContent);
148
+ const elements = [];
149
+ // The root is usually hierarchy -> node
150
+ if (result.hierarchy && result.hierarchy.node) {
151
+ // If the root is an array (unlikely for root, but good to be safe) or single object
152
+ if (Array.isArray(result.hierarchy.node)) {
153
+ result.hierarchy.node.forEach((n) => traverseNode(n, elements));
154
+ }
155
+ else {
156
+ traverseNode(result.hierarchy.node, elements);
157
+ }
158
+ }
159
+ return {
160
+ device: deviceInfo,
161
+ screen: "",
162
+ resolution,
163
+ elements
164
+ };
165
+ }
166
+ catch (e) {
167
+ const adbPath = process.env.ADB_PATH || "adb";
168
+ const errorMessage = `Failed to get UI tree. ADB Path: '${adbPath}'. Error: ${e instanceof Error ? e.message : String(e)}`;
169
+ console.error(errorMessage);
170
+ return {
171
+ device: deviceInfo,
172
+ screen: "",
173
+ resolution: { width: 0, height: 0 },
174
+ elements: [],
175
+ error: errorMessage
176
+ };
177
+ }
29
178
  }
30
- export async function getAndroidCrash(pkg, lines = 200) {
179
+ function getDeviceInfo(deviceId, metadata = {}) {
180
+ return {
181
+ platform: 'android',
182
+ id: deviceId || 'default',
183
+ osVersion: metadata.osVersion || '',
184
+ model: metadata.model || '',
185
+ simulator: metadata.simulator || false
186
+ };
187
+ }
188
+ export async function getAndroidDeviceMetadata(appId, deviceId) {
31
189
  try {
32
- const logs = await getAndroidLogs(pkg, lines);
33
- const crashLines = logs
34
- .split('\n')
35
- .filter(line => line.includes('FATAL EXCEPTION'));
36
- if (crashLines.length === 0) {
37
- return "No crashes found.";
190
+ // Run these in parallel to avoid sequential timeouts
191
+ const [osVersion, model, simOutput] = await Promise.all([
192
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], deviceId).catch(() => ''),
193
+ execAdb(['shell', 'getprop', 'ro.product.model'], deviceId).catch(() => ''),
194
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], deviceId).catch(() => '0')
195
+ ]);
196
+ const simulator = simOutput === '1';
197
+ return { platform: 'android', id: deviceId || 'default', osVersion, model, simulator };
198
+ }
199
+ catch (e) {
200
+ return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
201
+ }
202
+ }
203
+ export async function startAndroidApp(appId, deviceId) {
204
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
205
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
206
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
207
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
208
+ }
209
+ export async function terminateAndroidApp(appId, deviceId) {
210
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
211
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
212
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
213
+ return { device: deviceInfo, appTerminated: true };
214
+ }
215
+ export async function restartAndroidApp(appId, deviceId) {
216
+ await terminateAndroidApp(appId, deviceId);
217
+ const startResult = await startAndroidApp(appId, deviceId);
218
+ return {
219
+ device: startResult.device,
220
+ appRestarted: startResult.appStarted,
221
+ launchTimeMs: startResult.launchTimeMs
222
+ };
223
+ }
224
+ export async function resetAndroidAppData(appId, deviceId) {
225
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
226
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
227
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
228
+ return { device: deviceInfo, dataCleared: output === 'Success' };
229
+ }
230
+ export async function getAndroidLogs(appId, lines = 200, deviceId) {
231
+ const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
232
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
233
+ try {
234
+ // We'll skip PID lookup for now to avoid potential hangs with 'pidof' on some emulators
235
+ // and rely on robust string matching against the log line.
236
+ // Get logs
237
+ const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId);
238
+ const allLogs = stdout.split('\n');
239
+ let filteredLogs = allLogs;
240
+ if (appId) {
241
+ // Filter by checking if the line contains the appId string.
242
+ const matchingLogs = allLogs.filter(line => line.includes(appId));
243
+ if (matchingLogs.length > 0) {
244
+ filteredLogs = matchingLogs;
245
+ }
246
+ else {
247
+ // Fallback: if no logs match the appId, return the raw logs (last N lines)
248
+ // This matches the behavior of the "working" version provided by the user,
249
+ // ensuring they at least see system activity if the app is silent or crashing early.
250
+ filteredLogs = allLogs;
251
+ }
38
252
  }
39
- return crashLines.join('\n');
253
+ return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length };
40
254
  }
41
- catch (error) {
42
- return `Error retrieving crash logs: ${error}`;
255
+ catch (e) {
256
+ console.error("Error fetching logs:", e);
257
+ return { device: deviceInfo, logs: [], logCount: 0 };
43
258
  }
44
259
  }
260
+ export async function captureAndroidScreen(deviceId) {
261
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
262
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
263
+ return new Promise((resolve, reject) => {
264
+ const adbArgs = getAdbArgs(['exec-out', 'screencap', '-p'], deviceId);
265
+ // Using spawn for screencap as well to ensure consistent process handling
266
+ const child = spawn(ADB, adbArgs);
267
+ const chunks = [];
268
+ let stderr = '';
269
+ child.stdout.on('data', (chunk) => {
270
+ chunks.push(Buffer.from(chunk));
271
+ });
272
+ child.stderr.on('data', (data) => {
273
+ stderr += data.toString();
274
+ });
275
+ const timeout = setTimeout(() => {
276
+ child.kill();
277
+ reject(new Error(`ADB screencap timed out after 10s`));
278
+ }, 10000);
279
+ child.on('close', (code) => {
280
+ clearTimeout(timeout);
281
+ if (code !== 0) {
282
+ reject(new Error(stderr.trim() || `Screencap failed with code ${code}`));
283
+ return;
284
+ }
285
+ const screenshotBuffer = Buffer.concat(chunks);
286
+ const screenshotBase64 = screenshotBuffer.toString('base64');
287
+ // Get resolution
288
+ execAdb(['shell', 'wm', 'size'], deviceId)
289
+ .then(sizeStdout => {
290
+ let width = 0;
291
+ let height = 0;
292
+ const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
293
+ if (match) {
294
+ width = parseInt(match[1], 10);
295
+ height = parseInt(match[2], 10);
296
+ }
297
+ resolve({
298
+ device: deviceInfo,
299
+ screenshot: screenshotBase64,
300
+ resolution: { width, height }
301
+ });
302
+ })
303
+ .catch(() => {
304
+ resolve({
305
+ device: deviceInfo,
306
+ screenshot: screenshotBase64,
307
+ resolution: { width: 0, height: 0 }
308
+ });
309
+ });
310
+ });
311
+ child.on('error', (err) => {
312
+ clearTimeout(timeout);
313
+ reject(err);
314
+ });
315
+ });
316
+ }
@@ -0,0 +1,65 @@
1
+ import { promises as fs } from "fs";
2
+ import { execCommand, getIOSDeviceMetadata, validateBundleId } from "./utils.js";
3
+ export class iOSInteract {
4
+ async startApp(bundleId, deviceId = "booted") {
5
+ validateBundleId(bundleId);
6
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
7
+ const device = await getIOSDeviceMetadata(deviceId);
8
+ // Simulate launch time and appStarted for demonstration
9
+ return {
10
+ device,
11
+ appStarted: !!result.output,
12
+ launchTimeMs: 1000,
13
+ };
14
+ }
15
+ async terminateApp(bundleId, deviceId = "booted") {
16
+ validateBundleId(bundleId);
17
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
18
+ const device = await getIOSDeviceMetadata(deviceId);
19
+ return {
20
+ device,
21
+ appTerminated: true
22
+ };
23
+ }
24
+ async restartApp(bundleId, deviceId = "booted") {
25
+ // terminateApp already validates bundleId
26
+ await this.terminateApp(bundleId, deviceId);
27
+ const startResult = await this.startApp(bundleId, deviceId);
28
+ return {
29
+ device: startResult.device,
30
+ appRestarted: startResult.appStarted,
31
+ launchTimeMs: startResult.launchTimeMs
32
+ };
33
+ }
34
+ async resetAppData(bundleId, deviceId = "booted") {
35
+ validateBundleId(bundleId);
36
+ await this.terminateApp(bundleId, deviceId);
37
+ const device = await getIOSDeviceMetadata(deviceId);
38
+ // Get data container path
39
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
40
+ const dataPath = containerResult.output.trim();
41
+ if (!dataPath) {
42
+ throw new Error(`Could not find data container for ${bundleId}`);
43
+ }
44
+ // Clear contents of Library and Documents
45
+ try {
46
+ const libraryPath = `${dataPath}/Library`;
47
+ const documentsPath = `${dataPath}/Documents`;
48
+ const tmpPath = `${dataPath}/tmp`;
49
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
50
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
51
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
52
+ // Re-create empty directories as they are expected by apps
53
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
54
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
55
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
56
+ return {
57
+ device,
58
+ dataCleared: true
59
+ };
60
+ }
61
+ catch (err) {
62
+ throw new Error(`Failed to clear data for ${bundleId}: ${err instanceof Error ? err.message : String(err)}`);
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,219 @@
1
+ import { spawn } from "child_process";
2
+ import { promises as fs } from "fs";
3
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js";
4
+ // --- Helper Functions Specific to Observe ---
5
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6
+ function parseIDBFrame(frame) {
7
+ if (!frame)
8
+ return [0, 0, 0, 0];
9
+ const x = Number(frame.x || 0);
10
+ const y = Number(frame.y || 0);
11
+ const w = Number(frame.width || frame.w || 0);
12
+ const h = Number(frame.height || frame.h || 0);
13
+ return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
14
+ }
15
+ function getCenter(bounds) {
16
+ const [x1, y1, x2, y2] = bounds;
17
+ return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
18
+ }
19
+ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
20
+ if (!node)
21
+ return -1;
22
+ let currentIndex = -1;
23
+ // Prefer standard keys, fallback to alternatives
24
+ const type = node.AXElementType || node.type || "unknown";
25
+ const label = node.AXLabel || node.label || null;
26
+ const value = node.AXValue || null;
27
+ const frame = node.AXFrame || node.frame;
28
+ const traits = node.AXTraits || [];
29
+ const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell"; // Cells are often clickable
30
+ // Filtering Logic:
31
+ // Keep if clickable OR has visible text/label OR has value
32
+ // Also keep 'Window' or 'Application' types as they define the root structure often, though usually depth 0
33
+ const isUseful = clickable || (label && label.length > 0) || (value && value.length > 0) || type === "Application" || type === "Window";
34
+ if (isUseful) {
35
+ const bounds = parseIDBFrame(frame);
36
+ const element = {
37
+ text: label,
38
+ contentDescription: value,
39
+ type: type,
40
+ resourceId: node.AXUniqueId || null,
41
+ clickable: clickable,
42
+ enabled: true, // idb usually returns enabled elements
43
+ visible: true,
44
+ bounds: bounds,
45
+ center: getCenter(bounds),
46
+ depth: depth
47
+ };
48
+ if (parentIndex !== -1) {
49
+ element.parentId = parentIndex;
50
+ }
51
+ elements.push(element);
52
+ currentIndex = elements.length - 1;
53
+ }
54
+ // If current node was skipped, children inherit parentIndex and depth (flattening)
55
+ // If current node was added, children use currentIndex and depth + 1
56
+ const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
57
+ const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
58
+ const childrenIndices = [];
59
+ if (node.children && Array.isArray(node.children)) {
60
+ for (const child of node.children) {
61
+ const childIndex = traverseIDBNode(child, elements, nextParentIndex, nextDepth);
62
+ if (childIndex !== -1) {
63
+ childrenIndices.push(childIndex);
64
+ }
65
+ }
66
+ }
67
+ if (currentIndex !== -1 && childrenIndices.length > 0) {
68
+ elements[currentIndex].children = childrenIndices;
69
+ }
70
+ return currentIndex;
71
+ }
72
+ // Check if IDB is installed
73
+ async function isIDBInstalled() {
74
+ return new Promise((resolve) => {
75
+ // Check if 'idb' is in path by trying to run it
76
+ const child = spawn(IDB, ['--version']);
77
+ child.on('error', () => resolve(false));
78
+ child.on('close', (code) => resolve(code === 0));
79
+ });
80
+ }
81
+ export class iOSObserve {
82
+ async getDeviceMetadata(deviceId = "booted") {
83
+ return getIOSDeviceMetadata(deviceId);
84
+ }
85
+ async getLogs(appId, deviceId = "booted") {
86
+ // If appId is provided, use predicate filtering
87
+ // Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
88
+ // but we do need to construct the predicate correctly for log show.
89
+ const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m'];
90
+ if (appId) {
91
+ validateBundleId(appId);
92
+ args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
93
+ }
94
+ const result = await execCommand(args, deviceId);
95
+ const device = await getIOSDeviceMetadata(deviceId);
96
+ const logs = result.output ? result.output.split('\n') : [];
97
+ return {
98
+ device,
99
+ logs,
100
+ logCount: logs.length,
101
+ };
102
+ }
103
+ async captureScreenshot(deviceId = "booted") {
104
+ const device = await getIOSDeviceMetadata(deviceId);
105
+ const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`;
106
+ try {
107
+ // 1. Capture screenshot to temp file
108
+ await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
109
+ // 2. Read file as base64
110
+ const buffer = await fs.readFile(tmpFile);
111
+ const base64 = buffer.toString('base64');
112
+ // 3. Clean up
113
+ await fs.rm(tmpFile).catch(() => { });
114
+ return {
115
+ device,
116
+ screenshot: base64,
117
+ // Default resolution since we can't easily parse it without extra libs
118
+ // Clients will read the real dimensions from the PNG header anyway
119
+ resolution: { width: 0, height: 0 },
120
+ };
121
+ }
122
+ catch (err) {
123
+ // Ensure cleanup happens even on error
124
+ await fs.rm(tmpFile).catch(() => { });
125
+ throw new Error(`Failed to capture screenshot: ${err instanceof Error ? err.message : String(err)}`);
126
+ }
127
+ }
128
+ async getUITree(deviceId = "booted") {
129
+ const device = await getIOSDeviceMetadata(deviceId);
130
+ // idb is required
131
+ const idbExists = await isIDBInstalled();
132
+ if (!idbExists) {
133
+ return {
134
+ device,
135
+ screen: "",
136
+ resolution: { width: 0, height: 0 },
137
+ elements: [],
138
+ 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`."
139
+ };
140
+ }
141
+ // Resolve UDID if needed
142
+ const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
143
+ // Retry Logic
144
+ let jsonContent = null;
145
+ let attempts = 0;
146
+ const maxAttempts = 3;
147
+ while (attempts < maxAttempts) {
148
+ attempts++;
149
+ try {
150
+ // Stabilization delay
151
+ await delay(300 + (attempts * 100));
152
+ const args = ['ui', 'describe', '--json'];
153
+ if (targetUdid) {
154
+ args.push('--udid', targetUdid);
155
+ }
156
+ const output = await new Promise((resolve, reject) => {
157
+ const child = spawn(IDB, args);
158
+ let stdout = '';
159
+ let stderr = '';
160
+ child.stdout.on('data', (data) => stdout += data.toString());
161
+ child.stderr.on('data', (data) => stderr += data.toString());
162
+ child.on('error', (err) => reject(new Error(`Failed to execute idb: ${err.message}`)));
163
+ child.on('close', (code) => {
164
+ if (code !== 0) {
165
+ reject(new Error(`idb failed (code ${code}): ${stderr.trim()}`));
166
+ }
167
+ else {
168
+ resolve(stdout);
169
+ }
170
+ });
171
+ });
172
+ if (output && output.trim().length > 0) {
173
+ jsonContent = JSON.parse(output);
174
+ break; // Success
175
+ }
176
+ }
177
+ catch (err) {
178
+ console.error(`Attempt ${attempts} failed: ${err}`);
179
+ }
180
+ if (attempts === maxAttempts) {
181
+ return {
182
+ device,
183
+ screen: "",
184
+ resolution: { width: 0, height: 0 },
185
+ elements: [],
186
+ error: `Failed to retrieve valid UI dump after ${maxAttempts} attempts.`
187
+ };
188
+ }
189
+ }
190
+ try {
191
+ const elements = [];
192
+ const root = jsonContent;
193
+ traverseIDBNode(root, elements);
194
+ // Infer resolution from root element if possible (usually the Window/Application frame)
195
+ let width = 0;
196
+ let height = 0;
197
+ if (elements.length > 0) {
198
+ const rootBounds = elements[0].bounds;
199
+ width = rootBounds[2] - rootBounds[0];
200
+ height = rootBounds[3] - rootBounds[1];
201
+ }
202
+ return {
203
+ device,
204
+ screen: "",
205
+ resolution: { width, height },
206
+ elements
207
+ };
208
+ }
209
+ catch (e) {
210
+ return {
211
+ device,
212
+ screen: "",
213
+ resolution: { width: 0, height: 0 },
214
+ elements: [],
215
+ error: `Failed to parse idb output: ${e instanceof Error ? e.message : String(e)}`
216
+ };
217
+ }
218
+ }
219
+ }