Files
openclaw/apps/macos/Sources/OpenClaw/ComputerActionService.swift
2026-07-10 08:37:37 +01:00

1199 lines
49 KiB
Swift

import AppKit
import CoreGraphics
import Foundation
import OpenClawKit
import PeekabooAutomationKit
import PeekabooFoundation
@preconcurrency import ScreenCaptureKit
/// Linearizes caller cancellation against action completion outside MainActor.
/// The cancellation handler must record authority loss synchronously; its actor
/// hop is only a best-effort fast path for canceling work and releasing input.
private final class ComputerActionCancellationState: @unchecked Sendable {
private enum Phase {
case active
case cancelled
case completed
}
private let lock = NSLock()
private var phase: Phase = .active
private var operationReleaseSucceeded = false
var isCancelled: Bool {
self.lock.withLock { self.phase == .cancelled }
}
func requestCancellation() -> Bool {
self.lock.withLock {
guard self.phase == .active else { return false }
self.phase = .cancelled
return true
}
}
func recordOperationReleaseSuccess() {
self.lock.withLock {
guard self.phase == .cancelled else { return }
self.operationReleaseSucceeded = true
}
}
func finish() -> (wasCancelled: Bool, needsRelease: Bool) {
self.lock.withLock {
let wasCancelled = self.phase == .cancelled
let needsRelease = wasCancelled && !self.operationReleaseSucceeded
self.phase = .completed
return (wasCancelled, needsRelease)
}
}
}
/// Serializes native computer actions and carries the runtime lifecycle epoch
/// across the actor hop. A newer epoch releases held input and invalidates every
/// older queued or suspended action before another action can start.
@MainActor
final class ComputerActionExecutionQueue {
typealias Operation = @MainActor (OpenClawComputerActParams, UInt64) async throws
-> OpenClawComputerActResult
typealias CancellationHop = @Sendable (
@escaping @MainActor @Sendable () -> Void) -> Void
private struct QueuedAction {
let id: UUID
let params: OpenClawComputerActParams
let lifecycleGeneration: UInt64
let operation: Operation
let continuation: CheckedContinuation<OpenClawComputerActResult, Error>
let cancellationState: ComputerActionCancellationState
}
private let onLifecycleRelease: @MainActor () -> Bool
private let scheduleCancellationHop: CancellationHop
private var lifecycleGeneration: UInt64 = 0
private var pendingActions: [QueuedAction] = []
private var drainTask: Task<Void, Never>?
private var currentActionID: UUID?
private var currentActionGeneration: UInt64?
private var currentActionCancellationState: ComputerActionCancellationState?
private var currentActionTask: Task<OpenClawComputerActResult, Error>?
private var lifecycleReleasePending = false
init(
onLifecycleRelease: @escaping @MainActor () -> Bool,
scheduleCancellationHop: @escaping CancellationHop = { operation in
Task { @MainActor in operation() }
})
{
self.onLifecycleRelease = onLifecycleRelease
self.scheduleCancellationHop = scheduleCancellationHop
}
func perform(
_ params: OpenClawComputerActParams,
lifecycleGeneration: UInt64,
operation: @escaping Operation) async throws -> OpenClawComputerActResult
{
let actionID = UUID()
let cancellationState = ComputerActionCancellationState()
if lifecycleGeneration > self.lifecycleGeneration {
self.advanceLifecycle(to: lifecycleGeneration)
}
guard lifecycleGeneration == self.lifecycleGeneration else {
throw ComputerActionService.ComputerActionError.lifecycleChanged
}
try await self.waitForLifecycleRelease(lifecycleGeneration: lifecycleGeneration)
try Task.checkCancellation()
let scheduleCancellationHop = self.scheduleCancellationHop
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
guard !Task.isCancelled, !cancellationState.isCancelled else {
continuation.resume(throwing: CancellationError())
return
}
self.pendingActions.append(QueuedAction(
id: actionID,
params: params,
lifecycleGeneration: lifecycleGeneration,
operation: operation,
continuation: continuation,
cancellationState: cancellationState))
self.startDrainIfNeeded()
}
} onCancel: {
guard cancellationState.requestCancellation() else { return }
scheduleCancellationHop { @MainActor [weak self] in
self?.cancelAction(id: actionID)
}
}
}
func releaseHeldInput(lifecycleGeneration: UInt64) async {
guard lifecycleGeneration > self.lifecycleGeneration else { return }
let activeTask = self.currentActionTask
self.advanceLifecycle(to: lifecycleGeneration)
if let activeTask {
// Cancellation is cooperative. Do not let a replacement route install
// while an old operation can still post input. The operation task's
// lifecycle defer performs the scoped catch-up button release.
_ = try? await activeTask.value
}
try? await self.waitForLifecycleRelease(lifecycleGeneration: lifecycleGeneration)
}
func checkExecutionAllowed(lifecycleGeneration: UInt64) throws {
try Task.checkCancellation()
guard self.currentActionCancellationState?.isCancelled != true else {
throw CancellationError()
}
guard lifecycleGeneration == self.lifecycleGeneration else {
throw ComputerActionService.ComputerActionError.lifecycleChanged
}
}
#if DEBUG
var pendingActionCountForTesting: Int {
self.pendingActions.count
}
var lifecycleGenerationForTesting: UInt64 {
self.lifecycleGeneration
}
#endif
private func startDrainIfNeeded() {
guard self.drainTask == nil else { return }
self.drainTask = Task { @MainActor [weak self] in
await self?.drain()
}
}
private func drain() async {
while !self.pendingActions.isEmpty {
let queued = self.pendingActions.removeFirst()
guard queued.lifecycleGeneration == self.lifecycleGeneration else {
_ = queued.cancellationState.finish()
queued.continuation.resume(
throwing: ComputerActionService.ComputerActionError.lifecycleChanged)
continue
}
do {
try await self.waitForLifecycleRelease(
lifecycleGeneration: queued.lifecycleGeneration,
cancellationState: queued.cancellationState)
} catch {
_ = queued.cancellationState.finish()
queued.continuation.resume(throwing: error)
continue
}
guard !queued.cancellationState.isCancelled else {
_ = queued.cancellationState.finish()
queued.continuation.resume(throwing: CancellationError())
continue
}
self.currentActionID = queued.id
self.currentActionGeneration = queued.lifecycleGeneration
self.currentActionCancellationState = queued.cancellationState
let operationTask = Task { @MainActor [weak self] in
guard let self else { throw CancellationError() }
defer {
// An operation can ignore cancellation and arm a button after
// advanceLifecycle's immediate release. Catch it here, before
// this task completes and the drain admits newer-generation work.
let callerCancelled = queued.cancellationState.isCancelled
if Task.isCancelled || callerCancelled
|| queued.lifecycleGeneration != self.lifecycleGeneration
{
let released = self.attemptLifecycleRelease()
if callerCancelled, released {
queued.cancellationState.recordOperationReleaseSuccess()
}
}
}
guard !queued.cancellationState.isCancelled else { throw CancellationError() }
try Task.checkCancellation()
return try await queued.operation(queued.params, queued.lifecycleGeneration)
}
self.currentActionTask = operationTask
let outcome: Result<OpenClawComputerActResult, Error>
do {
outcome = try await .success(operationTask.value)
} catch {
outcome = .failure(error)
}
let cancellation = queued.cancellationState.finish()
if cancellation.needsRelease {
// Cancellation can win after the operation defer but before the
// result is committed. Release here so the actor hop cannot miss
// a just-finished left_mouse_down.
self.attemptLifecycleRelease()
}
if cancellation.wasCancelled {
// A failed synthetic mouse-up keeps lifecycleReleasePending set.
// Cancellation is not complete until the owned button is released
// or a newer lifecycle takes responsibility for the retry.
try? await self.waitForLifecycleRelease(
lifecycleGeneration: queued.lifecycleGeneration)
}
let lifecycleChanged = queued.lifecycleGeneration != self.lifecycleGeneration
self.currentActionID = nil
self.currentActionGeneration = nil
self.currentActionCancellationState = nil
self.currentActionTask = nil
if lifecycleChanged {
queued.continuation.resume(
throwing: ComputerActionService.ComputerActionError.lifecycleChanged)
} else if cancellation.wasCancelled {
queued.continuation.resume(throwing: CancellationError())
} else {
queued.continuation.resume(with: outcome)
}
}
self.drainTask = nil
}
private func advanceLifecycle(to generation: UInt64) {
guard generation > self.lifecycleGeneration else { return }
self.lifecycleGeneration = generation
if let currentActionGeneration, currentActionGeneration < generation {
self.currentActionTask?.cancel()
}
self.attemptLifecycleRelease()
let staleActions = self.pendingActions.filter { $0.lifecycleGeneration < generation }
self.pendingActions.removeAll { $0.lifecycleGeneration < generation }
for queued in staleActions {
_ = queued.cancellationState.finish()
queued.continuation.resume(
throwing: ComputerActionService.ComputerActionError.lifecycleChanged)
}
}
private func cancelAction(id: UUID) {
if let index = pendingActions.firstIndex(where: { $0.id == id }) {
let queued = self.pendingActions.remove(at: index)
_ = queued.cancellationState.finish()
queued.continuation.resume(throwing: CancellationError())
return
}
guard self.currentActionID == id else { return }
// A canceled action may already have posted left_mouse_down. Release now,
// and let the operation-task defer catch any later cancellation-ignoring post.
self.attemptLifecycleRelease()
self.currentActionTask?.cancel()
}
@discardableResult
private func attemptLifecycleRelease() -> Bool {
let released = self.onLifecycleRelease()
self.lifecycleReleasePending = !released
return released
}
private func waitForLifecycleRelease(
lifecycleGeneration: UInt64,
cancellationState: ComputerActionCancellationState? = nil) async throws
{
while self.lifecycleReleasePending {
try Task.checkCancellation()
if cancellationState?.isCancelled == true {
throw CancellationError()
}
guard lifecycleGeneration == self.lifecycleGeneration else {
throw ComputerActionService.ComputerActionError.lifecycleChanged
}
self.attemptLifecycleRelease()
guard self.lifecycleReleasePending else { return }
try await Task.sleep(for: .milliseconds(100))
}
}
}
/// Fulfills `computer.act` on this Mac by driving the embedded Peekaboo
/// automation engine in-process. Peekaboo covers single/right/double click,
/// move, drag, scroll, and key/hold. A narrow CoreGraphics path handles
/// grapheme-atomic typing plus computer_20251124 primitives Peekaboo cannot
/// express: middle/triple click, separate mouse down/up, and modified input.
@MainActor
final class ComputerActionService {
typealias MouseButtonEventPoster = @MainActor (
_ down: Bool,
_ point: CGPoint,
_ flags: CGEventFlags) throws -> Void
typealias MouseEventFactory = @MainActor (
_ type: CGEventType,
_ point: CGPoint,
_ button: CGMouseButton,
_ clickState: Int,
_ flags: CGEventFlags) throws -> CGEvent
typealias MouseEventPoster = @MainActor (_ event: CGEvent) throws -> Void
/// Posts one Swift grapheme as an atomic key-down/key-up pair. Cancellation
/// checkpoints live between calls so authority loss cannot strand a key.
typealias TextGraphemePoster = @MainActor (_ grapheme: Character) async throws -> Void
enum ComputerActionError: LocalizedError {
case accessibilityNotTrusted
case noDisplays
case invalidScreenIndex(Int)
case missingDisplayFrameId
case displayFrameChanged
case missingCoordinate
case coordinateOutOfBounds
case invalidReferenceWidth
case missingKeys
case emptyText
case invalidScroll
case invalidModifier(String)
case buttonAlreadyHeld
case buttonNotHeld
case eventCreationFailed
case lifecycleChanged
var errorDescription: String? {
switch self {
case .accessibilityNotTrusted:
"Accessibility permission is required for computer control"
case .noDisplays:
"No displays available for computer control"
case let .invalidScreenIndex(idx):
"Invalid screen index \(idx)"
case .missingDisplayFrameId:
"displayFrameId is required for coordinate input"
case .displayFrameChanged:
"display identity, geometry, or reference scale changed since the screenshot"
case .missingCoordinate:
"coordinate is required for this action"
case .coordinateOutOfBounds:
"coordinate is outside the captured screen"
case .invalidReferenceWidth:
"refWidth must be a positive integer"
case .missingKeys:
"keys are required for this action"
case .emptyText:
"text is required for this action"
case .invalidScroll:
"scrollDirection is required for scroll"
case let .invalidModifier(token):
"unsupported modifier '\(token)'"
case .buttonAlreadyHeld:
"left button is already held by a split drag"
case .buttonNotHeld:
"left button is not held by computer control"
case .eventCreationFailed:
"Failed to synthesize input event"
case .lifecycleChanged:
"Computer control lifecycle changed while the action was pending"
}
}
}
private let automation: UIAutomationService
private let permissions: PermissionsService
private let mouseButtonEventPoster: MouseButtonEventPoster
private let mouseEventFactory: MouseEventFactory
private let mouseEventPoster: MouseEventPoster
private let textGraphemePoster: TextGraphemePoster
/// Tracks whether a left_mouse_down is outstanding so mouse_move emits
/// drag events (state persists across invokes on the shared instance).
private var leftButtonDown = false
/// Bounded watchdog that releases a stuck left button if the matching
/// left_mouse_up never arrives (arm expiry, disconnect, or a failed turn).
private var buttonReleaseTask: Task<Void, Never>?
/// Modifier flags held since the outstanding left_mouse_down, reapplied to
/// drag and release events so a modifier-held split drag keeps Cmd/Opt/Shift
/// for the whole gesture even when later turns omit the modifier.
private var heldButtonFlags: CGEventFlags = []
private lazy var executionQueue = ComputerActionExecutionQueue { [weak self] in
self?.releaseCurrentHeldButton() ?? true
}
// Drag pacing: fast enough to feel responsive, slow enough that dropped
// targets (AppKit hit-testing mid-drag) do not misfire.
private static let dragDurationMs = 400
private static let dragSteps = 24
private static let clickInterEventDelay: useconds_t = 12000
/// Cap wheel ticks at the node so a direct armed caller cannot overflow the
/// Int32 wheel delta (line count = ticks * 5) and crash the app.
private static let maxScrollTicks = 100
/// Cap hold_key at the node: computer.act is directly invocable once armed,
/// so an unbounded durationMs must not pin a key down for minutes.
private static let maxHoldMs = 10000
/// Allow slightly-past-edge coordinates so clicks on the last row/column of
/// the reported frame still land instead of erroring on rounding.
private static let coordinateBoundsEpsilon: Double = 2
/// Idle timeout for an outstanding left button. Refreshed by each drag move,
/// so a legitimate multi-turn drag (every turn adds a screenshot plus a model
/// inference) is not force-released mid-gesture. Only a truly abandoned button
/// (arm expiry, disconnect, or a failed turn with no further activity) hits
/// this bounded cleanup.
private static let buttonHoldIdleTimeoutNanoseconds: UInt64 = 120 * 1_000_000_000
init() {
self.automation = UIAutomationService()
self.permissions = PermissionsService()
self.mouseButtonEventPoster = Self.postMouseButtonEvent
self.mouseEventFactory = Self.makeMouseEvent
self.mouseEventPoster = Self.postMouseEvent
self.textGraphemePoster = { try Self.postTextGrapheme($0) }
}
#if DEBUG
init(mouseButtonEventPoster: @escaping MouseButtonEventPoster) {
self.automation = UIAutomationService()
self.permissions = PermissionsService()
self.mouseButtonEventPoster = mouseButtonEventPoster
self.mouseEventFactory = Self.makeMouseEvent
self.mouseEventPoster = Self.postMouseEvent
self.textGraphemePoster = { try Self.postTextGrapheme($0) }
}
init(
mouseEventFactory: @escaping MouseEventFactory,
mouseEventPoster: @escaping MouseEventPoster)
{
self.automation = UIAutomationService()
self.permissions = PermissionsService()
self.mouseButtonEventPoster = Self.postMouseButtonEvent
self.mouseEventFactory = mouseEventFactory
self.mouseEventPoster = mouseEventPoster
self.textGraphemePoster = { try Self.postTextGrapheme($0) }
}
init(textGraphemePoster: @escaping TextGraphemePoster) {
self.automation = UIAutomationService()
self.permissions = PermissionsService()
self.mouseButtonEventPoster = Self.postMouseButtonEvent
self.mouseEventFactory = Self.makeMouseEvent
self.mouseEventPoster = Self.postMouseEvent
self.textGraphemePoster = textGraphemePoster
}
#endif
func perform(
_ params: OpenClawComputerActParams,
lifecycleGeneration: UInt64) async throws -> OpenClawComputerActResult
{
try await self.executionQueue.perform(
params,
lifecycleGeneration: lifecycleGeneration)
{ [weak self] params, lifecycleGeneration in
guard let self else { throw CancellationError() }
return try await self.performImmediately(
params,
lifecycleGeneration: lifecycleGeneration)
}
}
private func performImmediately(
_ params: OpenClawComputerActParams,
lifecycleGeneration: UInt64) async throws -> OpenClawComputerActResult
{
try self.executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
guard self.permissions.checkAccessibilityPermission() else {
throw ComputerActionError.accessibilityNotTrusted
}
let display = try await resolveDisplay(params: params)
try executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
try await self.dispatch(
params,
display: display,
lifecycleGeneration: lifecycleGeneration)
try self.executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
let cursor = self.automation.currentMouseLocation() ?? CGPoint.zero
return OpenClawComputerActResult(ok: true, cursorX: cursor.x, cursorY: cursor.y)
}
// MARK: - Dispatch
private func dispatch(
_ params: OpenClawComputerActParams,
display: ResolvedDisplay,
lifecycleGeneration: UInt64) async throws
{
try self.executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
let modifiers = try ComputerModifiers.parse(params.modifiers)
try Self.validateHeldButtonTransition(action: params.action, leftButtonDown: self.leftButtonDown)
switch params.action {
case .leftClick, .rightClick, .doubleClick:
let point = try requiredPoint(params, display: display)
let button: ComputerMouseButton = params.action == .rightClick ? .right : .left
let count = params.action == .doubleClick ? 2 : 1
if modifiers.isEmpty {
try await self.peekabooClick(at: point, action: params.action)
} else {
try self.rawClick(at: point, button: button, count: count, flags: modifiers.flags)
}
case .middleClick:
let point = try requiredPoint(params, display: display)
try rawClick(at: point, button: .middle, count: 1, flags: modifiers.flags)
case .tripleClick:
let point = try requiredPoint(params, display: display)
try rawClick(at: point, button: .left, count: 3, flags: modifiers.flags)
case .mouseMove:
let point = try requiredPoint(params, display: display)
if self.leftButtonDown {
// A drag is in progress; ordinary moveMouse would post
// mouseMoved and break drag targets, so emit dragged events
// carrying the modifiers held since left_mouse_down.
let event = try self.mouseEventFactory(
.leftMouseDragged,
point,
.left,
1,
self.heldButtonFlags)
try self.mouseEventPoster(event)
// Refresh the release watchdog: an active drag must not be
// auto-released mid-gesture during normal tool-loop latency.
self.armButtonWatchdog()
} else {
try await self.automation.moveMouse(to: point, duration: 0, steps: 1, profile: .linear)
}
case .leftClickDrag:
let to = try requiredPoint(params, display: display)
let from = try point(params.fromX, params.fromY, params: params, display: display)
?? to
try await self.rawDrag(from: from, to: to, flags: modifiers.flags)
case .leftMouseDown, .leftMouseUp:
// Coordinate is optional: press/release at the current cursor when omitted.
let mappedPoint = try self.point(params.x, params.y, params: params, display: display)
let point = if let mappedPoint {
mappedPoint
} else if params.action == .leftMouseDown {
try Self.validatedCurrentCursorPoint(
self.automation.currentMouseLocation(),
display: display.geometry)
} else {
self.automation.currentMouseLocation() ?? CGPoint.zero
}
if params.action == .leftMouseDown {
try self.rawMouseButton(down: true, at: point, flags: modifiers.flags)
self.setLeftButtonDown(true, flags: modifiers.flags)
} else {
// Release with the modifiers held since left_mouse_down (unioned
// with any the release turn resends) so modifier-held drops keep
// their copy/move semantics.
try self.releaseHeldButton(at: point, additionalFlags: modifiers.flags)
}
case .scroll:
try await self.performScroll(
params,
display: display,
modifiers: modifiers,
lifecycleGeneration: lifecycleGeneration)
case .type:
guard let text = params.text, !text.isEmpty else { throw ComputerActionError.emptyText }
try await self.typeText(text, lifecycleGeneration: lifecycleGeneration)
case .key:
let keys = try requireKeys(params.keys)
try await self.automation.hotkey(keys: keys, holdDuration: 0)
case .holdKey:
let keys = try requireKeys(params.keys)
let holdMs = min(Self.maxHoldMs, max(0, params.durationMs ?? 1000))
try await self.automation.hotkey(keys: keys, holdDuration: holdMs)
}
}
private func peekabooClick(at point: CGPoint, action: OpenClawComputerAction) async throws {
let clickType: ClickType = switch action {
case .rightClick: .right
case .doubleClick: .double
default: .single
}
try await self.automation.click(target: .coordinates(point), clickType: clickType, snapshotId: nil)
}
private func typeText(_ text: String, lifecycleGeneration: UInt64) async throws {
for grapheme in text {
// Caller cancellation is recorded synchronously by the execution
// queue, so this check remains authoritative even before its actor
// cancellation hop runs.
try self.executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
try await self.textGraphemePoster(grapheme)
// The default poster is synchronous. Yield explicitly so disconnect,
// pause, disable, and endpoint-replacement lifecycle hops can revoke
// authority before the next synthetic key pair.
await Task.yield()
}
try self.executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
}
private func performScroll(
_ params: OpenClawComputerActParams,
display: ResolvedDisplay,
modifiers: ComputerModifiers,
lifecycleGeneration: UInt64) async throws
{
guard let direction = params.scrollDirection else { throw ComputerActionError.invalidScroll }
let amount = min(Self.maxScrollTicks, max(1, params.scrollAmount ?? 3))
// Position the pointer over the requested region first; both Peekaboo
// and the raw wheel event scroll at the current mouse location.
if let point = try point(params.x, params.y, params: params, display: display) {
try await self.automation.moveMouse(to: point, duration: 0, steps: 1, profile: .linear)
} else {
_ = try Self.validatedCurrentCursorPoint(
self.automation.currentMouseLocation(),
display: display.geometry)
}
try self.executionQueue.checkExecutionAllowed(lifecycleGeneration: lifecycleGeneration)
if modifiers.isEmpty {
try await self.automation.scroll(ScrollRequest(
direction: Self.scrollDirection(direction),
amount: amount))
} else {
try self.rawScroll(direction: direction, amount: amount, flags: modifiers.flags)
}
}
// MARK: - Coordinate mapping
/// The target display in global points plus the capture source width used to
/// derive the captured screenshot pixel width for coordinate scaling.
private struct ResolvedDisplay {
var geometry: OpenClawComputerDisplayGeometry
var sourceWidth: Double
var sourceHeight: Double
}
private func requiredPoint(
_ params: OpenClawComputerActParams,
display: ResolvedDisplay) throws -> CGPoint
{
guard let point = try point(params.x, params.y, params: params, display: display) else {
throw ComputerActionError.missingCoordinate
}
return point
}
private func point(
_ x: Double?,
_ y: Double?,
params: OpenClawComputerActParams,
display: ResolvedDisplay) throws -> CGPoint?
{
if x == nil, y == nil {
return nil
}
// A partial coordinate (only x or only y) is malformed: optional-coordinate
// actions (scroll, mouse down/up) must fail rather than silently acting at
// the current cursor, and a partial drag origin must not fall back to the
// destination.
guard let x, let y else { throw ComputerActionError.missingCoordinate }
// Coordinates are meaningful only in the exact reference frame bound into
// displayFrameId. Missing or non-positive widths must never fall back to
// the native source width, which could turn valid screenshot pixels into a
// deterministic misclick.
let refWidth = try Self.requiredReferenceWidth(params)
let capturedWidth = OpenClawComputerInputGeometry.capturedWidth(
refWidth: refWidth,
sourceWidth: display.sourceWidth,
sourceHeight: display.sourceHeight)
let mapped = OpenClawComputerInputGeometry.mapReferencePointToGlobal(
x: x,
y: y,
capturedWidthPixels: capturedWidth,
display: display.geometry)
// Reject coordinates well outside the captured display: on a multi-display
// Mac an out-of-frame coordinate could otherwise map onto an adjacent
// screen and click content the model never saw.
let geometry = display.geometry
let epsilon = Self.coordinateBoundsEpsilon
let withinX = mapped.x >= geometry.originX - epsilon
&& mapped.x <= geometry.originX + geometry.widthPoints + epsilon
let withinY = mapped.y >= geometry.originY - epsilon
&& mapped.y <= geometry.originY + geometry.heightPoints + epsilon
guard withinX, withinY else { throw ComputerActionError.coordinateOutOfBounds }
// Clamp the epsilon-tolerated rounding to strictly inside the selected
// display so a far-edge coordinate cannot post onto an adjacent screen.
let clamped = OpenClawComputerInputGeometry.clampToDisplay(
x: mapped.x,
y: mapped.y,
display: geometry)
return CGPoint(x: clamped.x, y: clamped.y)
}
static func validatedCurrentCursorPoint(
_ point: CGPoint?,
display: OpenClawComputerDisplayGeometry) throws -> CGPoint
{
guard let point,
point.x >= display.originX,
point.x < display.originX + display.widthPoints,
point.y >= display.originY,
point.y < display.originY + display.heightPoints
else { throw ComputerActionError.coordinateOutOfBounds }
return point
}
static func validateHeldButtonTransition(
action: OpenClawComputerAction,
leftButtonDown: Bool) throws
{
if action == .leftMouseUp, !leftButtonDown {
// Never synthesize an unmatched up: it could terminate a physical
// user drag that this service did not start.
throw ComputerActionError.buttonNotHeld
}
guard leftButtonDown else { return }
switch action {
case .leftClick, .doubleClick, .tripleClick, .leftClickDrag, .leftMouseDown:
throw ComputerActionError.buttonAlreadyHeld
default:
return
}
}
// MARK: - Button-hold watchdog
private func setLeftButtonDown(_ down: Bool, flags: CGEventFlags = []) {
self.buttonReleaseTask?.cancel()
self.buttonReleaseTask = nil
self.leftButtonDown = down
self.heldButtonFlags = down ? flags : []
guard down else { return }
self.armButtonWatchdog()
}
/// Arms or re-arms the bounded idle watchdog for an outstanding left button.
/// Re-armed on each drag move so a live multi-turn gesture is never cut off,
/// while an abandoned button still gets released after the idle timeout.
private func armButtonWatchdog() {
self.buttonReleaseTask?.cancel()
self.buttonReleaseTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: Self.buttonHoldIdleTimeoutNanoseconds)
guard !Task.isCancelled else { return }
self?.autoReleaseLeftButton()
}
}
private func autoReleaseLeftButton() {
// This task has fired and cannot serve as a future retry. Clear its handle
// before attempting release; a failure must install a new watchdog below.
self.buttonReleaseTask = nil
self.releaseCurrentHeldButton()
}
/// Releases any outstanding synthetic left button immediately. Called on
/// lifecycle transitions (node disconnect, node stop, Computer Control
/// disabled) so a stranded left_mouse_down is not held until the idle
/// watchdog fires. Idempotent when nothing is held.
func releaseHeldInput(lifecycleGeneration: UInt64) async {
await self.executionQueue.releaseHeldInput(lifecycleGeneration: lifecycleGeneration)
}
/// Releases the current button without advancing the lifecycle epoch. The
/// execution queue owns epoch changes so a reordered duplicate release cannot
/// cancel a fresh action that already adopted the same epoch.
@discardableResult
private func releaseCurrentHeldButton() -> Bool {
guard self.leftButtonDown else {
self.buttonReleaseTask?.cancel()
self.buttonReleaseTask = nil
return true
}
let point = self.automation.currentMouseLocation() ?? CGPoint.zero
try? self.releaseHeldButton(at: point)
return !self.leftButtonDown
}
/// Posts the service-owned mouse-up and commits the state transition only
/// after synthesis succeeds. A failed explicit up carries its modifiers into
/// watchdog/lifecycle retries so the eventual drop preserves its semantics.
private func releaseHeldButton(
at point: CGPoint,
additionalFlags: CGEventFlags = []) throws
{
let releaseFlags = self.heldButtonFlags.union(additionalFlags)
do {
try self.rawMouseButton(down: false, at: point, flags: releaseFlags)
} catch {
// Ownership authorizes the only safe follow-up mouse-up. Keep it and
// its modifiers until synthesis succeeds, with a live watchdog retry.
self.heldButtonFlags = releaseFlags
self.armButtonWatchdog()
throw error
}
self.setLeftButtonDown(false)
}
#if DEBUG
var isLeftButtonDownForTesting: Bool {
self.leftButtonDown
}
var heldButtonFlagsForTesting: CGEventFlags {
self.heldButtonFlags
}
var buttonWatchdogArmedForTesting: Bool {
self.buttonReleaseTask != nil
}
func holdLeftButtonForTesting(flags: CGEventFlags) {
self.setLeftButtonDown(true, flags: flags)
}
func fireButtonWatchdogForTesting() {
self.buttonReleaseTask?.cancel()
self.autoReleaseLeftButton()
}
func releaseHeldButtonForTesting(additionalFlags: CGEventFlags) throws {
let point = self.automation.currentMouseLocation() ?? CGPoint.zero
try self.releaseHeldButton(at: point, additionalFlags: additionalFlags)
}
var lifecycleGenerationForTesting: UInt64 {
self.executionQueue.lifecycleGenerationForTesting
}
func typeTextForTesting(
_ text: String,
lifecycleGeneration: UInt64 = 0) async throws -> OpenClawComputerActResult
{
let params = OpenClawComputerActParams(action: .type, text: text)
return try await self.executionQueue.perform(
params,
lifecycleGeneration: lifecycleGeneration)
{ [weak self] _, generation in
guard let self else { throw CancellationError() }
try await self.typeText(text, lifecycleGeneration: generation)
return OpenClawComputerActResult(ok: true, cursorX: 0, cursorY: 0)
}
}
#endif
private func resolveDisplay(params: OpenClawComputerActParams) async throws -> ResolvedDisplay {
// Match ScreenSnapshotService display ordering so a computer.act
// screenIndex targets the same display the model saw in screen.snapshot.
let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID }
guard !displays.isEmpty else { throw ComputerActionError.noDisplays }
let idx = params.screenIndex ?? 0
guard idx >= 0, idx < displays.count else { throw ComputerActionError.invalidScreenIndex(idx) }
// CGDisplayBounds is the global top-left point space CGEvent uses;
// SCDisplay.width/height is the capture source size ScreenSnapshotService
// caps to refWidth, so together they recover the captured pixel scale and
// the source aspect ratio needed for portrait longest-edge scaling.
let bounds = CGDisplayBounds(displays[idx].displayID)
let geometry = OpenClawComputerDisplayGeometry(
originX: bounds.origin.x,
originY: bounds.origin.y,
widthPoints: bounds.width,
heightPoints: bounds.height)
let sourceWidth = Double(displays[idx].width)
let sourceHeight = Double(displays[idx].height)
// A display can disappear between the ScreenCaptureKit snapshot and
// CGDisplayBounds, which returns a zero rectangle for a stale id. Reject
// all degenerate geometry here; mapping it would collapse input to (0, 0).
guard OpenClawComputerInputGeometry.isValidMappingGeometry(
sourceWidth: sourceWidth,
sourceHeight: sourceHeight,
display: geometry)
else {
throw ComputerActionError.noDisplays
}
if Self.usesScreenshotCoordinates(params) {
let refWidth = try Self.requiredReferenceWidth(params)
let currentFrameId = OpenClawComputerInputGeometry.displayFrameId(
displayID: displays[idx].displayID,
sourceWidth: sourceWidth,
sourceHeight: sourceHeight,
referenceWidth: refWidth,
display: geometry)
try Self.validateDisplayFrame(params, currentFrameId: currentFrameId)
}
return ResolvedDisplay(
geometry: geometry,
sourceWidth: sourceWidth,
sourceHeight: sourceHeight)
}
private static func usesScreenshotCoordinates(_ params: OpenClawComputerActParams) -> Bool {
params.x != nil || params.y != nil || params.fromX != nil || params.fromY != nil
}
private static func requiredReferenceWidth(_ params: OpenClawComputerActParams) throws -> Int {
guard let refWidth = params.refWidth, refWidth > 0 else {
throw ComputerActionError.invalidReferenceWidth
}
return refWidth
}
static func validateDisplayFrame(
_ params: OpenClawComputerActParams,
currentFrameId: String) throws
{
guard self.usesScreenshotCoordinates(params) else { return }
_ = try self.requiredReferenceWidth(params)
guard let expectedFrameId = params.displayFrameId, !expectedFrameId.isEmpty else {
throw ComputerActionError.missingDisplayFrameId
}
guard expectedFrameId == currentFrameId else {
throw ComputerActionError.displayFrameChanged
}
}
private func requireKeys(_ keys: String?) throws -> String {
guard let keys, !keys.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ComputerActionError.missingKeys
}
return keys
}
private static func scrollDirection(
_ direction: OpenClawComputerScrollDirection) -> PeekabooFoundation.ScrollDirection
{
switch direction {
case .up: .up
case .down: .down
case .left: .left
case .right: .right
}
}
// MARK: - Raw CoreGraphics primitives
private func rawClick(at point: CGPoint, button: ComputerMouseButton, count: Int, flags: CGEventFlags) throws {
// Build every down/up pair before posting the first down. Event creation
// failure can then never strand a button between a successfully created
// down and a missing up.
let pairs = try (1...max(1, count)).map { click in
let down = try self.mouseEventFactory(
button.downType,
point,
button.cgButton,
click,
flags)
let up = try self.mouseEventFactory(
button.upType,
point,
button.cgButton,
click,
flags)
return (down: down, up: up)
}
for pair in pairs {
try self.mouseEventPoster(pair.down)
try self.mouseEventPoster(pair.up)
usleep(Self.clickInterEventDelay)
}
}
private func rawDrag(from: CGPoint, to: CGPoint, flags: CGEventFlags) async throws {
let down = try self.mouseEventFactory(.leftMouseDown, from, .left, 1, flags)
let moves = try (1...Self.dragSteps).map { step in
let fraction = Double(step) / Double(Self.dragSteps)
let point = CGPoint(
x: from.x + (to.x - from.x) * fraction,
y: from.y + (to.y - from.y) * fraction)
return try self.mouseEventFactory(.leftMouseDragged, point, .left, 1, flags)
}
let up = try self.mouseEventFactory(.leftMouseUp, to, .left, 1, flags)
try self.mouseEventPoster(down)
var needsRelease = true
defer {
if needsRelease {
try? self.mouseEventPoster(up)
}
}
let stepDelay = UInt64(Self.dragDurationMs) * 1_000_000 / UInt64(Self.dragSteps)
for move in moves {
try Task.checkCancellation()
try self.mouseEventPoster(move)
try await Task.sleep(nanoseconds: stepDelay)
}
try self.mouseEventPoster(up)
needsRelease = false
}
private func rawMouseButton(down: Bool, at point: CGPoint, flags: CGEventFlags) throws {
try self.mouseButtonEventPoster(down, point, flags)
}
private static func postMouseButtonEvent(
_ down: Bool,
_ point: CGPoint,
_ flags: CGEventFlags) throws
{
let type: CGEventType = down ? .leftMouseDown : .leftMouseUp
let event = try Self.makeMouseEvent(type, point, .left, 1, flags)
try Self.postMouseEvent(event)
}
private static func makeMouseEvent(
_ type: CGEventType,
_ point: CGPoint,
_ button: CGMouseButton,
_ clickState: Int,
_ flags: CGEventFlags) throws -> CGEvent
{
guard let event = CGEvent(
mouseEventSource: nil,
mouseType: type,
mouseCursorPosition: point,
mouseButton: button)
else {
throw ComputerActionError.eventCreationFailed
}
if clickState > 1 {
event.setIntegerValueField(.mouseEventClickState, value: Int64(clickState))
}
if !flags.isEmpty {
event.flags = flags
}
return event
}
private static func postMouseEvent(_ event: CGEvent) throws {
event.post(tap: .cghidEventTap)
}
/// Mirrors the pinned Peekaboo/AXorcist typing contract: iterate Swift
/// graphemes, map newline/tab to their physical keys, and post Unicode for
/// everything else. Both events are built before the first is posted.
private static func postTextGrapheme(_ grapheme: Character) throws {
let keyCode: CGKeyCode = switch grapheme {
case "\n": 0x24
case "\t": 0x30
default: 0
}
guard let keyDown = CGEvent(
keyboardEventSource: nil,
virtualKey: keyCode,
keyDown: true),
let keyUp = CGEvent(
keyboardEventSource: nil,
virtualKey: keyCode,
keyDown: false)
else { throw ComputerActionError.eventCreationFailed }
if grapheme != "\n", grapheme != "\t" {
let utf16 = Array(String(grapheme).utf16)
utf16.withUnsafeBufferPointer { buffer in
guard let baseAddress = buffer.baseAddress else { return }
keyDown.keyboardSetUnicodeString(
stringLength: buffer.count,
unicodeString: baseAddress)
keyUp.keyboardSetUnicodeString(
stringLength: buffer.count,
unicodeString: baseAddress)
}
}
keyDown.post(tap: .cghidEventTap)
usleep(1000)
keyUp.post(tap: .cghidEventTap)
}
#if DEBUG
func rawClickForTesting(count: Int = 1) throws {
try self.rawClick(at: .zero, button: .left, count: count, flags: [])
}
func rawDragForTesting() async throws {
try await self.rawDrag(
from: .zero,
to: CGPoint(x: 24, y: 24),
flags: [])
}
#endif
private func rawScroll(direction: OpenClawComputerScrollDirection, amount: Int, flags: CGEventFlags) throws {
// Line units per tick match Peekaboo's non-smooth scroll (~5 lines).
let lines = Int32(amount * 5)
let (wheel1, wheel2): (Int32, Int32) = switch direction {
case .up: (lines, 0)
case .down: (-lines, 0)
case .left: (0, lines)
case .right: (0, -lines)
}
guard let event = CGEvent(
scrollWheelEvent2Source: nil,
units: .line,
wheelCount: 2,
wheel1: wheel1,
wheel2: wheel2,
wheel3: 0)
else {
throw ComputerActionError.eventCreationFailed
}
if !flags.isEmpty {
event.flags = flags
}
event.post(tap: .cghidEventTap)
}
}
/// Mouse button plus the CoreGraphics event types for the raw click path.
private enum ComputerMouseButton {
case left
case right
case middle
var cgButton: CGMouseButton {
switch self {
case .left: .left
case .right: .right
case .middle: .center
}
}
var downType: CGEventType {
switch self {
case .left: .leftMouseDown
case .right: .rightMouseDown
case .middle: .otherMouseDown
}
}
var upType: CGEventType {
switch self {
case .left: .leftMouseUp
case .right: .rightMouseUp
case .middle: .otherMouseUp
}
}
}
/// Parses a portable modifier string ("shift", "cmd+alt") into CGEvent flags.
struct ComputerModifiers {
var flags: CGEventFlags
var isEmpty: Bool {
self.flags.isEmpty
}
static func parse(_ raw: String?) throws -> ComputerModifiers {
guard let raw, !raw.isEmpty else { return ComputerModifiers(flags: []) }
var flags: CGEventFlags = []
for piece in raw.split(whereSeparator: { $0 == "+" || $0 == "," || $0 == " " }) {
let key = piece.lowercased()
switch key {
case "cmd", "command", "meta", "super", "win", "windows":
flags.insert(.maskCommand)
case "shift":
flags.insert(.maskShift)
case "ctrl", "control":
flags.insert(.maskControl)
case "alt", "opt", "option":
flags.insert(.maskAlternate)
case "fn", "function":
flags.insert(.maskSecondaryFn)
default:
// A typo like "shfit" would otherwise silently drop the modifier
// and perform a materially different high-risk gesture (a plain
// click instead of a modifier-click); reject it instead.
throw ComputerActionService.ComputerActionError.invalidModifier(key)
}
}
return ComputerModifiers(flags: flags)
}
}