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/.github/copilot-instructions.md +33 -0
- package/README.md +220 -21
- package/dist/android/interact.js +30 -0
- package/dist/android/observe.js +313 -0
- package/dist/android/utils.js +82 -0
- package/dist/android.js +303 -31
- package/dist/ios/interact.js +65 -0
- package/dist/ios/observe.js +219 -0
- package/dist/ios/utils.js +114 -0
- package/dist/ios.js +337 -14
- package/dist/server.js +320 -20
- package/docs/CHANGELOG.md +28 -0
- package/package.json +3 -2
- package/smoke-test.js +102 -0
- package/smoke-test.ts +122 -0
- package/src/android/interact.ts +41 -0
- package/src/android/observe.ts +360 -0
- package/src/android/utils.ts +94 -0
- package/src/ios/interact.ts +75 -0
- package/src/ios/observe.ts +269 -0
- package/src/ios/utils.ts +133 -0
- package/src/server.ts +367 -24
- package/src/types.ts +92 -0
- package/test-ui-tree.js +68 -0
- package/test-ui-tree.ts +76 -0
- package/tsconfig.json +3 -1
- package/src/android.ts +0 -48
- package/src/ios.ts +0 -25
package/dist/android.js
CHANGED
|
@@ -1,44 +1,316 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2
3
|
const ADB = process.env.ADB_PATH || "adb";
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
const
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
253
|
+
return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length };
|
|
40
254
|
}
|
|
41
|
-
catch (
|
|
42
|
-
|
|
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
|
+
}
|