preflight-ios-mcp 1.0.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/LICENSE +21 -0
- package/README.md +406 -0
- package/dist/helpers/applescript.d.ts +24 -0
- package/dist/helpers/applescript.js +116 -0
- package/dist/helpers/coordinate-mapper.d.ts +44 -0
- package/dist/helpers/coordinate-mapper.js +132 -0
- package/dist/helpers/idb.d.ts +30 -0
- package/dist/helpers/idb.js +169 -0
- package/dist/helpers/logger.d.ts +9 -0
- package/dist/helpers/logger.js +47 -0
- package/dist/helpers/simctl.d.ts +47 -0
- package/dist/helpers/simctl.js +174 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +139 -0
- package/dist/mouse-events +0 -0
- package/dist/tools/advanced.d.ts +203 -0
- package/dist/tools/advanced.js +275 -0
- package/dist/tools/app.d.ts +85 -0
- package/dist/tools/app.js +177 -0
- package/dist/tools/debug.d.ts +140 -0
- package/dist/tools/debug.js +511 -0
- package/dist/tools/device.d.ts +74 -0
- package/dist/tools/device.js +130 -0
- package/dist/tools/interaction.d.ts +101 -0
- package/dist/tools/interaction.js +159 -0
- package/dist/tools/playwright.d.ts +69 -0
- package/dist/tools/playwright.js +204 -0
- package/dist/tools/screenshot.d.ts +27 -0
- package/dist/tools/screenshot.js +97 -0
- package/dist/tools/system.d.ts +85 -0
- package/dist/tools/system.js +107 -0
- package/dist/tools/ui.d.ts +86 -0
- package/dist/tools/ui.js +245 -0
- package/package.json +44 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { execSimctl, resolveDevice, runAppleScript } from '../helpers/simctl.js';
|
|
3
|
+
import * as idb from '../helpers/idb.js';
|
|
4
|
+
import * as logger from '../helpers/logger.js';
|
|
5
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { execFile, spawn } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { getScreenMapping } from '../helpers/coordinate-mapper.js';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
// Active log stream processes
|
|
13
|
+
const activeStreams = new Map();
|
|
14
|
+
// --- get_logs ---
|
|
15
|
+
export const getLogsParams = {
|
|
16
|
+
process: z.string().optional().describe('Filter by process name (e.g., "MyApp", "SpringBoard")'),
|
|
17
|
+
subsystem: z.string().optional().describe('Filter by log subsystem (e.g., "com.apple.UIKit")'),
|
|
18
|
+
category: z.string().optional().describe('Filter by log category'),
|
|
19
|
+
level: z.enum(['debug', 'info', 'default', 'error', 'fault']).optional().describe('Minimum log level (default: default)'),
|
|
20
|
+
since: z.string().optional().describe('Time range: "5m", "1h", "30s", or ISO date (default: "1m")'),
|
|
21
|
+
messageContains: z.string().optional().describe('Filter messages containing this text'),
|
|
22
|
+
limit: z.number().optional().describe('Max number of log lines to return (default: 100)'),
|
|
23
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
24
|
+
};
|
|
25
|
+
export async function handleGetLogs(args) {
|
|
26
|
+
const device = await resolveDevice(args.deviceId);
|
|
27
|
+
const predicateParts = [];
|
|
28
|
+
// Sanitize predicate values — escape quotes to prevent predicate syntax breaking
|
|
29
|
+
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
30
|
+
if (args.process)
|
|
31
|
+
predicateParts.push(`processImagePath CONTAINS "${esc(args.process)}"`);
|
|
32
|
+
if (args.subsystem)
|
|
33
|
+
predicateParts.push(`subsystem == "${esc(args.subsystem)}"`);
|
|
34
|
+
if (args.category)
|
|
35
|
+
predicateParts.push(`category == "${esc(args.category)}"`);
|
|
36
|
+
if (args.messageContains)
|
|
37
|
+
predicateParts.push(`eventMessage CONTAINS "${esc(args.messageContains)}"`);
|
|
38
|
+
const cmdArgs = ['simctl', 'spawn', device, 'log', 'show'];
|
|
39
|
+
if (args.level)
|
|
40
|
+
cmdArgs.push('--level', args.level);
|
|
41
|
+
cmdArgs.push('--last', args.since || '1m');
|
|
42
|
+
cmdArgs.push('--style', 'compact');
|
|
43
|
+
if (predicateParts.length > 0) {
|
|
44
|
+
cmdArgs.push('--predicate', predicateParts.join(' AND '));
|
|
45
|
+
}
|
|
46
|
+
logger.debug('tool:getLogs', `Running: xcrun ${cmdArgs.join(' ')}`);
|
|
47
|
+
try {
|
|
48
|
+
const result = await execFileAsync('xcrun', cmdArgs, {
|
|
49
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
50
|
+
encoding: 'utf-8',
|
|
51
|
+
timeout: 30000,
|
|
52
|
+
});
|
|
53
|
+
let lines = result.stdout.split('\n').filter(l => l.trim());
|
|
54
|
+
const total = lines.length;
|
|
55
|
+
const limit = args.limit || 100;
|
|
56
|
+
if (lines.length > limit) {
|
|
57
|
+
lines = lines.slice(-limit); // most recent
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
content: [{
|
|
61
|
+
type: 'text',
|
|
62
|
+
text: `Device logs (showing ${lines.length} of ${total} entries, last ${args.since || '1m'}):\n\n${lines.join('\n')}`,
|
|
63
|
+
}],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const e = err;
|
|
68
|
+
return {
|
|
69
|
+
content: [{
|
|
70
|
+
type: 'text',
|
|
71
|
+
text: `Log query returned: ${e.stdout?.split('\n').filter(l => l.trim()).slice(-50).join('\n') || e.stderr || e.message || 'no output'}`,
|
|
72
|
+
}],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// --- stream_logs ---
|
|
77
|
+
export const streamLogsParams = {
|
|
78
|
+
action: z.enum(['start', 'read', 'stop']).describe('"start" begins streaming, "read" returns current buffer, "stop" ends the stream'),
|
|
79
|
+
process: z.string().optional().describe('Filter by process name'),
|
|
80
|
+
level: z.enum(['debug', 'info', 'default', 'error', 'fault']).optional().describe('Minimum log level'),
|
|
81
|
+
bufferSize: z.number().optional().describe('Max lines to keep in buffer (default: 200)'),
|
|
82
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
83
|
+
};
|
|
84
|
+
export async function handleStreamLogs(args) {
|
|
85
|
+
const device = await resolveDevice(args.deviceId);
|
|
86
|
+
const streamKey = `${device}-${args.process || 'all'}`;
|
|
87
|
+
if (args.action === 'stop') {
|
|
88
|
+
const stream = activeStreams.get(streamKey);
|
|
89
|
+
if (stream) {
|
|
90
|
+
stream.process.kill();
|
|
91
|
+
activeStreams.delete(streamKey);
|
|
92
|
+
return { content: [{ type: 'text', text: 'Log stream stopped.' }] };
|
|
93
|
+
}
|
|
94
|
+
return { content: [{ type: 'text', text: 'No active stream to stop.' }] };
|
|
95
|
+
}
|
|
96
|
+
if (args.action === 'read') {
|
|
97
|
+
const stream = activeStreams.get(streamKey);
|
|
98
|
+
if (!stream) {
|
|
99
|
+
return { content: [{ type: 'text', text: 'No active stream. Use action="start" first.' }] };
|
|
100
|
+
}
|
|
101
|
+
const lines = stream.buffer.join('\n');
|
|
102
|
+
return {
|
|
103
|
+
content: [{
|
|
104
|
+
type: 'text',
|
|
105
|
+
text: `Live log buffer (${stream.buffer.length} lines):\n\n${lines || '(no output yet)'}`,
|
|
106
|
+
}],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// action === 'start'
|
|
110
|
+
const existing = activeStreams.get(streamKey);
|
|
111
|
+
if (existing) {
|
|
112
|
+
existing.process.kill();
|
|
113
|
+
activeStreams.delete(streamKey);
|
|
114
|
+
}
|
|
115
|
+
const cmdArgs = ['simctl', 'spawn', device, 'log', 'stream', '--style', 'compact'];
|
|
116
|
+
if (args.level)
|
|
117
|
+
cmdArgs.push('--level', args.level);
|
|
118
|
+
if (args.process)
|
|
119
|
+
cmdArgs.push('--predicate', `processImagePath CONTAINS "${args.process}"`);
|
|
120
|
+
const child = spawn('xcrun', cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
121
|
+
const maxLines = args.bufferSize || 200;
|
|
122
|
+
const buffer = [];
|
|
123
|
+
child.stdout.on('data', (data) => {
|
|
124
|
+
const newLines = data.toString().split('\n').filter(l => l.trim());
|
|
125
|
+
buffer.push(...newLines);
|
|
126
|
+
while (buffer.length > maxLines)
|
|
127
|
+
buffer.shift();
|
|
128
|
+
});
|
|
129
|
+
child.stderr.on('data', (data) => {
|
|
130
|
+
buffer.push(`[stderr] ${data.toString().trim()}`);
|
|
131
|
+
while (buffer.length > maxLines)
|
|
132
|
+
buffer.shift();
|
|
133
|
+
});
|
|
134
|
+
activeStreams.set(streamKey, { process: child, buffer, maxLines });
|
|
135
|
+
return {
|
|
136
|
+
content: [{
|
|
137
|
+
type: 'text',
|
|
138
|
+
text: `Log stream started (buffer=${maxLines} lines). Use action="read" to get buffer, action="stop" to end.`,
|
|
139
|
+
}],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// --- get_app_container ---
|
|
143
|
+
export const getAppContainerParams = {
|
|
144
|
+
bundleId: z.string().describe('App bundle identifier'),
|
|
145
|
+
containerType: z.enum(['app', 'data', 'groups']).optional().describe('Container type (default: data)'),
|
|
146
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
147
|
+
};
|
|
148
|
+
export async function handleGetAppContainer(args) {
|
|
149
|
+
const device = await resolveDevice(args.deviceId);
|
|
150
|
+
const type = args.containerType || 'data';
|
|
151
|
+
const cmdArgs = ['get_app_container', device, args.bundleId];
|
|
152
|
+
if (type !== 'data')
|
|
153
|
+
cmdArgs.push(type);
|
|
154
|
+
const { stdout } = await execSimctl(cmdArgs, 'tool:getAppContainer');
|
|
155
|
+
return { content: [{ type: 'text', text: `${type} container: ${stdout.trim()}` }] };
|
|
156
|
+
}
|
|
157
|
+
// --- list_app_files ---
|
|
158
|
+
export const listAppFilesParams = {
|
|
159
|
+
bundleId: z.string().describe('App bundle identifier'),
|
|
160
|
+
subPath: z.string().optional().describe('Subdirectory to list (e.g., "Documents", "Library/Preferences")'),
|
|
161
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
162
|
+
};
|
|
163
|
+
export async function handleListAppFiles(args) {
|
|
164
|
+
const device = await resolveDevice(args.deviceId);
|
|
165
|
+
const { stdout: containerPath } = await execSimctl(['get_app_container', device, args.bundleId, 'data'], 'tool:listAppFiles');
|
|
166
|
+
const basePath = args.subPath
|
|
167
|
+
? join(containerPath.trim(), args.subPath)
|
|
168
|
+
: containerPath.trim();
|
|
169
|
+
try {
|
|
170
|
+
const result = await execFileAsync('find', [basePath, '-maxdepth', '4', '-ls'], {
|
|
171
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
172
|
+
encoding: 'utf-8',
|
|
173
|
+
timeout: 10000,
|
|
174
|
+
});
|
|
175
|
+
const lines = result.stdout.split('\n').filter(l => l.trim());
|
|
176
|
+
const truncated = lines.length > 200;
|
|
177
|
+
const output = truncated ? lines.slice(0, 200) : lines;
|
|
178
|
+
return {
|
|
179
|
+
content: [{
|
|
180
|
+
type: 'text',
|
|
181
|
+
text: `Files in ${basePath} (${lines.length} entries${truncated ? ', showing first 200' : ''}):\n\n${output.join('\n')}`,
|
|
182
|
+
}],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
const e = err;
|
|
187
|
+
return { content: [{ type: 'text', text: `Error listing files: ${e.message}` }] };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// --- read_app_file ---
|
|
191
|
+
export const readAppFileParams = {
|
|
192
|
+
bundleId: z.string().describe('App bundle identifier'),
|
|
193
|
+
filePath: z.string().describe('Relative path within the data container (e.g., "Documents/data.json", "Library/Preferences/com.app.plist")'),
|
|
194
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
195
|
+
};
|
|
196
|
+
export async function handleReadAppFile(args) {
|
|
197
|
+
const device = await resolveDevice(args.deviceId);
|
|
198
|
+
const { stdout: containerPath } = await execSimctl(['get_app_container', device, args.bundleId, 'data'], 'tool:readAppFile');
|
|
199
|
+
const fullPath = join(containerPath.trim(), args.filePath);
|
|
200
|
+
try {
|
|
201
|
+
const stats = await stat(fullPath);
|
|
202
|
+
if (stats.size > 1024 * 1024) {
|
|
203
|
+
return { content: [{ type: 'text', text: `File too large (${Math.round(stats.size / 1024)}KB). Use a more specific path.` }] };
|
|
204
|
+
}
|
|
205
|
+
// Handle plist files specially
|
|
206
|
+
if (fullPath.endsWith('.plist')) {
|
|
207
|
+
try {
|
|
208
|
+
const result = await execFileAsync('plutil', ['-convert', 'json', '-o', '-', fullPath], {
|
|
209
|
+
encoding: 'utf-8',
|
|
210
|
+
timeout: 5000,
|
|
211
|
+
});
|
|
212
|
+
return { content: [{ type: 'text', text: `${args.filePath} (plist → JSON):\n\n${result.stdout}` }] };
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Fall through to binary read
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Handle sqlite files
|
|
219
|
+
if (fullPath.endsWith('.sqlite') || fullPath.endsWith('.db') || fullPath.endsWith('.sqlite3')) {
|
|
220
|
+
try {
|
|
221
|
+
const result = await execFileAsync('sqlite3', [fullPath, '.tables'], {
|
|
222
|
+
encoding: 'utf-8',
|
|
223
|
+
timeout: 5000,
|
|
224
|
+
});
|
|
225
|
+
const tables = result.stdout.trim();
|
|
226
|
+
let schemaInfo = `SQLite database: ${args.filePath}\nTables: ${tables}\n`;
|
|
227
|
+
// Get schema for each table
|
|
228
|
+
for (const table of tables.split(/\s+/).filter(t => t)) {
|
|
229
|
+
try {
|
|
230
|
+
const schema = await execFileAsync('sqlite3', [fullPath, `.schema ${table}`], {
|
|
231
|
+
encoding: 'utf-8',
|
|
232
|
+
timeout: 5000,
|
|
233
|
+
});
|
|
234
|
+
schemaInfo += `\n${schema.stdout}`;
|
|
235
|
+
}
|
|
236
|
+
catch { /* skip */ }
|
|
237
|
+
}
|
|
238
|
+
return { content: [{ type: 'text', text: schemaInfo }] };
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return { content: [{ type: 'text', text: `Binary SQLite file at ${args.filePath} (${Math.round(stats.size / 1024)}KB)` }] };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Try reading as text
|
|
245
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
246
|
+
return { content: [{ type: 'text', text: `${args.filePath}:\n\n${content.slice(0, 10000)}${content.length > 10000 ? '\n\n...(truncated)' : ''}` }] };
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const e = err;
|
|
250
|
+
if (e.code === 'ENOENT') {
|
|
251
|
+
return { content: [{ type: 'text', text: `File not found: ${args.filePath}. Use simulator_list_app_files to see available files.` }] };
|
|
252
|
+
}
|
|
253
|
+
return { content: [{ type: 'text', text: `Error reading file: ${e.message}` }] };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// --- get_crash_logs ---
|
|
257
|
+
export const getCrashLogsParams = {
|
|
258
|
+
processName: z.string().optional().describe('Filter by process/app name'),
|
|
259
|
+
since: z.string().optional().describe('Only crashes since this ISO date (e.g., "2026-03-22")'),
|
|
260
|
+
limit: z.number().optional().describe('Max number of crash reports (default: 5)'),
|
|
261
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
262
|
+
};
|
|
263
|
+
export async function handleGetCrashLogs(args) {
|
|
264
|
+
await resolveDevice(args.deviceId);
|
|
265
|
+
const diagDir = join(homedir(), 'Library', 'Logs', 'DiagnosticReports');
|
|
266
|
+
const limit = args.limit || 5;
|
|
267
|
+
try {
|
|
268
|
+
const files = await readdir(diagDir);
|
|
269
|
+
let crashFiles = files.filter(f => f.endsWith('.ips') || f.endsWith('.crash') || f.endsWith('.ips.ca'));
|
|
270
|
+
if (args.processName) {
|
|
271
|
+
crashFiles = crashFiles.filter(f => f.toLowerCase().includes(args.processName.toLowerCase()));
|
|
272
|
+
}
|
|
273
|
+
// Sort by name (which includes date) descending
|
|
274
|
+
crashFiles.sort().reverse();
|
|
275
|
+
crashFiles = crashFiles.slice(0, limit);
|
|
276
|
+
if (crashFiles.length === 0) {
|
|
277
|
+
return { content: [{ type: 'text', text: `No crash logs found${args.processName ? ` for "${args.processName}"` : ''} in ${diagDir}` }] };
|
|
278
|
+
}
|
|
279
|
+
const reports = [];
|
|
280
|
+
for (const file of crashFiles) {
|
|
281
|
+
try {
|
|
282
|
+
const content = await readFile(join(diagDir, file), 'utf-8');
|
|
283
|
+
reports.push(`--- ${file} ---\n${content.slice(0, 5000)}${content.length > 5000 ? '\n...(truncated)' : ''}`);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
reports.push(`--- ${file} --- (unreadable)`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
content: [{
|
|
291
|
+
type: 'text',
|
|
292
|
+
text: `Found ${crashFiles.length} crash report(s):\n\n${reports.join('\n\n')}`,
|
|
293
|
+
}],
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
const e = err;
|
|
298
|
+
return { content: [{ type: 'text', text: `Error reading crash logs: ${e.message}` }] };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// --- diagnose ---
|
|
302
|
+
export const diagnoseParams = {
|
|
303
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
304
|
+
};
|
|
305
|
+
export async function handleDiagnose(args) {
|
|
306
|
+
const device = await resolveDevice(args.deviceId);
|
|
307
|
+
// Collect various diagnostic info
|
|
308
|
+
const results = [];
|
|
309
|
+
// Device info
|
|
310
|
+
try {
|
|
311
|
+
const { stdout } = await execFileAsync('xcrun', ['simctl', 'list', '-j', 'devices'], {
|
|
312
|
+
encoding: 'utf-8',
|
|
313
|
+
timeout: 10000,
|
|
314
|
+
});
|
|
315
|
+
const data = JSON.parse(stdout);
|
|
316
|
+
for (const [runtime, devs] of Object.entries(data.devices)) {
|
|
317
|
+
for (const dev of devs) {
|
|
318
|
+
if (dev.state === 'Booted') {
|
|
319
|
+
results.push(`Booted device: ${dev.name} (${dev.udid})\nRuntime: ${runtime}\nDevice type: ${dev.deviceTypeIdentifier}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch { /* skip */ }
|
|
325
|
+
// Disk usage of simulator data
|
|
326
|
+
try {
|
|
327
|
+
const { stdout } = await execFileAsync('du', ['-sh', join(homedir(), 'Library', 'Developer', 'CoreSimulator', 'Devices')], {
|
|
328
|
+
encoding: 'utf-8',
|
|
329
|
+
timeout: 10000,
|
|
330
|
+
});
|
|
331
|
+
results.push(`Simulator disk usage: ${stdout.trim()}`);
|
|
332
|
+
}
|
|
333
|
+
catch { /* skip */ }
|
|
334
|
+
// System version
|
|
335
|
+
try {
|
|
336
|
+
const { stdout } = await execFileAsync('xcrun', ['--version'], { encoding: 'utf-8' });
|
|
337
|
+
results.push(`Xcode toolchain: ${stdout.trim()}`);
|
|
338
|
+
}
|
|
339
|
+
catch { /* skip */ }
|
|
340
|
+
try {
|
|
341
|
+
const { stdout } = await execFileAsync('xcodebuild', ['-version'], { encoding: 'utf-8', timeout: 5000 });
|
|
342
|
+
results.push(`Xcode: ${stdout.trim()}`);
|
|
343
|
+
}
|
|
344
|
+
catch { /* skip */ }
|
|
345
|
+
return {
|
|
346
|
+
content: [{
|
|
347
|
+
type: 'text',
|
|
348
|
+
text: `Simulator Diagnostics:\n\n${results.join('\n\n')}`,
|
|
349
|
+
}],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// --- accessibility_audit ---
|
|
353
|
+
export const accessibilityAuditParams = {
|
|
354
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
355
|
+
};
|
|
356
|
+
export async function handleAccessibilityAudit(args) {
|
|
357
|
+
const device = await resolveDevice(args.deviceId);
|
|
358
|
+
// Prefer idb: returns actual iOS UI elements (UIButton, UILabel, etc.)
|
|
359
|
+
if (await idb.checkIdbAvailable()) {
|
|
360
|
+
try {
|
|
361
|
+
const output = await idb.idbDescribeAll(device);
|
|
362
|
+
const lines = output.split('\n').filter(l => l.trim());
|
|
363
|
+
return {
|
|
364
|
+
content: [{
|
|
365
|
+
type: 'text',
|
|
366
|
+
text: `iOS Accessibility Tree via idb (${lines.length} elements):\n\n${output}`,
|
|
367
|
+
}],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
const e = err;
|
|
372
|
+
logger.warn('tool:accessibility', `idb describe-all failed, falling back to AppleScript: ${e.message}`);
|
|
373
|
+
// Fall through to AppleScript fallback
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Fallback: Deep traversal of Simulator's accessibility tree (4 levels).
|
|
377
|
+
// iOS content is nested inside the Simulator window hierarchy.
|
|
378
|
+
// NOTE: handlers using "my" lose the "tell" context, so we inline
|
|
379
|
+
// all element access within the tell block.
|
|
380
|
+
const elemFmt = (varName, indent) => `
|
|
381
|
+
set _r to "?"
|
|
382
|
+
try
|
|
383
|
+
set _r to role of ${varName}
|
|
384
|
+
end try
|
|
385
|
+
set _d to ""
|
|
386
|
+
try
|
|
387
|
+
set _d to description of ${varName}
|
|
388
|
+
end try
|
|
389
|
+
set _v to ""
|
|
390
|
+
try
|
|
391
|
+
set _v to value of ${varName} as text
|
|
392
|
+
end try
|
|
393
|
+
set _p to {0, 0}
|
|
394
|
+
try
|
|
395
|
+
set _p to position of ${varName}
|
|
396
|
+
end try
|
|
397
|
+
set _s to {0, 0}
|
|
398
|
+
try
|
|
399
|
+
set _s to size of ${varName}
|
|
400
|
+
end try
|
|
401
|
+
set _info to "${indent}" & _r
|
|
402
|
+
if _d is not "" then set _info to _info & " | " & _d
|
|
403
|
+
if _v is not "" and _v is not _d then set _info to _info & " | val=" & _v
|
|
404
|
+
set _info to _info & " @" & (item 1 of _p) & "," & (item 2 of _p) & " " & (item 1 of _s) & "x" & (item 2 of _s)
|
|
405
|
+
set output to output & _info & return`;
|
|
406
|
+
const script = `
|
|
407
|
+
tell application "System Events"
|
|
408
|
+
tell process "Simulator"
|
|
409
|
+
set frontWin to front window
|
|
410
|
+
set winName to name of frontWin
|
|
411
|
+
set output to "=== Simulator Accessibility Tree ===" & return
|
|
412
|
+
set output to output & "Window: " & winName & return & return
|
|
413
|
+
|
|
414
|
+
set L1 to every UI element of frontWin
|
|
415
|
+
repeat with e1 in L1
|
|
416
|
+
${elemFmt('e1', '')}
|
|
417
|
+
set L2 to {}
|
|
418
|
+
try
|
|
419
|
+
set L2 to every UI element of e1
|
|
420
|
+
end try
|
|
421
|
+
repeat with e2 in L2
|
|
422
|
+
${elemFmt('e2', ' ')}
|
|
423
|
+
set L3 to {}
|
|
424
|
+
try
|
|
425
|
+
set L3 to every UI element of e2
|
|
426
|
+
end try
|
|
427
|
+
repeat with e3 in L3
|
|
428
|
+
${elemFmt('e3', ' ')}
|
|
429
|
+
set L4 to {}
|
|
430
|
+
try
|
|
431
|
+
set L4 to every UI element of e3
|
|
432
|
+
end try
|
|
433
|
+
repeat with e4 in L4
|
|
434
|
+
${elemFmt('e4', ' ')}
|
|
435
|
+
end repeat
|
|
436
|
+
end repeat
|
|
437
|
+
end repeat
|
|
438
|
+
end repeat
|
|
439
|
+
|
|
440
|
+
return output
|
|
441
|
+
end tell
|
|
442
|
+
end tell`;
|
|
443
|
+
try {
|
|
444
|
+
const result = await runAppleScript(script, 'tool:accessibility');
|
|
445
|
+
// AppleScript "return" is \r (CR), not \n — split on all line endings
|
|
446
|
+
const lines = result.split(/\r\n|\r|\n/).filter(l => l.trim());
|
|
447
|
+
// De-duplicate adjacent identical lines (common in deep Simulator trees)
|
|
448
|
+
const deduped = [];
|
|
449
|
+
for (const line of lines) {
|
|
450
|
+
if (deduped[deduped.length - 1] !== line)
|
|
451
|
+
deduped.push(line);
|
|
452
|
+
}
|
|
453
|
+
// Count only element lines (those with AX roles), skip headers
|
|
454
|
+
const elementCount = deduped.filter(l => /^[\s]*(AX\w+|missing value)/.test(l)).length;
|
|
455
|
+
return {
|
|
456
|
+
content: [{
|
|
457
|
+
type: 'text',
|
|
458
|
+
text: `Accessibility Tree (${elementCount} elements):\n\n${deduped.join('\n')}`,
|
|
459
|
+
}],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
const e = err;
|
|
464
|
+
return {
|
|
465
|
+
content: [{
|
|
466
|
+
type: 'text',
|
|
467
|
+
text: `Accessibility audit failed: ${e.message}\n\nNote: This requires Accessibility permission in System Settings → Privacy & Security → Accessibility.`,
|
|
468
|
+
}],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// --- get_screen_info ---
|
|
473
|
+
export const getScreenInfoParams = {
|
|
474
|
+
deviceId: z.string().optional().describe('Device (default: booted)'),
|
|
475
|
+
};
|
|
476
|
+
export async function handleGetScreenInfo(args) {
|
|
477
|
+
const device = await resolveDevice(args.deviceId);
|
|
478
|
+
try {
|
|
479
|
+
const mapping = await getScreenMapping(device);
|
|
480
|
+
return {
|
|
481
|
+
content: [{
|
|
482
|
+
type: 'text',
|
|
483
|
+
text: `Screen Mapping Info:
|
|
484
|
+
|
|
485
|
+
Window position: (${mapping.windowGeometry.windowX}, ${mapping.windowGeometry.windowY})
|
|
486
|
+
Window size: ${mapping.windowGeometry.windowWidth} x ${mapping.windowGeometry.windowHeight}
|
|
487
|
+
Title bar height: ${mapping.titleBarHeight}
|
|
488
|
+
Content area: ${mapping.contentWidth} x ${mapping.contentHeight}
|
|
489
|
+
|
|
490
|
+
Device screen: ${mapping.devicePointWidth} x ${mapping.devicePointHeight} points
|
|
491
|
+
Scale factor: ${mapping.scaleFactor}x
|
|
492
|
+
|
|
493
|
+
Coordinate mapping:
|
|
494
|
+
scaleX: ${mapping.scaleX.toFixed(4)}
|
|
495
|
+
scaleY: ${mapping.scaleY.toFixed(4)}
|
|
496
|
+
|
|
497
|
+
Example: sim(0,0) → mac(${mapping.windowGeometry.windowX}, ${mapping.windowGeometry.windowY + mapping.titleBarHeight})
|
|
498
|
+
Example: sim(${mapping.devicePointWidth},${mapping.devicePointHeight}) → mac(${mapping.windowGeometry.windowX + mapping.contentWidth}, ${mapping.windowGeometry.windowY + mapping.titleBarHeight + mapping.contentHeight})`,
|
|
499
|
+
}],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
const e = err;
|
|
504
|
+
return {
|
|
505
|
+
content: [{
|
|
506
|
+
type: 'text',
|
|
507
|
+
text: `Failed to get screen info: ${e.message}\n\nMake sure Simulator is running and visible.`,
|
|
508
|
+
}],
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const listDevicesParams: {
|
|
3
|
+
filter: z.ZodOptional<z.ZodEnum<["available", "booted", "all"]>>;
|
|
4
|
+
};
|
|
5
|
+
export declare function handleListDevices(args: {
|
|
6
|
+
filter?: string;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
content: {
|
|
9
|
+
type: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
}[];
|
|
12
|
+
}>;
|
|
13
|
+
export declare const bootParams: {
|
|
14
|
+
deviceId: z.ZodString;
|
|
15
|
+
waitForBoot: z.ZodOptional<z.ZodBoolean>;
|
|
16
|
+
};
|
|
17
|
+
export declare function handleBoot(args: {
|
|
18
|
+
deviceId: string;
|
|
19
|
+
waitForBoot?: boolean;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
content: {
|
|
22
|
+
type: "text";
|
|
23
|
+
text: string;
|
|
24
|
+
}[];
|
|
25
|
+
}>;
|
|
26
|
+
export declare const shutdownParams: {
|
|
27
|
+
deviceId: z.ZodOptional<z.ZodString>;
|
|
28
|
+
};
|
|
29
|
+
export declare function handleShutdown(args: {
|
|
30
|
+
deviceId?: string;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
content: {
|
|
33
|
+
type: "text";
|
|
34
|
+
text: string;
|
|
35
|
+
}[];
|
|
36
|
+
}>;
|
|
37
|
+
export declare const eraseParams: {
|
|
38
|
+
deviceId: z.ZodString;
|
|
39
|
+
};
|
|
40
|
+
export declare function handleErase(args: {
|
|
41
|
+
deviceId: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
content: {
|
|
44
|
+
type: "text";
|
|
45
|
+
text: string;
|
|
46
|
+
}[];
|
|
47
|
+
}>;
|
|
48
|
+
export declare const openUrlParams: {
|
|
49
|
+
url: z.ZodString;
|
|
50
|
+
deviceId: z.ZodOptional<z.ZodString>;
|
|
51
|
+
};
|
|
52
|
+
export declare function handleOpenUrl(args: {
|
|
53
|
+
url: string;
|
|
54
|
+
deviceId?: string;
|
|
55
|
+
}): Promise<{
|
|
56
|
+
content: {
|
|
57
|
+
type: "text";
|
|
58
|
+
text: string;
|
|
59
|
+
}[];
|
|
60
|
+
}>;
|
|
61
|
+
export declare const openSimulatorParams: {};
|
|
62
|
+
export declare function handleOpenSimulator(): Promise<{
|
|
63
|
+
content: {
|
|
64
|
+
type: "text";
|
|
65
|
+
text: string;
|
|
66
|
+
}[];
|
|
67
|
+
}>;
|
|
68
|
+
export declare const getBootedSimIdParams: {};
|
|
69
|
+
export declare function handleGetBootedSimId(): Promise<{
|
|
70
|
+
content: {
|
|
71
|
+
type: "text";
|
|
72
|
+
text: string;
|
|
73
|
+
}[];
|
|
74
|
+
}>;
|