@mobileaidev/ai-app-bridge 0.2.9 → 0.2.11
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/README.md +105 -89
- package/bin/ai-app-bridge.js +4558 -4504
- package/bin/ios-provider.js +1183 -0
- package/bin/mcp-server.js +1230 -1098
- package/bin/web-provider.js +618 -0
- package/package.json +5 -2
- package/skills/ai-app-bridge-use/SKILL.md +38 -2
- package/skills/ai-app-bridge-use/agents/openai.yaml +2 -2
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
const { execFile, spawn } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { URL } = require('url');
|
|
7
|
+
|
|
8
|
+
const defaultRuntimePort = 18080;
|
|
9
|
+
const runtimePortSearchCount = 50;
|
|
10
|
+
const defaultHttpTimeoutMs = 5000;
|
|
11
|
+
const defaultProbeTimeoutMs = 700;
|
|
12
|
+
const defaultDeviceTimeoutSec = 30;
|
|
13
|
+
const defaultWdaBundleId = 'io.github.mobileaidev.aiappbridge.wda';
|
|
14
|
+
|
|
15
|
+
class IOSBridgeProvider {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.execFile = options.execFile || execFile;
|
|
18
|
+
this.httpRequest = options.httpRequest || requestJson;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async run(command, args = {}) {
|
|
22
|
+
try {
|
|
23
|
+
switch (command) {
|
|
24
|
+
case 'ios-devices':
|
|
25
|
+
return await this.devices(args);
|
|
26
|
+
case 'ios-doctor':
|
|
27
|
+
return await this.doctor(args);
|
|
28
|
+
case 'ios-setup':
|
|
29
|
+
return await this.setup(args);
|
|
30
|
+
case 'ios-install-app':
|
|
31
|
+
return await this.installApp(args);
|
|
32
|
+
case 'ios-launch-app':
|
|
33
|
+
return await this.launchApp(args);
|
|
34
|
+
case 'ios-status':
|
|
35
|
+
return await this.runtimeGet(args, '/v1/status');
|
|
36
|
+
case 'ios-tree':
|
|
37
|
+
return await this.runtimeGet(args, '/v1/view/tree');
|
|
38
|
+
case 'ios-logs':
|
|
39
|
+
return await this.runtimeGet(args, withQuery('/v1/logs', captureQuery(args)));
|
|
40
|
+
case 'ios-network':
|
|
41
|
+
return await this.runtimeGet(args, withQuery('/v1/network', captureQuery(args)));
|
|
42
|
+
case 'ios-state':
|
|
43
|
+
return await this.runtimeGet(args, withQuery('/v1/state', captureQuery(args)));
|
|
44
|
+
case 'ios-events':
|
|
45
|
+
return await this.runtimeGet(args, withQuery('/v1/events', captureQuery(args)));
|
|
46
|
+
case 'ios-h5-dom':
|
|
47
|
+
return await this.runtimeGet(args, '/v1/h5/dom');
|
|
48
|
+
case 'ios-h5-eval':
|
|
49
|
+
return await this.runtimePost(args, '/v1/h5/eval', { script: requiredString(args.script, 'script') });
|
|
50
|
+
case 'ios-flutter-tree':
|
|
51
|
+
return await this.flutterTree(args);
|
|
52
|
+
case 'ios-flutter-nodes':
|
|
53
|
+
return await this.flutterNodes(args);
|
|
54
|
+
case 'ios-flutter-action':
|
|
55
|
+
return await this.runtimePost(args, '/v1/flutter/action', parsePayload(args));
|
|
56
|
+
case 'ios-screenshot':
|
|
57
|
+
return await this.screenshot(args);
|
|
58
|
+
case 'ios-wda-status':
|
|
59
|
+
return await this.wdaStatus(args);
|
|
60
|
+
case 'ios-uia-tree':
|
|
61
|
+
return await this.wdaSource(args);
|
|
62
|
+
case 'ios-tap':
|
|
63
|
+
return await this.wdaTap(args);
|
|
64
|
+
case 'ios-input':
|
|
65
|
+
return await this.wdaInput(args);
|
|
66
|
+
case 'ios-swipe':
|
|
67
|
+
return await this.wdaSwipe(args);
|
|
68
|
+
default:
|
|
69
|
+
return { ok: false, error: 'unknown_ios_command', command };
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: iosErrorCode(command, error),
|
|
75
|
+
command,
|
|
76
|
+
message: firstErrorLine(error.message || String(error)),
|
|
77
|
+
details: truncateText(error.message || String(error), 4000),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
context(args = {}) {
|
|
83
|
+
return {
|
|
84
|
+
devicectl: stringArg(args.devicectl, process.env.AI_APP_BRIDGE_DEVICECTL || 'xcrun'),
|
|
85
|
+
xcodebuild: stringArg(args.xcodebuild, process.env.XCODEBUILD || 'xcodebuild'),
|
|
86
|
+
deviceTimeoutSec: numberArg(args.deviceTimeoutSec, defaultDeviceTimeoutSec),
|
|
87
|
+
httpTimeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async devices(args = {}) {
|
|
92
|
+
const ctx = this.context(args);
|
|
93
|
+
const raw = await this.devicectlJson(ctx, ['list', 'devices']);
|
|
94
|
+
const devices = parseDevicectlDevices(raw).map(shapeDevice);
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
devices,
|
|
98
|
+
count: devices.length,
|
|
99
|
+
selectedDevice: selectDeviceFromList(devices, args).device || null,
|
|
100
|
+
updatedAtMs: Date.now(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async doctor(args = {}) {
|
|
105
|
+
const ctx = this.context(args);
|
|
106
|
+
const checks = [];
|
|
107
|
+
const xcode = await this.xcodeVersion(ctx);
|
|
108
|
+
checks.push({ name: 'xcodebuild', ok: xcode.ok, ...xcode });
|
|
109
|
+
|
|
110
|
+
let devices = [];
|
|
111
|
+
let selected = null;
|
|
112
|
+
try {
|
|
113
|
+
const deviceResult = await this.devices(args);
|
|
114
|
+
devices = deviceResult.devices;
|
|
115
|
+
const selection = selectDeviceFromList(devices, args);
|
|
116
|
+
selected = selection.device;
|
|
117
|
+
checks.push({
|
|
118
|
+
name: 'device',
|
|
119
|
+
ok: Boolean(selection.device),
|
|
120
|
+
error: selection.error || null,
|
|
121
|
+
message: selection.message || null,
|
|
122
|
+
device: selection.device || null,
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
checks.push({ name: 'device', ok: false, error: 'devicectl_failed', message: error.message });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let runtime = { ok: false, error: 'bundle_id_or_runtime_endpoint_required' };
|
|
129
|
+
if (args.bundleId || args.runtimeUrl || args.iosHost || args.host) {
|
|
130
|
+
try {
|
|
131
|
+
runtime = await this.runtimeGet(args, '/v1/status', { allowUnavailable: true, device: selected });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
runtime = { ok: false, error: 'runtime_unavailable', message: error.message };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
checks.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
|
|
137
|
+
|
|
138
|
+
let wda = { ok: false, error: 'wda_url_required' };
|
|
139
|
+
if (args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL) {
|
|
140
|
+
wda = await this.wdaStatus(args);
|
|
141
|
+
}
|
|
142
|
+
checks.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
|
|
143
|
+
|
|
144
|
+
const developerModeReady = selected ? selected.developerModeStatus === 'enabled' : false;
|
|
145
|
+
const ddiReady = selected ? selected.ddiServicesAvailable !== false : false;
|
|
146
|
+
const ready = Boolean(xcode.ok && selected && developerModeReady && ddiReady && wda.ok === true);
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
ready,
|
|
150
|
+
fullControlReady: ready,
|
|
151
|
+
checks,
|
|
152
|
+
devices,
|
|
153
|
+
selectedDevice: selected,
|
|
154
|
+
requirements: {
|
|
155
|
+
xcode: xcode.ok,
|
|
156
|
+
deviceConnected: Boolean(selected),
|
|
157
|
+
developerModeEnabled: developerModeReady,
|
|
158
|
+
ddiServicesAvailable: ddiReady,
|
|
159
|
+
runtimeReachable: runtime.ok === true,
|
|
160
|
+
wdaReachable: wda.ok === true,
|
|
161
|
+
},
|
|
162
|
+
suggestion: ready ? null : iosSetupSuggestion(selected, runtime, wda),
|
|
163
|
+
updatedAtMs: Date.now(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async setup(args = {}) {
|
|
168
|
+
const ctx = this.context(args);
|
|
169
|
+
const xcode = await this.xcodeVersion(ctx);
|
|
170
|
+
if (!xcode.ok) {
|
|
171
|
+
return { ok: false, error: 'xcode_required', xcode };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let devices;
|
|
175
|
+
try {
|
|
176
|
+
devices = (await this.devices(args)).devices;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return { ok: false, error: 'devicectl_failed', message: error.message };
|
|
179
|
+
}
|
|
180
|
+
const selection = selectDeviceFromList(devices, args);
|
|
181
|
+
if (!selection.device) {
|
|
182
|
+
return { ok: false, error: selection.error || 'ios_device_required', message: selection.message, devices };
|
|
183
|
+
}
|
|
184
|
+
const device = selection.device;
|
|
185
|
+
if (device.developerModeStatus !== 'enabled') {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: 'ios_developer_mode_required',
|
|
189
|
+
device,
|
|
190
|
+
message: 'Developer Mode is disabled on the selected iPhone. Enable it on the device, reboot if iOS asks, unlock the phone, then rerun ios-setup.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (device.ddiServicesAvailable === false) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: 'ios_developer_disk_image_required',
|
|
197
|
+
device,
|
|
198
|
+
message: 'Xcode cannot access developer disk image services for this device. Unlock/trust the iPhone and let Xcode finish preparing it, then rerun ios-setup.',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const steps = [];
|
|
203
|
+
if (args.appPath) {
|
|
204
|
+
const install = await this.installApp({ ...args, deviceId: device.identifier || device.udid });
|
|
205
|
+
steps.push({ name: 'install-app', ...install });
|
|
206
|
+
if (install.ok === false) return { ok: false, error: 'ios_app_install_failed', device, steps };
|
|
207
|
+
}
|
|
208
|
+
if (args.bundleId) {
|
|
209
|
+
const launch = await this.launchApp({ ...args, deviceId: device.identifier || device.udid });
|
|
210
|
+
steps.push({ name: 'launch-app', ...launch });
|
|
211
|
+
if (launch.ok === false) return { ok: false, error: 'ios_app_launch_failed', device, steps };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const runtime = args.bundleId || args.runtimeUrl || args.iosHost || args.host
|
|
215
|
+
? await this.runtimeGet(args, '/v1/status', { allowUnavailable: true, device })
|
|
216
|
+
: { ok: false, error: 'bundle_id_or_runtime_endpoint_required' };
|
|
217
|
+
steps.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
|
|
218
|
+
|
|
219
|
+
let wda = await this.wdaStatus(args);
|
|
220
|
+
if (wda.ok !== true && booleanArg(args.startWda)) {
|
|
221
|
+
const start = await this.startWda({ ...args, deviceId: device.identifier || device.udid });
|
|
222
|
+
steps.push({ name: 'start-wda', ...start });
|
|
223
|
+
if (start.ok === true) {
|
|
224
|
+
wda = start.status || await this.wdaStatus(args);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
steps.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
|
|
228
|
+
|
|
229
|
+
if (wda.ok !== true) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: 'ios_wda_required',
|
|
233
|
+
device,
|
|
234
|
+
steps,
|
|
235
|
+
message: 'Full iOS control requires WebDriverAgent/XCUITest to be built, signed, installed, and reachable. Pass --wda-url for an existing WDA, or pass --wda-project-path, --team-id, and --start-wda to let setup run xcodebuild.',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (runtime.ok !== true) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: 'ios_runtime_required',
|
|
242
|
+
device,
|
|
243
|
+
steps,
|
|
244
|
+
message: 'WDA is reachable, but the in-app iOS runtime is not reachable. Launch a debug app that includes AiAppBridgeIOS and pass bundleId/iosHost/iosPort if auto-discovery cannot find it.',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
ready: true,
|
|
251
|
+
fullControlReady: true,
|
|
252
|
+
device,
|
|
253
|
+
runtimeEndpoint: runtime.endpoint,
|
|
254
|
+
wdaUrl: wda.url,
|
|
255
|
+
steps,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async installApp(args = {}) {
|
|
260
|
+
const ctx = this.context(args);
|
|
261
|
+
const device = await this.requireDevice(args);
|
|
262
|
+
const appPath = requiredString(args.appPath, 'appPath');
|
|
263
|
+
const resolvedPath = path.resolve(appPath);
|
|
264
|
+
const raw = await this.devicectlJson(ctx, [
|
|
265
|
+
'device',
|
|
266
|
+
'install',
|
|
267
|
+
'app',
|
|
268
|
+
'--device',
|
|
269
|
+
device.identifier || device.udid,
|
|
270
|
+
resolvedPath,
|
|
271
|
+
]);
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
device,
|
|
275
|
+
appPath: resolvedPath,
|
|
276
|
+
result: raw.result || raw,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async launchApp(args = {}) {
|
|
281
|
+
const ctx = this.context(args);
|
|
282
|
+
const device = await this.requireDevice(args);
|
|
283
|
+
const bundleId = requiredString(args.bundleId, 'bundleId');
|
|
284
|
+
const launchArgs = [
|
|
285
|
+
'device',
|
|
286
|
+
'process',
|
|
287
|
+
'launch',
|
|
288
|
+
'--device',
|
|
289
|
+
device.identifier || device.udid,
|
|
290
|
+
];
|
|
291
|
+
if (args.terminateExisting !== false) launchArgs.push('--terminate-existing');
|
|
292
|
+
launchArgs.push(bundleId);
|
|
293
|
+
const raw = await this.devicectlJson(ctx, launchArgs);
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
device,
|
|
297
|
+
bundleId,
|
|
298
|
+
result: raw.result || raw,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async screenshot(args = {}) {
|
|
303
|
+
const ctx = this.context(args);
|
|
304
|
+
const device = await this.requireDevice(args);
|
|
305
|
+
const outFile = args.outFile
|
|
306
|
+
? path.resolve(args.outFile)
|
|
307
|
+
: defaultArtifactPath('ios-screenshot', 'png', args.artifactDir);
|
|
308
|
+
await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
|
|
309
|
+
const command = [
|
|
310
|
+
'device',
|
|
311
|
+
'capture',
|
|
312
|
+
'screenshot',
|
|
313
|
+
'--device',
|
|
314
|
+
device.identifier || device.udid,
|
|
315
|
+
'--destination',
|
|
316
|
+
outFile,
|
|
317
|
+
];
|
|
318
|
+
if (args.displayUniqueId) command.push('--display-unique-id', String(args.displayUniqueId));
|
|
319
|
+
const raw = await this.devicectlJson(ctx, command);
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
device,
|
|
323
|
+
outFile,
|
|
324
|
+
result: raw.result || raw,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async runtimeGet(args, endpointPath, options = {}) {
|
|
329
|
+
const endpoint = await this.resolveRuntimeEndpoint(args, options);
|
|
330
|
+
if (!endpoint.ok) return endpoint;
|
|
331
|
+
try {
|
|
332
|
+
const response = await this.httpRequest('GET', `${endpoint.baseUrl}${endpointPath}`, null, {
|
|
333
|
+
timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
|
|
334
|
+
});
|
|
335
|
+
return {
|
|
336
|
+
...(response && typeof response === 'object' ? response : { value: response }),
|
|
337
|
+
ok: response?.ok === false ? false : true,
|
|
338
|
+
endpoint: endpoint.baseUrl,
|
|
339
|
+
device: endpoint.device || null,
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (options.allowUnavailable) {
|
|
343
|
+
return { ok: false, error: 'ios_runtime_unreachable', endpoint: endpoint.baseUrl, message: error.message };
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async runtimePost(args, endpointPath, body) {
|
|
350
|
+
const endpoint = await this.resolveRuntimeEndpoint(args);
|
|
351
|
+
if (!endpoint.ok) return endpoint;
|
|
352
|
+
const response = await this.httpRequest('POST', `${endpoint.baseUrl}${endpointPath}`, body, {
|
|
353
|
+
timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
...(response && typeof response === 'object' ? response : { value: response }),
|
|
357
|
+
ok: response?.ok === false ? false : true,
|
|
358
|
+
endpoint: endpoint.baseUrl,
|
|
359
|
+
device: endpoint.device || null,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async flutterTree(args = {}) {
|
|
364
|
+
const status = await this.runtimeGet(args, '/v1/status');
|
|
365
|
+
if (status.ok === false) return status;
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
endpoint: status.endpoint,
|
|
369
|
+
device: status.device || null,
|
|
370
|
+
flutter: status.flutter || null,
|
|
371
|
+
layout: status.flutter?.layout || null,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async flutterNodes(args = {}) {
|
|
376
|
+
const tree = await this.flutterTree(args);
|
|
377
|
+
if (tree.ok === false) return tree;
|
|
378
|
+
const operable = tree.layout?.operable || null;
|
|
379
|
+
return {
|
|
380
|
+
ok: Boolean(operable),
|
|
381
|
+
endpoint: tree.endpoint,
|
|
382
|
+
device: tree.device || null,
|
|
383
|
+
operable,
|
|
384
|
+
nodes: operable?.nodes || [],
|
|
385
|
+
count: operable?.count || 0,
|
|
386
|
+
error: operable ? null : 'flutter_operable_tree_absent',
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async resolveRuntimeEndpoint(args = {}, options = {}) {
|
|
391
|
+
if (args.runtimeUrl) {
|
|
392
|
+
return { ok: true, baseUrl: stripTrailingSlash(String(args.runtimeUrl)), device: options.device || null };
|
|
393
|
+
}
|
|
394
|
+
const explicitHost = args.iosHost || args.host;
|
|
395
|
+
const explicitPort = args.iosPort || args.port;
|
|
396
|
+
const device = options.device || await this.optionalDevice(args);
|
|
397
|
+
let port = explicitPort ? Number(explicitPort) : null;
|
|
398
|
+
let portSource = explicitPort ? 'explicit' : '';
|
|
399
|
+
if (!port && args.bundleId && device) {
|
|
400
|
+
const copied = await this.readRuntimePortFile(args, device);
|
|
401
|
+
if (copied.ok && copied.port) {
|
|
402
|
+
port = copied.port;
|
|
403
|
+
portSource = 'app-container-port-file';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const hosts = runtimeHostCandidates(explicitHost, device);
|
|
408
|
+
if (port && hosts.length) {
|
|
409
|
+
return {
|
|
410
|
+
ok: true,
|
|
411
|
+
baseUrl: `http://${formatHostForUrl(hosts[0])}:${port}`,
|
|
412
|
+
device,
|
|
413
|
+
port,
|
|
414
|
+
portSource,
|
|
415
|
+
hostSource: explicitHost ? 'explicit' : 'device',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (hosts.length) {
|
|
420
|
+
const probe = await this.probeRuntimeHosts(hosts, args, device);
|
|
421
|
+
if (probe.ok) return probe;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!hosts.length) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
error: 'ios_tunnel_unavailable',
|
|
428
|
+
device,
|
|
429
|
+
message: 'No iOS runtime host is available. Pass --runtime-url or --ios-host, or enable Developer Mode/unlock the device so devicectl exposes a tunnel IP.',
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
error: 'ios_runtime_port_unavailable',
|
|
435
|
+
device,
|
|
436
|
+
message: 'Could not discover the AiAppBridgeIOS runtime port. Pass --ios-port or launch an app that writes ai_app_bridge_port.json through the iOS runtime.',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async probeRuntimeHosts(hosts, args, device) {
|
|
441
|
+
const timeoutMs = numberArg(args.probeTimeoutMs, defaultProbeTimeoutMs);
|
|
442
|
+
const start = Number(args.portStart || defaultRuntimePort);
|
|
443
|
+
const count = Number(args.portSearchCount || runtimePortSearchCount);
|
|
444
|
+
for (const host of hosts) {
|
|
445
|
+
for (let offset = 0; offset < count; offset += 1) {
|
|
446
|
+
const port = start + offset;
|
|
447
|
+
const baseUrl = `http://${formatHostForUrl(host)}:${port}`;
|
|
448
|
+
try {
|
|
449
|
+
const status = await this.httpRequest('GET', `${baseUrl}/v1/status`, null, { timeoutMs });
|
|
450
|
+
if (status && status.debugBridge) {
|
|
451
|
+
return { ok: true, baseUrl, device, port, portSource: 'runtime-probe', hostSource: 'probe' };
|
|
452
|
+
}
|
|
453
|
+
} catch (_) {
|
|
454
|
+
// Probe failures are expected while scanning.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { ok: false };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async readRuntimePortFile(args, device) {
|
|
462
|
+
const ctx = {
|
|
463
|
+
...this.context(args),
|
|
464
|
+
deviceTimeoutSec: numberArg(args.portFileTimeoutSec, 5),
|
|
465
|
+
};
|
|
466
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ai-app-bridge-ios-port-'));
|
|
467
|
+
try {
|
|
468
|
+
await this.devicectlJson(ctx, [
|
|
469
|
+
'device',
|
|
470
|
+
'copy',
|
|
471
|
+
'from',
|
|
472
|
+
'--device',
|
|
473
|
+
device.identifier || device.udid,
|
|
474
|
+
'--domain-type',
|
|
475
|
+
'appDataContainer',
|
|
476
|
+
'--domain-identifier',
|
|
477
|
+
requiredString(args.bundleId, 'bundleId'),
|
|
478
|
+
'--source',
|
|
479
|
+
'Documents/ai_app_bridge_port.json',
|
|
480
|
+
'--destination',
|
|
481
|
+
tempDir,
|
|
482
|
+
]);
|
|
483
|
+
const portFile = findFileByName(tempDir, 'ai_app_bridge_port.json');
|
|
484
|
+
if (!portFile) return { ok: false, error: 'ios_runtime_port_file_absent' };
|
|
485
|
+
const payload = JSON.parse(await fs.promises.readFile(portFile, 'utf8'));
|
|
486
|
+
const port = Number(payload.port);
|
|
487
|
+
return Number.isFinite(port) ? { ok: true, port, payload } : { ok: false, error: 'invalid_ios_runtime_port_file', payload };
|
|
488
|
+
} catch (error) {
|
|
489
|
+
return { ok: false, error: 'ios_runtime_port_file_unavailable', message: error.message };
|
|
490
|
+
} finally {
|
|
491
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async wdaStatus(args = {}) {
|
|
496
|
+
const url = wdaBaseUrl(args);
|
|
497
|
+
try {
|
|
498
|
+
const response = await this.httpRequest('GET', `${url}/status`, null, {
|
|
499
|
+
timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
|
|
500
|
+
});
|
|
501
|
+
return {
|
|
502
|
+
ok: true,
|
|
503
|
+
url,
|
|
504
|
+
value: response?.value || response,
|
|
505
|
+
};
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
error: 'ios_wda_unreachable',
|
|
510
|
+
url,
|
|
511
|
+
message: error.message,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async wdaSource(args = {}) {
|
|
517
|
+
const session = await this.ensureWdaSession(args);
|
|
518
|
+
if (!session.ok) return session;
|
|
519
|
+
const response = await this.wdaRequest(args, 'GET', `/session/${session.sessionId}/source?format=json`);
|
|
520
|
+
return { ok: true, session, source: response?.value || response };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async wdaTap(args = {}) {
|
|
524
|
+
const x = requiredNumber(args.tapX ?? args.x, 'tapX');
|
|
525
|
+
const y = requiredNumber(args.tapY ?? args.y, 'tapY');
|
|
526
|
+
const session = await this.ensureWdaSession(args);
|
|
527
|
+
if (!session.ok) return session;
|
|
528
|
+
const body = { x, y };
|
|
529
|
+
try {
|
|
530
|
+
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/tap/0`, body);
|
|
531
|
+
return { ok: true, transport: 'wda', action: 'tap', session, x, y, response };
|
|
532
|
+
} catch (firstError) {
|
|
533
|
+
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/actions`, pointerAction(x, y));
|
|
534
|
+
return { ok: true, transport: 'wda-actions', action: 'tap', session, x, y, firstError: firstError.message, response };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async wdaInput(args = {}) {
|
|
539
|
+
const text = requiredString(args.text || args.value, 'text');
|
|
540
|
+
const session = await this.ensureWdaSession(args);
|
|
541
|
+
if (!session.ok) return session;
|
|
542
|
+
const tapped = args.tapX !== undefined && args.tapY !== undefined
|
|
543
|
+
? await this.wdaTap({ ...args, wdaSessionId: session.sessionId })
|
|
544
|
+
: null;
|
|
545
|
+
const body = { value: [...text], text };
|
|
546
|
+
|
|
547
|
+
const elementTarget = await this.resolveInputElement(args, session, tapped);
|
|
548
|
+
if (elementTarget.elementId) {
|
|
549
|
+
const elementPath = `/session/${session.sessionId}/element/${encodeURIComponent(elementTarget.elementId)}`;
|
|
550
|
+
const clickResponse = await this.wdaRequest(args, 'POST', `${elementPath}/click`, {});
|
|
551
|
+
const clearResponse = booleanArg(args.clearFirst)
|
|
552
|
+
? await this.wdaRequest(args, 'POST', `${elementPath}/clear`, {})
|
|
553
|
+
: null;
|
|
554
|
+
const response = await this.wdaRequest(args, 'POST', `${elementPath}/value`, body);
|
|
555
|
+
return {
|
|
556
|
+
ok: true,
|
|
557
|
+
transport: 'wda-element-value',
|
|
558
|
+
action: 'input',
|
|
559
|
+
session,
|
|
560
|
+
textLength: text.length,
|
|
561
|
+
tapped,
|
|
562
|
+
element: elementTarget,
|
|
563
|
+
clicked: Boolean(clickResponse),
|
|
564
|
+
cleared: Boolean(clearResponse),
|
|
565
|
+
response,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/keys`, body);
|
|
571
|
+
return { ok: true, transport: 'wda', action: 'input', session, textLength: text.length, tapped, response };
|
|
572
|
+
} catch (firstError) {
|
|
573
|
+
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/keys`, body);
|
|
574
|
+
return { ok: true, transport: 'wda', action: 'input', session, textLength: text.length, tapped, firstError: firstError.message, response };
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async resolveInputElement(args, session, tapped) {
|
|
579
|
+
if (args.elementId) {
|
|
580
|
+
return { elementId: String(args.elementId), source: 'explicit-element-id' };
|
|
581
|
+
}
|
|
582
|
+
if (args.accessibilityId) {
|
|
583
|
+
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/element`, {
|
|
584
|
+
using: 'accessibility id',
|
|
585
|
+
value: String(args.accessibilityId),
|
|
586
|
+
});
|
|
587
|
+
return {
|
|
588
|
+
elementId: wdaElementIdFromResponse(response),
|
|
589
|
+
source: 'accessibility-id',
|
|
590
|
+
accessibilityId: String(args.accessibilityId),
|
|
591
|
+
response,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (tapped?.ok === true) {
|
|
595
|
+
try {
|
|
596
|
+
const response = await this.wdaRequest(args, 'GET', `/session/${session.sessionId}/element/active`);
|
|
597
|
+
return {
|
|
598
|
+
elementId: wdaElementIdFromResponse(response),
|
|
599
|
+
source: 'active-element-after-tap',
|
|
600
|
+
response,
|
|
601
|
+
};
|
|
602
|
+
} catch (_) {
|
|
603
|
+
return { elementId: '', source: 'active-element-unavailable' };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { elementId: '', source: 'none' };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async wdaSwipe(args = {}) {
|
|
610
|
+
const startX = requiredNumber(args.startX, 'startX');
|
|
611
|
+
const startY = requiredNumber(args.startY, 'startY');
|
|
612
|
+
const endX = requiredNumber(args.endX, 'endX');
|
|
613
|
+
const endY = requiredNumber(args.endY, 'endY');
|
|
614
|
+
const durationMs = numberArg(args.durationMs, 500);
|
|
615
|
+
const session = await this.ensureWdaSession(args);
|
|
616
|
+
if (!session.ok) return session;
|
|
617
|
+
const actions = swipeAction(startX, startY, endX, endY, durationMs);
|
|
618
|
+
try {
|
|
619
|
+
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/actions`, actions);
|
|
620
|
+
return { ok: true, transport: 'wda-actions', action: 'swipe', session, startX, startY, endX, endY, durationMs, response };
|
|
621
|
+
} catch (firstError) {
|
|
622
|
+
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/dragfromtoforduration`, {
|
|
623
|
+
fromX: startX,
|
|
624
|
+
fromY: startY,
|
|
625
|
+
toX: endX,
|
|
626
|
+
toY: endY,
|
|
627
|
+
duration: Math.max(durationMs / 1000, 0.05),
|
|
628
|
+
});
|
|
629
|
+
return { ok: true, transport: 'wda-drag', action: 'swipe', session, startX, startY, endX, endY, durationMs, firstError: firstError.message, response };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async ensureWdaSession(args = {}) {
|
|
634
|
+
if (args.wdaSessionId) {
|
|
635
|
+
return { ok: true, sessionId: String(args.wdaSessionId), reused: true };
|
|
636
|
+
}
|
|
637
|
+
const bundleId = args.bundleId ? String(args.bundleId) : undefined;
|
|
638
|
+
const body = {
|
|
639
|
+
capabilities: {
|
|
640
|
+
alwaysMatch: {
|
|
641
|
+
platformName: 'iOS',
|
|
642
|
+
automationName: 'XCUITest',
|
|
643
|
+
...(bundleId ? { bundleId } : {}),
|
|
644
|
+
},
|
|
645
|
+
firstMatch: [{}],
|
|
646
|
+
},
|
|
647
|
+
desiredCapabilities: {
|
|
648
|
+
platformName: 'iOS',
|
|
649
|
+
automationName: 'XCUITest',
|
|
650
|
+
...(bundleId ? { bundleId } : {}),
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
try {
|
|
654
|
+
const response = await this.wdaRequest(args, 'POST', '/session', body);
|
|
655
|
+
const sessionId = wdaSessionIdFromResponse(response);
|
|
656
|
+
if (!sessionId) {
|
|
657
|
+
return { ok: false, error: 'ios_wda_session_missing', response };
|
|
658
|
+
}
|
|
659
|
+
return { ok: true, sessionId, created: true, bundleId: bundleId || null };
|
|
660
|
+
} catch (error) {
|
|
661
|
+
return {
|
|
662
|
+
ok: false,
|
|
663
|
+
error: 'ios_wda_session_failed',
|
|
664
|
+
message: error.message,
|
|
665
|
+
suggestion: 'Ensure WebDriverAgentRunner is installed, signed, running, and reachable; pass --wda-url if it is not on http://127.0.0.1:8100.',
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async wdaRequest(args, method, endpointPath, body) {
|
|
671
|
+
return this.httpRequest(method, `${wdaBaseUrl(args)}${endpointPath}`, body, {
|
|
672
|
+
timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async startWda(args = {}) {
|
|
677
|
+
const wdaProjectPath = resolveWdaProjectPath(args);
|
|
678
|
+
if (!wdaProjectPath) {
|
|
679
|
+
return {
|
|
680
|
+
ok: false,
|
|
681
|
+
error: 'ios_wda_project_required',
|
|
682
|
+
message: 'Cannot start WDA because no WebDriverAgent.xcodeproj was found. Pass --wda-project-path or install a package that vendors appium-webdriveragent.',
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const teamId = args.teamId || process.env.DEVELOPMENT_TEAM || process.env.AI_APP_BRIDGE_IOS_TEAM_ID;
|
|
686
|
+
if (!teamId) {
|
|
687
|
+
return {
|
|
688
|
+
ok: false,
|
|
689
|
+
error: 'ios_team_id_required',
|
|
690
|
+
message: 'Cannot sign WebDriverAgentRunner without a DEVELOPMENT_TEAM. Add an Apple account in Xcode and pass --team-id.',
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const wdaBundleId = stringArg(args.wdaBundleId, process.env.AI_APP_BRIDGE_WDA_BUNDLE_ID || defaultWdaBundleId);
|
|
694
|
+
const ctx = this.context(args);
|
|
695
|
+
const device = await this.requireDevice(args);
|
|
696
|
+
const xcodeArgs = [
|
|
697
|
+
'-project',
|
|
698
|
+
wdaProjectPath,
|
|
699
|
+
'-scheme',
|
|
700
|
+
'WebDriverAgentRunner',
|
|
701
|
+
'-destination',
|
|
702
|
+
`id=${device.udid || device.identifier}`,
|
|
703
|
+
'DEVELOPMENT_TEAM=' + teamId,
|
|
704
|
+
'PRODUCT_BUNDLE_IDENTIFIER=' + wdaBundleId,
|
|
705
|
+
'-allowProvisioningUpdates',
|
|
706
|
+
'test',
|
|
707
|
+
];
|
|
708
|
+
const logFile = path.join(os.tmpdir(), `ai-app-bridge-wda-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
709
|
+
const out = fs.openSync(logFile, 'a');
|
|
710
|
+
const child = spawn(ctx.xcodebuild, xcodeArgs, {
|
|
711
|
+
detached: true,
|
|
712
|
+
stdio: ['ignore', out, out],
|
|
713
|
+
windowsHide: true,
|
|
714
|
+
});
|
|
715
|
+
fs.closeSync(out);
|
|
716
|
+
let spawnError = null;
|
|
717
|
+
let exited = false;
|
|
718
|
+
child.once('error', (error) => { spawnError = error; });
|
|
719
|
+
child.once('exit', () => { exited = true; });
|
|
720
|
+
child.unref();
|
|
721
|
+
await sleep(1200);
|
|
722
|
+
if (spawnError) {
|
|
723
|
+
return { ok: false, error: 'ios_wda_xcodebuild_spawn_failed', message: spawnError.message, logFile };
|
|
724
|
+
}
|
|
725
|
+
const deadline = Date.now() + numberArg(args.wdaStartupTimeoutMs, 60000);
|
|
726
|
+
while (Date.now() < deadline) {
|
|
727
|
+
const status = await this.bestWdaStatus(args, device, logFile);
|
|
728
|
+
if (status.ok) {
|
|
729
|
+
return {
|
|
730
|
+
ok: true,
|
|
731
|
+
device,
|
|
732
|
+
wdaProjectPath,
|
|
733
|
+
wdaBundleId,
|
|
734
|
+
teamId,
|
|
735
|
+
pid: child.pid,
|
|
736
|
+
logFile,
|
|
737
|
+
status,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
if (exited) {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
error: 'ios_wda_xcodebuild_exited',
|
|
744
|
+
device,
|
|
745
|
+
wdaProjectPath,
|
|
746
|
+
wdaBundleId,
|
|
747
|
+
teamId,
|
|
748
|
+
pid: child.pid,
|
|
749
|
+
logFile,
|
|
750
|
+
message: 'xcodebuild exited before WebDriverAgent became reachable. Check the log file for signing or provisioning errors.',
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
await sleep(1000);
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
ok: false,
|
|
757
|
+
error: 'ios_wda_start_timeout',
|
|
758
|
+
device,
|
|
759
|
+
wdaProjectPath,
|
|
760
|
+
wdaBundleId,
|
|
761
|
+
teamId,
|
|
762
|
+
pid: child.pid,
|
|
763
|
+
logFile,
|
|
764
|
+
message: 'Timed out waiting for WebDriverAgent /status. If Xcode is showing a signing, trust, or device prompt, resolve it and rerun ios-setup.',
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async bestWdaStatus(args, device, logFile) {
|
|
769
|
+
const urls = [
|
|
770
|
+
args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL || '',
|
|
771
|
+
device?.tunnelIPAddress ? `http://${formatHostForUrl(device.tunnelIPAddress)}:8100` : '',
|
|
772
|
+
parseWdaServerUrlFromLog(logFile),
|
|
773
|
+
'http://127.0.0.1:8100',
|
|
774
|
+
].filter(Boolean);
|
|
775
|
+
const uniqueUrls = [...new Set(urls.map(stripTrailingSlash))];
|
|
776
|
+
let last = null;
|
|
777
|
+
for (const url of uniqueUrls) {
|
|
778
|
+
const status = await this.wdaStatus({ ...args, wdaUrl: url });
|
|
779
|
+
if (status.ok) return status;
|
|
780
|
+
last = status;
|
|
781
|
+
}
|
|
782
|
+
return last || { ok: false, error: 'ios_wda_unreachable', url: 'http://127.0.0.1:8100' };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async optionalDevice(args = {}) {
|
|
786
|
+
try {
|
|
787
|
+
return await this.requireDevice(args);
|
|
788
|
+
} catch (_) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async requireDevice(args = {}) {
|
|
794
|
+
const devices = (await this.devices(args)).devices;
|
|
795
|
+
const selection = selectDeviceFromList(devices, args);
|
|
796
|
+
if (!selection.device) {
|
|
797
|
+
const error = new Error(selection.message || selection.error || 'iOS device is required');
|
|
798
|
+
error.code = selection.error || 'ios_device_required';
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
801
|
+
return selection.device;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async xcodeVersion(ctx) {
|
|
805
|
+
try {
|
|
806
|
+
const result = await execFileText(this.execFile, ctx.xcodebuild, ['-version'], { timeoutMs: 10000 });
|
|
807
|
+
return { ok: true, version: result.stdout.trim() };
|
|
808
|
+
} catch (error) {
|
|
809
|
+
return { ok: false, error: 'xcodebuild_unavailable', message: error.message };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async devicectlJson(ctx, args) {
|
|
814
|
+
const jsonPath = path.join(os.tmpdir(), `ai-app-bridge-devicectl-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
815
|
+
const allArgs = [
|
|
816
|
+
...devicectlPrefix(ctx.devicectl),
|
|
817
|
+
...args,
|
|
818
|
+
'--timeout',
|
|
819
|
+
String(ctx.deviceTimeoutSec),
|
|
820
|
+
'--json-output',
|
|
821
|
+
jsonPath,
|
|
822
|
+
];
|
|
823
|
+
try {
|
|
824
|
+
const command = devicectlBinary(ctx.devicectl);
|
|
825
|
+
await execFileText(this.execFile, command, allArgs, { timeoutMs: (ctx.deviceTimeoutSec * 1000) + 5000 });
|
|
826
|
+
return JSON.parse(await fs.promises.readFile(jsonPath, 'utf8'));
|
|
827
|
+
} finally {
|
|
828
|
+
await fs.promises.rm(jsonPath, { force: true });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function parseDevicectlDevices(payload) {
|
|
834
|
+
if (Array.isArray(payload?.result?.devices)) return payload.result.devices;
|
|
835
|
+
if (Array.isArray(payload?.devices)) return payload.devices;
|
|
836
|
+
return [];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function shapeDevice(device) {
|
|
840
|
+
const state = device?.properties?.state || {};
|
|
841
|
+
const hardware = device?.hardwareProperties || device?.properties?.hardware || {};
|
|
842
|
+
const software = device?.deviceProperties || device?.properties?.software || {};
|
|
843
|
+
const connection = device?.connectionProperties || device?.properties?.connection || {};
|
|
844
|
+
return {
|
|
845
|
+
identifier: device?.identifier || '',
|
|
846
|
+
udid: hardware.udid || '',
|
|
847
|
+
serialNumber: hardware.serialNumber || '',
|
|
848
|
+
name: device?.deviceProperties?.name || state.name || '',
|
|
849
|
+
platform: hardware.platform || 'iOS',
|
|
850
|
+
productType: hardware.productType || '',
|
|
851
|
+
marketingName: hardware.marketingName || '',
|
|
852
|
+
osVersion: device?.deviceProperties?.osVersionNumber || software.osVersionNumber?.stringValue || '',
|
|
853
|
+
osBuild: device?.deviceProperties?.osBuildUpdate || '',
|
|
854
|
+
bootState: device?.deviceProperties?.bootState || normalizedObjectEnum(state.bootState),
|
|
855
|
+
developerModeStatus: device?.deviceProperties?.developerModeStatus || normalizedObjectEnum(state.developerModeStatus),
|
|
856
|
+
ddiServicesAvailable: device?.deviceProperties?.ddiServicesAvailable,
|
|
857
|
+
pairingState: connection.pairingState || '',
|
|
858
|
+
transportType: connection.transportType || '',
|
|
859
|
+
tunnelState: connection.tunnelState || '',
|
|
860
|
+
tunnelIPAddress: connection.tunnelIPAddress || connection.tunnelIPAddressString || '',
|
|
861
|
+
potentialHostnames: Array.isArray(connection.potentialHostnames) ? connection.potentialHostnames : [],
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function selectDeviceFromList(devices, args = {}) {
|
|
866
|
+
const target = String(args.deviceId || args.iosDeviceId || args.udid || args.serial || '').trim();
|
|
867
|
+
const iosDevices = devices.filter((device) => !device.platform || String(device.platform).toLowerCase().includes('ios') || device.udid || device.identifier);
|
|
868
|
+
if (target) {
|
|
869
|
+
const device = iosDevices.find((item) => [item.identifier, item.udid, item.serialNumber, item.name].filter(Boolean).includes(target));
|
|
870
|
+
return device
|
|
871
|
+
? { device }
|
|
872
|
+
: { device: null, error: 'ios_device_not_found', message: `No connected iOS device matched ${target}.` };
|
|
873
|
+
}
|
|
874
|
+
if (iosDevices.length === 1) return { device: iosDevices[0] };
|
|
875
|
+
if (iosDevices.length === 0) {
|
|
876
|
+
return { device: null, error: 'ios_device_required', message: 'No connected iOS device was found by xcrun devicectl.' };
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
device: null,
|
|
880
|
+
error: 'ios_device_ambiguous',
|
|
881
|
+
message: 'Multiple iOS devices are connected. Pass --device-id with the devicectl identifier or UDID.',
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function devicectlBinary(value) {
|
|
886
|
+
return path.basename(String(value || 'xcrun')) === 'xcrun' ? value : value;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function devicectlPrefix(value) {
|
|
890
|
+
return path.basename(String(value || 'xcrun')) === 'xcrun' ? ['devicectl'] : [];
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function runtimeHostCandidates(explicitHost, device) {
|
|
894
|
+
const hosts = [];
|
|
895
|
+
if (explicitHost) hosts.push(String(explicitHost));
|
|
896
|
+
if (device?.tunnelIPAddress) hosts.push(device.tunnelIPAddress);
|
|
897
|
+
if (Array.isArray(device?.potentialHostnames)) hosts.push(...device.potentialHostnames);
|
|
898
|
+
return [...new Set(hosts.filter(Boolean))];
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function formatHostForUrl(host) {
|
|
902
|
+
const value = String(host || '').trim();
|
|
903
|
+
if (value.includes(':') && !value.startsWith('[')) return `[${value}]`;
|
|
904
|
+
return value;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function wdaBaseUrl(args = {}) {
|
|
908
|
+
return stripTrailingSlash(String(args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL || 'http://127.0.0.1:8100'));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function stripTrailingSlash(value) {
|
|
912
|
+
return String(value).replace(/\/+$/, '');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function wdaSessionIdFromResponse(response) {
|
|
916
|
+
return response?.sessionId
|
|
917
|
+
|| response?.value?.sessionId
|
|
918
|
+
|| response?.value?.session_id
|
|
919
|
+
|| response?.value?.capabilities?.sessionId
|
|
920
|
+
|| '';
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function wdaElementIdFromResponse(response) {
|
|
924
|
+
return response?.value?.ELEMENT
|
|
925
|
+
|| response?.value?.['element-6066-11e4-a52e-4f735466cecf']
|
|
926
|
+
|| response?.ELEMENT
|
|
927
|
+
|| response?.['element-6066-11e4-a52e-4f735466cecf']
|
|
928
|
+
|| '';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function parseWdaServerUrlFromLog(logFile) {
|
|
932
|
+
if (!logFile) return '';
|
|
933
|
+
try {
|
|
934
|
+
const text = fs.readFileSync(logFile, 'utf8');
|
|
935
|
+
const matches = [...text.matchAll(/ServerURLHere->(http:\/\/[^<\s]+)<-ServerURLHere/g)];
|
|
936
|
+
return matches.length ? matches[matches.length - 1][1] : '';
|
|
937
|
+
} catch (_) {
|
|
938
|
+
return '';
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function pointerAction(x, y) {
|
|
943
|
+
return {
|
|
944
|
+
actions: [{
|
|
945
|
+
type: 'pointer',
|
|
946
|
+
id: 'finger1',
|
|
947
|
+
parameters: { pointerType: 'touch' },
|
|
948
|
+
actions: [
|
|
949
|
+
{ type: 'pointerMove', duration: 0, origin: 'viewport', x, y },
|
|
950
|
+
{ type: 'pointerDown', button: 0 },
|
|
951
|
+
{ type: 'pause', duration: 60 },
|
|
952
|
+
{ type: 'pointerUp', button: 0 },
|
|
953
|
+
],
|
|
954
|
+
}],
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function swipeAction(startX, startY, endX, endY, durationMs) {
|
|
959
|
+
return {
|
|
960
|
+
actions: [{
|
|
961
|
+
type: 'pointer',
|
|
962
|
+
id: 'finger1',
|
|
963
|
+
parameters: { pointerType: 'touch' },
|
|
964
|
+
actions: [
|
|
965
|
+
{ type: 'pointerMove', duration: 0, origin: 'viewport', x: startX, y: startY },
|
|
966
|
+
{ type: 'pointerDown', button: 0 },
|
|
967
|
+
{ type: 'pause', duration: 80 },
|
|
968
|
+
{ type: 'pointerMove', duration: durationMs, origin: 'viewport', x: endX, y: endY },
|
|
969
|
+
{ type: 'pointerUp', button: 0 },
|
|
970
|
+
],
|
|
971
|
+
}],
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function parsePayload(args = {}) {
|
|
976
|
+
if (args.payload && typeof args.payload === 'object') return args.payload;
|
|
977
|
+
if (args.payload) return JSON.parse(String(args.payload));
|
|
978
|
+
return { action: requiredString(args.action, 'action') };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function captureQuery(args = {}) {
|
|
982
|
+
return {
|
|
983
|
+
sinceId: args.sinceId,
|
|
984
|
+
sinceMs: args.sinceMs,
|
|
985
|
+
limit: args.limit,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function withQuery(endpointPath, query) {
|
|
990
|
+
const entries = Object.entries(query || {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
|
991
|
+
if (entries.length === 0) return endpointPath;
|
|
992
|
+
const params = new URLSearchParams();
|
|
993
|
+
for (const [key, value] of entries) params.set(key, String(value));
|
|
994
|
+
return `${endpointPath}?${params.toString()}`;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function iosSetupSuggestion(device, runtime, wda) {
|
|
998
|
+
if (!device) return 'Connect one iPhone, trust this Mac on the device, then rerun ios-doctor.';
|
|
999
|
+
if (device.developerModeStatus !== 'enabled') return 'Enable Developer Mode on the iPhone and rerun ios-setup.';
|
|
1000
|
+
if (device.ddiServicesAvailable === false) return 'Unlock/trust the iPhone and let Xcode finish preparing developer services.';
|
|
1001
|
+
if (wda?.ok !== true) return 'Start or build WebDriverAgentRunner, then pass --wda-url if it is not on http://127.0.0.1:8100.';
|
|
1002
|
+
if (runtime?.ok !== true) return 'Launch a debug app that includes AiAppBridgeIOS, then pass bundleId/iosHost/iosPort if needed.';
|
|
1003
|
+
return 'Rerun ios-setup after resolving the failing check.';
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function iosErrorCode(command, error) {
|
|
1007
|
+
const stderr = String(error?.stderr || '');
|
|
1008
|
+
const stdout = String(error?.stdout || '');
|
|
1009
|
+
const message = String(error?.message || error || '');
|
|
1010
|
+
const text = [stderr, stdout, message]
|
|
1011
|
+
.filter(Boolean)
|
|
1012
|
+
.join('\n')
|
|
1013
|
+
.replace(/\s--timeout\s+\S+/g, '');
|
|
1014
|
+
if (/No code signature found|integrity could not be verified|ApplicationVerificationFailed/i.test(text)) {
|
|
1015
|
+
return 'ios_code_signing_required';
|
|
1016
|
+
}
|
|
1017
|
+
if (/profile has not been explicitly trusted|not been explicitly trusted by the user|inadequate entitlements|invalid code signature/i.test(text)) {
|
|
1018
|
+
return 'ios_developer_profile_trust_required';
|
|
1019
|
+
}
|
|
1020
|
+
if (/requested application .* is not installed|not installed|valid bundle identifier/i.test(text)) {
|
|
1021
|
+
return 'ios_app_not_installed';
|
|
1022
|
+
}
|
|
1023
|
+
if (/provisioning|development team|DEVELOPMENT_TEAM|Signing/i.test(text)) {
|
|
1024
|
+
return 'ios_provisioning_required';
|
|
1025
|
+
}
|
|
1026
|
+
if (/timed out|timeout/i.test(text)) {
|
|
1027
|
+
return 'ios_command_timeout';
|
|
1028
|
+
}
|
|
1029
|
+
if (/device.*not.*found|No connected iOS device/i.test(text)) {
|
|
1030
|
+
return 'ios_device_required';
|
|
1031
|
+
}
|
|
1032
|
+
return command ? `${command.replace(/-/g, '_')}_failed` : 'ios_command_failed';
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function firstErrorLine(value) {
|
|
1036
|
+
return String(value || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function truncateText(value, maxChars) {
|
|
1040
|
+
const text = String(value || '');
|
|
1041
|
+
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}...`;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function resolveWdaProjectPath(args = {}) {
|
|
1045
|
+
const explicit = args.wdaProjectPath || process.env.AI_APP_BRIDGE_WDA_PROJECT;
|
|
1046
|
+
if (explicit && fs.existsSync(explicit)) return path.resolve(explicit);
|
|
1047
|
+
const candidates = [
|
|
1048
|
+
path.join(process.cwd(), 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
|
|
1049
|
+
path.join(process.cwd(), 'node_modules', 'appium-xcuitest-driver', 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
|
|
1050
|
+
path.join(__dirname, '..', 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
|
|
1051
|
+
path.join(__dirname, '..', 'node_modules', 'appium-xcuitest-driver', 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
|
|
1052
|
+
];
|
|
1053
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || '';
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function requestJson(method, rawUrl, body, options = {}) {
|
|
1057
|
+
const url = new URL(rawUrl);
|
|
1058
|
+
const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body), 'utf8');
|
|
1059
|
+
return new Promise((resolve, reject) => {
|
|
1060
|
+
const request = http.request({
|
|
1061
|
+
method,
|
|
1062
|
+
protocol: url.protocol,
|
|
1063
|
+
hostname: url.hostname.replace(/^\[|\]$/g, ''),
|
|
1064
|
+
port: url.port,
|
|
1065
|
+
path: `${url.pathname}${url.search}`,
|
|
1066
|
+
timeout: options.timeoutMs || defaultHttpTimeoutMs,
|
|
1067
|
+
headers: {
|
|
1068
|
+
Accept: 'application/json',
|
|
1069
|
+
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}),
|
|
1070
|
+
},
|
|
1071
|
+
}, (response) => {
|
|
1072
|
+
let text = '';
|
|
1073
|
+
response.setEncoding('utf8');
|
|
1074
|
+
response.on('data', (chunk) => { text += chunk; });
|
|
1075
|
+
response.on('end', () => {
|
|
1076
|
+
let parsed = null;
|
|
1077
|
+
try {
|
|
1078
|
+
parsed = text ? JSON.parse(text) : {};
|
|
1079
|
+
} catch (_) {
|
|
1080
|
+
parsed = { raw: text };
|
|
1081
|
+
}
|
|
1082
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
1083
|
+
const error = new Error(`HTTP ${response.statusCode}: ${text.slice(0, 500)}`);
|
|
1084
|
+
error.response = parsed;
|
|
1085
|
+
reject(error);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
resolve(parsed);
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
request.on('timeout', () => {
|
|
1092
|
+
request.destroy(new Error(`HTTP timeout: ${rawUrl}`));
|
|
1093
|
+
});
|
|
1094
|
+
request.on('error', reject);
|
|
1095
|
+
if (payload) request.write(payload);
|
|
1096
|
+
request.end();
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function execFileText(execFileImpl, command, args, options = {}) {
|
|
1101
|
+
return new Promise((resolve, reject) => {
|
|
1102
|
+
execFileImpl(command, args, {
|
|
1103
|
+
encoding: 'utf8',
|
|
1104
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1105
|
+
timeout: options.timeoutMs || 30000,
|
|
1106
|
+
windowsHide: true,
|
|
1107
|
+
}, (error, stdout, stderr) => {
|
|
1108
|
+
if (error) {
|
|
1109
|
+
error.message = `${error.message}${stderr ? `\n${stderr}` : ''}`;
|
|
1110
|
+
reject(error);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
resolve({ stdout, stderr });
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function findFileByName(root, fileName) {
|
|
1119
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1120
|
+
for (const entry of entries) {
|
|
1121
|
+
const candidate = path.join(root, entry.name);
|
|
1122
|
+
if (entry.isFile() && entry.name === fileName) return candidate;
|
|
1123
|
+
if (entry.isDirectory()) {
|
|
1124
|
+
const found = findFileByName(candidate, fileName);
|
|
1125
|
+
if (found) return found;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return '';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function normalizedObjectEnum(value) {
|
|
1132
|
+
if (!value) return '';
|
|
1133
|
+
if (typeof value === 'string') return value;
|
|
1134
|
+
if (typeof value === 'object') return Object.keys(value)[0] || '';
|
|
1135
|
+
return String(value);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function requiredString(value, name) {
|
|
1139
|
+
if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} is required`);
|
|
1140
|
+
return value;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function requiredNumber(value, name) {
|
|
1144
|
+
const number = Number(value);
|
|
1145
|
+
if (!Number.isFinite(number)) throw new Error(`${name} is required`);
|
|
1146
|
+
return number;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function stringArg(value, fallback) {
|
|
1150
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
1151
|
+
return String(value);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function numberArg(value, fallback) {
|
|
1155
|
+
const number = Number(value);
|
|
1156
|
+
return Number.isFinite(number) ? number : fallback;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function booleanArg(value) {
|
|
1160
|
+
return value === true || value === 'true' || value === '1' || value === 1;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function sleep(ms) {
|
|
1164
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function defaultArtifactPath(prefix, extension, artifactDir) {
|
|
1168
|
+
const dir = artifactDir ? path.resolve(artifactDir) : path.join(process.cwd(), 'build', 'ai_app_bridge_artifacts');
|
|
1169
|
+
return path.join(dir, `${prefix}-${artifactTimestamp()}.${extension}`);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function artifactTimestamp(date = new Date()) {
|
|
1173
|
+
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
module.exports = {
|
|
1177
|
+
IOSBridgeProvider,
|
|
1178
|
+
formatHostForUrl,
|
|
1179
|
+
parseDevicectlDevices,
|
|
1180
|
+
selectDeviceFromList,
|
|
1181
|
+
shapeDevice,
|
|
1182
|
+
wdaSessionIdFromResponse,
|
|
1183
|
+
};
|