@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2

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.
Files changed (156) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +306 -53
  4. package/bin/ai-app-bridge.js +51 -4504
  5. package/bin/android-permissions.js +152 -0
  6. package/bin/android-uia-xml.js +74 -0
  7. package/bin/artifact-paths.js +127 -1
  8. package/bin/bridge-forward.js +56 -0
  9. package/bin/command-discovery.js +86 -0
  10. package/bin/command-errors.js +60 -0
  11. package/bin/command-registry.js +504 -0
  12. package/bin/command-request.js +14 -0
  13. package/bin/command-router.js +80 -0
  14. package/bin/connection-cache.js +119 -0
  15. package/bin/device-provider.js +2934 -0
  16. package/bin/execution-host.js +596 -0
  17. package/bin/execution-runtime.js +101 -0
  18. package/bin/fact-codec.js +321 -0
  19. package/bin/fact-recorder.js +691 -0
  20. package/bin/fact-store.js +226 -0
  21. package/bin/feedback-probe.js +285 -0
  22. package/bin/intent/install-intent.js +267 -0
  23. package/bin/intent/intent-action-executor.js +129 -0
  24. package/bin/intent/intent-autonomous-adapter.js +46 -0
  25. package/bin/intent/intent-capture-port.js +64 -0
  26. package/bin/intent/intent-entry.js +226 -0
  27. package/bin/intent/intent-errors.js +28 -0
  28. package/bin/intent/intent-evidence-store.js +121 -0
  29. package/bin/intent/intent-lifetime.js +62 -0
  30. package/bin/intent/intent-observation-target.js +33 -0
  31. package/bin/intent/intent-observer.js +194 -0
  32. package/bin/intent/intent-production-adapter.js +334 -0
  33. package/bin/intent/intent-provider.js +27 -0
  34. package/bin/intent/intent-runtime.js +63 -0
  35. package/bin/intent/intent-worker.js +432 -0
  36. package/bin/intent/ios-intent-adapter.js +90 -0
  37. package/bin/intent/permission-intent.js +239 -0
  38. package/bin/intent/web-intent-adapter.js +31 -0
  39. package/bin/ios-device-outcome.js +47 -0
  40. package/bin/ios-execution.js +108 -0
  41. package/bin/ios-provider.js +497 -583
  42. package/bin/ios-runtime-binding.js +54 -0
  43. package/bin/ios-wda-execution.js +70 -0
  44. package/bin/ios-wda-port.js +98 -0
  45. package/bin/ios-wda-project.js +79 -0
  46. package/bin/mcp-server.js +83 -1040
  47. package/bin/mmap-scan-index.js +329 -0
  48. package/bin/observation-collector.js +862 -0
  49. package/bin/runtime-client.js +154 -0
  50. package/bin/runtime-directory.js +107 -0
  51. package/bin/runtime-protocol.js +36 -0
  52. package/bin/script/bounded-script-registry.js +103 -0
  53. package/bin/script/node-runtime-adapter.js +233 -0
  54. package/bin/script/progress-projector.js +63 -0
  55. package/bin/script/python-runtime-adapter.js +111 -0
  56. package/bin/script/rolling-summary.js +134 -0
  57. package/bin/script/script-agent-port.js +40 -0
  58. package/bin/script/script-assert.js +95 -0
  59. package/bin/script/script-capture-port.js +76 -0
  60. package/bin/script/script-catalog.js +85 -0
  61. package/bin/script/script-durable-restore.js +195 -0
  62. package/bin/script/script-entry-code.js +26 -0
  63. package/bin/script/script-entry-route.js +38 -0
  64. package/bin/script/script-entry.js +3 -0
  65. package/bin/script/script-errors.js +29 -0
  66. package/bin/script/script-evidence-store.js +22 -0
  67. package/bin/script/script-format-removed.js +26 -0
  68. package/bin/script/script-host-port.js +397 -0
  69. package/bin/script/script-ledger.js +64 -0
  70. package/bin/script/script-result.js +57 -0
  71. package/bin/script/script-sdk.js +152 -0
  72. package/bin/script/script-sdk.py +153 -0
  73. package/bin/script/script-session-channel.js +127 -0
  74. package/bin/script/script-spec.js +86 -0
  75. package/bin/script/script-supervisor.js +919 -0
  76. package/bin/script/templates/checkpoint-reentry.js +13 -0
  77. package/bin/segment-index.js +481 -0
  78. package/bin/segmented-fact-store.js +1571 -0
  79. package/bin/shared-kernel/android-h5-target.js +10 -0
  80. package/bin/shared-kernel/android-install-execution.js +176 -0
  81. package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
  82. package/bin/shared-kernel/android-shell-execution.js +195 -0
  83. package/bin/shared-kernel/argument-schema.js +117 -0
  84. package/bin/shared-kernel/canonical-path.js +17 -0
  85. package/bin/shared-kernel/device-acknowledgements.js +53 -0
  86. package/bin/shared-kernel/device-completion-history.js +52 -0
  87. package/bin/shared-kernel/device-mutation-lease.js +219 -0
  88. package/bin/shared-kernel/device-ownership-recovery.js +95 -0
  89. package/bin/shared-kernel/device-ownership-store.js +94 -0
  90. package/bin/shared-kernel/evidence-adapters.js +251 -0
  91. package/bin/shared-kernel/evidence-archive.js +329 -0
  92. package/bin/shared-kernel/evidence-recording.js +131 -0
  93. package/bin/shared-kernel/evidence-schema.js +194 -0
  94. package/bin/shared-kernel/evidence-store.js +190 -0
  95. package/bin/shared-kernel/execution-admission.js +22 -0
  96. package/bin/shared-kernel/execution-contracts.js +171 -0
  97. package/bin/shared-kernel/execution-io.js +106 -0
  98. package/bin/shared-kernel/execution-ledger.js +125 -0
  99. package/bin/shared-kernel/execution-scope.js +87 -0
  100. package/bin/shared-kernel/execution-target.js +115 -0
  101. package/bin/shared-kernel/flutter-execution.js +13 -0
  102. package/bin/shared-kernel/flutter-h5-port.js +60 -0
  103. package/bin/shared-kernel/flutter-h5-target.js +9 -0
  104. package/bin/shared-kernel/flutter-target.js +75 -0
  105. package/bin/shared-kernel/h5-execution.js +11 -0
  106. package/bin/shared-kernel/h5-target.js +31 -0
  107. package/bin/shared-kernel/host-fact-store.js +49 -0
  108. package/bin/shared-kernel/ios-h5-target.js +9 -0
  109. package/bin/shared-kernel/ios-native-target.js +71 -0
  110. package/bin/shared-kernel/live-capture-query.js +115 -0
  111. package/bin/shared-kernel/managed-sdk-execution.js +78 -0
  112. package/bin/shared-kernel/native-execution.js +13 -0
  113. package/bin/shared-kernel/native-target.js +156 -0
  114. package/bin/shared-kernel/provider-command-contracts.js +55 -0
  115. package/bin/shared-kernel/recorded-payload-archive.js +195 -0
  116. package/bin/shared-kernel/request-context.js +35 -0
  117. package/bin/shared-kernel/semantic-node.js +55 -0
  118. package/bin/shared-kernel/summary-transformer.js +352 -0
  119. package/bin/shared-kernel/target-lease-protocol.js +47 -0
  120. package/bin/shared-kernel/text-wait.js +111 -0
  121. package/bin/shared-kernel/uia-execution.js +96 -0
  122. package/bin/shared-kernel/uia-protocol.js +214 -0
  123. package/bin/shared-kernel/uia-runtime-port.js +377 -0
  124. package/bin/shared-kernel/uia-target.js +39 -0
  125. package/bin/shared-kernel/web-dom-target.js +44 -0
  126. package/bin/shared-kernel/xml-attributes.js +25 -0
  127. package/bin/target-execution.js +275 -0
  128. package/bin/web/command-schema.js +60 -0
  129. package/bin/web/session-store.js +157 -0
  130. package/bin/web-provider.js +334 -553
  131. package/docs/COMMAND_CONTRACT.md +1563 -0
  132. package/docs/EVIDENCE_ARCHIVE.md +214 -0
  133. package/docs/INTENT_FOREGROUND.md +71 -0
  134. package/docs/INTENT_NATIVE_EDITING.md +79 -0
  135. package/docs/RELEASE.md +59 -0
  136. package/docs/SCRIPT_AUTHORING.md +489 -0
  137. package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
  138. package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
  139. package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
  140. package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
  141. package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
  142. package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
  143. package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
  144. package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
  145. package/package.json +60 -5
  146. package/runtime/ios-wda/AABWDABinding.h +19 -0
  147. package/runtime/ios-wda/AABWDABinding.m +97 -0
  148. package/runtime/ios-wda/AABWDAExecution.h +26 -0
  149. package/runtime/ios-wda/AABWDAExecution.m +172 -0
  150. package/runtime/ios-wda/AABWDAIntegration.h +71 -0
  151. package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
  152. package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
  153. package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
  154. package/runtime/uia/ai-app-bridge-uia.jar +0 -0
  155. package/runtime/uia/manifest.json +22 -0
  156. package/skills/ai-app-bridge-use/SKILL.md +23 -360
@@ -1,26 +1,86 @@
1
1
  const { execFile, spawn } = require('child_process');
2
2
  const fs = require('fs');
3
- const http = require('http');
4
3
  const os = require('os');
5
4
  const path = require('path');
6
5
  const { URL } = require('url');
