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,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
|
+
}>;
|
package/dist/tools/ui.js
ADDED
|
@@ -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
|
+
}
|