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.
- 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
- package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.21.apk.sha256 +1 -0
- 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
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.21.apk +0 -0
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.21.apk.sha256 +1 -0
- 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
- package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/AppWindowScreenshot.swift +73 -0
- package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/BackgroundInteraction.swift +557 -0
- package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/GhostCursor.swift +141 -0
- package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/SnapshotTraversal.swift +107 -3
- package/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +136 -22
- package/apple/macos-helper/Tests/AgentDeviceMacOSHelperTests/BackgroundInteractionTests.swift +88 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +121 -4
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +10 -2
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +36 -9
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +28 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +27 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift +6 -1
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +7 -2
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +21 -7
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +121 -17
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +179 -6
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryConfirmation.swift +119 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift +8 -1
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryReadiness.swift +23 -11
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift +59 -6
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +112 -23
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift +25 -12
- package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/RecordingExportSupport.swift +6 -1
- package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +43 -13
- package/dist/src/adapter.js +1 -1
- package/dist/src/adb-executor.js +2 -2
- package/dist/src/adb-failure.js +1 -1
- package/dist/src/adb-transport.js +1 -1
- package/dist/src/agent-browser-lifecycle.js +1 -1
- package/dist/src/agent-browser-process-record.js +1 -1
- package/dist/src/agent-browser-provider.js +1 -1
- package/dist/src/agent-browser-tool.js +1 -1
- package/dist/src/agent-device-client.js +2 -2
- package/dist/src/ai-sdk.js +1 -1
- package/dist/src/alert-runtime.js +1 -1
- package/dist/src/alert.js +1 -1
- package/dist/src/android-adb.d.ts +12 -0
- package/dist/src/android-input-ownership.js +1 -1
- package/dist/src/app-backend.js +1 -0
- package/dist/src/app-catalog.js +1 -1
- package/dist/src/app-deployment-resolution.js +1 -1
- package/dist/src/app-inventory.js +1 -1
- package/dist/src/app-launch.js +1 -1
- package/dist/src/app-lifecycle.js +2 -2
- package/dist/src/app-lifecycle2.js +1 -1
- package/dist/src/app-log-files.js +1 -1
- package/dist/src/app-log-reconnect.js +1 -1
- package/dist/src/app-log-runtime2.js +3 -3
- package/dist/src/app-log-session-resource.js +1 -0
- package/dist/src/app-resolution.js +2 -2
- package/dist/src/apple-runner-platform.js +1 -1
- package/dist/src/application-lifecycle-interaction.js +1 -1
- package/dist/src/archive.js +2 -2
- package/dist/src/artifact-download.js +1 -1
- package/dist/src/auth-session.js +1 -1
- package/dist/src/auth.js +1 -1
- package/dist/src/back-mode.js +1 -1
- package/dist/src/backend-snapshot-options.js +1 -1
- package/dist/src/boot-diagnostics.js +1 -1
- package/dist/src/cache.js +1 -1
- package/dist/src/capabilities.js +1 -1
- package/dist/src/capture.js +1 -1
- package/dist/src/cli-config.js +1 -1
- package/dist/src/cli-help.js +13 -5
- package/dist/src/cli.js +4 -4
- package/dist/src/click-button.js +1 -1
- package/dist/src/client-metro-companion.js +1 -1
- package/dist/src/client-metro.js +1 -1
- package/dist/src/client-options.js +3 -0
- package/dist/src/client-types.d.ts +88 -5
- package/dist/src/clipboard-shell-response.js +1 -1
- package/dist/src/cloud-artifacts.d.ts +7 -2
- package/dist/src/code-signature-cache.js +1 -1
- package/dist/src/code-signature.js +1 -1
- package/dist/src/command-schema.js +1 -1
- package/dist/src/command-tools.js +2 -2
- package/dist/src/connection-runtime.js +1 -1
- package/dist/src/connection.js +5 -5
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/contracts.js +1 -1
- package/dist/src/daemon-artifacts.js +1 -0
- package/dist/src/daemon-client-lease-beat.js +1 -1
- package/dist/src/daemon-client-lifecycle.js +1 -1
- package/dist/src/daemon-client-liveness-probe.js +1 -0
- package/dist/src/daemon-client-transport.js +1 -1
- package/dist/src/daemon-http.js +1 -0
- package/dist/src/daemon-policy-file.js +1 -1
- package/dist/src/daemon-registration-owner.js +1 -0
- package/dist/src/daemon-registration.js +1 -1
- package/dist/src/daemon-resolution.js +1 -1
- package/dist/src/daemon-shutdown-report.js +1 -1
- package/dist/src/daemon.js +2 -2
- package/dist/src/debug-symbols-facade.js +1 -1
- package/dist/src/device-claim-inspection.js +1 -1
- package/dist/src/device-claim-rule.js +1 -1
- package/dist/src/device-inventory-context.js +1 -1
- package/dist/src/device-readiness-runtime.js +1 -1
- package/dist/src/device-release.js +1 -1
- package/dist/src/device-rotation.js +1 -1
- package/dist/src/device-selection-resolver.js +1 -1
- package/dist/src/device-session.js +1 -1
- package/dist/src/device-shell.js +1 -1
- package/dist/src/device.js +1 -1
- package/dist/src/device2.js +1 -3
- package/dist/src/device3.js +3 -0
- package/dist/src/devicectl.js +1 -1
- package/dist/src/diagnostics.js +3 -3
- package/dist/src/dispatch-resolve.js +4 -1
- package/dist/src/display-inventory.js +1 -1
- package/dist/src/doctor-facade.js +1 -1
- package/dist/src/doctor2.js +1 -1
- package/dist/src/durable-capture-resource.js +1 -0
- package/dist/src/engine-eval-script.js +1 -1
- package/dist/src/error.js +1 -1
- package/dist/src/exec.js +1 -1
- package/dist/src/file.js +1 -1
- package/dist/src/find.js +1 -1
- package/dist/src/generic-settle.js +1 -1
- package/dist/src/generic.js +1 -1
- package/dist/src/gesture-admission.js +1 -1
- package/dist/src/gesture-plan.js +1 -1
- package/dist/src/gesture-runtime.js +1 -1
- package/dist/src/harmonyos.js +1 -1
- package/dist/src/hdc.js +1 -1
- package/dist/src/helper.js +2 -2
- package/dist/src/host-file.js +1 -1
- package/dist/src/host.js +1 -1
- package/dist/src/human-control-contract.js +1 -1
- package/dist/src/human-control.js +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/input-actions.js +1 -1
- package/dist/src/input-actions2.js +2 -2
- package/dist/src/install-artifact-facade.js +1 -1
- package/dist/src/install-source-network-transport.js +1 -1
- package/dist/src/install-source.d.ts +5 -0
- package/dist/src/install-source2.js +1 -1
- package/dist/src/instance-client.js +1 -0
- package/dist/src/interaction-positionals.js +1 -1
- package/dist/src/interaction-ref-policy.js +1 -1
- package/dist/src/interaction.js +1 -1
- package/dist/src/interaction2.js +1 -1
- package/dist/src/interactor-operation-binding.js +1 -1
- package/dist/src/interactor.js +5 -5
- package/dist/src/interactor2.js +1 -1
- package/dist/src/interactors.js +1 -1
- package/dist/src/internal/bin.js +1 -1
- package/dist/src/internal/daemon.js +1 -2
- package/dist/src/internal/png-worker.js +1 -1
- package/dist/src/internal/run-script-http-child.js +1 -1
- package/dist/src/inventory.js +4 -4
- package/dist/src/inventory2.js +1 -1
- package/dist/src/inventory3.js +1 -1
- package/dist/src/inventory5.js +1 -1
- package/dist/src/ios-client.js +4 -30
- package/dist/src/ios-settings.js +1 -0
- package/dist/src/ios-snapshot-engine.js +1 -1
- package/dist/src/ios-snapshot-runtime.js +1 -1
- package/dist/src/launch-confirmation.js +1 -1
- package/dist/src/lease-context.js +1 -1
- package/dist/src/lease-registry-scope.js +1 -0
- package/dist/src/lease-scope.js +1 -1
- package/dist/src/lease.js +1 -1
- package/dist/src/legacy-xctest-device-set.js +1 -1
- package/dist/src/limrun-download-file.js +1 -1
- package/dist/src/limrun-runtime-dependencies.js +1 -1
- package/dist/src/limrun.d.ts +28 -2
- package/dist/src/limrun.js +1 -1
- package/dist/src/linux.js +1 -1
- package/dist/src/load.js +1 -0
- package/dist/src/location-coordinates.js +1 -1
- package/dist/src/macos-facade.js +1 -1
- package/dist/src/managed-device-scope.js +1 -1
- package/dist/src/mechanics.js +16 -16
- package/dist/src/observation.js +1 -1
- package/dist/src/observe-until.js +1 -1
- package/dist/src/overlay.js +2 -2
- package/dist/src/owner-identity.js +3 -3
- package/dist/src/path-resolution.js +1 -1
- package/dist/src/perf-facade.js +1 -1
- package/dist/src/perf-runtime-operation-builder.js +1 -1
- package/dist/src/perf-runtime-plan.js +1 -1
- package/dist/src/perf-target.js +1 -1
- package/dist/src/perf.js +1 -1
- package/dist/src/physical-device-control.js +1 -1
- package/dist/src/platform-request-scope.js +1 -1
- package/dist/src/platform-runtime-android-adb-host.js +1 -1
- package/dist/src/platform-runtime-app-log-process.js +1 -1
- package/dist/src/platform-runtime-host.js +1 -1
- package/dist/src/platform-runtime-managed-owner.js +1 -1
- package/dist/src/platform-runtime-open-target.js +1 -1
- package/dist/src/platform-runtime-operation-host.js +2 -2
- package/dist/src/platform-runtime-runtime-hints.js +2 -2
- package/dist/src/platform-runtime-screen-recording-android-host.js +1 -1
- package/dist/src/platform-runtime-screen-recording-apple-runner-transport.js +1 -1
- package/dist/src/platform-runtime-screen-recording-apple-simulator-host.js +1 -1
- package/dist/src/platform-runtime-screen-recording-finalizer-host.js +1 -1
- package/dist/src/platform-runtime-screen-recording-output-host.js +1 -1
- package/dist/src/platform-runtime-screen-recording-process-host.js +1 -1
- package/dist/src/platform-runtime-unavailable.js +1 -1
- package/dist/src/platform-runtime.js +1 -1
- package/dist/src/platform-runtime2.js +1 -1
- package/dist/src/plugins.d.ts +9 -0
- package/dist/src/plugins.js +1 -0
- package/dist/src/plugins2.js +2 -0
- package/dist/src/png-crop-bytes.js +1 -1
- package/dist/src/png-size.js +1 -1
- package/dist/src/png-worker-client.js +1 -1
- package/dist/src/png.js +1 -1
- package/dist/src/prepare-kind.js +1 -1
- package/dist/src/process.js +1 -1
- package/dist/src/project-runtime.js +1 -1
- package/dist/src/provider-credential-fingerprint.js +1 -0
- package/dist/src/provider-device-admission.js +1 -1
- package/dist/src/provider-device-runtimes.js +1 -0
- package/dist/src/provider-limrun-credentials.js +1 -0
- package/dist/src/provider-profile-fields.js +1 -0
- package/dist/src/provider-session-ownership.js +2 -2
- package/dist/src/provider-webdriver.js +2 -1
- package/dist/src/providers.js +1 -1
- package/dist/src/proxy.js +1 -1
- package/dist/src/quality-warnings.js +1 -1
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-runtime.js +1 -1
- package/dist/src/record-trace.js +1 -1
- package/dist/src/recording.js +1 -1
- package/dist/src/rect-center.js +1 -1
- package/dist/src/register-builtins.js +1 -1
- package/dist/src/registry.js +39 -39
- package/dist/src/remote-config2.js +1 -1
- package/dist/src/replay-divergence.js +1 -1
- package/dist/src/replay.js +2 -2
- package/dist/src/reporting.js +5 -5
- package/dist/src/request-admission.js +1 -1
- package/dist/src/request-cancel.js +1 -1
- package/dist/src/request-cancellation.js +1 -1
- package/dist/src/request-runtime-binding.js +1 -1
- package/dist/src/result-serialization.js +1 -1
- package/dist/src/retry.js +1 -1
- package/dist/src/runner-artifact-manifest.js +1 -1
- package/dist/src/runner-cache-metadata.js +3 -3
- package/dist/src/runner-client.js +1 -1
- package/dist/src/runner-dev-tools-security.js +1 -1
- package/dist/src/runner-device-readiness.js +1 -1
- package/dist/src/runner-disposal.js +1 -1
- package/dist/src/runner-error-classification.js +2 -2
- package/dist/src/runner-exchange.js +1 -1
- package/dist/src/runner-operations-facade.js +1 -1
- package/dist/src/runner-session-types.js +1 -1
- package/dist/src/runner-start-budget.js +1 -1
- package/dist/src/runner.js +1 -1
- package/dist/src/runtime-admission.js +1 -1
- package/dist/src/runtime-transport-hints.js +1 -1
- package/dist/src/runtime.js +1 -1
- package/dist/src/runtime10.js +2 -2
- package/dist/src/runtime2.js +3 -3
- package/dist/src/runtime3.js +1 -1
- package/dist/src/runtime4.js +5 -5
- package/dist/src/runtime5.js +1 -1
- package/dist/src/runtime6.js +1 -1
- package/dist/src/runtime7.js +1 -1
- package/dist/src/runtime8.js +1 -1
- package/dist/src/runtime9.js +1 -1
- package/dist/src/screen-recording-admission-ledger.js +1 -1
- package/dist/src/screen-recording-completion.js +1 -1
- package/dist/src/screen-recording-options.js +1 -1
- package/dist/src/screen-recording-resource-recovery.js +1 -1
- package/dist/src/screen-recording-transport.js +1 -1
- package/dist/src/screenshot-density.js +1 -1
- package/dist/src/screenshot-image.js +1 -1
- package/dist/src/screenshot-result.js +1 -1
- package/dist/src/screenshot-runtime.js +1 -1
- package/dist/src/screenshot.js +1 -1
- package/dist/src/screenshot3.js +1 -1
- package/dist/src/script.js +1 -1
- package/dist/src/scroll-command.js +1 -1
- package/dist/src/scroll-gesture.js +1 -1
- package/dist/src/sdk-batch-runner.js +2 -2
- package/dist/src/sdk-batch.js +2 -2
- package/dist/src/sdk-contracts.d.ts +45 -2
- package/dist/src/sdk-contracts.js +2 -2
- package/dist/src/sdk-device.js +1 -1
- package/dist/src/sdk-io.js +1 -1
- package/dist/src/sdk-remote-config.js +1 -1
- package/dist/src/sdk-selectors.d.ts +33 -4
- package/dist/src/selection.js +0 -0
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/selectors.d.ts +2 -2
- package/dist/src/selectors.js +1 -1
- package/dist/src/server.js +1 -1
- package/dist/src/session-allocation.js +1 -1
- package/dist/src/session-artifact-paths.js +1 -1
- package/dist/src/session-repair-tombstone.js +1 -1
- package/dist/src/session-routing.js +1 -1
- package/dist/src/session-runtime-admission.js +1 -1
- package/dist/src/session-snapshot.js +1 -1
- package/dist/src/session-stream.js +27 -0
- package/dist/src/session.js +1 -1
- package/dist/src/session2.js +5 -5
- package/dist/src/settings.js +1 -1
- package/dist/src/settings2.js +1 -1
- package/dist/src/simctl-settings-facade.js +1 -0
- package/dist/src/simctl-settings.js +1 -0
- package/dist/src/simctl.js +1 -1
- package/dist/src/simulator.js +1 -1
- package/dist/src/snapshot-diagnostics.js +1 -1
- package/dist/src/snapshot-runtime-binding.js +1 -1
- package/dist/src/snapshot-runtime.js +1 -1
- package/dist/src/snapshot-state.js +1 -1
- package/dist/src/snapshot2.js +1 -1
- package/dist/src/snapshot3.js +1 -1
- package/dist/src/snapshot4.js +1 -1
- package/dist/src/src.js +1 -1
- package/dist/src/src7.js +1 -1
- package/dist/src/src8.js +2 -2
- package/dist/src/store.js +1 -0
- package/dist/src/string-enum.js +1 -1
- package/dist/src/surface-snapshot.js +1 -1
- package/dist/src/takeover.js +1 -1
- package/dist/src/target-evidence.js +1 -1
- package/dist/src/telemetry.js +1 -1
- package/dist/src/text-entry-focus.js +1 -1
- package/dist/src/tool-provider.js +1 -1
- package/dist/src/tool-provider2.js +1 -1
- package/dist/src/toolchain-probe.js +2 -2
- package/dist/src/touch-runtime.js +1 -1
- package/dist/src/touch-runtime2.js +1 -1
- package/dist/src/transport.js +2 -2
- package/dist/src/tv-remote.js +1 -1
- package/dist/src/type-text-runtime.js +1 -1
- package/dist/src/update-check.js +1 -1
- package/dist/src/vars.js +1 -1
- package/dist/src/verified-file.js +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/video.js +1 -1
- package/dist/src/viewport.js +1 -1
- package/dist/src/web.js +1 -1
- package/dist/src/web2.js +1 -1
- package/dist/src/webdriver-source.js +1 -1
- package/package.json +5 -1
- package/server.json +2 -2
- package/android/ime-helper/dist/agent-device-android-ime-helper-0.21.20.apk.sha256 +0 -1
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.20.apk +0 -0
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.21.20.apk.sha256 +0 -1
- package/dist/src/api.js +0 -3
- package/dist/src/daemon-process.js +0 -1
- package/dist/src/multitouch-support.js +0 -1
- package/dist/src/parameterized-recorded-fill.js +0 -1
- package/dist/src/runner-startup-transport.js +0 -1
- package/dist/src/runner-xctestrun.js +0 -1
- 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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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 == "
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|