7
- const { defaultArtifactPath } = require('./artifact-paths');
6
+ const { createHash } = require('node:crypto');
7
+ const { isDeepStrictEqual } = require('node:util');
8
+ const { defaultArtifactPath, pruneGeneratedArtifacts } = require('./artifact-paths');
9
+ const { execFileBounded, httpRequestBounded } = require('./shared-kernel/execution-io');
10
+ const { runExecution, checkExecution, currentExecution, executionSleep, markExecutionDispatched } = require('./shared-kernel/execution-scope');
11
+ const { getProcessDeviceMutationLease, runDeviceEffect } = require('./shared-kernel/device-mutation-lease');
12
+ const { iosDeviceKey, executeIOSAction, lookupCompletion, reconcileIOS } = require('./ios-execution');
13
+ const { isMutationCommand, executionTimeoutMs } = require('./command-registry');
14
+ const { normalizeExecutionTarget } = require('./shared-kernel/execution-target');
15
+ const { descriptorBinding, bindingHeaders, assertRuntimeResponse, bindingFailure } = require('./ios-runtime-binding');
16
+ const { openWdaPort, target: wdaTarget } = require('./ios-wda-port');
17
+ const { prepareWdaProject, wdaBuildEnvironment } = require('./ios-wda-project');
18
+ const { executeWDAAction, reconcileWDA, completionPort } = require('./ios-wda-execution');
19
+ const { deviceCommandRejection } = require('./ios-device-outcome');
20
+ const { bindFlutterAction } = require('./shared-kernel/flutter-target');
21
+ const nativeTarget = require('./shared-kernel/ios-native-target');
22
+ const h5Target = require('./shared-kernel/ios-h5-target');
23
+ const h5Controls = new Map([['ios-h5-click','click'], ['ios-h5-input','input'], ['ios-h5-scroll','scroll']]);
24
+
25
+ const nativeControls = new Map([['ios-tap-native', 'native-tap'], ['ios-input-native-text', 'native-input']]);
26
+
27
+ const flutterControls = new Map([
28
+ ['ios-tap-flutter', 'tapTarget'], ['ios-input-flutter-text', 'inputText'],
29
+ ['ios-scroll-flutter', 'scrollBy'], ['ios-flutter-back', 'back'],
30
+ ['ios-flutter-hide-keyboard', 'hideKeyboard'],
31
+ ]);
8
32
 
9
- const defaultRuntimePort = 18080;
10
- const runtimePortSearchCount = 50;
11
33
  const defaultHttpTimeoutMs = 5000;
12
- const defaultProbeTimeoutMs = 700;
13
34
  const defaultDeviceTimeoutSec = 30;
14
- const defaultWdaBundleId = 'io.github.mobileaidev.aiappbridge.wda';
35
+ const defaultWdaTestBundleId = 'io.github.mobileaidev.aiappbridge.wda';
15
36
 
