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.
@@ -0,0 +1,97 @@
1
+ import { z } from 'zod';
2
+ import { execSimctlBuffer, resolveDevice } from '../helpers/simctl.js';
3
+ import * as logger from '../helpers/logger.js';
4
+ import { writeFile, mkdir, unlink } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ const execFileAsync = promisify(execFile);
10
+ // Optimized for AI chat: small images that fit in context windows
11
+ // Target ~200-400KB JPEG — enough detail for UI analysis, minimal token waste
12
+ const MAX_BYTES = 1.5 * 1024 * 1024; // 1.5MB absolute cap
13
+ export const screenshotParams = {
14
+ deviceId: z.string().optional().describe('Device UDID, name, or "booted" (default: booted)'),
15
+ format: z.enum(['png', 'jpeg']).optional().describe('Image format (default: jpeg). JPEG recommended for AI — smaller, faster.'),
16
+ display: z.enum(['internal', 'external']).optional().describe('Display to capture (default: internal)'),
17
+ mask: z.enum(['ignored', 'alpha', 'black']).optional().describe('For non-rectangular displays, handle mask by policy'),
18
+ savePath: z.string().optional().describe('Optional: save a copy to this path on disk'),
19
+ };
20
+ /**
21
+ * Compress a PNG buffer to JPEG using sips, optimized for AI chat.
22
+ * Targets ~200-400KB — enough for UI analysis without wasting tokens.
23
+ */
24
+ async function compressToJpeg(pngBuffer) {
25
+ const tmpPng = join(tmpdir(), `simscr-${Date.now()}.png`);
26
+ const tmpJpg = join(tmpdir(), `simscr-${Date.now()}.jpg`);
27
+ try {
28
+ await writeFile(tmpPng, pngBuffer);
29
+ // First pass: quality 60 (good for AI analysis, small file)
30
+ await execFileAsync('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '60', tmpPng, '--out', tmpJpg], { timeout: 10000 });
31
+ const { readFile: rf } = await import('node:fs/promises');
32
+ let jpgBuffer = await rf(tmpJpg);
33
+ // If still too large, compress harder
34
+ if (jpgBuffer.length > MAX_BYTES) {
35
+ await execFileAsync('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '35', tmpPng, '--out', tmpJpg], { timeout: 10000 });
36
+ jpgBuffer = await rf(tmpJpg);
37
+ }
38
+ return jpgBuffer;
39
+ }
40
+ catch (err) {
41
+ logger.debug('tool:screenshot', `JPEG compression failed: ${err.message}`);
42
+ return pngBuffer;
43
+ }
44
+ finally {
45
+ await unlink(tmpPng).catch(() => { });
46
+ await unlink(tmpJpg).catch(() => { });
47
+ }
48
+ }
49
+ export async function handleScreenshot(args) {
50
+ const device = await resolveDevice(args.deviceId);
51
+ const requestedFormat = args.format || 'jpeg';
52
+ // Capture PNG from simctl (always start with PNG for best quality source)
53
+ const simctlArgs = ['io', device, 'screenshot', '--type=png'];
54
+ if (args.display)
55
+ simctlArgs.push(`--display=${args.display}`);
56
+ if (args.mask)
57
+ simctlArgs.push(`--mask=${args.mask}`);
58
+ simctlArgs.push('-');
59
+ const { stdout: pngBuffer } = await execSimctlBuffer(simctlArgs, 'tool:screenshot');
60
+ let outputBuffer;
61
+ let mimeType;
62
+ let format;
63
+ if (requestedFormat === 'jpeg') {
64
+ outputBuffer = await compressToJpeg(pngBuffer);
65
+ mimeType = 'image/jpeg';
66
+ format = 'jpeg';
67
+ }
68
+ else {
69
+ outputBuffer = pngBuffer;
70
+ mimeType = 'image/png';
71
+ format = 'png';
72
+ }
73
+ // Only save to disk if explicitly requested
74
+ let savedPath = '';
75
+ if (args.savePath) {
76
+ try {
77
+ const dir = args.savePath.substring(0, args.savePath.lastIndexOf('/'));
78
+ if (dir)
79
+ await mkdir(dir, { recursive: true });
80
+ await writeFile(args.savePath, outputBuffer);
81
+ savedPath = args.savePath;
82
+ }
83
+ catch (err) {
84
+ logger.debug('tool:screenshot', `Failed to save to ${args.savePath}: ${err.message}`);
85
+ }
86
+ }
87
+ const base64Data = outputBuffer.toString('base64');
88
+ return {
89
+ content: [
90
+ { type: 'image', data: base64Data, mimeType },
91
+ {
92
+ type: 'text',
93
+ text: `Screenshot captured (${format}, ${Math.round(outputBuffer.length / 1024)}KB)${savedPath ? `\nSaved to: ${savedPath}` : ''}`,
94
+ },
95
+ ],
96
+ };
97
+ }
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ export declare const setLocationParams: {
3
+ latitude: z.ZodNumber;
4
+ longitude: z.ZodNumber;
5
+ deviceId: z.ZodOptional<z.ZodString>;
6
+ };
7
+ export declare function handleSetLocation(args: {
8
+ latitude: number;
9
+ longitude: number;
10
+ deviceId?: string;
11
+ }): Promise<{
12
+ content: {
13
+ type: "text";
14
+ text: string;
15
+ }[];
16
+ }>;
17
+ export declare const sendPushParams: {
18
+ bundleId: z.ZodString;
19
+ payload: z.ZodRecord<z.ZodString, z.ZodAny>;
20
+ deviceId: z.ZodOptional<z.ZodString>;
21
+ };
22
+ export declare function handleSendPush(args: {
23
+ bundleId: string;
24
+ payload: Record<string, unknown>;
25
+ deviceId?: string;
26
+ }): Promise<{
27
+ content: {
28
+ type: "text";
29
+ text: string;
30
+ }[];
31
+ }>;
32
+ export declare const setClipboardParams: {
33
+ text: z.ZodString;
34
+ deviceId: z.ZodOptional<z.ZodString>;
35
+ };
36
+ export declare function handleSetClipboard(args: {
37
+ text: string;
38
+ deviceId?: string;
39
+ }): Promise<{
40
+ content: {
41
+ type: "text";
42
+ text: string;
43
+ }[];
44
+ }>;
45
+ export declare const getClipboardParams: {
46
+ deviceId: z.ZodOptional<z.ZodString>;
47
+ };
48
+ export declare function handleGetClipboard(args: {
49
+ deviceId?: string;
50
+ }): Promise<{
51
+ content: {
52
+ type: "text";
53
+ text: string;
54
+ }[];
55
+ }>;
56
+ export declare const addMediaParams: {
57
+ filePaths: z.ZodArray<z.ZodString, "many">;
58
+ deviceId: z.ZodOptional<z.ZodString>;
59
+ };
60
+ export declare function handleAddMedia(args: {
61
+ filePaths: string[];
62
+ deviceId?: string;
63
+ }): Promise<{
64
+ content: {
65
+ type: "text";
66
+ text: string;
67
+ }[];
68
+ }>;
69
+ export declare const grantPermissionParams: {
70
+ bundleId: z.ZodString;
71
+ service: z.ZodString;
72
+ action: z.ZodEnum<["grant", "revoke", "reset"]>;
73
+ deviceId: z.ZodOptional<z.ZodString>;
74
+ };
75
+ export declare function handleGrantPermission(args: {
76
+ bundleId: string;
77
+ service: string;
78
+ action: 'grant' | 'revoke' | 'reset';
79
+ deviceId?: string;
80
+ }): Promise<{
81
+ content: {
82
+ type: "text";
83
+ text: string;
84
+ }[];
85
+ }>;
@@ -0,0 +1,107 @@
1
+ import { z } from 'zod';
2
+ import { execSimctl, resolveDevice } from '../helpers/simctl.js';
3
+ import { spawn } from 'node:child_process';
4
+ // --- set_location ---
5
+ export const setLocationParams = {
6
+ latitude: z.number().min(-90).max(90).describe('Latitude (-90 to 90)'),
7
+ longitude: z.number().min(-180).max(180).describe('Longitude (-180 to 180)'),
8
+ deviceId: z.string().optional().describe('Device (default: booted)'),
9
+ };
10
+ export async function handleSetLocation(args) {
11
+ const device = await resolveDevice(args.deviceId);
12
+ await execSimctl(['location', device, 'set', `${args.latitude},${args.longitude}`], 'tool:setLocation');
13
+ return { content: [{ type: 'text', text: `Location set to ${args.latitude}, ${args.longitude}` }] };
14
+ }
15
+ // --- send_push ---
16
+ export const sendPushParams = {
17
+ bundleId: z.string().describe('App bundle ID to receive the push'),
18
+ payload: z.record(z.any()).describe('Push notification JSON payload (e.g., {"aps": {"alert": "Hello"}})'),
19
+ deviceId: z.string().optional().describe('Device (default: booted)'),
20
+ };
21
+ export async function handleSendPush(args) {
22
+ const device = await resolveDevice(args.deviceId);
23
+ const payloadJson = JSON.stringify(args.payload);
24
+ // Write payload to stdin of simctl push
25
+ const child = spawn('xcrun', [
26
+ 'simctl', 'push', device, args.bundleId, '-',
27
+ ]);
28
+ return new Promise((resolve, reject) => {
29
+ let stderr = '';
30
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
31
+ child.on('error', (err) => reject(new Error(`Push spawn failed: ${err.message}`)));
32
+ child.on('close', (code) => {
33
+ if (code !== 0) {
34
+ reject(new Error(`Push failed: ${stderr}`));
35
+ }
36
+ else {
37
+ resolve({ content: [{ type: 'text', text: `Push notification sent to ${args.bundleId}` }] });
38
+ }
39
+ });
40
+ child.stdin.on('error', () => { }); // prevent EPIPE crash
41
+ child.stdin.write(payloadJson);
42
+ child.stdin.end();
43
+ });
44
+ }
45
+ // --- set_clipboard ---
46
+ export const setClipboardParams = {
47
+ text: z.string().describe('Text to copy to simulator clipboard'),
48
+ deviceId: z.string().optional().describe('Device (default: booted)'),
49
+ };
50
+ export async function handleSetClipboard(args) {
51
+ const device = await resolveDevice(args.deviceId);
52
+ const child = spawn('xcrun', [
53
+ 'simctl', 'pbcopy', device,
54
+ ]);
55
+ return new Promise((resolve, reject) => {
56
+ let stderr = '';
57
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
58
+ child.on('error', (err) => reject(new Error(`pbcopy spawn failed: ${err.message}`)));
59
+ child.on('close', (code) => {
60
+ if (code !== 0) {
61
+ reject(new Error(`pbcopy failed: ${stderr}`));
62
+ }
63
+ else {
64
+ resolve({ content: [{ type: 'text', text: `Clipboard set (${args.text.length} chars)` }] });
65
+ }
66
+ });
67
+ child.stdin.on('error', () => { }); // prevent EPIPE crash
68
+ child.stdin.write(args.text);
69
+ child.stdin.end();
70
+ });
71
+ }
72
+ // --- get_clipboard ---
73
+ export const getClipboardParams = {
74
+ deviceId: z.string().optional().describe('Device (default: booted)'),
75
+ };
76
+ export async function handleGetClipboard(args) {
77
+ const device = await resolveDevice(args.deviceId);
78
+ const { stdout } = await execSimctl(['pbpaste', device], 'tool:getClipboard');
79
+ return { content: [{ type: 'text', text: stdout || '(clipboard is empty)' }] };
80
+ }
81
+ // --- add_media ---
82
+ export const addMediaParams = {
83
+ filePaths: z.array(z.string()).describe('Paths to photo/video files to add to camera roll'),
84
+ deviceId: z.string().optional().describe('Device (default: booted)'),
85
+ };
86
+ export async function handleAddMedia(args) {
87
+ const device = await resolveDevice(args.deviceId);
88
+ await execSimctl(['addmedia', device, ...args.filePaths], 'tool:addMedia');
89
+ return { content: [{ type: 'text', text: `Added ${args.filePaths.length} media file(s) to camera roll.` }] };
90
+ }
91
+ // --- grant_permission ---
92
+ export const grantPermissionParams = {
93
+ bundleId: z.string().describe('App bundle ID'),
94
+ service: z.string().describe('Permission service: all, calendar, contacts-limited, contacts, location, location-always, photos-add, photos, media-library, microphone, motion, reminders, siri'),
95
+ action: z.enum(['grant', 'revoke', 'reset']).describe('Permission action'),
96
+ deviceId: z.string().optional().describe('Device (default: booted)'),
97
+ };
98
+ export async function handleGrantPermission(args) {
99
+ const device = await resolveDevice(args.deviceId);
100
+ await execSimctl(['privacy', device, args.action, args.service, args.bundleId], 'tool:grantPermission');
101
+ return {
102
+ content: [{
103
+ type: 'text',
104
+ text: `${args.action === 'grant' ? 'Granted' : args.action === 'revoke' ? 'Revoked' : 'Reset'} ${args.service} permission for ${args.bundleId}`,
105
+ }],
106
+ };
107
+ }
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod';
2
+ export declare const setAppearanceParams: {
3
+ mode: z.ZodEnum<["light", "dark"]>;
4
+ deviceId: z.ZodOptional<z.ZodString>;
5
+ };
6
+ export declare function handleSetAppearance(args: {
7
+ mode: 'light' | 'dark';
8
+ deviceId?: string;
9
+ }): Promise<{
10
+ content: {
11
+ type: "text";
12
+ text: string;
13
+ }[];
14
+ }>;
15
+ export declare const overrideStatusBarParams: {
16
+ time: z.ZodOptional<z.ZodString>;
17
+ batteryLevel: z.ZodOptional<z.ZodNumber>;
18
+ batteryState: z.ZodOptional<z.ZodEnum<["charging", "charged", "discharging"]>>;
19
+ cellularBars: z.ZodOptional<z.ZodNumber>;
20
+ wifiBars: z.ZodOptional<z.ZodNumber>;
21
+ networkType: z.ZodOptional<z.ZodString>;
22
+ operatorName: z.ZodOptional<z.ZodString>;
23
+ clear: z.ZodOptional<z.ZodBoolean>;
24
+ deviceId: z.ZodOptional<z.ZodString>;
25
+ };
26
+ export declare function handleOverrideStatusBar(args: {
27
+ time?: string;
28
+ batteryLevel?: number;
29
+ batteryState?: string;
30
+ cellularBars?: number;
31
+ wifiBars?: number;
32
+ networkType?: string;
33
+ operatorName?: string;
34
+ clear?: boolean;
35
+ deviceId?: string;
36
+ }): Promise<{
37
+ content: {
38
+ type: "text";
39
+ text: string;
40
+ }[];
41
+ }>;
42
+ export declare const recordVideoParams: {
43
+ codec: z.ZodOptional<z.ZodEnum<["h264", "hevc"]>>;
44
+ display: z.ZodOptional<z.ZodEnum<["internal", "external"]>>;
45
+ mask: z.ZodOptional<z.ZodEnum<["ignored", "alpha", "black"]>>;
46
+ deviceId: z.ZodOptional<z.ZodString>;
47
+ };
48
+ export declare function handleRecordVideo(args: {
49
+ codec?: string;
50
+ display?: string;
51
+ mask?: string;
52
+ deviceId?: string;
53
+ }): Promise<{
54
+ content: {
55
+ type: "text";
56
+ text: string;
57
+ }[];
58
+ }>;
59
+ export declare const stopRecordingParams: {
60
+ savePath: z.ZodOptional<z.ZodString>;
61
+ maxFrames: z.ZodOptional<z.ZodNumber>;
62
+ deviceId: z.ZodOptional<z.ZodString>;
63
+ };
64
+ export declare function handleStopRecording(args: {
65
+ savePath?: string;
66
+ maxFrames?: number;
67
+ deviceId?: string;
68
+ }): Promise<{
69
+ content: {
70
+ type: "text" | "image";
71
+ text?: string;
72
+ data?: string;
73
+ mimeType?: string;
74
+ }[];
75
+ }>;
76
+ export declare const navigateBackParams: {
77
+ deviceId: z.ZodOptional<z.ZodString>;
78
+ };
79
+ export declare function handleNavigateBack(args: {
80
+ deviceId?: string;
81
+ }): Promise<{
82
+ content: {
83
+ type: "text";
84
+ text: string;
85
+ }[];
86
+ }>;
@@ -0,0 +1,245 @@
1
+ import { z } from 'zod';
2
+ import { execSimctl, execSimctlBuffer, resolveDevice } from '../helpers/simctl.js';
3
+ import * as logger from '../helpers/logger.js';
4
+ import { spawn, execFile } from 'node:child_process';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { mkdir, writeFile, unlink, readFile } from 'node:fs/promises';
8
+ import { promisify } from 'node:util';
9
+ const execFileAsync = promisify(execFile);
10
+ // Active video recording state
11
+ const activeRecordings = new Map();
12
+ // --- set_appearance ---
13
+ export const setAppearanceParams = {
14
+ mode: z.enum(['light', 'dark']).describe('Appearance mode'),
15
+ deviceId: z.string().optional().describe('Device (default: booted)'),
16
+ };
17
+ export async function handleSetAppearance(args) {
18
+ const device = await resolveDevice(args.deviceId);
19
+ await execSimctl(['ui', device, 'appearance', args.mode], 'tool:setAppearance');
20
+ return { content: [{ type: 'text', text: `Appearance set to ${args.mode} mode.` }] };
21
+ }
22
+ // --- override_status_bar ---
23
+ export const overrideStatusBarParams = {
24
+ time: z.string().optional().describe('Status bar time string (e.g., "9:41")'),
25
+ batteryLevel: z.number().optional().describe('Battery level 0-100'),
26
+ batteryState: z.enum(['charging', 'charged', 'discharging']).optional().describe('Battery state'),
27
+ cellularBars: z.number().optional().describe('Cellular signal bars 0-4'),
28
+ wifiBars: z.number().optional().describe('WiFi signal bars 0-3'),
29
+ networkType: z.string().optional().describe('Data network type: wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc'),
30
+ operatorName: z.string().optional().describe('Carrier/operator name'),
31
+ clear: z.boolean().optional().describe('Set to true to clear all overrides'),
32
+ deviceId: z.string().optional().describe('Device (default: booted)'),
33
+ };
34
+ export async function handleOverrideStatusBar(args) {
35
+ const device = await resolveDevice(args.deviceId);
36
+ if (args.clear) {
37
+ await execSimctl(['status_bar', device, 'clear'], 'tool:statusBar');
38
+ return { content: [{ type: 'text', text: 'Status bar overrides cleared.' }] };
39
+ }
40
+ const flags = [];
41
+ if (args.time)
42
+ flags.push('--time', args.time);
43
+ if (args.batteryLevel !== undefined)
44
+ flags.push('--batteryLevel', String(args.batteryLevel));
45
+ if (args.batteryState)
46
+ flags.push('--batteryState', args.batteryState);
47
+ if (args.cellularBars !== undefined)
48
+ flags.push('--cellularBars', String(args.cellularBars));
49
+ if (args.wifiBars !== undefined)
50
+ flags.push('--wifiBars', String(args.wifiBars));
51
+ if (args.networkType)
52
+ flags.push('--dataNetwork', args.networkType);
53
+ if (args.operatorName)
54
+ flags.push('--operatorName', args.operatorName);
55
+ if (flags.length === 0) {
56
+ return { content: [{ type: 'text', text: 'No overrides specified. Provide at least one parameter or set clear=true.' }] };
57
+ }
58
+ await execSimctl(['status_bar', device, 'override', ...flags], 'tool:statusBar');
59
+ return {
60
+ content: [{
61
+ type: 'text',
62
+ text: `Status bar overrides applied: ${flags.filter((_, i) => i % 2 === 0).map(f => f.replace('--', '')).join(', ')}`,
63
+ }],
64
+ };
65
+ }
66
+ // --- record_video ---
67
+ // Records to a temp file. On stop, extracts key frames and returns them as images
68
+ // directly in chat. No permanent disk clutter. Most AI models can't view video files
69
+ // so key frames are much more useful.
70
+ export const recordVideoParams = {
71
+ codec: z.enum(['h264', 'hevc']).optional().describe('Video codec (default: h264)'),
72
+ display: z.enum(['internal', 'external']).optional().describe('Display to capture (default: internal)'),
73
+ mask: z.enum(['ignored', 'alpha', 'black']).optional().describe('For non-rectangular displays, handle mask by policy'),
74
+ deviceId: z.string().optional().describe('Device (default: booted)'),
75
+ };
76
+ export async function handleRecordVideo(args) {
77
+ const device = await resolveDevice(args.deviceId);
78
+ const codec = args.codec || 'h264';
79
+ if (activeRecordings.has(device)) {
80
+ return { content: [{ type: 'text', text: 'A recording is already in progress. Use simulator_stop_recording to stop it first.' }] };
81
+ }
82
+ // Record to temp file — will be cleaned up after frame extraction
83
+ const tmpPath = join(tmpdir(), `preflight-rec-${Date.now()}.mp4`);
84
+ const cmdArgs = ['simctl', 'io', device, 'recordVideo', '--codec', codec];
85
+ if (args.display)
86
+ cmdArgs.push(`--display=${args.display}`);
87
+ if (args.mask)
88
+ cmdArgs.push(`--mask=${args.mask}`);
89
+ cmdArgs.push('--force', tmpPath);
90
+ const child = spawn('xcrun', cmdArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
91
+ activeRecordings.set(device, { process: child, tmpPath, startTime: Date.now() });
92
+ child.on('exit', () => { activeRecordings.delete(device); });
93
+ child.on('error', (err) => {
94
+ logger.error('tool:recordVideo', `Recording process error: ${err.message}`);
95
+ activeRecordings.delete(device);
96
+ });
97
+ return {
98
+ content: [{
99
+ type: 'text',
100
+ text: `Video recording started (${codec}). Use simulator_stop_recording to stop and get key frames.`,
101
+ }],
102
+ };
103
+ }
104
+ // --- stop_recording ---
105
+ // Stops recording, extracts key frames as JPEG images, returns them inline.
106
+ // Cleans up the temp video file. No disk clutter.
107
+ export const stopRecordingParams = {
108
+ savePath: z.string().optional().describe('Optional: save the video file to this path instead of discarding it'),
109
+ maxFrames: z.number().optional().describe('Max number of key frames to extract (default: 3, max: 6)'),
110
+ deviceId: z.string().optional().describe('Device (default: booted)'),
111
+ };
112
+ export async function handleStopRecording(args) {
113
+ const device = await resolveDevice(args.deviceId);
114
+ const recording = activeRecordings.get(device);
115
+ if (!recording) {
116
+ return { content: [{ type: 'text', text: 'No active recording to stop.' }] };
117
+ }
118
+ // Gracefully stop recording
119
+ recording.process.kill('SIGINT');
120
+ const duration = Math.round((Date.now() - recording.startTime) / 1000);
121
+ activeRecordings.delete(device);
122
+ // Wait for file to finalize
123
+ await new Promise(resolve => setTimeout(resolve, 1500));
124
+ const maxFrames = Math.min(args.maxFrames || 3, 6);
125
+ const content = [];
126
+ // Extract key frames using ffmpeg if available, otherwise use sips on a single frame
127
+ try {
128
+ // Check if ffmpeg is available for frame extraction
129
+ let hasFFmpeg = false;
130
+ try {
131
+ await execFileAsync('which', ['ffmpeg'], { timeout: 3000 });
132
+ hasFFmpeg = true;
133
+ }
134
+ catch { /* no ffmpeg */ }
135
+ if (hasFFmpeg && duration > 0) {
136
+ // Extract evenly spaced frames
137
+ const interval = Math.max(1, Math.floor(duration / maxFrames));
138
+ const frameDir = join(tmpdir(), `preflight-frames-${Date.now()}`);
139
+ await mkdir(frameDir, { recursive: true });
140
+ await execFileAsync('ffmpeg', [
141
+ '-i', recording.tmpPath,
142
+ '-vf', `fps=1/${interval}`,
143
+ '-frames:v', String(maxFrames),
144
+ '-q:v', '8',
145
+ join(frameDir, 'frame-%02d.jpg'),
146
+ ], { timeout: 15000 }).catch(() => { });
147
+ // Read extracted frames
148
+ const { readdir } = await import('node:fs/promises');
149
+ const frames = (await readdir(frameDir).catch(() => []))
150
+ .filter(f => f.endsWith('.jpg'))
151
+ .sort()
152
+ .slice(0, maxFrames);
153
+ for (const frame of frames) {
154
+ try {
155
+ const buf = await readFile(join(frameDir, frame));
156
+ content.push({ type: 'image', data: buf.toString('base64'), mimeType: 'image/jpeg' });
157
+ }
158
+ catch { /* skip unreadable frame */ }
159
+ }
160
+ // Cleanup frame dir
161
+ for (const frame of frames) {
162
+ await unlink(join(frameDir, frame)).catch(() => { });
163
+ }
164
+ await unlink(frameDir).catch(() => { });
165
+ }
166
+ }
167
+ catch (err) {
168
+ logger.debug('tool:stopRecording', `Frame extraction failed: ${err.message}`);
169
+ }
170
+ // If no frames extracted, take a final screenshot as fallback
171
+ if (content.length === 0) {
172
+ try {
173
+ const { stdout: pngBuffer } = await execSimctlBuffer(['io', device, 'screenshot', '--type=png', '-'], 'tool:stopRecording');
174
+ // Compress to JPEG
175
+ const tmpPng = join(tmpdir(), `stoprec-${Date.now()}.png`);
176
+ const tmpJpg = join(tmpdir(), `stoprec-${Date.now()}.jpg`);
177
+ await writeFile(tmpPng, pngBuffer);
178
+ await execFileAsync('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '60', tmpPng, '--out', tmpJpg], { timeout: 10000 });
179
+ const jpgBuf = await readFile(tmpJpg);
180
+ content.push({ type: 'image', data: jpgBuf.toString('base64'), mimeType: 'image/jpeg' });
181
+ await unlink(tmpPng).catch(() => { });
182
+ await unlink(tmpJpg).catch(() => { });
183
+ }
184
+ catch {
185
+ content.push({ type: 'text', text: '(Could not capture final frame)' });
186
+ }
187
+ }
188
+ // Handle the video file
189
+ let videoNote = '';
190
+ if (args.savePath) {
191
+ try {
192
+ const { copyFile } = await import('node:fs/promises');
193
+ const dir = args.savePath.substring(0, args.savePath.lastIndexOf('/'));
194
+ if (dir)
195
+ await mkdir(dir, { recursive: true });
196
+ await copyFile(recording.tmpPath, args.savePath);
197
+ videoNote = `\nVideo saved to: ${args.savePath}`;
198
+ }
199
+ catch (err) {
200
+ videoNote = `\nFailed to save video: ${err.message}`;
201
+ }
202
+ }
203
+ // Clean up temp video
204
+ await unlink(recording.tmpPath).catch(() => { });
205
+ content.push({
206
+ type: 'text',
207
+ text: `Recording stopped (${duration}s). ${content.filter(c => c.type === 'image').length} key frame(s) extracted.${videoNote}`,
208
+ });
209
+ return { content };
210
+ }
211
+ // --- navigate_back ---
212
+ export const navigateBackParams = {
213
+ deviceId: z.string().optional().describe('Device (default: booted)'),
214
+ };
215
+ export async function handleNavigateBack(args) {
216
+ const device = await resolveDevice(args.deviceId);
217
+ const { pressKey } = await import('../helpers/applescript.js');
218
+ try {
219
+ await pressKey('[', ['command']);
220
+ return {
221
+ content: [{
222
+ type: 'text',
223
+ text: 'Sent back navigation command (Cmd+[). Works in Safari and apps with standard navigation.',
224
+ }],
225
+ };
226
+ }
227
+ catch {
228
+ const { checkIdbAvailable, idbTap } = await import('../helpers/idb.js');
229
+ if (await checkIdbAvailable()) {
230
+ await idbTap(30, 55, device);
231
+ return {
232
+ content: [{
233
+ type: 'text',
234
+ text: 'Tapped back button area (30, 55) via idb. Use simulator_accessibility_audit to find exact back button coordinates if this didn\'t work.',
235
+ }],
236
+ };
237
+ }
238
+ return {
239
+ content: [{
240
+ type: 'text',
241
+ text: 'Back navigation failed. Use simulator_tap on the visible back button, or simulator_accessibility_audit to find its coordinates.',
242
+ }],
243
+ };
244
+ }
245
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "preflight-ios-mcp",
3
+ "version": "1.0.0",
4
+ "description": "The most comprehensive MCP server for iOS Simulator automation — 57 tools for AI-powered iOS development. Works with Claude Code, Cursor, Windsurf, VS Code, and any MCP client.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "preflight-ios-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/EthanAckerman-git/Preflight.git"
18
+ },
19
+ "homepage": "https://github.com/EthanAckerman-git/Preflight",
20
+ "bugs": {
21
+ "url": "https://github.com/EthanAckerman-git/Preflight/issues"
22
+ },
23
+ "author": "Ethan Ackerman",
24
+ "scripts": {
25
+ "build": "tsc && swiftc src/helpers/mouse-events.swift -o dist/mouse-events",
26
+ "start": "node dist/index.js",
27
+ "dev": "tsc --watch",
28
+ "prepublishOnly": "npm run build",
29
+ "postinstall": "which idb > /dev/null 2>&1 || echo '\\n[Preflight MCP] Optional: Install idb for cursor-free touch injection:\\n brew tap facebook/fb && brew install idb-companion\\n pip3 install fb-idb\\n'"
30
+ },
31
+ "keywords": ["mcp", "model-context-protocol", "ios", "simulator", "xcode", "testing", "automation", "claude", "cursor", "windsurf", "ai", "accessibility", "debugging", "playwright"],
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.27.0",
38
+ "zod": "^3.23.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "typescript": "^5.5.0"
43
+ }
44
+ }