@sensaiorg/adapter-ios 0.1.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/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/ios-adapter.d.ts.map +1 -0
- package/dist/ios-adapter.js +75 -0
- package/dist/tools/ios-tools.d.ts.map +1 -0
- package/dist/tools/ios-tools.js +870 -0
- package/dist/transport/simctl-client.d.ts.map +1 -0
- package/dist/transport/simctl-client.js +232 -0
- package/package.json +21 -0
- package/src/index.ts +8 -0
- package/src/ios-adapter.ts +104 -0
- package/src/tools/ios-tools.ts +1055 -0
- package/src/transport/simctl-client.ts +264 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"simctl-client.d.ts","sourceRoot":"","sources":["../../src/transport/simctl-client.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,OAAO,CAAuB;gBAE1B,WAAW,EAAE,MAAM;IAI/B;;OAEG;YACW,SAAS;IAwBvB;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAI9B;;OAEG;YACW,GAAG;IAejB;;OAEG;IACG,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,GAAE,MAAM,EAAO,GAAG,OAAO,CAAC,MAAM,CAAC;IAapE;;OAEG;IACG,aAAa,IAAI,OAAO,CAAC,aAAa,CAAC;IAgD7C;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IASnC;;OAEG;IACG,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIhD;;OAEG;IACG,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD;;OAEG;IACG,GAAG,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C;;OAEG;IACG,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3C;;OAEG;IACG,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,SAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOzG;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC;IAIpC;;OAEG;IACG,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI1D;;OAEG;IACH,MAAM,IAAI,OAAO;IAIjB;;OAEG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,SAAQ,GAAG,OAAO,CAAC,MAAM,CAAC;IASnE;;OAEG;IACG,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC;IAMxC;;OAEG;IACG,YAAY,CAAC,OAAO,SAAI,GAAG,OAAO,CAAC,MAAM,CAAC;IAUhD;;OAEG;IACG,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,QAAQ,IAAI,OAAO;CAGpB"}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SimctlClient — wraps xcrun simctl + idb commands for iOS Simulator interaction.
|
|
4
|
+
*
|
|
5
|
+
* Uses simctl for: screenshots, app lifecycle, logs, device info
|
|
6
|
+
* Uses idb for: tap, type, swipe (simctl doesn't support these)
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.SimctlClient = void 0;
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const node_util_1 = require("node:util");
|
|
12
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
13
|
+
class SimctlClient {
|
|
14
|
+
simulatorId;
|
|
15
|
+
bootedState = false;
|
|
16
|
+
idbPath = null;
|
|
17
|
+
constructor(simulatorId) {
|
|
18
|
+
this.simulatorId = simulatorId;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Detect idb binary path asynchronously. Checks common locations.
|
|
22
|
+
*/
|
|
23
|
+
async detectIdb() {
|
|
24
|
+
const candidates = [
|
|
25
|
+
process.env.IDB_PATH,
|
|
26
|
+
process.env.HOME + "/Library/Python/3.9/bin/idb",
|
|
27
|
+
process.env.HOME + "/Library/Python/3.11/bin/idb",
|
|
28
|
+
process.env.HOME + "/Library/Python/3.12/bin/idb",
|
|
29
|
+
"/usr/local/bin/idb",
|
|
30
|
+
"/opt/homebrew/bin/idb",
|
|
31
|
+
"idb", // fallback to PATH
|
|
32
|
+
].filter(Boolean);
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
try {
|
|
35
|
+
await execFileAsync(candidate, ["--help"], {
|
|
36
|
+
timeout: 5_000,
|
|
37
|
+
});
|
|
38
|
+
this.idbPath = candidate;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// try next
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Initialize idb detection. Call this before using interaction commands.
|
|
48
|
+
*/
|
|
49
|
+
async initIdb() {
|
|
50
|
+
await this.detectIdb();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Execute an idb command. Throws if idb is not available.
|
|
54
|
+
*/
|
|
55
|
+
async idb(subcommands, args = []) {
|
|
56
|
+
if (!this.idbPath) {
|
|
57
|
+
throw new Error("idb (Facebook's iOS Development Bridge) is not installed. " +
|
|
58
|
+
"Install with: pip3 install fb-idb. " +
|
|
59
|
+
"Also ensure idb_companion is installed: brew install idb-companion");
|
|
60
|
+
}
|
|
61
|
+
const { stdout } = await execFileAsync(this.idbPath, [
|
|
62
|
+
...subcommands,
|
|
63
|
+
...args,
|
|
64
|
+
], { timeout: 15_000 });
|
|
65
|
+
return stdout;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Execute a simctl command.
|
|
69
|
+
*/
|
|
70
|
+
async exec(subcommand, args = []) {
|
|
71
|
+
const { stdout } = await execFileAsync("xcrun", [
|
|
72
|
+
"simctl",
|
|
73
|
+
subcommand,
|
|
74
|
+
this.simulatorId,
|
|
75
|
+
...args,
|
|
76
|
+
], {
|
|
77
|
+
timeout: 30_000,
|
|
78
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
return stdout;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get simulator device info.
|
|
84
|
+
*/
|
|
85
|
+
async getDeviceInfo() {
|
|
86
|
+
try {
|
|
87
|
+
const { stdout } = await execFileAsync("xcrun", [
|
|
88
|
+
"simctl", "list", "devices", "--json",
|
|
89
|
+
], { timeout: 10_000 });
|
|
90
|
+
const data = JSON.parse(stdout);
|
|
91
|
+
// Find our simulator
|
|
92
|
+
for (const [runtime, devices] of Object.entries(data.devices)) {
|
|
93
|
+
for (const device of devices) {
|
|
94
|
+
const isTarget = this.simulatorId === "booted"
|
|
95
|
+
? device.state === "Booted"
|
|
96
|
+
: device.udid === this.simulatorId;
|
|
97
|
+
if (isTarget) {
|
|
98
|
+
this.bootedState = device.state === "Booted";
|
|
99
|
+
return {
|
|
100
|
+
udid: device.udid,
|
|
101
|
+
name: device.name,
|
|
102
|
+
runtime,
|
|
103
|
+
state: device.state,
|
|
104
|
+
booted: this.bootedState,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
udid: this.simulatorId,
|
|
111
|
+
name: "Unknown",
|
|
112
|
+
runtime: "Unknown",
|
|
113
|
+
state: "Shutdown",
|
|
114
|
+
booted: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return {
|
|
119
|
+
udid: this.simulatorId,
|
|
120
|
+
name: "Not Available",
|
|
121
|
+
runtime: "Unknown",
|
|
122
|
+
state: "Not Available",
|
|
123
|
+
booted: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Take a screenshot and return as base64 PNG.
|
|
129
|
+
*/
|
|
130
|
+
async screenshot() {
|
|
131
|
+
const tmpPath = `/tmp/sensai-ios-${Date.now()}.png`;
|
|
132
|
+
await this.exec("io", ["screenshot", tmpPath]);
|
|
133
|
+
const { readFile, unlink } = await import("node:fs/promises");
|
|
134
|
+
const data = await readFile(tmpPath);
|
|
135
|
+
await unlink(tmpPath).catch(() => { });
|
|
136
|
+
return data;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Launch an app by bundle ID.
|
|
140
|
+
*/
|
|
141
|
+
async launchApp(bundleId) {
|
|
142
|
+
await this.exec("launch", [bundleId]);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Terminate an app by bundle ID.
|
|
146
|
+
*/
|
|
147
|
+
async terminateApp(bundleId) {
|
|
148
|
+
await this.exec("terminate", [bundleId]);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Tap at screen coordinates using idb.
|
|
152
|
+
*/
|
|
153
|
+
async tap(x, y) {
|
|
154
|
+
await this.idb(["ui", "tap"], [String(x), String(y)]);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Type text into the focused field using idb.
|
|
158
|
+
*/
|
|
159
|
+
async typeText(text) {
|
|
160
|
+
await this.idb(["ui", "text"], [text]);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Swipe from one point to another using idb.
|
|
164
|
+
*/
|
|
165
|
+
async swipe(startX, startY, endX, endY, durationSec = 0.3) {
|
|
166
|
+
await this.idb(["ui", "swipe"], [
|
|
167
|
+
String(startX), String(startY), String(endX), String(endY),
|
|
168
|
+
"--duration", String(durationSec),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Describe all accessibility elements on screen using idb.
|
|
173
|
+
*/
|
|
174
|
+
async describeAll() {
|
|
175
|
+
return this.idb(["ui", "describe-all"]);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Describe accessibility element at a point using idb.
|
|
179
|
+
*/
|
|
180
|
+
async describePoint(x, y) {
|
|
181
|
+
return this.idb(["ui", "describe-point"], [String(x), String(y)]);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Check if idb is available for interaction commands.
|
|
185
|
+
*/
|
|
186
|
+
hasIdb() {
|
|
187
|
+
return this.idbPath !== null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get recent logs from the simulator.
|
|
191
|
+
*/
|
|
192
|
+
async getLogs(predicate, duration = "30s") {
|
|
193
|
+
const { stdout } = await execFileAsync("xcrun", [
|
|
194
|
+
"simctl", "spawn", this.simulatorId, "log", "show",
|
|
195
|
+
"--last", duration,
|
|
196
|
+
"--predicate", predicate,
|
|
197
|
+
], { timeout: 30_000, maxBuffer: 10 * 1024 * 1024 });
|
|
198
|
+
return stdout;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get the resolved UDID (needed for spawn commands when using "booted").
|
|
202
|
+
*/
|
|
203
|
+
async getResolvedUdid() {
|
|
204
|
+
if (this.simulatorId !== "booted")
|
|
205
|
+
return this.simulatorId;
|
|
206
|
+
const info = await this.getDeviceInfo();
|
|
207
|
+
return info.udid;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Get crash/exception logs from the last N minutes.
|
|
211
|
+
*/
|
|
212
|
+
async getCrashLogs(minutes = 5) {
|
|
213
|
+
const { stdout } = await execFileAsync("xcrun", [
|
|
214
|
+
"simctl", "spawn", this.simulatorId, "log", "show",
|
|
215
|
+
"--last", `${minutes}m`,
|
|
216
|
+
"--predicate",
|
|
217
|
+
'eventMessage contains "crash" OR eventMessage contains "exception" OR eventMessage contains "fatal" OR eventMessage contains "SIGABRT"',
|
|
218
|
+
], { timeout: 30_000, maxBuffer: 10 * 1024 * 1024 });
|
|
219
|
+
return stdout;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Send key input to the simulator via idb.
|
|
223
|
+
*/
|
|
224
|
+
async keyPress(keycode) {
|
|
225
|
+
await this.idb(["ui", "key"], [String(keycode)]);
|
|
226
|
+
}
|
|
227
|
+
isBooted() {
|
|
228
|
+
return this.bootedState;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
exports.SimctlClient = SimctlClient;
|
|
232
|
+
//# sourceMappingURL=simctl-client.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sensaiorg/adapter-ios",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SensAI iOS adapter — debug iOS apps via xcrun simctl and Xcode instruments",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -b",
|
|
9
|
+
"clean": "rm -rf dist .tsbuildinfo"
|
|
10
|
+
},
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@sensaiorg/core": "0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
17
|
+
"@types/node": "^22.0.0",
|
|
18
|
+
"typescript": "^5.7.0",
|
|
19
|
+
"zod": "^3.24.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IosAdapter — implements IPlatformAdapter for iOS Simulator and devices.
|
|
3
|
+
*
|
|
4
|
+
* Uses:
|
|
5
|
+
* - xcrun simctl: screenshots, app lifecycle, UI hierarchy, status bar
|
|
6
|
+
* - Xcode instruments: performance profiling
|
|
7
|
+
* - Safari Web Inspector Protocol: WebView debugging
|
|
8
|
+
* - IDB (Facebook's iOS Development Bridge): advanced interactions
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import type {
|
|
13
|
+
IPlatformAdapter,
|
|
14
|
+
IUiProvider,
|
|
15
|
+
ILogProvider,
|
|
16
|
+
IInteractionProvider,
|
|
17
|
+
INetworkProvider,
|
|
18
|
+
IPerformanceProvider,
|
|
19
|
+
IScreenshotProvider,
|
|
20
|
+
IAppStateProvider,
|
|
21
|
+
PlatformCapabilities,
|
|
22
|
+
ConnectionStatus,
|
|
23
|
+
} from "@sensai/core";
|
|
24
|
+
|
|
25
|
+
import { SimctlClient } from "./transport/simctl-client.js";
|
|
26
|
+
import { registerIosTools } from "./tools/ios-tools.js";
|
|
27
|
+
|
|
28
|
+
export interface IosAdapterConfig {
|
|
29
|
+
/** Specific simulator UDID (default: "booted" = first booted simulator) */
|
|
30
|
+
simulatorId?: string;
|
|
31
|
+
/** Bundle ID of the target app */
|
|
32
|
+
bundleId?: string;
|
|
33
|
+
/** Path to idb binary (optional, for advanced features) */
|
|
34
|
+
idbPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class IosAdapter implements IPlatformAdapter {
|
|
38
|
+
readonly platform = "ios" as const;
|
|
39
|
+
readonly displayName: string;
|
|
40
|
+
readonly capabilities: PlatformCapabilities = {
|
|
41
|
+
uiTree: true, // via XCUITest accessibility hierarchy
|
|
42
|
+
logs: true, // via os_log / simctl spawn log
|
|
43
|
+
interaction: true, // via simctl ui / idb
|
|
44
|
+
network: false, // Future: mitmproxy integration
|
|
45
|
+
performance: true, // via instruments
|
|
46
|
+
screenshot: true, // via simctl io screenshot
|
|
47
|
+
appState: false, // Future: on-device agent
|
|
48
|
+
hotReload: true, // Metro bundler (React Native)
|
|
49
|
+
evalCode: false,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
private readonly config: Required<IosAdapterConfig>;
|
|
53
|
+
private readonly simctl: SimctlClient;
|
|
54
|
+
|
|
55
|
+
readonly ui: IUiProvider | null = null; // TODO: SimctlUiProvider
|
|
56
|
+
readonly logs: ILogProvider | null = null; // TODO: OsLogProvider
|
|
57
|
+
readonly interaction: IInteractionProvider | null = null; // TODO: SimctlInteractionProvider
|
|
58
|
+
readonly network: INetworkProvider | null = null;
|
|
59
|
+
readonly performance: IPerformanceProvider | null = null; // TODO: InstrumentsProvider
|
|
60
|
+
readonly screenshot: IScreenshotProvider | null = null; // TODO: SimctlScreenshotProvider
|
|
61
|
+
readonly appState: IAppStateProvider | null = null;
|
|
62
|
+
|
|
63
|
+
constructor(config: IosAdapterConfig = {}) {
|
|
64
|
+
this.config = {
|
|
65
|
+
simulatorId: config.simulatorId ?? "booted",
|
|
66
|
+
bundleId: config.bundleId ?? process.env.IOS_BUNDLE_ID ?? "com.chronocrew",
|
|
67
|
+
idbPath: config.idbPath ?? process.env.IDB_PATH ?? "idb",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
this.displayName = `iOS Simulator (${this.config.simulatorId})`;
|
|
71
|
+
this.simctl = new SimctlClient(this.config.simulatorId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async initialize(): Promise<ConnectionStatus> {
|
|
75
|
+
await this.simctl.initIdb();
|
|
76
|
+
const deviceInfo = await this.simctl.getDeviceInfo();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
platform: "ios",
|
|
80
|
+
device: deviceInfo.name ?? this.config.simulatorId,
|
|
81
|
+
connected: deviceInfo.booted,
|
|
82
|
+
capabilities: this.capabilities,
|
|
83
|
+
details: {
|
|
84
|
+
udid: deviceInfo.udid,
|
|
85
|
+
runtime: deviceInfo.runtime,
|
|
86
|
+
bundleId: this.config.bundleId,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
shutdown(): void {
|
|
92
|
+
// No persistent connections to clean up for simctl
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
isConnected(): boolean {
|
|
96
|
+
return this.simctl.isBooted();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
registerPlatformTools(server: McpServer): void {
|
|
100
|
+
// Use "ios_" prefix so tools don't clash with Android tool names
|
|
101
|
+
const prefix = "ios_";
|
|
102
|
+
registerIosTools(server, this.simctl, this.config.bundleId, prefix);
|
|
103
|
+
}
|
|
104
|
+
}
|