16
37
  class IOSBridgeProvider {
17
38
  constructor(options = {}) {
18
39
  this.execFile = options.execFile || execFile;
19
40
  this.httpRequest = options.httpRequest || requestJson;
41
+ this.lease = options.lease;
20
42
  }
21
43
 
22
44
  async run(command, args = {}) {
23
45
  try {
46
+ return await runExecution({ timeoutMs: executionTimeoutMs(command, args) ?? 30000,
47
+ mutation: isMutationCommand(command, args) }, async () => {
48
+ if (!isMutationCommand(command, args)) return this.dispatch(command, args);
49
+ const device = await this.requireDevice(args);
50
+ const key = iosDeviceKey(device);
51
+ const lease = this.lease || getProcessDeviceMutationLease();
52
+ return lease.run(key, async () => {
53
+ if (command === 'ios-h5-eval' || h5Controls.has(command) || command === 'ios-flutter-action' || flutterControls.has(command) || nativeControls.has(command)
54
+ || ['ios-wda-session', 'ios-tap', 'ios-input', 'ios-swipe', 'ios-set-orientation'].includes(command)) return this.dispatch(command, args, { device });
55
+ return runDeviceEffect({ kind: 'ios-command', command, target: { deviceId: device.udid, bundleId: args.bundleId ?? null } }, async () => {
56
+ let result;
57
+ try { result = await this.dispatch(command, args, { device }); }
58
+ catch (error) {
59
+ if (!error.deviceOutcome) throw error;
60
+ result = { ...error.deviceOutcome, command };
61
+ }
62
+ return currentExecution()?.dispatched ? result : { ...result, dispatched: false, ambiguous: false };
63
+ });
64
+ });
65
+ });
66
+ } catch (error) {
67
+ return {
68
+ ok: false,
69
+ error: iosErrorCode(command, error),
70
+ command,
71
+ message: firstErrorLine(error.message || String(error)),
72
+ details: truncateText(error.message || String(error), 4000),
73
+ ...(error.field ? { field: error.field } : {}),
74
+ dispatched: typeof error.dispatched === 'boolean' ? error.dispatched : null,
75
+ ambiguous: error.ambiguous !== false,
76
+ ...(error.devicectlReply ? { deviceOutcome: error.devicectlReply } : {}),
77
+ };
78
+ }
79
+ }
80
+
81
+ async dispatch(command, args, context = {}) {
82
+ if (flutterControls.has(command)) return this.flutterControl(command, args, context);
83
+ if (nativeControls.has(command)) return this.nativeControl(command, args, context);
24
84
  switch (command) {
25
85
  case 'ios-devices':
26
86
  return await this.devices(args);
@@ -45,19 +105,25 @@ class IOSBridgeProvider {
45
105
  case 'ios-events':
46
106
  return await this.runtimeGet(args, withQuery('/v1/events', captureQuery(args)));
47
107
  case 'ios-h5-dom':
48
- return await this.runtimeGet(args, '/v1/h5/dom');
108
+ return await this.runtimeGet(args, withQuery('/v1/h5/dom', args.webViewId === undefined ? {} : { webViewId: args.webViewId }));
49
109
  case 'ios-h5-eval':
50
- return await this.runtimePost(args, '/v1/h5/eval', { script: requiredString(args.script, 'script') });
110
+ return await this.runtimePost(args, '/v1/h5/action', { payload: { action: 'eval', pageRef: args.expectedPage, script: requiredString(args.script, 'script') } }, context);
111
+ case 'ios-h5-click': case 'ios-h5-input': case 'ios-h5-scroll':
112
+ return await this.h5Control(command, args, context);
51
113
  case 'ios-flutter-tree':
52
114
  return await this.flutterTree(args);
53
115
  case 'ios-flutter-nodes':
54
116
  return await this.flutterNodes(args);
55
117
  case 'ios-flutter-action':
56
- return await this.runtimePost(args, '/v1/flutter/action', parsePayload(args));
118
+ return await this.runtimePost(args, '/v1/flutter/action', parsePayload(args), context);
119
+ case 'ios-execution':
120
+ return await this.executionControl(args);
57
121
  case 'ios-screenshot':
58
122
  return await this.screenshot(args);
59
123
  case 'ios-wda-status':
60
124
  return await this.wdaStatus(args);
125
+ case 'ios-wda-session':
126
+ return await this.wdaSession(args, context);
61
127
  case 'ios-uia-tree':
62
128
  return await this.wdaSource(args);
63
129
  case 'ios-tap':
@@ -66,18 +132,11 @@ class IOSBridgeProvider {
66
132
  return await this.wdaInput(args);
67
133
  case 'ios-swipe':
68
134
  return await this.wdaSwipe(args);
135
+ case 'ios-set-orientation':
136
+ return await this.wdaSetOrientation(args, context);
69
137
  default:
70
138
  return { ok: false, error: 'unknown_ios_command', command };
71
139
  }
72
- } catch (error) {
73
- return {
74
- ok: false,
75
- error: iosErrorCode(command, error),
76
- command,
77
- message: firstErrorLine(error.message || String(error)),
78
- details: truncateText(error.message || String(error), 4000),
79
- };
80
- }
81
140
  }
82
141
 
83
142
  context(args = {}) {
@@ -85,7 +144,7 @@ class IOSBridgeProvider {
85
144
  devicectl: stringArg(args.devicectl, process.env.AI_APP_BRIDGE_DEVICECTL || 'xcrun'),
86
145
  xcodebuild: stringArg(args.xcodebuild, process.env.XCODEBUILD || 'xcodebuild'),
87
146
  deviceTimeoutSec: numberArg(args.deviceTimeoutSec, defaultDeviceTimeoutSec),
88
- httpTimeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
147
+ httpTimeoutMs: numberArg(args.timeoutMs, defaultHttpTimeoutMs),
89
148
  };
90
149
  }
91
150
 
@@ -136,15 +195,16 @@ class IOSBridgeProvider {
136
195
  }
137
196
  checks.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
138
197
 
139
- let wda = { ok: false, error: 'wda_url_required' };
140
- if (args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL) {
198
+ let wda = { ok: false, error: 'ios_wda_target_required' };
199
+ if (args.wdaRunnerBundleId) {
141
200
  wda = await this.wdaStatus(args);
142
201
  }
143
202
  checks.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
144
203
 
145
204
  const developerModeReady = selected ? selected.developerModeStatus === 'enabled' : false;
146
- const ddiReady = selected ? selected.ddiServicesAvailable !== false : false;
147
- const ready = Boolean(xcode.ok && selected && developerModeReady && ddiReady && wda.ok === true);
205
+ const ddiReady = selected?.ddiServicesAvailable === true;
206
+ const deviceConnected = selected?.tunnelState === 'connected';
207
+ const ready = Boolean(xcode.ok && deviceConnected && developerModeReady && ddiReady && runtime.ok === true && wda.ok === true);
148
208
  return {
149
209
  ok: true,
150
210
  ready,
@@ -154,7 +214,7 @@ class IOSBridgeProvider {
154
214
  selectedDevice: selected,
155
215
  requirements: {
156
216
  xcode: xcode.ok,
157
- deviceConnected: Boolean(selected),
217
+ deviceConnected,
158
218
  developerModeEnabled: developerModeReady,
159
219
  ddiServicesAvailable: ddiReady,
160
220
  runtimeReachable: runtime.ok === true,
@@ -206,23 +266,14 @@ class IOSBridgeProvider {
206
266
  steps.push({ name: 'install-app', ...install });
207
267
  if (install.ok === false) return { ok: false, error: 'ios_app_install_failed', device, steps };
208
268
  }
209
- if (args.bundleId) {
210
- const launch = await this.launchApp({ ...args, deviceId: device.identifier || device.udid });
211
- steps.push({ name: 'launch-app', ...launch });
212
- if (launch.ok === false) return { ok: false, error: 'ios_app_launch_failed', device, steps };
213
- }
214
-
215
- const runtime = args.bundleId || args.runtimeUrl || args.iosHost || args.host
216
- ? await this.runtimeGet(args, '/v1/status', { allowUnavailable: true, device })
217
- : { ok: false, error: 'bundle_id_or_runtime_endpoint_required' };
218
- steps.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
219
-
220
269
  let wda = await this.wdaStatus(args);
221
270
  if (wda.ok !== true && booleanArg(args.startWda)) {
222
271
  const start = await this.startWda({ ...args, deviceId: device.identifier || device.udid });
223
272
  steps.push({ name: 'start-wda', ...start });
224
273
  if (start.ok === true) {
225
274
  wda = start.status || await this.wdaStatus(args);
275
+ } else {
276
+ return { ok: false, error: start.error, message: start.message, device, steps };
226
277
  }
227
278
  }
228
279
  steps.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
@@ -233,16 +284,27 @@ class IOSBridgeProvider {
233
284
  error: 'ios_wda_required',
234
285
  device,
235
286
  steps,
236
- 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.',
287
+ message: 'Full iOS control requires the prepared, signed WDA Runner. Pass --wda-runner-bundle-id for an existing bound Runner, or --team-id and --start-wda to build and start it. An optional --wda-url must match the selected Runner container identity.',
237
288
  };
238
289
  }
290
+ // Starting the XCTest Runner changes the foreground App. Launch the target
291
+ // afterwards and verify a fresh SDK response, not its pre-WDA snapshot.
292
+ if (args.bundleId) {
293
+ const launch = await this.launchApp({ ...args, deviceId: device.identifier || device.udid });
294
+ steps.push({ name: 'launch-app', ...launch });
295
+ if (launch.ok === false) return { ok: false, error: 'ios_app_launch_failed', device, steps };
296
+ }
297
+ const runtime = args.bundleId || args.runtimeUrl || args.iosHost || args.host
298
+ ? await this.runtimeGet(args, '/v1/status', { allowUnavailable: true, device })
299
+ : { ok: false, error: 'bundle_id_or_runtime_endpoint_required' };
300
+ steps.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
239
301
  if (runtime.ok !== true) {
240
302
  return {
241
303
  ok: false,
242
304
  error: 'ios_runtime_required',
243
305
  device,
244
306
  steps,
245
- 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.',
307
+ message: 'The bound WDA Runner is reachable, but the App SDK is not. Launch a debug App with AiAppBridgeIOS and supply its exact deviceId and bundleId; runtimeUrl or iosHost/iosPort may specify an explicitly forwarded endpoint.',
246
308
  };
247
309
  }
248
310
 
@@ -253,6 +315,7 @@ class IOSBridgeProvider {
253
315
  device,
254
316
  runtimeEndpoint: runtime.endpoint,
255
317
  wdaUrl: wda.url,
318
+ wdaRunnerBundleId: wda.runtimeBinding.bundleId,
256
319
  steps,
257
320
  };
258
321
  }
@@ -269,7 +332,7 @@ class IOSBridgeProvider {
269
332
  '--device',
270
333
  device.identifier || device.udid,
271
334
  resolvedPath,
272
- ]);
335
+ ], { mutation: true });
273
336
  return {
274
337
  ok: true,
275
338
  device,
@@ -291,7 +354,7 @@ class IOSBridgeProvider {
291
354
  ];
292
355
  if (args.terminateExisting !== false) launchArgs.push('--terminate-existing');
293
356
  launchArgs.push(bundleId);
294
- const raw = await this.devicectlJson(ctx, launchArgs);
357
+ const raw = await this.devicectlJson(ctx, launchArgs, { mutation: true });
295
358
  return {
296
359
  ok: true,
297
360
  device,
@@ -303,6 +366,7 @@ class IOSBridgeProvider {
303
366
  async screenshot(args = {}) {
304
367
  const ctx = this.context(args);
305
368
  const device = await this.requireDevice(args);
369
+ const generatedDefault = !args.outFile;
306
370
  const outFile = args.outFile
307
371
  ? path.resolve(args.outFile)
308
372
  : defaultArtifactPath('ios-screenshot', 'png', { artifactDir: args.artifactDir });
@@ -318,47 +382,123 @@ class IOSBridgeProvider {
318
382
  ];
319
383
  if (args.displayUniqueId) command.push('--display-unique-id', String(args.displayUniqueId));
320
384
  const raw = await this.devicectlJson(ctx, command);
385
+ const artifact = {
386
+ path: outFile,
387
+ generatedDefault,
388
+ directory: path.dirname(outFile),
389
+ sha256: createHash('sha256').update(await fs.promises.readFile(outFile)).digest('hex'),
390
+ };
391
+ if (generatedDefault) {
392
+ artifact.retention = await pruneGeneratedArtifacts({
393
+ directory: artifact.directory,
394
+ prefix: 'ios-screenshot',
395
+ extension: 'png',
396
+ currentPath: outFile,
397
+ });
398
+ }
321
399
  return {
322
400
  ok: true,
323
401
  device,
324
402
  outFile,
403
+ artifact,
325
404
  result: raw.result || raw,
326
405
  };
327
406
  }
328
407
 
329
408
  async runtimeGet(args, endpointPath, options = {}) {
330
- const endpoint = await this.resolveRuntimeEndpoint(args, options);
331
- if (!endpoint.ok) return endpoint;
332
409
  try {
333
- const response = await this.httpRequest('GET', `${endpoint.baseUrl}${endpointPath}`, null, {
334
- timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
335
- });
336
- return {
337
- ...(response && typeof response === 'object' ? response : { value: response }),
338
- ok: response?.ok === false ? false : true,
339
- endpoint: endpoint.baseUrl,
340
- device: endpoint.device || null,
341
- };
410
+ return await this.runtimeRequest('GET', args, endpointPath, null, options);
342
411
  } catch (error) {
343
- if (options.allowUnavailable) {
344
- return { ok: false, error: 'ios_runtime_unreachable', endpoint: endpoint.baseUrl, message: error.message };
345
- }
346
- throw error;
412
+ if (!options.allowUnavailable) throw error;
413
+ return { ok: false, error: iosErrorCode(null, error), message: error.message,
414
+ dispatched: error.dispatched ?? false, ambiguous: error.ambiguous === true };
347
415
  }
348
416
  }
349
417
 
350
- async runtimePost(args, endpointPath, body) {
351
- const endpoint = await this.resolveRuntimeEndpoint(args);
352
- if (!endpoint.ok) return endpoint;
353
- const response = await this.httpRequest('POST', `${endpoint.baseUrl}${endpointPath}`, body, {
354
- timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
418
+ async runtimePost(args, endpointPath, body, options = {}) {
419
+ return this.runtimeRequest('POST', args, endpointPath, body, options);
420
+ }
421
+
422
+ async runtimePort(args, options = {}) {
423
+ const endpoint = await this.resolveRuntimeEndpoint(args, options);
424
+ const httpOptions = { timeoutMs: this.context(args).httpTimeoutMs, headers: bindingHeaders(endpoint.runtimeBinding) };
425
+ const request = async (method, endpointPath, payload) => {
426
+ let response;
427
+ try {
428
+ response = await this.httpRequest(method, `${endpoint.baseUrl}${endpointPath}`, payload, httpOptions);
429
+ } catch (error) {
430
+ if (!error.response) throw error;
431
+ assertRuntimeResponse(error.response, endpoint.runtimeBinding);
432
+ if (error.response.ok !== false || typeof error.response.error !== 'string' || !/^[a-z][a-z0-9_]*$/.test(error.response.error)) {
433
+ throw bindingFailure('invalid_ios_runtime_response', 'The bound runtime HTTP error has no structured rejection reason.');
434
+ }
435
+ // A validated SDK rejection is still a protocol response. Preserve its
436
+ // candidates, receipt and dispatch status instead of replacing it with
437
+ // an exception containing only the error string.
438
+ return error.response;
439
+ }
440
+ assertRuntimeResponse(response, endpoint.runtimeBinding);
441
+ return response;
442
+ };
443
+ return { endpoint, get: endpointPath => request('GET', endpointPath, null), post: (endpointPath, payload) => request('POST', endpointPath, payload) };
444
+ }
445
+
446
+ async runtimeRequest(method, args, endpointPath, body, options = {}) {
447
+ return runExecution({ timeoutMs: args.timeoutMs ?? 30000, mutation: method === 'POST' }, async () => {
448
+ const port = await this.runtimePort(args, options);
449
+ const endpoint = port.endpoint;
450
+ let response;
451
+ if (method === 'POST') {
452
+ const status = await port.get('/v1/status');
453
+ if (status.ok === false) return { ...status, dispatched: false, ambiguous: false };
454
+ const kind = endpointPath === '/v1/h5/action' ? 'h5' : 'flutter';
455
+ const target = { platform: 'ios', deviceId: endpoint.device.udid, bundleId: args.bundleId,
456
+ ...Object.fromEntries(['runtimeUrl', 'iosHost', 'iosPort', 'devicectl'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) };
457
+ response = await executeIOSAction({ port, kind, payload: body, status, target, timeoutMs: args.timeoutMs ?? 30000,
458
+ actionId: args.runtimeActionId ?? args.requestId });
459
+ } else { response = await port.get(endpointPath); checkExecution(); }
460
+ return { ...response, endpoint: endpoint.baseUrl, device: endpoint.device, runtimeBinding: endpoint.runtimeBinding };
355
461
  });
356
- return {
357
- ...(response && typeof response === 'object' ? response : { value: response }),
358
- ok: response?.ok === false ? false : true,
359
- endpoint: endpoint.baseUrl,
360
- device: endpoint.device || null,
462
+ }
463
+
464
+ async executionControl(args) {
465
+ const device = await this.requireDevice(args);
466
+ const lease = this.lease || getProcessDeviceMutationLease();
467
+ const key = iosDeviceKey(device);
468
+ if (args.kind === 'wda') {
469
+ if (args.operation === 'reconcile') return reconcileWDA({ lease, device, args,
470
+ createPort: target => openWdaPort(this, target, device) });
471
+ const port = await openWdaPort(this, args, device);
472
+ if (args.operation === 'status') return { ok: true, device, ownership: lease.status(key), runtime: port.status,
473
+ runtimeBinding: port.runtimeBinding };
474
+ const identity = { actionId: args.actionId, runtimeEpoch: args.runtimeEpoch };
475
+ const cancelled = args.operation === 'cancel' ? await port.request('POST', '/aab/execution/cancel', identity) : undefined;
476
+ if (cancelled && cancelled.ok !== true) return cancelled;
477
+ return lookupCompletion(completionPort(port), 'wda', identity, cancelled);
478
+ }
479
+ if (args.operation === 'reconcile') return reconcileIOS({ lease, device, args,
480
+ createPort: target => this.runtimePort(target, { device }) });
481
+ if (args.operation === 'status') return {
482
+ ok: true, device, ownership: lease.status(key),
483
+ runtime: await this.runtimeGet(args, '/v1/execution/status', { device, allowUnavailable: true }),
361
484
  };
485
+ const port = await this.runtimePort(args, { device });
486
+ const identity = { actionId: args.actionId, runtimeEpoch: args.runtimeEpoch };
487
+ if (args.operation === 'cancel') {
488
+ const response = await port.post(`/v1/${args.kind}/cancel`, identity);
489
+ if (response.ok !== true) return response;
490
+ return lookupCompletion(port, args.kind, identity, response);
491
+ }
492
+ return lookupCompletion(port, args.kind, identity);
493
+ }
494
+
495
+ async h5Control(command, args, context) {
496
+ const tree = await this.runtimeGet(args, withQuery('/v1/h5/dom', args.webViewId === undefined ? {} : { webViewId: args.webViewId }), context);
497
+ if (tree.ok !== true) return tree;
498
+ const selected = h5Target.selectH5Node(tree, args.selector, args.expectedTarget);
499
+ if (!selected.ok) return selected;
500
+ return this.runtimePost(args, '/v1/h5/action', { payload: { action: h5Controls.get(command),
501
+ ...selected.targetRef, ...(command === 'ios-h5-input' ? { text: args.text } : {}) } }, context);
362
502
  }
363
503
 
364
504
  async flutterTree(args = {}) {
@@ -367,6 +507,7 @@ class IOSBridgeProvider {
367
507
  return {
368
508
  ok: true,
369
509
  endpoint: status.endpoint,
510
+ runtimeBinding: status.runtimeBinding,
370
511
  device: status.device || null,
371
512
  flutter: status.flutter || null,
372
513
  layout: status.flutter?.layout || null,
@@ -378,8 +519,10 @@ class IOSBridgeProvider {
378
519
  if (tree.ok === false) return tree;
379
520
  const operable = tree.layout?.operable || null;
380
521
  return {
522
+ ...operable,
381
523
  ok: Boolean(operable),
382
524
  endpoint: tree.endpoint,
525
+ runtimeBinding: tree.runtimeBinding,
383
526
  device: tree.device || null,
384
527
  operable,
385
528
  nodes: operable?.nodes || [],
@@ -388,406 +531,261 @@ class IOSBridgeProvider {
388
531
  };
389
532
  }
390
533
 
391
- async resolveRuntimeEndpoint(args = {}, options = {}) {
392
- if (args.runtimeUrl) {
393
- return { ok: true, baseUrl: stripTrailingSlash(String(args.runtimeUrl)), device: options.device || null };
394
- }
395
- const explicitHost = args.iosHost || args.host;
396
- const explicitPort = args.iosPort || args.port;
397
- const device = options.device || await this.optionalDevice(args);
398
- let port = explicitPort ? Number(explicitPort) : null;
399
- let portSource = explicitPort ? 'explicit' : '';
400
- if (!port && args.bundleId && device) {
401
- const copied = await this.readRuntimePortFile(args, device);
402
- if (copied.ok && copied.port) {
403
- port = copied.port;
404
- portSource = 'app-container-port-file';
405
- }
534
+ async flutterControl(command, args, context = {}) {
535
+ const port = await this.runtimePort(args, context);
536
+ const status = await port.get('/v1/status');
537
+ if (status.ok !== true) return { ...status, dispatched: false, ambiguous: false };
538
+ const action = flutterControls.get(command);
539
+ let payload = { action };
540
+ if (!['back', 'hideKeyboard'].includes(action)) {
541
+ const bound = bindFlutterAction(status.flutter?.layout?.operable, {
542
+ action, selector: args.selector,
543
+ ...(action === 'inputText' ? { text: args.text } : {}),
544
+ ...(action === 'scrollBy' ? { delta: args.delta } : {}),
545
+ });
546
+ if (!bound.ok) return bound;
547
+ payload = bound.payload;
406
548
  }
549
+ const endpoint = port.endpoint;
550
+ const target = { platform: 'ios', deviceId: endpoint.device.udid, bundleId: args.bundleId,
551
+ ...Object.fromEntries(['runtimeUrl', 'iosHost', 'iosPort', 'devicectl'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) };
552
+ const result = await executeIOSAction({ port, kind: 'flutter', payload, status, target,
553
+ timeoutMs: args.timeoutMs ?? 30000, actionId: args.runtimeActionId ?? args.requestId });
554
+ return { ...result, endpoint: endpoint.baseUrl, device: endpoint.device, runtimeBinding: endpoint.runtimeBinding };
555
+ }
407
556
 
408
- const hosts = runtimeHostCandidates(explicitHost, device);
409
- if (port && hosts.length) {
410
- return {
411
- ok: true,
412
- baseUrl: `http://${formatHostForUrl(hosts[0])}:${port}`,
413
- device,
414
- port,
415
- portSource,
416
- hostSource: explicitHost ? 'explicit' : 'device',
417
- };
557
+ async resolveRuntimeEndpoint(args = {}, options = {}) {
558
+ const target = normalizeExecutionTarget({ platform: 'ios', deviceId: args.deviceId, bundleId: args.bundleId,
559
+ ...Object.fromEntries(['runtimeUrl', 'iosHost', 'iosPort'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) });
560
+ const device = options.device ?? await this.requireDevice(args);
561
+ if (![device.identifier, device.udid].includes(target.deviceId)) {
562
+ throw bindingFailure('ios_device_not_found', 'deviceId must match the selected devicectl identifier or UDID.');
418
563
  }
419
-
420
- if (hosts.length) {
421
- const probe = await this.probeRuntimeHosts(hosts, args, device);
422
- if (probe.ok) return probe;
564
+ if (device.developerModeStatus !== 'enabled' || device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') {
565
+ throw bindingFailure('ios_tunnel_unavailable', 'The selected iPhone must expose a connected developer tunnel. Unlock/trust the device and let Xcode prepare it.');
423
566
  }
424
-
425
- if (!hosts.length) {
426
- return {
427
- ok: false,
428
- error: 'ios_tunnel_unavailable',
429
- device,
430
- 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.',
431
- };
567
+ const runtimeBinding = await this.readRuntimePortFile(args, device);
568
+ const host = target.iosHost ?? device.tunnelIPAddress;
569
+ if (!target.runtimeUrl && !host) {
570
+ throw bindingFailure('ios_tunnel_unavailable', 'The selected device has no tunnel IP. Supply runtimeUrl or iosHost for an explicitly forwarded endpoint.');
432
571
  }
433
- return {
434
- ok: false,
435
- error: 'ios_runtime_port_unavailable',
436
- device,
437
- 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.',
438
- };
439
- }
440
-
441
- async probeRuntimeHosts(hosts, args, device) {
442
- const timeoutMs = numberArg(args.probeTimeoutMs, defaultProbeTimeoutMs);
443
- const start = Number(args.portStart || defaultRuntimePort);
444
- const count = Number(args.portSearchCount || runtimePortSearchCount);
445
- for (const host of hosts) {
446
- for (let offset = 0; offset < count; offset += 1) {
447
- const port = start + offset;
448
- const baseUrl = `http://${formatHostForUrl(host)}:${port}`;
449
- try {
450
- const status = await this.httpRequest('GET', `${baseUrl}/v1/status`, null, { timeoutMs });
451
- if (status && status.debugBridge) {
452
- return { ok: true, baseUrl, device, port, portSource: 'runtime-probe', hostSource: 'probe' };
453
- }
454
- } catch (_) {
455
- // Probe failures are expected while scanning.
456
- }
457
- }
572
+ const port = target.iosPort ?? runtimeBinding.port;
573
+ const baseUrl = target.runtimeUrl ?? `http://${formatHostForUrl(host)}:${port}`;
574
+ // Validate the constructed URL too; a host cannot smuggle a path or query.
575
+ let url;
576
+ try { url = new URL(baseUrl); }
577
+ catch { throw bindingFailure('invalid_argument', 'The iOS runtime endpoint is not a valid URL.'); }
578
+ if (url.username || url.password || url.search || url.hash || (!target.runtimeUrl && url.pathname !== '/')) {
579
+ throw bindingFailure('invalid_argument', 'iosHost must be a hostname or IP address. runtimeUrl must have no credentials, query or fragment.');
458
580
  }
459
- return { ok: false };
581
+ checkExecution();
582
+ return { ok: true, baseUrl: stripTrailingSlash(url.href), device, runtimeBinding };
460
583
  }
461
584
 
462
585
  async readRuntimePortFile(args, device) {
463
- const ctx = {
464
- ...this.context(args),
465
- deviceTimeoutSec: numberArg(args.portFileTimeoutSec, 5),
466
- };
586
+ return descriptorBinding(await this.readContainerDescriptor(args, device, args.bundleId, 'ai_app_bridge_port.json'), args.bundleId);
587
+ }
588
+
589
+ async readContainerDescriptor(args, device, bundleId, filename) {
590
+ const ctx = { ...this.context(args), deviceTimeoutSec: 5 };
467
591
  const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ai-app-bridge-ios-port-'));
468
592
  try {
469
593
  await this.devicectlJson(ctx, [
470
- 'device',
471
- 'copy',
472
- 'from',
473
- '--device',
474
- device.identifier || device.udid,
475
- '--domain-type',
476
- 'appDataContainer',
477
- '--domain-identifier',
478
- requiredString(args.bundleId, 'bundleId'),
479
- '--source',
480
- 'Documents/ai_app_bridge_port.json',
481
- '--destination',
482
- tempDir,
594
+ 'device', 'copy', 'from', '--device', device.identifier,
595
+ '--domain-type', 'appDataContainer', '--domain-identifier', bundleId,
596
+ '--source', `Documents/${filename}`, '--destination', path.join(tempDir, filename),
483
597
  ]);
484
- const portFile = findFileByName(tempDir, 'ai_app_bridge_port.json');
485
- if (!portFile) return { ok: false, error: 'ios_runtime_port_file_absent' };
486
- const payload = JSON.parse(await fs.promises.readFile(portFile, 'utf8'));
487
- const port = Number(payload.port);
488
- return Number.isFinite(port) ? { ok: true, port, payload } : { ok: false, error: 'invalid_ios_runtime_port_file', payload };
489
- } catch (error) {
490
- return { ok: false, error: 'ios_runtime_port_file_unavailable', message: error.message };
598
+ checkExecution();
599
+ const portFile = path.join(tempDir, filename);
600
+ let data;
601
+ try {
602
+ const handle = await fs.promises.open(portFile, 'r');
603
+ try {
604
+ const buffer = Buffer.alloc(4097);
605
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
606
+ if (bytesRead > 4096) throw bindingFailure('invalid_ios_runtime_descriptor', 'Runtime descriptor exceeds 4096 bytes.');
607
+ data = buffer.subarray(0, bytesRead).toString('utf8');
608
+ } finally { await handle.close(); }
609
+ } catch (error) {
610
+ if (error.code === 'ENOENT') throw bindingFailure('ios_runtime_descriptor_absent', `The selected App container has no ${filename}. Start the matching runtime before querying it.`);
611
+ throw error;
612
+ }
613
+ let descriptor;
614
+ try { descriptor = JSON.parse(data); }
615
+ catch { throw bindingFailure('invalid_ios_runtime_descriptor', 'The selected App runtime descriptor is not valid JSON.'); }
616
+ return descriptor;
491
617
  } finally {
492
618
  await fs.promises.rm(tempDir, { recursive: true, force: true });
493
619
  }
494
620
  }
495
621
 
496
622
  async wdaStatus(args = {}) {
497
- const url = wdaBaseUrl(args);
498
623
  try {
499
- const response = await this.httpRequest('GET', `${url}/status`, null, {
500
- timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
501
- });
502
- return {
503
- ok: true,
504
- url,
505
- value: response?.value || response,
506
- };
624
+ const port = await openWdaPort(this, args);
625
+ return { ok: true, url: port.baseUrl, device: port.device, runtimeBinding: port.runtimeBinding, value: port.status };
507
626
  } catch (error) {
508
- return {
509
- ok: false,
510
- error: 'ios_wda_unreachable',
511
- url,
512
- message: error.message,
513
- };
627
+ return { ok: false, error: iosErrorCode(null, error), message: error.message,
628
+ dispatched: error.dispatched ?? false, ambiguous: error.ambiguous === true };
629
+ }
630
+ }
631
+
632
+ async wdaSession(args, context = {}) {
633
+ const port = await openWdaPort(this, args, context.device);
634
+ if (args.operation === 'status') return { ok: true, runtimeBinding: port.runtimeBinding, ...(await port.session(false)) };
635
+ if (args.operation === 'create') {
636
+ const state = await port.session(false);
637
+ if (state.session !== null) return { ok: false, error: 'ios_wda_session_busy', dispatched: false, ambiguous: false };
638
+ const foreground = wdaTarget(state.foreground);
639
+ if (foreground.bundleId !== args.bundleId) return { ok: false, error: 'ios_wda_target_changed', dispatched: false, ambiguous: false };
640
+ const result = await executeWDAAction({ port, args, operation: 'session-create', selected: foreground });
641
+ if (result.ok !== true) return { ...result, runtimeBinding: port.runtimeBinding };
642
+ const selected = wdaTarget(result.value, true);
643
+ if (selected.bundleId !== foreground.bundleId || selected.processId !== foreground.processId) {
644
+ throw bindingFailure('ios_wda_target_changed', 'The App process changed while the WDA session was being created.');
645
+ }
646
+ return { ...result, session: selected, runtimeBinding: port.runtimeBinding, device: port.device };
514
647
  }
648
+ const selected = await port.session();
649
+ const result = await executeWDAAction({ port, args, operation: 'session-close', selected });
650
+ return { ...result, ...(result.ok ? { closedSession: selected } : {}), runtimeBinding: port.runtimeBinding };
515
651
  }
516
652
 
517
653
  async wdaSource(args = {}) {
518
- const session = await this.ensureWdaSession(args);
519
- if (!session.ok) return session;
520
- const response = await this.wdaRequest(args, 'GET', `/session/${session.sessionId}/source?format=json`);
521
- return { ok: true, session, source: response?.value || response };
654
+ const port = await openWdaPort(this, args);
655
+ return this.nativeObservation(port);
522
656
  }
523
657
 
524
- async wdaTap(args = {}) {
525
- const x = requiredNumber(args.tapX ?? args.x, 'tapX');
526
- const y = requiredNumber(args.tapY ?? args.y, 'tapY');
527
- const session = await this.ensureWdaSession(args);
528
- if (!session.ok) return session;
529
- const body = { x, y };
530
- try {
531
- const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/tap/0`, body);
532
- return { ok: true, transport: 'wda', action: 'tap', session, x, y, response };
533
- } catch (firstError) {
534
- const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/actions`, pointerAction(x, y));
535
- return { ok: true, transport: 'wda-actions', action: 'tap', session, x, y, firstError: firstError.message, response };
536
- }
658
+ async nativeObservation(port) {
659
+ const session = await port.session();
660
+ const source = await port.request('GET', `/session/${encodeURIComponent(session.sessionId)}/source?format=json`, null, session);
661
+ if (!source || typeof source !== 'object' || Array.isArray(source)) throw bindingFailure('invalid_ios_wda_tree', 'WDA must return its JSON UI tree.');
662
+ return { ok: true, session, source, device: port.device, runtimeBinding: port.runtimeBinding,
663
+ nativeTargetSchema: port.status.nativeTargetSchema };
537
664
  }
538
665
 
539
- async wdaInput(args = {}) {
540
- const text = requiredString(args.text || args.value, 'text');
541
- const session = await this.ensureWdaSession(args);
542
- if (!session.ok) return session;
543
- const tapped = args.tapX !== undefined && args.tapY !== undefined
544
- ? await this.wdaTap({ ...args, wdaSessionId: session.sessionId })
545
- : null;
546
- const body = { value: [...text], text };
547
-
548
- const elementTarget = await this.resolveInputElement(args, session, tapped);
549
- if (elementTarget.elementId) {
550
- const elementPath = `/session/${session.sessionId}/element/${encodeURIComponent(elementTarget.elementId)}`;
551
- const clickResponse = await this.wdaRequest(args, 'POST', `${elementPath}/click`, {});
552
- const clearResponse = booleanArg(args.clearFirst)
553
- ? await this.wdaRequest(args, 'POST', `${elementPath}/clear`, {})
554
- : null;
555
- const response = await this.wdaRequest(args, 'POST', `${elementPath}/value`, body);
556
- return {
557
- ok: true,
558
- transport: 'wda-element-value',
559
- action: 'input',
560
- session,
561
- textLength: text.length,
562
- tapped,
563
- element: elementTarget,
564
- clicked: Boolean(clickResponse),
565
- cleared: Boolean(clearResponse),
566
- response,
567
- };
568
- }
666
+ async nativeControl(command, args, context = {}) {
667
+ const port = await openWdaPort(this, args, context.device);
668
+ if (port.status.nativeTargetSchema !== nativeTarget.schema) return {
669
+ ok: false, error: 'ios_native_target_schema_required', dispatched: false, ambiguous: false,
670
+ };
671
+ const observation = await this.nativeObservation(port);
672
+ const selected = nativeTarget.bindNativeTarget(observation, args.selector, args.expectedTarget);
673
+ if (!selected.ok) return selected;
674
+ const operation = nativeControls.get(command);
675
+ const payload = { targetRef: selected.targetRef.element,
676
+ ...(operation === 'native-input' ? { text: args.text, clearFirst: true } : {}) };
677
+ const result = await executeWDAAction({ port, args, operation, selected: observation.session, payload });
678
+ return { ...result, transport: 'wda-managed-native', action: operation, session: observation.session,
679
+ resolved: selected.targetRef, matched: 1, runtimeBinding: port.runtimeBinding };
680
+ }
569
681
 
570
- try {
571
- const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/keys`, body);
572
- return { ok: true, transport: 'wda', action: 'input', session, textLength: text.length, tapped, response };
573
- } catch (firstError) {
574
- const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/keys`, body);
575
- return { ok: true, transport: 'wda', action: 'input', session, textLength: text.length, tapped, firstError: firstError.message, response };
576
- }
682
+ async wdaTap(args = {}) {
683
+ const port = await openWdaPort(this, args);
684
+ const session = await port.session();
685
+ const x = requiredNumber(args.tapX, 'tapX'), y = requiredNumber(args.tapY, 'tapY');
686
+ const result = await executeWDAAction({ port, args, operation: 'tap', selected: session, payload: { x, y } });
687
+ return { ...result, transport: 'wda', action: 'tap', session, x, y, runtimeBinding: port.runtimeBinding };
577
688
  }
578
689
 
579
- async resolveInputElement(args, session, tapped) {
580
- if (args.elementId) {
581
- return { elementId: String(args.elementId), source: 'explicit-element-id' };
582
- }
583
- if (args.accessibilityId) {
584
- const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/element`, {
585
- using: 'accessibility id',
586
- value: String(args.accessibilityId),
587
- });
588
- return {
589
- elementId: wdaElementIdFromResponse(response),
590
- source: 'accessibility-id',
591
- accessibilityId: String(args.accessibilityId),
592
- response,
593
- };
594
- }
595
- if (tapped?.ok === true) {
596
- try {
597
- const response = await this.wdaRequest(args, 'GET', `/session/${session.sessionId}/element/active`);
598
- return {
599
- elementId: wdaElementIdFromResponse(response),
600
- source: 'active-element-after-tap',
601
- response,
602
- };
603
- } catch (_) {
604
- return { elementId: '', source: 'active-element-unavailable' };
605
- }
690
+ async wdaInput(args = {}) {
691
+ const port = await openWdaPort(this, args);
692
+ const session = await port.session();
693
+ const text = requiredInputString(args.text, 'text');
694
+ const prefix = `/session/${encodeURIComponent(session.sessionId)}`;
695
+ let elementId = args.elementId;
696
+ if (args.accessibilityId !== undefined) {
697
+ const matches = await port.request('POST', `${prefix}/elements`, { using: 'accessibility id', value: args.accessibilityId }, session, false);
698
+ if (!Array.isArray(matches)) throw bindingFailure('invalid_ios_wda_elements', 'WDA must return an array of matching elements.');
699
+ if (matches.length !== 1) return { ok: false, error: matches.length ? 'ios_wda_target_ambiguous' : 'ios_wda_target_not_found', dispatched: false, ambiguous: false };
700
+ elementId = matches[0]?.['element-6066-11e4-a52e-4f735466cecf'];
606
701
  }
607
- return { elementId: '', source: 'none' };
702
+ if (typeof elementId !== 'string' || !elementId) throw bindingFailure('invalid_ios_wda_element', 'WDA must return the exact W3C element identity.');
703
+ const result = await executeWDAAction({ port, args, operation: 'input', selected: session,
704
+ payload: { elementId, text, clearFirst: args.clearFirst === true } });
705
+ return { ...result, transport: 'wda-managed-input', action: 'input', session, elementId, textLength: text.length,
706
+ runtimeBinding: port.runtimeBinding };
608
707
  }
609
708
 
610
709
  async wdaSwipe(args = {}) {
611
- const startX = requiredNumber(args.startX, 'startX');
612
- const startY = requiredNumber(args.startY, 'startY');
613
- const endX = requiredNumber(args.endX, 'endX');
614
- const endY = requiredNumber(args.endY, 'endY');
615
- const durationMs = numberArg(args.durationMs, 500);
616
- const session = await this.ensureWdaSession(args);
617
- if (!session.ok) return session;
618
- const actions = swipeAction(startX, startY, endX, endY, durationMs);
619
- try {
620
- const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/actions`, actions);
621
- return { ok: true, transport: 'wda-actions', action: 'swipe', session, startX, startY, endX, endY, durationMs, response };
622
- } catch (firstError) {
623
- const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/dragfromtoforduration`, {
624
- fromX: startX,
625
- fromY: startY,
626
- toX: endX,
627
- toY: endY,
628
- duration: Math.max(durationMs / 1000, 0.05),
629
- });
630
- return { ok: true, transport: 'wda-drag', action: 'swipe', session, startX, startY, endX, endY, durationMs, firstError: firstError.message, response };
631
- }
710
+ const port = await openWdaPort(this, args);
711
+ const session = await port.session();
712
+ const startX = requiredNumber(args.startX, 'startX'), startY = requiredNumber(args.startY, 'startY');
713
+ const endX = requiredNumber(args.endX, 'endX'), endY = requiredNumber(args.endY, 'endY');
714
+ const durationMs = args.durationMs ?? 500;
715
+ const result = await executeWDAAction({ port, args, operation: 'swipe', selected: session,
716
+ payload: { startX, startY, endX, endY, durationMs } });
717
+ return { ...result, transport: 'wda-actions', action: 'swipe', session, startX, startY, endX, endY, durationMs,
718
+ runtimeBinding: port.runtimeBinding };
632
719
  }
633
720
 
634
- async ensureWdaSession(args = {}) {
635
- if (args.wdaSessionId) {
636
- return { ok: true, sessionId: String(args.wdaSessionId), reused: true };
637
- }
638
- const bundleId = args.bundleId ? String(args.bundleId) : undefined;
639
- const body = {
640
- capabilities: {
641
- alwaysMatch: {
642
- platformName: 'iOS',
643
- automationName: 'XCUITest',
644
- ...(bundleId ? { bundleId } : {}),
645
- },
646
- firstMatch: [{}],
647
- },
648
- desiredCapabilities: {
649
- platformName: 'iOS',
650
- automationName: 'XCUITest',
651
- ...(bundleId ? { bundleId } : {}),
652
- },
721
+ async wdaSetOrientation(args, context = {}) {
722
+ if (!nativeTarget.orientationSchema.enum.includes(args.orientation)) return {
723
+ ok: false, error: 'invalid_ios_wda_orientation', dispatched: false, ambiguous: false,
653
724
  };
654
- try {
655
- const response = await this.wdaRequest(args, 'POST', '/session', body);
656
- const sessionId = wdaSessionIdFromResponse(response);
657
- if (!sessionId) {
658
- return { ok: false, error: 'ios_wda_session_missing', response };
659
- }
660
- return { ok: true, sessionId, created: true, bundleId: bundleId || null };
661
- } catch (error) {
662
- return {
663
- ok: false,
664
- error: 'ios_wda_session_failed',
665
- message: error.message,
666
- suggestion: 'Ensure WebDriverAgentRunner is installed, signed, running, and reachable; pass --wda-url if it is not on http://127.0.0.1:8100.',
667
- };
668
- }
669
- }
670
-
671
- async wdaRequest(args, method, endpointPath, body) {
672
- return this.httpRequest(method, `${wdaBaseUrl(args)}${endpointPath}`, body, {
673
- timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
674
- });
725
+ const port = await openWdaPort(this, args, context.device);
726
+ if (port.status.orientationSchema !== 'aab.ios-orientation/v1') return {
727
+ ok: false, error: 'ios_wda_orientation_schema_required', dispatched: false, ambiguous: false,
728
+ };
729
+ const session = await port.session();
730
+ const resolved = nativeTarget.sessionRef({ session, runtimeBinding: port.runtimeBinding });
731
+ if (args.expectedSession !== undefined && !isDeepStrictEqual(args.expectedSession, resolved)) return {
732
+ ok: false, error: 'reobserve_required', dispatched: false, ambiguous: false,
733
+ };
734
+ const result = await executeWDAAction({ port, args, operation: 'set-orientation', selected: session,
735
+ payload: { orientation: args.orientation } });
736
+ return { ...result, transport: 'wda-managed-orientation', action: 'set-orientation', session,
737
+ resolved, runtimeBinding: port.runtimeBinding };
675
738
  }
676
739
 
677
740
  async startWda(args = {}) {
678
- const wdaProjectPath = resolveWdaProjectPath(args);
679
- if (!wdaProjectPath) {
680
- return {
681
- ok: false,
682
- error: 'ios_wda_project_required',
683
- message: 'Cannot start WDA because no WebDriverAgent.xcodeproj was found. Pass --wda-project-path or install a package that vendors appium-webdriveragent.',
684
- };
741
+ const device = await this.requireDevice(args);
742
+ const wdaTestBundleId = args.wdaTestBundleId ?? defaultWdaTestBundleId;
743
+ const wdaRunnerBundleId = `${wdaTestBundleId}.xctrunner`;
744
+ if (args.wdaRunnerBundleId !== undefined && args.wdaRunnerBundleId !== wdaRunnerBundleId) {
745
+ return { ok: false, error: 'ios_wda_runner_bundle_mismatch', message: 'Starting WDA uses the Runner application ID <wdaTestBundleId>.xctrunner.' };
685
746
  }
747
+ const existing = await this.wdaStatus({ ...args, deviceId: device.udid, wdaRunnerBundleId });
748
+ if (existing.ok) return { ok: true, reused: true, device, wdaTestBundleId, wdaRunnerBundleId, status: existing };
686
749
  const teamId = args.teamId || process.env.DEVELOPMENT_TEAM || process.env.AI_APP_BRIDGE_IOS_TEAM_ID;
687
- if (!teamId) {
688
- return {
689
- ok: false,
690
- error: 'ios_team_id_required',
691
- message: 'Cannot sign WebDriverAgentRunner without a DEVELOPMENT_TEAM. Add an Apple account in Xcode and pass --team-id.',
692
- };
693
- }
694
- const wdaBundleId = stringArg(args.wdaBundleId, process.env.AI_APP_BRIDGE_WDA_BUNDLE_ID || defaultWdaBundleId);
750
+ if (!teamId) return { ok: false, error: 'ios_team_id_required', message: 'Signing WDA requires an explicit teamId or configured DEVELOPMENT_TEAM.' };
751
+ checkExecution();
752
+ const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'aab-wda-runtime-'));
753
+ const prepared = prepareWdaProject({ destination: path.join(directory, 'source') });
695
754
  const ctx = this.context(args);
696
- const device = await this.requireDevice(args);
697
- const xcodeArgs = [
698
- '-project',
699
- wdaProjectPath,
700
- '-scheme',
701
- 'WebDriverAgentRunner',
702
- '-destination',
703
- `id=${device.udid || device.identifier}`,
704
- 'DEVELOPMENT_TEAM=' + teamId,
705
- 'PRODUCT_BUNDLE_IDENTIFIER=' + wdaBundleId,
706
- '-allowProvisioningUpdates',
707
- 'test',
708
- ];
709
- const logFile = path.join(os.tmpdir(), `ai-app-bridge-wda-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
710
- const out = fs.openSync(logFile, 'a');
711
- const child = spawn(ctx.xcodebuild, xcodeArgs, {
712
- detached: true,
713
- stdio: ['ignore', out, out],
714
- windowsHide: true,
715
- });
716
- fs.closeSync(out);
717
- let spawnError = null;
718
- let exited = false;
719
- child.once('error', (error) => { spawnError = error; });
720
- child.once('exit', () => { exited = true; });
721
- child.unref();
722
- await sleep(1200);
723
- if (spawnError) {
724
- return { ok: false, error: 'ios_wda_xcodebuild_spawn_failed', message: spawnError.message, logFile };
725
- }
726
- const deadline = Date.now() + numberArg(args.wdaStartupTimeoutMs, 60000);
727
- while (Date.now() < deadline) {
728
- const status = await this.bestWdaStatus(args, device, logFile);
729
- if (status.ok) {
730
- return {
731
- ok: true,
732
- device,
733
- wdaProjectPath,
734
- wdaBundleId,
735
- teamId,
736
- pid: child.pid,
737
- logFile,
738
- status,
739
- };
740
- }
741
- if (exited) {
742
- return {
743
- ok: false,
744
- error: 'ios_wda_xcodebuild_exited',
745
- device,
746
- wdaProjectPath,
747
- wdaBundleId,
748
- teamId,
749
- pid: child.pid,
750
- logFile,
751
- message: 'xcodebuild exited before WebDriverAgent became reachable. Check the log file for signing or provisioning errors.',
752
- };
753
- }
754
- await sleep(1000);
755
- }
756
- return {
757
- ok: false,
758
- error: 'ios_wda_start_timeout',
759
- device,
760
- wdaProjectPath,
761
- wdaBundleId,
762
- teamId,
763
- pid: child.pid,
764
- logFile,
765
- message: 'Timed out waiting for WebDriverAgent /status. If Xcode is showing a signing, trust, or device prompt, resolve it and rerun ios-setup.',
766
- };
767
- }
768
-
769
- async bestWdaStatus(args, device, logFile) {
770
- const urls = [
771
- args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL || '',
772
- device?.tunnelIPAddress ? `http://${formatHostForUrl(device.tunnelIPAddress)}:8100` : '',
773
- parseWdaServerUrlFromLog(logFile),
774
- 'http://127.0.0.1:8100',
775
- ].filter(Boolean);
776
- const uniqueUrls = [...new Set(urls.map(stripTrailingSlash))];
777
- let last = null;
778
- for (const url of uniqueUrls) {
779
- const status = await this.wdaStatus({ ...args, wdaUrl: url });
780
- if (status.ok) return status;
781
- last = status;
782
- }
783
- return last || { ok: false, error: 'ios_wda_unreachable', url: 'http://127.0.0.1:8100' };
784
- }
785
-
786
- async optionalDevice(args = {}) {
755
+ const xcodeArgs = ['-project', prepared.projectPath, '-scheme', 'WebDriverAgentRunner', '-sdk', 'iphoneos', '-destination', `id=${device.udid}`,
756
+ '-derivedDataPath', path.join(directory, 'build'), `DEVELOPMENT_TEAM=${teamId}`, `PRODUCT_BUNDLE_IDENTIFIER=${wdaTestBundleId}`,
757
+ 'ENABLE_DEFAULT_HEADER_SEARCH_PATHS=NO', '-allowProvisioningUpdates'];
758
+ // Compile/sign on the Host before starting any remote test operation.
759
+ // A local compiler failure or cancellation cannot leave a phone task unknown.
760
+ const buildLogFile = path.join(directory, 'xcodebuild-build.log');
761
+ const build = spawnWdaProcess(ctx.xcodebuild, [...xcodeArgs, 'build-for-testing'], buildLogFile, false);
762
+ try {
763
+ while (!build.terminal) { checkExecution(); await executionSleep(100); }
764
+ if (build.spawnError || build.exitCode !== 0) return {
765
+ ok: false, error: build.spawnError ? 'ios_wda_xcodebuild_spawn_failed' : 'ios_wda_build_failed',
766
+ message: build.spawnError?.message ?? 'WDA compilation/signing failed before device test execution.',
767
+ phase: 'build', exitCode: build.exitCode, logFile: buildLogFile, prepared,
768
+ };
769
+ } finally { await build.stop(); }
770
+ const logFile = path.join(directory, 'xcodebuild.log');
771
+ const runtime = spawnWdaProcess(ctx.xcodebuild, [...xcodeArgs, 'test-without-building'], logFile, true);
772
+ const child = runtime.child;
773
+ let ready = false;
787
774
  try {
788
- return await this.requireDevice(args);
789
- } catch (_) {
790
- return null;
775
+ while (!runtime.terminal) {
776
+ checkExecution();
777
+ const status = await this.wdaStatus({ ...args, deviceId: device.udid, wdaRunnerBundleId });
778
+ if (status.ok) {
779
+ ready = true; child.unref();
780
+ return { ok: true, device, wdaTestBundleId, wdaRunnerBundleId, pid: child.pid, logFile, buildLogFile, prepared, status };
781
+ }
782
+ await executionSleep(1000);
783
+ }
784
+ return { ok: false, error: runtime.spawnError ? 'ios_wda_xcodebuild_spawn_failed' : 'ios_wda_xcodebuild_exited',
785
+ message: runtime.spawnError?.message ?? 'xcodebuild closed before the selected Runner published a bound endpoint.',
786
+ phase: 'device-test', exitCode: runtime.exitCode, logFile, buildLogFile, prepared };
787
+ } finally {
788
+ if (!ready) await runtime.stop();
791
789
  }
792
790
  }
793
791
 
@@ -811,7 +809,7 @@ class IOSBridgeProvider {
811
809
  }
812
810
  }
813
811
 
814
- async devicectlJson(ctx, args) {
812
+ async devicectlJson(ctx, args, { mutation = false } = {}) {
815
813
  const jsonPath = path.join(os.tmpdir(), `ai-app-bridge-devicectl-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
816
814
  const allArgs = [
817
815
  ...devicectlPrefix(ctx.devicectl),
@@ -823,7 +821,17 @@ class IOSBridgeProvider {
823
821
  ];
824
822
  try {
825
823
  const command = devicectlBinary(ctx.devicectl);
826
- await execFileText(this.execFile, command, allArgs, { timeoutMs: (ctx.deviceTimeoutSec * 1000) + 5000 });
824
+ try { await execFileText(this.execFile, command, allArgs, { timeoutMs: (ctx.deviceTimeoutSec * 1000) + 5000, mutation }); }
825
+ catch (error) {
826
+ if (mutation) {
827
+ let reply;
828
+ try { reply = JSON.parse(await fs.promises.readFile(jsonPath, 'utf8')); } catch { /* No complete original reply: retain unknown ownership. */ }
829
+ if (reply) error.devicectlReply = reply;
830
+ const outcome = deviceCommandRejection(reply, allArgs.slice(devicectlPrefix(ctx.devicectl).length), error);
831
+ if (outcome) error.deviceOutcome = outcome;
832
+ }
833
+ throw error;
834
+ }
827
835
  return JSON.parse(await fs.promises.readFile(jsonPath, 'utf8'));
828
836
  } finally {
829
837
  await fs.promises.rm(jsonPath, { force: true });
@@ -831,6 +839,32 @@ class IOSBridgeProvider {
831
839
  }
832
840
  }
833
841
 
842
+ function spawnWdaProcess(executable, args, logFile, mutation) {
843
+ checkExecution();
844
+ const out = fs.openSync(logFile, 'a');
845
+ let child;
846
+ try {
847
+ child = spawn(executable, args, { detached: true, stdio: ['ignore', out, out], windowsHide: true, env: wdaBuildEnvironment() });
848
+ if (mutation) child.once('spawn', () => markExecutionDispatched());
849
+ } finally { fs.closeSync(out); }
850
+ const state = { child, terminal: false, spawnError: null, exitCode: null };
851
+ const closed = new Promise(resolve => {
852
+ child.once('error', error => { state.spawnError = error; });
853
+ child.once('close', code => { state.terminal = true; state.exitCode = code; resolve(); });
854
+ });
855
+ state.stop = async () => {
856
+ if (state.terminal) return;
857
+ const signalGroup = signal => {
858
+ if (child.pid === undefined) return;
859
+ try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== 'ESRCH') throw error; }
860
+ };
861
+ signalGroup('SIGTERM');
862
+ const escalation = setTimeout(() => signalGroup('SIGKILL'), 1500);
863
+ try { await closed; } finally { clearTimeout(escalation); }
864
+ };
865
+ return state;
866
+ }
867
+
834
868
  function parseDevicectlDevices(payload) {
835
869
  if (Array.isArray(payload?.result?.devices)) return payload.result.devices;
836
870
  if (Array.isArray(payload?.devices)) return payload.devices;
@@ -891,92 +925,18 @@ function devicectlPrefix(value) {
891
925
  return path.basename(String(value || 'xcrun')) === 'xcrun' ? ['devicectl'] : [];
892
926
  }
893
927
 
894
- function runtimeHostCandidates(explicitHost, device) {
895
- const hosts = [];
896
- if (explicitHost) hosts.push(String(explicitHost));
897
- if (device?.tunnelIPAddress) hosts.push(device.tunnelIPAddress);
898
- if (Array.isArray(device?.potentialHostnames)) hosts.push(...device.potentialHostnames);
899
- return [...new Set(hosts.filter(Boolean))];
900
- }
901
-
902
928
  function formatHostForUrl(host) {
903
929
  const value = String(host || '').trim();
904
930
  if (value.includes(':') && !value.startsWith('[')) return `[${value}]`;
905
931
  return value;
906
932
  }
907
933
 
908
- function wdaBaseUrl(args = {}) {
909
- return stripTrailingSlash(String(args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL || 'http://127.0.0.1:8100'));
910
- }
911
-
912
934
  function stripTrailingSlash(value) {
913
935
  return String(value).replace(/\/+$/, '');
914
936
  }
915
937
 
916
- function wdaSessionIdFromResponse(response) {
917
- return response?.sessionId
918
- || response?.value?.sessionId
919
- || response?.value?.session_id
920
- || response?.value?.capabilities?.sessionId
921
- || '';
922
- }
923
-
924
- function wdaElementIdFromResponse(response) {
925
- return response?.value?.ELEMENT
926
- || response?.value?.['element-6066-11e4-a52e-4f735466cecf']
927
- || response?.ELEMENT
928
- || response?.['element-6066-11e4-a52e-4f735466cecf']
929
- || '';
930
- }
931
-
932
- function parseWdaServerUrlFromLog(logFile) {
933
- if (!logFile) return '';
934
- try {
935
- const text = fs.readFileSync(logFile, 'utf8');
936
- const matches = [...text.matchAll(/ServerURLHere->(http:\/\/[^<\s]+)<-ServerURLHere/g)];
937
- return matches.length ? matches[matches.length - 1][1] : '';
938
- } catch (_) {
939
- return '';
940
- }
941
- }
942
-
943
- function pointerAction(x, y) {
944
- return {
945
- actions: [{
946
- type: 'pointer',
947
- id: 'finger1',
948
- parameters: { pointerType: 'touch' },
949
- actions: [
950
- { type: 'pointerMove', duration: 0, origin: 'viewport', x, y },
951
- { type: 'pointerDown', button: 0 },
952
- { type: 'pause', duration: 60 },
953
- { type: 'pointerUp', button: 0 },
954
- ],
955
- }],
956
- };
957
- }
958
-
959
- function swipeAction(startX, startY, endX, endY, durationMs) {
960
- return {
961
- actions: [{
962
- type: 'pointer',
963
- id: 'finger1',
964
- parameters: { pointerType: 'touch' },
965
- actions: [
966
- { type: 'pointerMove', duration: 0, origin: 'viewport', x: startX, y: startY },
967
- { type: 'pointerDown', button: 0 },
968
- { type: 'pause', duration: 80 },
969
- { type: 'pointerMove', duration: durationMs, origin: 'viewport', x: endX, y: endY },
970
- { type: 'pointerUp', button: 0 },
971
- ],
972
- }],
973
- };
974
- }
975
-
976
938
  function parsePayload(args = {}) {
977
- if (args.payload && typeof args.payload === 'object') return args.payload;
978
- if (args.payload) return JSON.parse(String(args.payload));
979
- return { action: requiredString(args.action, 'action') };
939
+ return args.payload;
980
940
  }
981
941
 
982
942
  function captureQuery(args = {}) {
@@ -984,6 +944,12 @@ function captureQuery(args = {}) {
984
944
  sinceId: args.sinceId,
985
945
  sinceMs: args.sinceMs,
986
946
  limit: args.limit,
947
+ view: args.view ?? (args.history === true || args.history === 'true' ? 'connected-history' : undefined),
948
+ runtimeEpoch: args.runtimeEpoch,
949
+ afterActionId: args.afterActionId,
950
+ factCursor: args.factCursor,
951
+ mobileFactId: args.mobileFactId,
952
+ targetKey: args.targetKey,
987
953
  };
988
954
  }
989
955
 
@@ -998,13 +964,14 @@ function withQuery(endpointPath, query) {
998
964
  function iosSetupSuggestion(device, runtime, wda) {
999
965
  if (!device) return 'Connect one iPhone, trust this Mac on the device, then rerun ios-doctor.';
1000
966
  if (device.developerModeStatus !== 'enabled') return 'Enable Developer Mode on the iPhone and rerun ios-setup.';
1001
- if (device.ddiServicesAvailable === false) return 'Unlock/trust the iPhone and let Xcode finish preparing developer services.';
1002
- if (wda?.ok !== true) return 'Start or build WebDriverAgentRunner, then pass --wda-url if it is not on http://127.0.0.1:8100.';
1003
- if (runtime?.ok !== true) return 'Launch a debug app that includes AiAppBridgeIOS, then pass bundleId/iosHost/iosPort if needed.';
967
+ if (device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') return 'Unlock/trust the iPhone and let Xcode finish preparing a connected developer tunnel.';
968
+ if (wda?.ok !== true) return 'Start the prepared Runner with ios-setup --start-wda --team-id, or supply its exact wdaRunnerBundleId. An optional wdaUrl still requires container binding.';
969
+ if (runtime?.ok !== true) return 'Launch a debug App with AiAppBridgeIOS and supply its exact deviceId and bundleId.';
1004
970
  return 'Rerun ios-setup after resolving the failing check.';
1005
971
  }
1006
972
 
1007
973
  function iosErrorCode(command, error) {
974
+ if (typeof error?.code === 'string' && /^[a-z][a-z0-9_]*$/.test(error.code)) return error.code;
1008
975
  const stderr = String(error?.stderr || '');
1009
976
  const stdout = String(error?.stdout || '');
1010
977
  const message = String(error?.message || error || '');
@@ -1042,91 +1009,37 @@ function truncateText(value, maxChars) {
1042
1009
  return text.length <= maxChars ? text : `${text.slice(0, maxChars)}...`;
1043
1010
  }
1044
1011
 
1045
- function resolveWdaProjectPath(args = {}) {
1046
- const explicit = args.wdaProjectPath || process.env.AI_APP_BRIDGE_WDA_PROJECT;
1047
- if (explicit && fs.existsSync(explicit)) return path.resolve(explicit);
1048
- const candidates = [
1049
- path.join(process.cwd(), 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
1050
- path.join(process.cwd(), 'node_modules', 'appium-xcuitest-driver', 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
1051
- path.join(__dirname, '..', 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
1052
- path.join(__dirname, '..', 'node_modules', 'appium-xcuitest-driver', 'node_modules', 'appium-webdriveragent', 'WebDriverAgent.xcodeproj'),
1053
- ];
1054
- return candidates.find((candidate) => fs.existsSync(candidate)) || '';
1055
- }
1056
-
1057
- function requestJson(method, rawUrl, body, options = {}) {
1058
- const url = new URL(rawUrl);
1059
- const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body), 'utf8');
1060
- return new Promise((resolve, reject) => {
1061
- const request = http.request({
1062
- method,
1063
- protocol: url.protocol,
1064
- hostname: url.hostname.replace(/^\[|\]$/g, ''),
1065
- port: url.port,
1066
- path: `${url.pathname}${url.search}`,
1067
- timeout: options.timeoutMs || defaultHttpTimeoutMs,
1068
- headers: {
1069
- Accept: 'application/json',
1070
- ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}),
1071
- },
1072
- }, (response) => {
1073
- let text = '';
1074
- response.setEncoding('utf8');
1075
- response.on('data', (chunk) => { text += chunk; });
1076
- response.on('end', () => {
1077
- let parsed = null;
1078
- try {
1079
- parsed = text ? JSON.parse(text) : {};
1080
- } catch (_) {
1081
- parsed = { raw: text };
1082
- }
1083
- if (response.statusCode < 200 || response.statusCode >= 300) {
1084
- const error = new Error(`HTTP ${response.statusCode}: ${text.slice(0, 500)}`);
1085
- error.response = parsed;
1086
- reject(error);
1087
- return;
1088
- }
1089
- resolve(parsed);
1090
- });
1091
- });
1092
- request.on('timeout', () => {
1093
- request.destroy(new Error(`HTTP timeout: ${rawUrl}`));
1094
- });
1095
- request.on('error', reject);
1096
- if (payload) request.write(payload);
1097
- request.end();
1098
- });
1099
- }
1100
-
1101
- function execFileText(execFileImpl, command, args, options = {}) {
1102
- return new Promise((resolve, reject) => {
1103
- execFileImpl(command, args, {
1104
- encoding: 'utf8',
1105
- maxBuffer: 64 * 1024 * 1024,
1106
- timeout: options.timeoutMs || 30000,
1107
- windowsHide: true,
1108
- }, (error, stdout, stderr) => {
1109
- if (error) {
1110
- error.message = `${error.message}${stderr ? `\n${stderr}` : ''}`;
1111
- reject(error);
1112
- return;
1012
+ async function requestJson(method, rawUrl, body, options = {}) {
1013
+ return runExecution({ mutation: options.mutation ?? method !== 'GET' }, async () => {
1014
+ let text;
1015
+ try {
1016
+ text = await httpRequestBounded(rawUrl, { method, payload: body === null ? undefined : body,
1017
+ headers: { Accept: 'application/json', ...options.headers }, timeoutMs: options.timeoutMs ?? defaultHttpTimeoutMs,
1018
+ maxBytes: 8 * 1024 * 1024 });
1019
+ } catch (error) {
1020
+ if (error.responseBody) {
1021
+ try { error.response = JSON.parse(error.responseBody); } catch { /* preserve the transport failure */ }
1113
1022
  }
1114
- resolve({ stdout, stderr });
1115
- });
1023
+ throw error;
1024
+ }
1025
+ let parsed;
1026
+ try { parsed = JSON.parse(text); }
1027
+ catch { throw bindingFailure('invalid_ios_json_response', 'The iOS endpoint returned invalid JSON.'); }
1028
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1029
+ throw bindingFailure('invalid_ios_json_response', 'The iOS endpoint must return a JSON object.');
1030
+ }
1031
+ return parsed;
1116
1032
  });
1117
1033
  }
1118
1034
 
1119
- function findFileByName(root, fileName) {
1120
- const entries = fs.readdirSync(root, { withFileTypes: true });
1121
- for (const entry of entries) {
1122
- const candidate = path.join(root, entry.name);
1123
- if (entry.isFile() && entry.name === fileName) return candidate;
1124
- if (entry.isDirectory()) {
1125
- const found = findFileByName(candidate, fileName);
1126
- if (found) return found;
1127
- }
1035
+ async function execFileText(execFileImpl, command, args, options = {}) {
1036
+ try {
1037
+ return await execFileBounded(command, args, { execFileImpl, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
1038
+ timeoutMs: options.timeoutMs ?? 30000, mutation: options.mutation === true, windowsHide: true });
1039
+ } catch (error) {
1040
+ if (error.stderr) error.message += `\n${error.stderr}`;
1041
+ throw error;
1128
1042
  }
1129
- return '';
1130
1043
  }
1131
1044
 
1132
1045
  function normalizedObjectEnum(value) {
@@ -1141,6 +1054,11 @@ function requiredString(value, name) {
1141
1054
  return value;
1142
1055
  }
1143
1056
 
1057
+ function requiredInputString(value, name) {
1058
+ if (typeof value !== 'string') throw new Error(`${name} is required`);
1059
+ return value;
1060
+ }
1061
+
1144
1062
  function requiredNumber(value, name) {
1145
1063
  const number = Number(value);
1146
1064
  if (!Number.isFinite(number)) throw new Error(`${name} is required`);
@@ -1161,15 +1079,11 @@ function booleanArg(value) {
1161
1079
  return value === true || value === 'true' || value === '1' || value === 1;
1162
1080
  }
1163
1081
 
1164
- function sleep(ms) {
1165
- return new Promise((resolve) => setTimeout(resolve, ms));
1166
- }
1167
-
1168
1082
  module.exports = {
1169
1083
  IOSBridgeProvider,
1170
1084
  formatHostForUrl,
1171
1085
  parseDevicectlDevices,
1172
1086
  selectDeviceFromList,
1173
1087
  shapeDevice,
1174
- wdaSessionIdFromResponse,
1088
+ requiredInputString,
1175
1089
  };