agent-device 0.21.20 → 0.21.21

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 (357) hide show
  1. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.21.20.apk → agent-device-android-ime-helper-0.21.21.apk} +0 -0
  2. package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.21.apk.sha256 +1 -0
  3. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.21.20.manifest.json → agent-device-android-ime-helper-0.21.21.manifest.json} +4 -4
  4. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.21.apk +0 -0
  5. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.21.apk.sha256 +1 -0
  6. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.21.20.manifest.json → agent-device-android-snapshot-helper-0.21.21.manifest.json} +6 -6
  7. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/AppWindowScreenshot.swift +73 -0
  8. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/BackgroundInteraction.swift +557 -0
  9. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/GhostCursor.swift +141 -0
  10. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/SnapshotTraversal.swift +107 -3
  11. package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +136 -22
  12. package/apple/macos-helper/Tests/AgentDeviceMacOSHelperTests/BackgroundInteractionTests.swift +88 -0
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +121 -4
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +10 -2
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +36 -9
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +28 -0
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +27 -0
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift +6 -1
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +7 -2
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +21 -7
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +121 -17
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +179 -6
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryConfirmation.swift +119 -0
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift +8 -1
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryReadiness.swift +23 -11
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift +59 -6
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +112 -23
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift +25 -12
  29. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/RecordingExportSupport.swift +6 -1
  30. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +43 -13
  31. package/dist/src/adapter.js +1 -1
  32. package/dist/src/adb-executor.js +2 -2
  33. package/dist/src/adb-failure.js +1 -1
  34. package/dist/src/adb-transport.js +1 -1
  35. package/dist/src/agent-browser-lifecycle.js +1 -1
  36. package/dist/src/agent-browser-process-record.js +1 -1
  37. package/dist/src/agent-browser-provider.js +1 -1
  38. package/dist/src/agent-browser-tool.js +1 -1
  39. package/dist/src/agent-device-client.js +2 -2
  40. package/dist/src/ai-sdk.js +1 -1
  41. package/dist/src/alert-runtime.js +1 -1
  42. package/dist/src/alert.js +1 -1
  43. package/dist/src/android-adb.d.ts +12 -0
  44. package/dist/src/android-input-ownership.js +1 -1
  45. package/dist/src/app-backend.js +1 -0
  46. package/dist/src/app-catalog.js +1 -1
  47. package/dist/src/app-deployment-resolution.js +1 -1
  48. package/dist/src/app-inventory.js +1 -1
  49. package/dist/src/app-launch.js +1 -1
  50. package/dist/src/app-lifecycle.js +2 -2
  51. package/dist/src/app-lifecycle2.js +1 -1
  52. package/dist/src/app-log-files.js +1 -1
  53. package/dist/src/app-log-reconnect.js +1 -1
  54. package/dist/src/app-log-runtime2.js +3 -3
  55. package/dist/src/app-log-session-resource.js +1 -0
  56. package/dist/src/app-resolution.js +2 -2
  57. package/dist/src/apple-runner-platform.js +1 -1
  58. package/dist/src/application-lifecycle-interaction.js +1 -1
  59. package/dist/src/archive.js +2 -2
  60. package/dist/src/artifact-download.js +1 -1
  61. package/dist/src/auth-session.js +1 -1
  62. package/dist/src/auth.js +1 -1
  63. package/dist/src/back-mode.js +1 -1
  64. package/dist/src/backend-snapshot-options.js +1 -1
  65. package/dist/src/boot-diagnostics.js +1 -1
  66. package/dist/src/cache.js +1 -1
  67. package/dist/src/capabilities.js +1 -1
  68. package/dist/src/capture.js +1 -1
  69. package/dist/src/cli-config.js +1 -1
  70. package/dist/src/cli-help.js +13 -5
  71. package/dist/src/cli.js +4 -4
  72. package/dist/src/click-button.js +1 -1
  73. package/dist/src/client-metro-companion.js +1 -1
  74. package/dist/src/client-metro.js +1 -1
  75. package/dist/src/client-options.js +3 -0
  76. package/dist/src/client-types.d.ts +88 -5
  77. package/dist/src/clipboard-shell-response.js +1 -1
  78. package/dist/src/cloud-artifacts.d.ts +7 -2
  79. package/dist/src/code-signature-cache.js +1 -1
  80. package/dist/src/code-signature.js +1 -1
  81. package/dist/src/command-schema.js +1 -1
  82. package/dist/src/command-tools.js +2 -2
  83. package/dist/src/connection-runtime.js +1 -1
  84. package/dist/src/connection.js +5 -5
  85. package/dist/src/contracts.d.ts +1 -1
  86. package/dist/src/contracts.js +1 -1
  87. package/dist/src/daemon-artifacts.js +1 -0
  88. package/dist/src/daemon-client-lease-beat.js +1 -1
  89. package/dist/src/daemon-client-lifecycle.js +1 -1
  90. package/dist/src/daemon-client-liveness-probe.js +1 -0
  91. package/dist/src/daemon-client-transport.js +1 -1
  92. package/dist/src/daemon-http.js +1 -0
  93. package/dist/src/daemon-policy-file.js +1 -1
  94. package/dist/src/daemon-registration-owner.js +1 -0
  95. package/dist/src/daemon-registration.js +1 -1
  96. package/dist/src/daemon-resolution.js +1 -1
  97. package/dist/src/daemon-shutdown-report.js +1 -1
  98. package/dist/src/daemon.js +2 -2
  99. package/dist/src/debug-symbols-facade.js +1 -1
  100. package/dist/src/device-claim-inspection.js +1 -1
  101. package/dist/src/device-claim-rule.js +1 -1
  102. package/dist/src/device-inventory-context.js +1 -1
  103. package/dist/src/device-readiness-runtime.js +1 -1
  104. package/dist/src/device-release.js +1 -1
  105. package/dist/src/device-rotation.js +1 -1
  106. package/dist/src/device-selection-resolver.js +1 -1
  107. package/dist/src/device-session.js +1 -1
  108. package/dist/src/device-shell.js +1 -1
  109. package/dist/src/device.js +1 -1
  110. package/dist/src/device2.js +1 -3
  111. package/dist/src/device3.js +3 -0
  112. package/dist/src/devicectl.js +1 -1
  113. package/dist/src/diagnostics.js +3 -3
  114. package/dist/src/dispatch-resolve.js +4 -1
  115. package/dist/src/display-inventory.js +1 -1
  116. package/dist/src/doctor-facade.js +1 -1
  117. package/dist/src/doctor2.js +1 -1
  118. package/dist/src/durable-capture-resource.js +1 -0
  119. package/dist/src/engine-eval-script.js +1 -1
  120. package/dist/src/error.js +1 -1
  121. package/dist/src/exec.js +1 -1
  122. package/dist/src/file.js +1 -1
  123. package/dist/src/find.js +1 -1
  124. package/dist/src/generic-settle.js +1 -1
  125. package/dist/src/generic.js +1 -1
  126. package/dist/src/gesture-admission.js +1 -1
  127. package/dist/src/gesture-plan.js +1 -1
  128. package/dist/src/gesture-runtime.js +1 -1
  129. package/dist/src/harmonyos.js +1 -1
  130. package/dist/src/hdc.js +1 -1
  131. package/dist/src/helper.js +2 -2
  132. package/dist/src/host-file.js +1 -1
  133. package/dist/src/host.js +1 -1
  134. package/dist/src/human-control-contract.js +1 -1
  135. package/dist/src/human-control.js +1 -1
  136. package/dist/src/index.d.ts +1 -1
  137. package/dist/src/index.js +1 -1
  138. package/dist/src/input-actions.js +1 -1
  139. package/dist/src/input-actions2.js +2 -2
  140. package/dist/src/install-artifact-facade.js +1 -1
  141. package/dist/src/install-source-network-transport.js +1 -1
  142. package/dist/src/install-source.d.ts +5 -0
  143. package/dist/src/install-source2.js +1 -1
  144. package/dist/src/instance-client.js +1 -0
  145. package/dist/src/interaction-positionals.js +1 -1
  146. package/dist/src/interaction-ref-policy.js +1 -1
  147. package/dist/src/interaction.js +1 -1
  148. package/dist/src/interaction2.js +1 -1
  149. package/dist/src/interactor-operation-binding.js +1 -1
  150. package/dist/src/interactor.js +5 -5
  151. package/dist/src/interactor2.js +1 -1
  152. package/dist/src/interactors.js +1 -1
  153. package/dist/src/internal/bin.js +1 -1
  154. package/dist/src/internal/daemon.js +1 -2
  155. package/dist/src/internal/png-worker.js +1 -1
  156. package/dist/src/internal/run-script-http-child.js +1 -1
  157. package/dist/src/inventory.js +4 -4
  158. package/dist/src/inventory2.js +1 -1
  159. package/dist/src/inventory3.js +1 -1
  160. package/dist/src/inventory5.js +1 -1
  161. package/dist/src/ios-client.js +4 -30
  162. package/dist/src/ios-settings.js +1 -0
  163. package/dist/src/ios-snapshot-engine.js +1 -1
  164. package/dist/src/ios-snapshot-runtime.js +1 -1
  165. package/dist/src/launch-confirmation.js +1 -1
  166. package/dist/src/lease-context.js +1 -1
  167. package/dist/src/lease-registry-scope.js +1 -0
  168. package/dist/src/lease-scope.js +1 -1
  169. package/dist/src/lease.js +1 -1
  170. package/dist/src/legacy-xctest-device-set.js +1 -1
  171. package/dist/src/limrun-download-file.js +1 -1
  172. package/dist/src/limrun-runtime-dependencies.js +1 -1
  173. package/dist/src/limrun.d.ts +28 -2
  174. package/dist/src/limrun.js +1 -1
  175. package/dist/src/linux.js +1 -1
  176. package/dist/src/load.js +1 -0
  177. package/dist/src/location-coordinates.js +1 -1
  178. package/dist/src/macos-facade.js +1 -1
  179. package/dist/src/managed-device-scope.js +1 -1
  180. package/dist/src/mechanics.js +16 -16
  181. package/dist/src/observation.js +1 -1
  182. package/dist/src/observe-until.js +1 -1
  183. package/dist/src/overlay.js +2 -2
  184. package/dist/src/owner-identity.js +3 -3
  185. package/dist/src/path-resolution.js +1 -1
  186. package/dist/src/perf-facade.js +1 -1
  187. package/dist/src/perf-runtime-operation-builder.js +1 -1
  188. package/dist/src/perf-runtime-plan.js +1 -1
  189. package/dist/src/perf-target.js +1 -1
  190. package/dist/src/perf.js +1 -1
  191. package/dist/src/physical-device-control.js +1 -1
  192. package/dist/src/platform-request-scope.js +1 -1
  193. package/dist/src/platform-runtime-android-adb-host.js +1 -1
  194. package/dist/src/platform-runtime-app-log-process.js +1 -1
  195. package/dist/src/platform-runtime-host.js +1 -1
  196. package/dist/src/platform-runtime-managed-owner.js +1 -1
  197. package/dist/src/platform-runtime-open-target.js +1 -1
  198. package/dist/src/platform-runtime-operation-host.js +2 -2
  199. package/dist/src/platform-runtime-runtime-hints.js +2 -2
  200. package/dist/src/platform-runtime-screen-recording-android-host.js +1 -1
  201. package/dist/src/platform-runtime-screen-recording-apple-runner-transport.js +1 -1
  202. package/dist/src/platform-runtime-screen-recording-apple-simulator-host.js +1 -1
  203. package/dist/src/platform-runtime-screen-recording-finalizer-host.js +1 -1
  204. package/dist/src/platform-runtime-screen-recording-output-host.js +1 -1
  205. package/dist/src/platform-runtime-screen-recording-process-host.js +1 -1
  206. package/dist/src/platform-runtime-unavailable.js +1 -1
  207. package/dist/src/platform-runtime.js +1 -1
  208. package/dist/src/platform-runtime2.js +1 -1
  209. package/dist/src/plugins.d.ts +9 -0
  210. package/dist/src/plugins.js +1 -0
  211. package/dist/src/plugins2.js +2 -0
  212. package/dist/src/png-crop-bytes.js +1 -1
  213. package/dist/src/png-size.js +1 -1
  214. package/dist/src/png-worker-client.js +1 -1
  215. package/dist/src/png.js +1 -1
  216. package/dist/src/prepare-kind.js +1 -1
  217. package/dist/src/process.js +1 -1
  218. package/dist/src/project-runtime.js +1 -1
  219. package/dist/src/provider-credential-fingerprint.js +1 -0
  220. package/dist/src/provider-device-admission.js +1 -1
  221. package/dist/src/provider-device-runtimes.js +1 -0
  222. package/dist/src/provider-limrun-credentials.js +1 -0
  223. package/dist/src/provider-profile-fields.js +1 -0
  224. package/dist/src/provider-session-ownership.js +2 -2
  225. package/dist/src/provider-webdriver.js +2 -1
  226. package/dist/src/providers.js +1 -1
  227. package/dist/src/proxy.js +1 -1
  228. package/dist/src/quality-warnings.js +1 -1
  229. package/dist/src/react-native.js +1 -1
  230. package/dist/src/record-runtime.js +1 -1
  231. package/dist/src/record-trace.js +1 -1
  232. package/dist/src/recording.js +1 -1
  233. package/dist/src/rect-center.js +1 -1
  234. package/dist/src/register-builtins.js +1 -1
  235. package/dist/src/registry.js +39 -39
  236. package/dist/src/remote-config2.js +1 -1
  237. package/dist/src/replay-divergence.js +1 -1
  238. package/dist/src/replay.js +2 -2
  239. package/dist/src/reporting.js +5 -5
  240. package/dist/src/request-admission.js +1 -1
  241. package/dist/src/request-cancel.js +1 -1
  242. package/dist/src/request-cancellation.js +1 -1
  243. package/dist/src/request-runtime-binding.js +1 -1
  244. package/dist/src/result-serialization.js +1 -1
  245. package/dist/src/retry.js +1 -1
  246. package/dist/src/runner-artifact-manifest.js +1 -1
  247. package/dist/src/runner-cache-metadata.js +3 -3
  248. package/dist/src/runner-client.js +1 -1
  249. package/dist/src/runner-dev-tools-security.js +1 -1
  250. package/dist/src/runner-device-readiness.js +1 -1
  251. package/dist/src/runner-disposal.js +1 -1
  252. package/dist/src/runner-error-classification.js +2 -2
  253. package/dist/src/runner-exchange.js +1 -1
  254. package/dist/src/runner-operations-facade.js +1 -1
  255. package/dist/src/runner-session-types.js +1 -1
  256. package/dist/src/runner-start-budget.js +1 -1
  257. package/dist/src/runner.js +1 -1
  258. package/dist/src/runtime-admission.js +1 -1
  259. package/dist/src/runtime-transport-hints.js +1 -1
  260. package/dist/src/runtime.js +1 -1
  261. package/dist/src/runtime10.js +2 -2
  262. package/dist/src/runtime2.js +3 -3
  263. package/dist/src/runtime3.js +1 -1
  264. package/dist/src/runtime4.js +5 -5
  265. package/dist/src/runtime5.js +1 -1
  266. package/dist/src/runtime6.js +1 -1
  267. package/dist/src/runtime7.js +1 -1
  268. package/dist/src/runtime8.js +1 -1
  269. package/dist/src/runtime9.js +1 -1
  270. package/dist/src/screen-recording-admission-ledger.js +1 -1
  271. package/dist/src/screen-recording-completion.js +1 -1
  272. package/dist/src/screen-recording-options.js +1 -1
  273. package/dist/src/screen-recording-resource-recovery.js +1 -1
  274. package/dist/src/screen-recording-transport.js +1 -1
  275. package/dist/src/screenshot-density.js +1 -1
  276. package/dist/src/screenshot-image.js +1 -1
  277. package/dist/src/screenshot-result.js +1 -1
  278. package/dist/src/screenshot-runtime.js +1 -1
  279. package/dist/src/screenshot.js +1 -1
  280. package/dist/src/screenshot3.js +1 -1
  281. package/dist/src/script.js +1 -1
  282. package/dist/src/scroll-command.js +1 -1
  283. package/dist/src/scroll-gesture.js +1 -1
  284. package/dist/src/sdk-batch-runner.js +2 -2
  285. package/dist/src/sdk-batch.js +2 -2
  286. package/dist/src/sdk-contracts.d.ts +45 -2
  287. package/dist/src/sdk-contracts.js +2 -2
  288. package/dist/src/sdk-device.js +1 -1
  289. package/dist/src/sdk-io.js +1 -1
  290. package/dist/src/sdk-remote-config.js +1 -1
  291. package/dist/src/sdk-selectors.d.ts +33 -4
  292. package/dist/src/selection.js +0 -0
  293. package/dist/src/selector-runtime.js +1 -1
  294. package/dist/src/selectors.d.ts +2 -2
  295. package/dist/src/selectors.js +1 -1
  296. package/dist/src/server.js +1 -1
  297. package/dist/src/session-allocation.js +1 -1
  298. package/dist/src/session-artifact-paths.js +1 -1
  299. package/dist/src/session-repair-tombstone.js +1 -1
  300. package/dist/src/session-routing.js +1 -1
  301. package/dist/src/session-runtime-admission.js +1 -1
  302. package/dist/src/session-snapshot.js +1 -1
  303. package/dist/src/session-stream.js +27 -0
  304. package/dist/src/session.js +1 -1
  305. package/dist/src/session2.js +5 -5
  306. package/dist/src/settings.js +1 -1
  307. package/dist/src/settings2.js +1 -1
  308. package/dist/src/simctl-settings-facade.js +1 -0
  309. package/dist/src/simctl-settings.js +1 -0
  310. package/dist/src/simctl.js +1 -1
  311. package/dist/src/simulator.js +1 -1
  312. package/dist/src/snapshot-diagnostics.js +1 -1
  313. package/dist/src/snapshot-runtime-binding.js +1 -1
  314. package/dist/src/snapshot-runtime.js +1 -1
  315. package/dist/src/snapshot-state.js +1 -1
  316. package/dist/src/snapshot2.js +1 -1
  317. package/dist/src/snapshot3.js +1 -1
  318. package/dist/src/snapshot4.js +1 -1
  319. package/dist/src/src.js +1 -1
  320. package/dist/src/src7.js +1 -1
  321. package/dist/src/src8.js +2 -2
  322. package/dist/src/store.js +1 -0
  323. package/dist/src/string-enum.js +1 -1
  324. package/dist/src/surface-snapshot.js +1 -1
  325. package/dist/src/takeover.js +1 -1
  326. package/dist/src/target-evidence.js +1 -1
  327. package/dist/src/telemetry.js +1 -1
  328. package/dist/src/text-entry-focus.js +1 -1
  329. package/dist/src/tool-provider.js +1 -1
  330. package/dist/src/tool-provider2.js +1 -1
  331. package/dist/src/toolchain-probe.js +2 -2
  332. package/dist/src/touch-runtime.js +1 -1
  333. package/dist/src/touch-runtime2.js +1 -1
  334. package/dist/src/transport.js +2 -2
  335. package/dist/src/tv-remote.js +1 -1
  336. package/dist/src/type-text-runtime.js +1 -1
  337. package/dist/src/update-check.js +1 -1
  338. package/dist/src/vars.js +1 -1
  339. package/dist/src/verified-file.js +1 -1
  340. package/dist/src/version.js +1 -1
  341. package/dist/src/video.js +1 -1
  342. package/dist/src/viewport.js +1 -1
  343. package/dist/src/web.js +1 -1
  344. package/dist/src/web2.js +1 -1
  345. package/dist/src/webdriver-source.js +1 -1
  346. package/package.json +5 -1
  347. package/server.json +2 -2
  348. package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.20.apk.sha256 +0 -1
  349. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.20.apk +0 -0
  350. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.20.apk.sha256 +0 -1
  351. package/dist/src/api.js +0 -3
  352. package/dist/src/daemon-process.js +0 -1
  353. package/dist/src/multitouch-support.js +0 -1
  354. package/dist/src/parameterized-recorded-fill.js +0 -1
  355. package/dist/src/runner-startup-transport.js +0 -1
  356. package/dist/src/runner-xctestrun.js +0 -1
  357. package/dist/src/session-store.js +0 -4
