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,132 @@
|
|
|
1
|
+
import { execSimctlBuffer, runAppleScript } from './simctl.js';
|
|
2
|
+
import * as logger from './logger.js';
|
|
3
|
+
const TITLE_BAR_HEIGHT = 28;
|
|
4
|
+
/**
|
|
5
|
+
* Get the Simulator.app front window position and size using AppleScript.
|
|
6
|
+
*/
|
|
7
|
+
export async function getSimulatorWindowGeometry() {
|
|
8
|
+
const script = `
|
|
9
|
+
tell application "System Events"
|
|
10
|
+
tell process "Simulator"
|
|
11
|
+
set frontWindow to front window
|
|
12
|
+
set winPos to position of frontWindow
|
|
13
|
+
set winSize to size of frontWindow
|
|
14
|
+
return (item 1 of winPos as text) & "," & (item 2 of winPos as text) & "," & (item 1 of winSize as text) & "," & (item 2 of winSize as text)
|
|
15
|
+
end tell
|
|
16
|
+
end tell`;
|
|
17
|
+
const result = await runAppleScript(script, 'coordinate-mapper');
|
|
18
|
+
const parts = result.split(',').map(Number);
|
|
19
|
+
if (parts.length !== 4 || parts.some(isNaN)) {
|
|
20
|
+
throw new Error(`Failed to parse window geometry: "${result}"`);
|
|
21
|
+
}
|
|
22
|
+
const geometry = {
|
|
23
|
+
windowX: parts[0],
|
|
24
|
+
windowY: parts[1],
|
|
25
|
+
windowWidth: parts[2],
|
|
26
|
+
windowHeight: parts[3],
|
|
27
|
+
};
|
|
28
|
+
logger.debug('coordinate-mapper', 'Window geometry', geometry);
|
|
29
|
+
return geometry;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get the device screen dimensions in points by taking a screenshot and reading its pixel dimensions.
|
|
33
|
+
* Returns { pointWidth, pointHeight, scaleFactor }.
|
|
34
|
+
*/
|
|
35
|
+
export async function getDeviceScreenDimensions(device) {
|
|
36
|
+
// Take a screenshot and get its pixel dimensions
|
|
37
|
+
const { stdout } = await execSimctlBuffer(['io', device, 'screenshot', '--type=png', '-'], 'coordinate-mapper');
|
|
38
|
+
// Parse PNG header to get dimensions (IHDR chunk at offset 16)
|
|
39
|
+
// PNG signature (8 bytes) + IHDR length (4 bytes) + "IHDR" (4 bytes) + width (4 bytes) + height (4 bytes)
|
|
40
|
+
if (stdout.length < 24) {
|
|
41
|
+
throw new Error('Screenshot too small to parse dimensions');
|
|
42
|
+
}
|
|
43
|
+
const pixelWidth = stdout.readUInt32BE(16);
|
|
44
|
+
const pixelHeight = stdout.readUInt32BE(20);
|
|
45
|
+
// Determine scale factor from known device resolutions
|
|
46
|
+
// Common: 3x for iPhones (Pro), 2x for iPads, 2x for older iPhones
|
|
47
|
+
let scaleFactor = 3; // default to 3x for modern iPhones
|
|
48
|
+
if (pixelWidth > 1500 && pixelHeight < 2000) {
|
|
49
|
+
scaleFactor = 2; // likely iPad
|
|
50
|
+
}
|
|
51
|
+
// Try to infer from common resolutions
|
|
52
|
+
const knownScales = {
|
|
53
|
+
// iPhone pixel widths → scale
|
|
54
|
+
'1179': 3, // iPhone 14/15/16 Pro
|
|
55
|
+
'1206': 3, // iPhone 15/16 Pro Max
|
|
56
|
+
'1170': 3, // iPhone 14/15/16
|
|
57
|
+
'1290': 3, // iPhone 14/15/16 Pro Max
|
|
58
|
+
'750': 2, // iPhone SE
|
|
59
|
+
'828': 2, // iPhone 11
|
|
60
|
+
// iPad pixel widths → scale
|
|
61
|
+
'2048': 2, // iPad Air/Pro 10.5"
|
|
62
|
+
'2224': 2, // iPad Pro 11"
|
|
63
|
+
'2388': 2, // iPad Pro 11" M-series
|
|
64
|
+
'2732': 2, // iPad Pro 12.9"
|
|
65
|
+
};
|
|
66
|
+
const widthStr = String(pixelWidth);
|
|
67
|
+
if (knownScales[widthStr]) {
|
|
68
|
+
scaleFactor = knownScales[widthStr];
|
|
69
|
+
}
|
|
70
|
+
const pointWidth = Math.round(pixelWidth / scaleFactor);
|
|
71
|
+
const pointHeight = Math.round(pixelHeight / scaleFactor);
|
|
72
|
+
logger.debug('coordinate-mapper', 'Device screen dimensions', {
|
|
73
|
+
pixelWidth,
|
|
74
|
+
pixelHeight,
|
|
75
|
+
scaleFactor,
|
|
76
|
+
pointWidth,
|
|
77
|
+
pointHeight,
|
|
78
|
+
});
|
|
79
|
+
return { pointWidth, pointHeight, scaleFactor, pixelWidth, pixelHeight };
|
|
80
|
+
}
|
|
81
|
+
// Cache screen mapping for 10 seconds to avoid repeated AppleScript + screenshot calls
|
|
82
|
+
let cachedMapping = null;
|
|
83
|
+
let cachedMappingTime = 0;
|
|
84
|
+
const CACHE_TTL_MS = 10000;
|
|
85
|
+
/**
|
|
86
|
+
* Compute the full screen mapping from simulator points to macOS screen coordinates.
|
|
87
|
+
* Cached for 10 seconds to improve performance during rapid interactions.
|
|
88
|
+
*/
|
|
89
|
+
export async function getScreenMapping(device) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
if (cachedMapping && (now - cachedMappingTime) < CACHE_TTL_MS) {
|
|
92
|
+
logger.debug('coordinate-mapper', 'Using cached screen mapping');
|
|
93
|
+
return cachedMapping;
|
|
94
|
+
}
|
|
95
|
+
const [windowGeometry, screenDims] = await Promise.all([
|
|
96
|
+
getSimulatorWindowGeometry(),
|
|
97
|
+
getDeviceScreenDimensions(device),
|
|
98
|
+
]);
|
|
99
|
+
const contentWidth = windowGeometry.windowWidth;
|
|
100
|
+
const contentHeight = windowGeometry.windowHeight - TITLE_BAR_HEIGHT;
|
|
101
|
+
const scaleX = contentWidth / screenDims.pointWidth;
|
|
102
|
+
const scaleY = contentHeight / screenDims.pointHeight;
|
|
103
|
+
const mapping = {
|
|
104
|
+
windowGeometry,
|
|
105
|
+
devicePointWidth: screenDims.pointWidth,
|
|
106
|
+
devicePointHeight: screenDims.pointHeight,
|
|
107
|
+
scaleFactor: screenDims.scaleFactor,
|
|
108
|
+
titleBarHeight: TITLE_BAR_HEIGHT,
|
|
109
|
+
contentWidth,
|
|
110
|
+
contentHeight,
|
|
111
|
+
scaleX,
|
|
112
|
+
scaleY,
|
|
113
|
+
};
|
|
114
|
+
logger.debug('coordinate-mapper', 'Screen mapping computed', mapping);
|
|
115
|
+
cachedMapping = mapping;
|
|
116
|
+
cachedMappingTime = Date.now();
|
|
117
|
+
return mapping;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Convert simulator screen point coordinates to macOS absolute screen coordinates.
|
|
121
|
+
*/
|
|
122
|
+
export function simToMac(simX, simY, mapping) {
|
|
123
|
+
const macX = mapping.windowGeometry.windowX + simX * mapping.scaleX;
|
|
124
|
+
const macY = mapping.windowGeometry.windowY +
|
|
125
|
+
mapping.titleBarHeight +
|
|
126
|
+
simY * mapping.scaleY;
|
|
127
|
+
logger.debug('coordinate-mapper', `Mapped sim(${simX},${simY}) → mac(${Math.round(macX)},${Math.round(macY)})`, {
|
|
128
|
+
scaleX: mapping.scaleX.toFixed(3),
|
|
129
|
+
scaleY: mapping.scaleY.toFixed(3),
|
|
130
|
+
});
|
|
131
|
+
return { macX, macY };
|
|
132
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if idb CLI is installed and available.
|
|
3
|
+
* Checks PATH via `which`, then searches common install locations.
|
|
4
|
+
* Caches the result for the lifetime of the server process.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkIdbAvailable(): Promise<boolean>;
|
|
7
|
+
/**
|
|
8
|
+
* Tap at simulator screen coordinates using idb.
|
|
9
|
+
* No macOS coordinate mapping needed — idb uses sim points directly.
|
|
10
|
+
*/
|
|
11
|
+
export declare function idbTap(x: number, y: number, deviceId: string, duration?: number): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Swipe between two points using idb.
|
|
14
|
+
* idb takes duration in fractional seconds.
|
|
15
|
+
*/
|
|
16
|
+
export declare function idbSwipe(startX: number, startY: number, endX: number, endY: number, durationMs: number, deviceId: string, delta?: number): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Long press at simulator screen coordinates using idb.
|
|
19
|
+
* idb uses `tap --duration` for long presses (no separate long-press command).
|
|
20
|
+
*/
|
|
21
|
+
export declare function idbLongPress(x: number, y: number, durationMs: number, deviceId: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Get the full iOS accessibility tree via idb.
|
|
24
|
+
* Returns actual iOS UI elements (UIButton, UILabel, etc.) with labels, frames, and enabled state.
|
|
25
|
+
*/
|
|
26
|
+
export declare function idbDescribeAll(deviceId: string): Promise<string>;
|
|
27
|
+
/**
|
|
28
|
+
* Describe the iOS UI element at a specific point.
|
|
29
|
+
*/
|
|
30
|
+
export declare function idbDescribePoint(x: number, y: number, deviceId: string): Promise<string>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { listDevices } from './simctl.js';
|
|
4
|
+
import * as logger from './logger.js';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
// Module-level cache for idb availability
|
|
7
|
+
let idbAvailable = null;
|
|
8
|
+
let idbPath = null;
|
|
9
|
+
// Common install locations for idb (pip3 installs to user bin)
|
|
10
|
+
const IDB_SEARCH_PATHS = [
|
|
11
|
+
'/opt/homebrew/bin/idb',
|
|
12
|
+
'/usr/local/bin/idb',
|
|
13
|
+
`${process.env.HOME}/Library/Python/3.9/bin/idb`,
|
|
14
|
+
`${process.env.HOME}/Library/Python/3.10/bin/idb`,
|
|
15
|
+
`${process.env.HOME}/Library/Python/3.11/bin/idb`,
|
|
16
|
+
`${process.env.HOME}/Library/Python/3.12/bin/idb`,
|
|
17
|
+
`${process.env.HOME}/Library/Python/3.13/bin/idb`,
|
|
18
|
+
`${process.env.HOME}/.local/bin/idb`,
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Check if idb CLI is installed and available.
|
|
22
|
+
* Checks PATH via `which`, then searches common install locations.
|
|
23
|
+
* Caches the result for the lifetime of the server process.
|
|
24
|
+
*/
|
|
25
|
+
export async function checkIdbAvailable() {
|
|
26
|
+
if (idbAvailable !== null)
|
|
27
|
+
return idbAvailable;
|
|
28
|
+
// Check env var first (custom idb path)
|
|
29
|
+
const envPath = process.env.PREFLIGHT_IDB_PATH || process.env.IOS_SIMULATOR_MCP_IDB_PATH;
|
|
30
|
+
if (envPath) {
|
|
31
|
+
const { access } = await import('node:fs/promises');
|
|
32
|
+
try {
|
|
33
|
+
await access(envPath);
|
|
34
|
+
idbPath = envPath;
|
|
35
|
+
idbAvailable = true;
|
|
36
|
+
logger.debug('idb', `idb found via env var: ${idbPath}`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
logger.warn('idb', `idb path from env var not found: ${envPath}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Try `which idb` (respects PATH)
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFileAsync('which', ['idb'], {
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
timeout: 5000,
|
|
48
|
+
});
|
|
49
|
+
const found = stdout.trim();
|
|
50
|
+
if (found) {
|
|
51
|
+
idbPath = found;
|
|
52
|
+
idbAvailable = true;
|
|
53
|
+
logger.debug('idb', `idb found via PATH: ${idbPath}`);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* not in PATH */ }
|
|
58
|
+
// Search common install locations
|
|
59
|
+
const { access } = await import('node:fs/promises');
|
|
60
|
+
for (const candidate of IDB_SEARCH_PATHS) {
|
|
61
|
+
try {
|
|
62
|
+
await access(candidate);
|
|
63
|
+
idbPath = candidate;
|
|
64
|
+
idbAvailable = true;
|
|
65
|
+
logger.debug('idb', `idb found at: ${idbPath}`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch { /* not here */ }
|
|
69
|
+
}
|
|
70
|
+
idbAvailable = false;
|
|
71
|
+
logger.debug('idb', 'idb not found in PATH or common locations');
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a device identifier to an actual UDID.
|
|
76
|
+
* idb requires real UDIDs — it doesn't support "booted".
|
|
77
|
+
*/
|
|
78
|
+
async function resolveUdid(deviceId) {
|
|
79
|
+
if (deviceId === 'booted') {
|
|
80
|
+
const booted = await listDevices('booted');
|
|
81
|
+
if (booted.length === 0) {
|
|
82
|
+
throw new Error('No booted simulator found');
|
|
83
|
+
}
|
|
84
|
+
return booted[0].udid;
|
|
85
|
+
}
|
|
86
|
+
return deviceId;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Execute an idb command. Throws on failure.
|
|
90
|
+
*/
|
|
91
|
+
async function runIdb(args, ctx) {
|
|
92
|
+
const cmd = idbPath || 'idb';
|
|
93
|
+
logger.debug(ctx, `Running: idb ${args.join(' ')}`);
|
|
94
|
+
try {
|
|
95
|
+
const result = await execFileAsync(cmd, args, {
|
|
96
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
97
|
+
encoding: 'utf-8',
|
|
98
|
+
timeout: 30000,
|
|
99
|
+
});
|
|
100
|
+
return result.stdout;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const e = err;
|
|
104
|
+
logger.error(ctx, `idb failed: ${e.stderr || e.message}`);
|
|
105
|
+
throw new Error(`idb ${args[0]} failed: ${e.stderr || e.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Tap at simulator screen coordinates using idb.
|
|
110
|
+
* No macOS coordinate mapping needed — idb uses sim points directly.
|
|
111
|
+
*/
|
|
112
|
+
export async function idbTap(x, y, deviceId, duration) {
|
|
113
|
+
const udid = await resolveUdid(deviceId);
|
|
114
|
+
const args = ['ui', 'tap', '--udid', udid, String(Math.round(x)), String(Math.round(y))];
|
|
115
|
+
if (duration !== undefined)
|
|
116
|
+
args.push('--duration', String(duration));
|
|
117
|
+
await runIdb(args, 'idb:tap');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Swipe between two points using idb.
|
|
121
|
+
* idb takes duration in fractional seconds.
|
|
122
|
+
*/
|
|
123
|
+
export async function idbSwipe(startX, startY, endX, endY, durationMs, deviceId, delta) {
|
|
124
|
+
const udid = await resolveUdid(deviceId);
|
|
125
|
+
const durationSec = (durationMs / 1000).toFixed(2);
|
|
126
|
+
const args = [
|
|
127
|
+
'ui', 'swipe',
|
|
128
|
+
'--udid', udid,
|
|
129
|
+
String(Math.round(startX)), String(Math.round(startY)),
|
|
130
|
+
String(Math.round(endX)), String(Math.round(endY)),
|
|
131
|
+
'--duration', durationSec,
|
|
132
|
+
];
|
|
133
|
+
if (delta !== undefined)
|
|
134
|
+
args.push('--delta', String(delta));
|
|
135
|
+
await runIdb(args, 'idb:swipe');
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Long press at simulator screen coordinates using idb.
|
|
139
|
+
* idb uses `tap --duration` for long presses (no separate long-press command).
|
|
140
|
+
*/
|
|
141
|
+
export async function idbLongPress(x, y, durationMs, deviceId) {
|
|
142
|
+
const udid = await resolveUdid(deviceId);
|
|
143
|
+
const durationSec = (durationMs / 1000).toFixed(2);
|
|
144
|
+
await runIdb([
|
|
145
|
+
'ui', 'tap',
|
|
146
|
+
'--udid', udid,
|
|
147
|
+
'--duration', durationSec,
|
|
148
|
+
String(Math.round(x)), String(Math.round(y)),
|
|
149
|
+
], 'idb:longPress');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Get the full iOS accessibility tree via idb.
|
|
153
|
+
* Returns actual iOS UI elements (UIButton, UILabel, etc.) with labels, frames, and enabled state.
|
|
154
|
+
*/
|
|
155
|
+
export async function idbDescribeAll(deviceId) {
|
|
156
|
+
const udid = await resolveUdid(deviceId);
|
|
157
|
+
return await runIdb(['ui', 'describe-all', '--udid', udid], 'idb:describeAll');
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Describe the iOS UI element at a specific point.
|
|
161
|
+
*/
|
|
162
|
+
export async function idbDescribePoint(x, y, deviceId) {
|
|
163
|
+
const udid = await resolveUdid(deviceId);
|
|
164
|
+
return await runIdb([
|
|
165
|
+
'ui', 'describe-point',
|
|
166
|
+
'--udid', udid,
|
|
167
|
+
String(Math.round(x)), String(Math.round(y)),
|
|
168
|
+
], 'idb:describePoint');
|
|
169
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
export declare function log(level: LogLevel, context: string, message: string, data?: unknown): void;
|
|
3
|
+
export declare function debug(context: string, message: string, data?: unknown): void;
|
|
4
|
+
export declare function info(context: string, message: string, data?: unknown): void;
|
|
5
|
+
export declare function warn(context: string, message: string, data?: unknown): void;
|
|
6
|
+
export declare function error(context: string, message: string, data?: unknown): void;
|
|
7
|
+
export declare function toolStart(toolName: string, params: unknown): void;
|
|
8
|
+
export declare function toolEnd(toolName: string, durationMs: number, success: boolean): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const LOG_LEVELS = {
|
|
2
|
+
debug: 0,
|
|
3
|
+
info: 1,
|
|
4
|
+
warn: 2,
|
|
5
|
+
error: 3,
|
|
6
|
+
};
|
|
7
|
+
const currentLevel = process.env.LOG_LEVEL || 'info';
|
|
8
|
+
function shouldLog(level) {
|
|
9
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
10
|
+
}
|
|
11
|
+
function formatMessage(level, context, message) {
|
|
12
|
+
const timestamp = new Date().toISOString();
|
|
13
|
+
return `[${timestamp}] [${level.toUpperCase()}] [${context}] ${message}`;
|
|
14
|
+
}
|
|
15
|
+
export function log(level, context, message, data) {
|
|
16
|
+
if (!shouldLog(level))
|
|
17
|
+
return;
|
|
18
|
+
let line = formatMessage(level, context, message);
|
|
19
|
+
if (data !== undefined) {
|
|
20
|
+
try {
|
|
21
|
+
line += ' ' + JSON.stringify(data);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
line += ' [unserializable data]';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
process.stderr.write(line + '\n');
|
|
28
|
+
}
|
|
29
|
+
export function debug(context, message, data) {
|
|
30
|
+
log('debug', context, message, data);
|
|
31
|
+
}
|
|
32
|
+
export function info(context, message, data) {
|
|
33
|
+
log('info', context, message, data);
|
|
34
|
+
}
|
|
35
|
+
export function warn(context, message, data) {
|
|
36
|
+
log('warn', context, message, data);
|
|
37
|
+
}
|
|
38
|
+
export function error(context, message, data) {
|
|
39
|
+
log('error', context, message, data);
|
|
40
|
+
}
|
|
41
|
+
export function toolStart(toolName, params) {
|
|
42
|
+
debug(`tool:${toolName}`, 'Invoked', params);
|
|
43
|
+
}
|
|
44
|
+
export function toolEnd(toolName, durationMs, success) {
|
|
45
|
+
const level = success ? 'debug' : 'error';
|
|
46
|
+
log(level, `tool:${toolName}`, `Completed in ${durationMs}ms, success=${success}`);
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface ExecResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ExecBufferResult {
|
|
6
|
+
stdout: Buffer;
|
|
7
|
+
stderr: Buffer;
|
|
8
|
+
}
|
|
9
|
+
export interface SimDevice {
|
|
10
|
+
name: string;
|
|
11
|
+
udid: string;
|
|
12
|
+
state: string;
|
|
13
|
+
runtime: string;
|
|
14
|
+
isAvailable: boolean;
|
|
15
|
+
deviceTypeIdentifier?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Execute an xcrun simctl command and return stdout/stderr as strings.
|
|
19
|
+
*/
|
|
20
|
+
export declare function execSimctl(args: string[], ctx?: string): Promise<ExecResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Execute an xcrun simctl command and return stdout as a Buffer (for binary data like screenshots).
|
|
23
|
+
*/
|
|
24
|
+
export declare function execSimctlBuffer(args: string[], ctx?: string): Promise<ExecBufferResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Execute an arbitrary command (not simctl).
|
|
27
|
+
*/
|
|
28
|
+
export declare function execCommand(command: string, args: string[], ctx?: string): Promise<ExecResult>;
|
|
29
|
+
/**
|
|
30
|
+
* List all simulator devices. Optionally filter by state.
|
|
31
|
+
*/
|
|
32
|
+
export declare function listDevices(filter?: 'booted' | 'available' | 'all'): Promise<SimDevice[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a device identifier to a UDID. Accepts:
|
|
35
|
+
* - undefined / "booted" → "booted" (with validation)
|
|
36
|
+
* - A UDID string → returned as-is
|
|
37
|
+
* - A device name → resolved to UDID
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveDevice(deviceId?: string): Promise<string>;
|
|
40
|
+
/**
|
|
41
|
+
* Run osascript (AppleScript) and return stdout.
|
|
42
|
+
*/
|
|
43
|
+
export declare function runAppleScript(script: string, ctx?: string): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Run osascript with the ObjC bridge (for CGEvents).
|
|
46
|
+
*/
|
|
47
|
+
export declare function runJXA(script: string, ctx?: string): Promise<string>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import * as logger from './logger.js';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
/**
|
|
6
|
+
* Execute an xcrun simctl command and return stdout/stderr as strings.
|
|
7
|
+
*/
|
|
8
|
+
export async function execSimctl(args, ctx = 'simctl') {
|
|
9
|
+
const cmd = ['simctl', ...args];
|
|
10
|
+
logger.debug(ctx, `Running: xcrun ${cmd.join(' ')}`);
|
|
11
|
+
try {
|
|
12
|
+
const result = await execFileAsync('xcrun', cmd, {
|
|
13
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
});
|
|
16
|
+
logger.debug(ctx, `Exit code: 0, stdout=${result.stdout.length}b, stderr=${result.stderr.length}b`);
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const e = err;
|
|
21
|
+
logger.error(ctx, `Command failed: xcrun ${cmd.join(' ')}`, {
|
|
22
|
+
code: e.code,
|
|
23
|
+
stderr: e.stderr?.slice(0, 500),
|
|
24
|
+
});
|
|
25
|
+
throw new Error(`simctl ${args[0]} failed: ${e.stderr || e.message || 'unknown error'}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Execute an xcrun simctl command and return stdout as a Buffer (for binary data like screenshots).
|
|
30
|
+
*/
|
|
31
|
+
export async function execSimctlBuffer(args, ctx = 'simctl') {
|
|
32
|
+
const cmd = ['simctl', ...args];
|
|
33
|
+
logger.debug(ctx, `Running (buffer): xcrun ${cmd.join(' ')}`);
|
|
34
|
+
try {
|
|
35
|
+
const result = await execFileAsync('xcrun', cmd, {
|
|
36
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
37
|
+
encoding: 'buffer',
|
|
38
|
+
});
|
|
39
|
+
logger.debug(ctx, `Exit code: 0, stdout=${result.stdout.length}b`);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const e = err;
|
|
44
|
+
const stderrStr = e.stderr ? e.stderr.toString('utf-8').slice(0, 500) : '';
|
|
45
|
+
logger.error(ctx, `Command failed: xcrun ${cmd.join(' ')}`, {
|
|
46
|
+
code: e.code,
|
|
47
|
+
stderr: stderrStr,
|
|
48
|
+
});
|
|
49
|
+
throw new Error(`simctl ${args[0]} failed: ${stderrStr || e.message || 'unknown error'}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Execute an arbitrary command (not simctl).
|
|
54
|
+
*/
|
|
55
|
+
export async function execCommand(command, args, ctx = 'exec') {
|
|
56
|
+
logger.debug(ctx, `Running: ${command} ${args.join(' ')}`);
|
|
57
|
+
try {
|
|
58
|
+
const result = await execFileAsync(command, args, {
|
|
59
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
});
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const e = err;
|
|
66
|
+
logger.error(ctx, `Command failed: ${command} ${args.join(' ')}`, {
|
|
67
|
+
code: e.code,
|
|
68
|
+
stderr: e.stderr?.slice(0, 500),
|
|
69
|
+
});
|
|
70
|
+
throw new Error(`${command} failed: ${e.stderr || e.message || 'unknown error'}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* List all simulator devices. Optionally filter by state.
|
|
75
|
+
*/
|
|
76
|
+
export async function listDevices(filter) {
|
|
77
|
+
const { stdout } = await execSimctl(['list', '-j', 'devices'], 'listDevices');
|
|
78
|
+
const data = JSON.parse(stdout);
|
|
79
|
+
const devices = [];
|
|
80
|
+
for (const [runtime, devs] of Object.entries(data.devices)) {
|
|
81
|
+
for (const dev of devs) {
|
|
82
|
+
devices.push({
|
|
83
|
+
name: dev.name,
|
|
84
|
+
udid: dev.udid,
|
|
85
|
+
state: dev.state,
|
|
86
|
+
runtime,
|
|
87
|
+
isAvailable: dev.isAvailable ?? false,
|
|
88
|
+
deviceTypeIdentifier: dev.deviceTypeIdentifier,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (filter === 'booted')
|
|
93
|
+
return devices.filter(d => d.state === 'Booted');
|
|
94
|
+
if (filter === 'available')
|
|
95
|
+
return devices.filter(d => d.isAvailable);
|
|
96
|
+
return devices;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a device identifier to a UDID. Accepts:
|
|
100
|
+
* - undefined / "booted" → "booted" (with validation)
|
|
101
|
+
* - A UDID string → returned as-is
|
|
102
|
+
* - A device name → resolved to UDID
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveDevice(deviceId) {
|
|
105
|
+
if (!deviceId || deviceId === 'booted') {
|
|
106
|
+
const booted = await listDevices('booted');
|
|
107
|
+
if (booted.length === 0) {
|
|
108
|
+
const available = await listDevices('available');
|
|
109
|
+
const suggestions = available
|
|
110
|
+
.filter(d => d.runtime.includes('iOS'))
|
|
111
|
+
.slice(0, 5)
|
|
112
|
+
.map(d => ` - ${d.name} (${d.udid})`)
|
|
113
|
+
.join('\n');
|
|
114
|
+
throw new Error(`No simulator is currently booted. Use simulator_boot to start one.\n\nAvailable iOS devices:\n${suggestions}`);
|
|
115
|
+
}
|
|
116
|
+
return 'booted';
|
|
117
|
+
}
|
|
118
|
+
// Check if it's a UDID pattern
|
|
119
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(deviceId)) {
|
|
120
|
+
return deviceId;
|
|
121
|
+
}
|
|
122
|
+
// Try name match
|
|
123
|
+
const devices = await listDevices();
|
|
124
|
+
const match = devices.find(d => d.name.toLowerCase() === deviceId.toLowerCase());
|
|
125
|
+
if (match)
|
|
126
|
+
return match.udid;
|
|
127
|
+
throw new Error(`Device "${deviceId}" not found. Use simulator_list_devices to see available devices.`);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Run osascript (AppleScript) and return stdout.
|
|
131
|
+
*/
|
|
132
|
+
export async function runAppleScript(script, ctx = 'applescript') {
|
|
133
|
+
logger.debug(ctx, 'Executing AppleScript', { scriptLength: script.length });
|
|
134
|
+
try {
|
|
135
|
+
const result = await execFileAsync('osascript', ['-e', script], {
|
|
136
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
137
|
+
encoding: 'utf-8',
|
|
138
|
+
timeout: 15000,
|
|
139
|
+
});
|
|
140
|
+
return result.stdout.trim();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const e = err;
|
|
144
|
+
const stderr = e.stderr || '';
|
|
145
|
+
if (stderr.includes('not allowed') || stderr.includes('accessibility')) {
|
|
146
|
+
throw new Error('Accessibility permission required. Go to System Settings → Privacy & Security → Accessibility and add your terminal app.');
|
|
147
|
+
}
|
|
148
|
+
logger.error(ctx, 'AppleScript failed', { stderr, script: script.slice(0, 200) });
|
|
149
|
+
throw new Error(`AppleScript failed: ${stderr || e.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Run osascript with the ObjC bridge (for CGEvents).
|
|
154
|
+
*/
|
|
155
|
+
export async function runJXA(script, ctx = 'jxa') {
|
|
156
|
+
logger.debug(ctx, 'Executing JXA script', { scriptLength: script.length });
|
|
157
|
+
try {
|
|
158
|
+
const result = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
|
159
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
160
|
+
encoding: 'utf-8',
|
|
161
|
+
timeout: 15000,
|
|
162
|
+
});
|
|
163
|
+
return result.stdout.trim();
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const e = err;
|
|
167
|
+
const stderr = e.stderr || '';
|
|
168
|
+
if (stderr.includes('not allowed') || stderr.includes('accessibility')) {
|
|
169
|
+
throw new Error('Accessibility permission required. Go to System Settings → Privacy & Security → Accessibility and add your terminal app.');
|
|
170
|
+
}
|
|
171
|
+
logger.error(ctx, 'JXA script failed', { stderr });
|
|
172
|
+
throw new Error(`JXA script failed: ${stderr || e.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
package/dist/index.d.ts
ADDED