@@ -0,0 +1,141 @@
1
+ import AppKit
2
+ import QuartzCore
3
+
4
+ /// A drawn pointer that shows where a background action lands while the user keeps the real
5
+ /// one. It appears just above and left of the target, glides onto it, and pulses when the action
6
+ /// is delivered: about 0.3 s per action. It holds no state between helper processes.
7
+ final class GhostCursor {
8
+ private static let size = CGSize(width: 64, height: 64)
9
+ /// Where the arrow's tip sits inside the panel, from its top-left corner; it leaves room for
10
+ /// the pulse ring's full radius.
11
+ private static let tipOffset = CGPoint(x: 22, y: 22)
12
+ /// Where the glide starts, relative to the target.
13
+ private static let approach = CGVector(dx: -36, dy: -36)
14
+
15
+ private let panel: NSPanel
16
+ private let view: GhostCursorView
17
+ private let primaryScreenHeight: CGFloat
18
+
19
+ /// Nil when no display is attached; the action still runs, just unseen.
20
+ static func show() -> GhostCursor? {
21
+ _ = NSApplication.shared
22
+ NSApp.setActivationPolicy(.accessory)
23
+ guard let primary = NSScreen.screens.first else { return nil }
24
+ return GhostCursor(primaryScreenHeight: primary.frame.height)
25
+ }
26
+
27
+ private init(primaryScreenHeight: CGFloat) {
28
+ self.primaryScreenHeight = primaryScreenHeight
29
+ view = GhostCursorView(frame: CGRect(origin: .zero, size: Self.size), tip: Self.tipOffset)
30
+ panel = NSPanel(
31
+ contentRect: CGRect(origin: .zero, size: Self.size),
32
+ styleMask: [.borderless, .nonactivatingPanel],
33
+ backing: .buffered,
34
+ defer: false
35
+ )
36
+ panel.isOpaque = false
37
+ panel.backgroundColor = .clear
38
+ panel.hasShadow = false
39
+ panel.ignoresMouseEvents = true
40
+ panel.level = .screenSaver
41
+ panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
42
+ panel.contentView = view
43
+ }
44
+
45
+ /// Glides onto a point in global top-left coordinates, the space AX and CGEvent share.
46
+ func move(to target: CGPoint, duration: TimeInterval = 0.15) {
47
+ let start = CGPoint(x: target.x + Self.approach.dx, y: target.y + Self.approach.dy)
48
+ place(at: start)
49
+ panel.orderFrontRegardless()
50
+ let steps = max(1, Int(duration / 0.016))
51
+ for step in 1...steps {
52
+ let t = Double(step) / Double(steps)
53
+ let eased = 1 - pow(1 - t, 3)
54
+ place(at: CGPoint(x: start.x + (target.x - start.x) * eased, y: start.y + (target.y - start.y) * eased))
55
+ flush(for: 0.016)
56
+ }
57
+ }
58
+
59
+ /// A ring at the tip, marking the moment the action was delivered; nothing when the cursor never
60
+ /// moved onto a target.
61
+ func pulse(duration: TimeInterval = 0.12) {
62
+ guard panel.isVisible else { return }
63
+ let steps = max(1, Int(duration / 0.016))
64
+ for step in 1...steps {
65
+ view.ringProgress = CGFloat(step) / CGFloat(steps)
66
+ view.display()
67
+ flush(for: 0.016)
68
+ }
69
+ }
70
+
71
+ func hide() {
72
+ panel.orderOut(nil)
73
+ CATransaction.flush()
74
+ }
75
+
76
+ private func place(at tip: CGPoint) {
77
+ panel.setFrameOrigin(
78
+ CGPoint(
79
+ x: tip.x - Self.tipOffset.x,
80
+ y: primaryScreenHeight - tip.y - Self.size.height + Self.tipOffset.y
81
+ )
82
+ )
83
+ }
84
+
85
+ private func flush(for interval: TimeInterval) {
86
+ CATransaction.flush()
87
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: interval))
88
+ }
89
+ }
90
+
91
+ private final class GhostCursorView: NSView {
92
+ private let tip: CGPoint
93
+ var ringProgress: CGFloat?
94
+
95
+ init(frame: CGRect, tip: CGPoint) {
96
+ self.tip = tip
97
+ super.init(frame: frame)
98
+ }
99
+
100
+ required init?(coder: NSCoder) {
101
+ nil
102
+ }
103
+
104
+ override var isFlipped: Bool { true }
105
+
106
+ override func draw(_ dirtyRect: NSRect) {
107
+ NSColor.clear.setFill()
108
+ dirtyRect.fill()
109
+ if let progress = ringProgress {
110
+ let radius = 6 + 14 * progress
111
+ let ring = NSBezierPath(
112
+ ovalIn: CGRect(x: tip.x - radius, y: tip.y - radius, width: radius * 2, height: radius * 2)
113
+ )
114
+ ring.lineWidth = 3
115
+ NSColor.systemPurple.withAlphaComponent(0.85 * (1 - progress)).setStroke()
116
+ ring.stroke()
117
+ }
118
+ let arrow = NSBezierPath()
119
+ arrow.move(to: tip)
120
+ arrow.line(to: CGPoint(x: tip.x, y: tip.y + 22))
121
+ arrow.line(to: CGPoint(x: tip.x + 6, y: tip.y + 17))
122
+ arrow.line(to: CGPoint(x: tip.x + 10, y: tip.y + 26))
123
+ arrow.line(to: CGPoint(x: tip.x + 14, y: tip.y + 24))
124
+ arrow.line(to: CGPoint(x: tip.x + 10, y: tip.y + 15))
125
+ arrow.line(to: CGPoint(x: tip.x + 17, y: tip.y + 15))
126
+ arrow.close()
127
+ arrow.lineJoinStyle = .round
128
+ let shadow = NSShadow()
129
+ shadow.shadowBlurRadius = 3
130
+ shadow.shadowOffset = CGSize(width: 0, height: -1)
131
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.35)
132
+ NSGraphicsContext.saveGraphicsState()
133
+ shadow.set()
134
+ NSColor.systemPurple.setFill()
135
+ arrow.fill()
136
+ NSGraphicsContext.restoreGraphicsState()
137
+ arrow.lineWidth = 1.5
138
+ NSColor.white.setStroke()
139
+ arrow.stroke()
140
+ }
141
+ }
@@ -7,6 +7,8 @@ private enum SnapshotTraversalLimits {
7
7
  static let maxDesktopApps = 24
8
8
  static let maxNodes = 1500
9
9
  static let maxDepth = 12
10
+ /// An app session is one app's tree; Electron alone wraps its page in about ten groups.
11
+ static let maxAppDepth = 48
10
12
  static let maxMenuBarBandY = 64.0
11
13
  static let maxMenuBarBandHeight = 64.0
12
14
  static let maxMenuBarExtraWidth = 256.0
@@ -45,11 +47,13 @@ struct SnapshotResponse: Encodable {
45
47
  let nodes: [SnapshotNodeResponse]
46
48
  let truncated: Bool
47
49
  let backend = "macos-helper"
50
+ let warnings: [String]?
48
51
  }
49
52
 
50
53
  private struct SnapshotBuildResult {
51
54
  let nodes: [SnapshotNodeResponse]
52
55
  let truncated: Bool
56
+ var warnings: [String]? = nil
53
57
  }
54
58
 
55
59
  private struct SnapshotContext {
@@ -64,6 +68,8 @@ private struct SnapshotTraversalState {
64
68
  var nodes: [SnapshotNodeResponse] = []
65
69
  var visited: [AXUIElement] = []
66
70
  var truncated = false
71
+ /// An element sat at the depth cap with children the walk left out.
72
+ var depthCapped = false
67
73
  }
68
74
 
69
75
  private struct MenuBarWindowFallbackCandidate {
@@ -79,6 +85,8 @@ private struct MenuBarWindowFallbackCandidate {
79
85
  func captureSnapshotResponse(surface: String, bundleId: String? = nil) throws -> SnapshotResponse {
80
86
  let result: SnapshotBuildResult
81
87
  switch surface {
88
+ case "app":
89
+ result = try snapshotSessionApp(bundleId: bundleId)
82
90
  case "frontmost-app":
83
91
  result = try snapshotFrontmostApp()
84
92
  case "desktop":
@@ -86,10 +94,101 @@ func captureSnapshotResponse(surface: String, bundleId: String? = nil) throws ->
86
94
  case "menubar":
87
95
  result = try snapshotMenuBar(bundleId: bundleId)
88
96
  default:
89
- throw HelperError.invalidArgs("snapshot requires --surface <frontmost-app|desktop|menubar>")
97
+ throw HelperError.invalidArgs("snapshot requires --surface <app|frontmost-app|desktop|menubar>")
90
98
  }
91
99
 
92
- return SnapshotResponse(surface: surface, nodes: result.nodes, truncated: result.truncated)
100
+ return SnapshotResponse(
101
+ surface: surface, nodes: result.nodes, truncated: result.truncated, warnings: result.warnings)
102
+ }
103
+
104
+ /// The session's own app, whether or not it is frontmost.
105
+ private func snapshotSessionApp(bundleId: String?) throws -> SnapshotBuildResult {
106
+ let app = try requireSessionApplication(bundleId: bundleId)
107
+ let webContentReady = enableRemoteAccessibilityTree(app)
108
+ var state = SnapshotTraversalState()
109
+ _ = appendApplicationSnapshot(
110
+ app,
111
+ depth: 0,
112
+ parentIndex: nil,
113
+ surface: "app",
114
+ maxDepth: SnapshotTraversalLimits.maxAppDepth,
115
+ state: &state
116
+ )
117
+ return SnapshotBuildResult(
118
+ nodes: state.nodes,
119
+ truncated: state.truncated || state.depthCapped,
120
+ warnings: webContentReady
121
+ ? nil
122
+ : [
123
+ "The app's Chromium accessibility tree did not populate, so web content may be missing from this snapshot."
124
+ ]
125
+ )
126
+ }
127
+
128
+ /// Chromium and Electron build their accessibility tree only once a client asks for it, so an
129
+ /// unannounced reader sees a window of empty groups. Electron answers `AXManualAccessibility`;
130
+ /// other Chromium shells answer the VoiceOver-style `AXEnhancedUserInterface`, which native apps
131
+ /// also honor by changing window behavior, so it is set only on a Chromium app. The tree stays on
132
+ /// for the app's lifetime, as it does for any assistive client.
133
+ ///
134
+ /// Whether the app's web content is readable: true for an app that is not Chromium-based or whose
135
+ /// tree is already on. The wait is paid only by the call that turns the tree on.
136
+ private func enableRemoteAccessibilityTree(_ app: NSRunningApplication) -> Bool {
137
+ guard isChromiumApplication(app) else { return true }
138
+ let appElement = AXUIElementCreateApplication(app.processIdentifier)
139
+ let attributes = ["AXManualAccessibility", "AXEnhancedUserInterface"]
140
+ if attributes.contains(where: { boolAttribute(appElement, attribute: $0) == true }) {
141
+ return true
142
+ }
143
+ guard
144
+ attributes.contains(where: {
145
+ AXUIElementSetAttributeValue(appElement, $0 as CFString, kCFBooleanTrue) == .success
146
+ })
147
+ else {
148
+ return false
149
+ }
150
+ return awaitPopulatedWebContent(appElement)
151
+ }
152
+
153
+ /// Chromium builds the tree after the request returns, so a walk right after enabling it would see
154
+ /// only the wrapper groups. Bounded well inside the helper's own deadline.
155
+ private func awaitPopulatedWebContent(_ appElement: AXUIElement) -> Bool {
156
+ let deadline = Date().addingTimeInterval(1.0)
157
+ while !hasPopulatedWebArea(appElement) {
158
+ guard Date() < deadline else { return false }
159
+ Thread.sleep(forTimeInterval: 0.05)
160
+ }
161
+ return true
162
+ }
163
+
164
+ private func hasPopulatedWebArea(_ appElement: AXUIElement) -> Bool {
165
+ var budget = 400
166
+ func visit(_ element: AXUIElement, depth: Int) -> Bool {
167
+ guard budget > 0, depth < 16 else { return false }
168
+ budget -= 1
169
+ let elementChildren = children(of: element)
170
+ if stringAttribute(element, attribute: kAXRoleAttribute as String) == "AXWebArea" {
171
+ return !elementChildren.isEmpty
172
+ }
173
+ return elementChildren.contains { visit($0, depth: depth + 1) }
174
+ }
175
+ return windows(of: appElement).contains { visit($0, depth: 0) }
176
+ }
177
+
178
+ /// Every Chromium embedder, Electron or a renamed framework, ships Chromium's resource pack.
179
+ private func isChromiumApplication(_ app: NSRunningApplication) -> Bool {
180
+ guard let frameworksURL = app.bundleURL?.appendingPathComponent("Contents/Frameworks"),
181
+ let frameworks = try? FileManager.default.contentsOfDirectory(atPath: frameworksURL.path)
182
+ else {
183
+ return false
184
+ }
185
+ return frameworks.contains { framework in
186
+ framework.hasSuffix(".framework")
187
+ && FileManager.default.fileExists(
188
+ atPath: frameworksURL.appendingPathComponent(framework)
189
+ .appendingPathComponent("Resources/chrome_100_percent.pak").path
190
+ )
191
+ }
93
192
  }
94
193
 
95
194
  private func snapshotFrontmostApp() throws -> SnapshotBuildResult {
@@ -166,6 +265,7 @@ private func appendApplicationSnapshot(
166
265
  depth: Int,
167
266
  parentIndex: Int?,
168
267
  surface: String,
268
+ maxDepth: Int = SnapshotTraversalLimits.maxDepth,
169
269
  state: inout SnapshotTraversalState
170
270
  ) -> Bool {
171
271
  let appElement = AXUIElementCreateApplication(app.processIdentifier)
@@ -207,7 +307,8 @@ private func appendApplicationSnapshot(
207
307
  appName: app.localizedName,
208
308
  windowTitle: windowTitle
209
309
  ),
210
- state: &state
310
+ state: &state,
311
+ maxDepth: maxDepth
211
312
  )
212
313
  }
213
314
 
@@ -542,6 +643,9 @@ private func appendElementSnapshot(
542
643
  )
543
644
 
544
645
  guard depth < maxDepth, !state.truncated else {
646
+ if depth >= maxDepth, !snapshotChildren(of: element, role: role).isEmpty {
647
+ state.depthCapped = true
648
+ }
545
649
  return index
546
650
  }
547
651
 
@@ -122,6 +122,12 @@ struct AgentDeviceMacOSHelper {
122
122
  return try handleRead(arguments: Array(arguments.dropFirst()))
123
123
  case "press":
124
124
  return try handlePress(arguments: Array(arguments.dropFirst()))
125
+ case "type":
126
+ return try handleType(arguments: Array(arguments.dropFirst()))
127
+ case "fill":
128
+ return try handleFill(arguments: Array(arguments.dropFirst()))
129
+ case "scroll":
130
+ return try handleScroll(arguments: Array(arguments.dropFirst()))
125
131
  case "screenshot":
126
132
  return try handleScreenshot(arguments: Array(arguments.dropFirst()))
127
133
  case "audio-probe":
@@ -343,18 +349,16 @@ struct AgentDeviceMacOSHelper {
343
349
  .lowercased(),
344
350
  !surface.isEmpty
345
351
  else {
346
- throw HelperError.invalidArgs("snapshot requires --surface <frontmost-app|desktop|menubar>")
352
+ throw HelperError.invalidArgs("snapshot requires --surface <app|frontmost-app|desktop|menubar>")
347
353
  }
348
354
 
349
355
  let bundleId = try optionValue(arguments: arguments, name: "--bundle-id").map(validatedBundleId)
350
356
 
351
357
  switch surface {
352
- case "frontmost-app":
353
- return SuccessEnvelope(data: try captureSnapshotResponse(surface: surface, bundleId: bundleId))
354
- case "desktop", "menubar":
358
+ case "app", "frontmost-app", "desktop", "menubar":
355
359
  return SuccessEnvelope(data: try captureSnapshotResponse(surface: surface, bundleId: bundleId))
356
360
  default:
357
- throw HelperError.invalidArgs("snapshot requires --surface <frontmost-app|desktop|menubar>")
361
+ throw HelperError.invalidArgs("snapshot requires --surface <app|frontmost-app|desktop|menubar>")
358
362
  }
359
363
  }
360
364
 
@@ -376,25 +380,25 @@ struct AgentDeviceMacOSHelper {
376
380
  static func handlePress(arguments: [String]) throws -> any Encodable {
377
381
  guard let rawX = optionValue(arguments: arguments, name: "--x"),
378
382
  let rawY = optionValue(arguments: arguments, name: "--y"),
379
- let x = Double(rawX),
380
- let y = Double(rawY)
383
+ let x = Double(rawX), x.isFinite,
384
+ let y = Double(rawY), y.isFinite
381
385
  else {
382
386
  throw HelperError.invalidArgs("press requires --x <number> --y <number>")
383
387
  }
384
388
 
385
- let holdMs = try validatedPressInt(
389
+ let holdMs = try validatedIntOption(
386
390
  optionValue(arguments: arguments, name: "--hold-ms"),
387
391
  name: "--hold-ms",
388
392
  minimum: 0,
389
393
  default: 0
390
394
  )
391
- let clicks = try validatedPressInt(
395
+ let clicks = try validatedIntOption(
392
396
  optionValue(arguments: arguments, name: "--clicks"),
393
397
  name: "--clicks",
394
398
  minimum: 1,
395
399
  default: 1
396
400
  )
397
- let intervalMs = try validatedPressInt(
401
+ let intervalMs = try validatedIntOption(
398
402
  optionValue(arguments: arguments, name: "--interval-ms"),
399
403
  name: "--interval-ms",
400
404
  minimum: 0,
@@ -412,6 +416,14 @@ struct AgentDeviceMacOSHelper {
412
416
  doubleClick: doubleClick,
413
417
  intervalMs: intervalMs
414
418
  )
419
+ if surface == "app" {
420
+ let app = try requireSessionApplication(bundleId: bundleId)
421
+ return SuccessEnvelope(
422
+ data: try withGhostCursor(arguments) { cursor in
423
+ try pressInBackground(request, app: app, cursor: cursor)
424
+ }
425
+ )
426
+ }
415
427
  try pressAtPosition(request)
416
428
  return SuccessEnvelope(
417
429
  data: PressResponse(
@@ -426,6 +438,71 @@ struct AgentDeviceMacOSHelper {
426
438
  )
427
439
  }
428
440
 
441
+ static func handleType(arguments: [String]) throws -> any Encodable {
442
+ guard let text = optionValue(arguments: arguments, name: "--text") else {
443
+ throw HelperError.invalidArgs("type requires --text <text>")
444
+ }
445
+ let delayMs = try validatedIntOption(
446
+ optionValue(arguments: arguments, name: "--delay-ms"),
447
+ name: "--delay-ms",
448
+ minimum: 0,
449
+ default: 0
450
+ )
451
+ let app = try requireSessionApplication(
452
+ bundleId: try optionValue(arguments: arguments, name: "--bundle-id").map(validatedBundleId)
453
+ )
454
+ return SuccessEnvelope(
455
+ data: try withGhostCursor(arguments) { cursor in
456
+ try typeInBackground(text: text, delayMs: delayMs, app: app, cursor: cursor)
457
+ }
458
+ )
459
+ }
460
+
461
+ static func handleFill(arguments: [String]) throws -> any Encodable {
462
+ guard let rawX = optionValue(arguments: arguments, name: "--x"),
463
+ let rawY = optionValue(arguments: arguments, name: "--y"),
464
+ let x = Double(rawX), x.isFinite,
465
+ let y = Double(rawY), y.isFinite,
466
+ let text = optionValue(arguments: arguments, name: "--text")
467
+ else {
468
+ throw HelperError.invalidArgs("fill requires --x <number> --y <number> --text <text>")
469
+ }
470
+ let app = try requireSessionApplication(
471
+ bundleId: try optionValue(arguments: arguments, name: "--bundle-id").map(validatedBundleId)
472
+ )
473
+ return SuccessEnvelope(
474
+ data: try withGhostCursor(arguments) { cursor in
475
+ try fillInBackground(
476
+ point: CGPoint(x: x, y: y), text: text, app: app, cursor: cursor)
477
+ }
478
+ )
479
+ }
480
+
481
+ static func handleScroll(arguments: [String]) throws -> any Encodable {
482
+ guard let direction = optionValue(arguments: arguments, name: "--direction") else {
483
+ throw HelperError.invalidArgs("scroll requires --direction <up|down|left|right>")
484
+ }
485
+ let amount = try positiveDoubleOption(arguments: arguments, name: "--amount")
486
+ let pixels = try positiveDoubleOption(arguments: arguments, name: "--pixels")
487
+ if amount != nil, pixels != nil {
488
+ throw HelperError.invalidArgs("scroll accepts --amount or --pixels, not both")
489
+ }
490
+ let app = try requireSessionApplication(
491
+ bundleId: try optionValue(arguments: arguments, name: "--bundle-id").map(validatedBundleId)
492
+ )
493
+ return SuccessEnvelope(
494
+ data: try withGhostCursor(arguments) { cursor in
495
+ try scrollInBackground(
496
+ direction: direction,
497
+ amount: amount,
498
+ pixels: pixels,
499
+ app: app,
500
+ cursor: cursor
501
+ )
502
+ }
503
+ )
504
+ }
505
+
429
506
  static func handleScreenshot(arguments: [String]) throws -> any Encodable {
430
507
  guard let outPath = optionValue(arguments: arguments, name: "--out")?
431
508
  .trimmingCharacters(in: .whitespacesAndNewlines),
@@ -435,6 +512,13 @@ struct AgentDeviceMacOSHelper {
435
512
  }
436
513
 
437
514
  let surface = optionValue(arguments: arguments, name: "--surface")
515
+ if surface == "app" {
516
+ let app = try requireSessionApplication(
517
+ bundleId: try optionValue(arguments: arguments, name: "--bundle-id").map(validatedBundleId)
518
+ )
519
+ try captureAppWindowScreenshot(app: app, outPath: outPath)
520
+ return SuccessEnvelope(data: ScreenshotResponse(path: outPath, surface: surface))
521
+ }
438
522
  try captureSurfaceScreenshot(surface: surface, outPath: outPath)
439
523
  return SuccessEnvelope(data: ScreenshotResponse(path: outPath, surface: surface))
440
524
  }
@@ -468,6 +552,21 @@ private func optionValue(arguments: [String], name: String) -> String? {
468
552
  return arguments[index + 1]
469
553
  }
470
554
 
555
+ /// Runs a background action under the ghost cursor when the host asked for one.
556
+ private func withGhostCursor<T>(_ arguments: [String], _ body: (GhostCursor?) throws -> T) throws -> T {
557
+ let cursor = arguments.contains("--ghost-cursor") ? GhostCursor.show() : nil
558
+ defer { cursor?.hide() }
559
+ return try body(cursor)
560
+ }
561
+
562
+ private func positiveDoubleOption(arguments: [String], name: String) throws -> Double? {
563
+ guard let raw = optionValue(arguments: arguments, name: name) else { return nil }
564
+ guard let value = Double(raw), value.isFinite, value > 0 else {
565
+ throw HelperError.invalidArgs("\(name) must be a positive number")
566
+ }
567
+ return value
568
+ }
569
+
471
570
  private func intOption(arguments: [String], name: String) -> Int? {
472
571
  guard let value = optionValue(arguments: arguments, name: name) else {
473
572
  return nil
@@ -475,7 +574,7 @@ private func intOption(arguments: [String], name: String) -> Int? {
475
574
  return Int(value)
476
575
  }
477
576
 
478
- private func validatedPressInt(
577
+ private func validatedIntOption(
479
578
  _ raw: String?,
480
579
  name: String,
481
580
  minimum: Int,
@@ -485,22 +584,30 @@ private func validatedPressInt(
485
584
  return fallback
486
585
  }
487
586
  guard let value = Int(raw), value >= minimum else {
488
- throw HelperError.invalidArgs("press \(name) must be an integer of at least \(minimum)")
587
+ throw HelperError.invalidArgs("\(name) must be an integer of at least \(minimum)")
489
588
  }
490
589
  return value
491
590
  }
492
591
 
493
592
  private func readTextAtPosition(bundleId: String?, surface: String?, x: Double, y: Double) throws -> String {
494
593
  let targetApp: NSRunningApplication?
495
- if surface == "frontmost-app" || (surface == nil && bundleId != nil) {
594
+ if surface == "app" {
595
+ targetApp = try requireSessionApplication(bundleId: bundleId)
596
+ } else if surface == "frontmost-app" || (surface == nil && bundleId != nil) {
496
597
  targetApp = try resolveTargetApplication(bundleId: bundleId, surface: surface)
497
598
  } else {
498
599
  targetApp = nil
499
600
  }
500
601
 
501
- let systemWide = AXUIElementCreateSystemWide()
602
+ // An app session's window may sit behind other apps, so it is hit-tested on its own.
603
+ let hitRoot =
604
+ if surface == "app", let targetApp {
605
+ AXUIElementCreateApplication(targetApp.processIdentifier)
606
+ } else {
607
+ AXUIElementCreateSystemWide()
608
+ }
502
609
  var hitElement: AXUIElement?
503
- guard AXUIElementCopyElementAtPosition(systemWide, Float(x), Float(y), &hitElement) == .success,
610
+ guard AXUIElementCopyElementAtPosition(hitRoot, Float(x), Float(y), &hitElement) == .success,
504
611
  let hitElement
505
612
  else {
506
613
  throw HelperError.commandFailed("read did not resolve an accessibility element")
@@ -567,18 +674,25 @@ private func captureSurfaceScreenshot(surface: String?, outPath: String) throws
567
674
  semaphore.wait()
568
675
 
569
676
  if let error = capturedError as NSError? {
570
- if error.domain == "com.apple.ScreenCaptureKit.SCStreamErrorDomain", error.code == -3801 {
571
- throw HelperError.commandFailed(
572
- "screenshot requires Screen Recording permission on macOS desktop and menubar surfaces",
573
- details: ["surface": surface ?? "", "permission": "screen-recording"]
574
- )
575
- }
576
- throw HelperError.commandFailed("screenshot failed", details: ["error": error.localizedDescription])
677
+ throw screenshotFailure(error, surface: surface)
577
678
  }
578
679
  guard let capturedImage else {
579
680
  throw HelperError.commandFailed("screenshot failed")
580
681
  }
682
+ try writePNG(capturedImage, to: outPath)
683
+ }
684
+
685
+ func screenshotFailure(_ error: NSError, surface: String?) -> HelperError {
686
+ if error.domain == "com.apple.ScreenCaptureKit.SCStreamErrorDomain", error.code == -3801 {
687
+ return HelperError.commandFailed(
688
+ "screenshot requires Screen Recording permission on the macOS \(surface ?? "desktop") surface",
689
+ details: ["surface": surface ?? "", "permission": "screen-recording"]
690
+ )
691
+ }
692
+ return HelperError.commandFailed("screenshot failed", details: ["error": error.localizedDescription])
693
+ }
581
694
 
695
+ func writePNG(_ capturedImage: CGImage, to outPath: String) throws {
582
696
  let outputURL = URL(fileURLWithPath: outPath)
583
697
  if let parent = outputURL.deletingLastPathComponent().path.removingPercentEncoding, !parent.isEmpty {
584
698
  try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
@@ -0,0 +1,88 @@
1
+ import XCTest
2
+
3
+ @testable import AgentDeviceMacOSHelper
4
+
5
+ private struct ScrollGestureFixture: Decodable {
6
+ struct Constants: Decodable {
7
+ let defaultScrollAmount: Double
8
+ }
9
+ struct Expected: Decodable {
10
+ let pixels: Double
11
+ }
12
+ struct Case: Decodable {
13
+ let name: String
14
+ let direction: String
15
+ let amount: Double?
16
+ let pixels: Double?
17
+ let referenceWidth: Double
18
+ let referenceHeight: Double
19
+ let expected: Expected
20
+ }
21
+
22
+ let constants: Constants
23
+ let cases: [Case]
24
+ }
25
+
26
+ private struct HelperOutcomesFixture: Decodable {
27
+ let refusalReasons: [String]
28
+ let deliveryMechanisms: [String]
29
+ }
30
+
31
+ /// `#filePath` is `<repo>/apple/macos-helper/Tests/AgentDeviceMacOSHelperTests/<file>`.
32
+ private let repoRoot = URL(fileURLWithPath: #filePath)
33
+ .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
34
+ .deletingLastPathComponent().deletingLastPathComponent()
35
+
36
+ final class BackgroundInteractionTests: XCTestCase {
37
+ /// The host keys refusals and mechanisms on these strings; the table is their one declaration.
38
+ func testOutcomeVocabularyMatchesTheSharedTable() throws {
39
+ let fixture = try JSONDecoder().decode(
40
+ HelperOutcomesFixture.self,
41
+ from: Data(
42
+ contentsOf: repoRoot.appendingPathComponent("contracts/fixtures/macos-native-helper-outcomes.json"))
43
+ )
44
+ XCTAssertEqual(BackgroundRefusal.allCases.map(\.rawValue), fixture.refusalReasons)
45
+ XCTAssertEqual(BackgroundDeliveryMechanism.allCases.map(\.rawValue), fixture.deliveryMechanisms)
46
+ }
47
+
48
+ /// The native backend scrolls as far as the runner does: its travel agrees with every case of
49
+ /// the cross-language table the runner and the TypeScript planner are held to.
50
+ func testScrollTravelMatchesTheSharedScrollGestureTable() throws {
51
+ let fixtureURL = repoRoot.appendingPathComponent("contracts/fixtures/scroll-gesture.json")
52
+ let fixture = try JSONDecoder().decode(
53
+ ScrollGestureFixture.self, from: Data(contentsOf: fixtureURL))
54
+ XCTAssertFalse(fixture.cases.isEmpty)
55
+ for testCase in fixture.cases {
56
+ let isVertical = testCase.direction == "up" || testCase.direction == "down"
57
+ let travel = scrollTravelPixels(
58
+ axisLength: isVertical ? testCase.referenceHeight : testCase.referenceWidth,
59
+ amount: testCase.amount,
60
+ pixels: testCase.pixels
61
+ )
62
+ XCTAssertEqual(travel, testCase.expected.pixels, testCase.name)
63
+ }
64
+ XCTAssertEqual(
65
+ scrollTravelPixels(axisLength: 1000, amount: nil, pixels: nil),
66
+ (1000 * fixture.constants.defaultScrollAmount).rounded(),
67
+ "an unspecified distance travels the table's default amount"
68
+ )
69
+ }
70
+
71
+ func testScrollBarValueMovesByTheTravelShareOfTheOverflow() {
72
+ XCTAssertEqual(scrollBarValue(current: 0, signedTravel: 300, overflow: 1200), 0.25)
73
+ XCTAssertEqual(scrollBarValue(current: 0.5, signedTravel: -300, overflow: 1200), 0.25)
74
+ }
75
+
76
+ func testScrollBarValueStopsAtTheEnds() {
77
+ XCTAssertEqual(scrollBarValue(current: 0.9, signedTravel: 600, overflow: 1200), 1)
78
+ XCTAssertEqual(scrollBarValue(current: 0.1, signedTravel: -600, overflow: 1200), 0)
79
+ }
80
+
81
+ /// Chromium marks wrapper groups pressable; only a control role may win the press outright.
82
+ func testWrapperGroupsAreNotPressableControls() {
83
+ XCTAssertTrue(isPressableControlRole("AXButton"))
84
+ XCTAssertTrue(isPressableControlRole("AXLink"))
85
+ XCTAssertFalse(isPressableControlRole("AXGroup"))
86
+ XCTAssertFalse(isPressableControlRole("AXWebArea"))
87
+ }
88
+ }