Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 63 additions & 16 deletions apps/desktop/native/computer-use/ComputerUse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,11 @@ func attribute(_ element: AXUIElement, _ name: String) -> CFTypeRef? {
/// the element lacks or fails to return is omitted, as `attribute` would return nil for it.
func attributes(_ element: AXUIElement, _ names: [String]) -> [String: CFTypeRef] {
var raw: CFArray?
guard AXUIElementCopyMultipleAttributeValues(element, names as CFArray, AXCopyMultipleAttributeOptions(rawValue: 0), &raw) == .success,
let values = raw as [AnyObject]?, values.count == names.count else {
let status = AXUIElementCopyMultipleAttributeValues(element, names as CFArray, AXCopyMultipleAttributeOptions(rawValue: 0), &raw)
guard status == .success, let values = raw as [AnyObject]?, values.count == names.count else {
// Only an element that cannot answer a batched read is asked one attribute at a time; a
// timeout or failure would only repeat once per attribute.
guard [.attributeUnsupported, .illegalArgument, .notImplemented].contains(status) else { return [:] }
return names.reduce(into: [:]) { result, name in result[name] = attribute(element, name) }
}
var result: [String: CFTypeRef] = [:]
Expand Down Expand Up @@ -467,9 +470,23 @@ func validateBatchSteps(_ steps: [Parameters]) throws {
guard (2...8).contains(steps.count) else { throw ComputerError("invalid_arguments", "A batch requires 2 to 8 steps.") }
var units = 0
for step in steps {
guard let action = step.action, BATCH_ACTIONS.contains(action), step.bundleId == nil, step.snapshotId == nil, step.steps == nil else {
guard let action = step.action, BATCH_ACTIONS.contains(action), step.bundleId == nil, step.snapshotId == nil else {
throw ComputerError("invalid_arguments", "Batch steps are click, type_text, set_value, press_key, scroll or perform_action, without their own app or snapshot.")
}
// Malformed steps are refused before step 1 runs, so a typo never leaves a half-applied batch.
switch action {
case "type_text": _ = try required(step.elementId, "elementId"); _ = try required(step.text, "text")
case "set_value": _ = try required(step.elementId, "elementId"); _ = try required(step.value, "value")
case "perform_action": _ = try required(step.elementId, "elementId"); _ = try required(step.accessibilityAction, "accessibilityAction")
case "press_key": _ = try parseKey(required(step.key, "key"))
case "click":
guard ["left", "right"].contains(step.button ?? "left"), (1...3).contains(step.clickCount ?? 1) else { throw ComputerError("invalid_arguments", "Invalid button or clickCount.") }
guard step.elementId != nil || (step.x != nil && step.y != nil) else { throw ComputerError("invalid_arguments", "A click step needs an elementId or x and y.") }
case "scroll":
guard step.elementId != nil || (step.x != nil && step.y != nil) else { throw ComputerError("invalid_arguments", "A scroll step needs an elementId or x and y.") }
guard step.deltaX != nil || step.deltaY != nil else { throw ComputerError("invalid_arguments", "A scroll step needs deltaX or deltaY.") }
default: break
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
}
units += (step.text?.utf16.count ?? 0) + (step.value?.utf16.count ?? 0)
}
guard units <= 32000 else { throw ComputerError("invalid_arguments", "Combined batch text exceeds 32000 UTF-16 units.") }
Expand Down Expand Up @@ -599,15 +616,35 @@ func focusByClicking(_ element: AXUIElement, pid: pid_t, snapshot: Snapshot) asy
let center = CGPoint(x: rect.midX, y: rect.midY)
// The point must land on the editor or a text control inside it, never a button or row nested in a container.
guard takesText(element), frame.contains(center), let hit = elementAtPoint(center) else { return }
let insideEditor = takesText(hit) ? try ancestorPath(hit, to: element) != nil : false
let insideEditor = try takesText(hit) && ancestorPath(hit, to: element) != nil
// Secure inputs stay untargetable, so a password field is never clicked into focus either.
guard CFEqual(hit, element) || insideEditor, (try? requireNonSecure(hit)) != nil else { return }
try await coordinateAction { dispatch in
try checkCancellation()
try postMouse(pid: pid, type: .leftMouseDown, point: center, button: .left, windowID: windowID, window: window, frame: frame, dispatch: dispatch)
try postMouse(pid: pid, type: .leftMouseUp, point: center, button: .left, windowID: windowID, window: window, frame: frame, release: true, dispatch: dispatch)
do {
try await coordinateAction { dispatch in
try checkCancellation()
try postMouse(pid: pid, type: .leftMouseDown, point: center, button: .left, windowID: windowID, window: window, frame: frame, dispatch: dispatch)
try postMouse(pid: pid, type: .leftMouseUp, point: center, button: .left, windowID: windowID, window: window, frame: frame, release: true, dispatch: dispatch)
}
} catch let error as ComputerError where error.dispatchState == "not_started" {
// A mouse guard refused the point before anything was sent; the focus check reports the outcome.
return
}
// A click leaves the caret where it landed; typing inserts at the end, as after an Accessibility focus.
let root = AXUIElementCreateApplication(pid)
for _ in 0..<5 {
try await Task.sleep(for: .milliseconds(50))
guard let focused = attribute(root, kAXFocusedUIElementAttribute), holdsFocus(focused, element) else { continue }
let editor = unsafeBitCast(focused, to: AXUIElement.self)
var settable: DarwinBoolean = false
if AXUIElementIsAttributeSettable(editor, kAXSelectedTextRangeAttribute as CFString, &settable) == .success, settable.boolValue,
let value = attribute(editor, kAXValueAttribute) as? String {
var range = CFRange(location: value.utf16.count, length: 0)
guard let end = AXValueCreate(.cfRange, &range), AXUIElementSetAttributeValue(editor, kAXSelectedTextRangeAttribute as CFString, end) == .success else {
throw ComputerError("caret_unconfirmed", "The editor was clicked into focus, but its caret could not be moved to the end, so no text was sent. Observe the app, then use set_value or move the caret with press_key.")
}
}
return
}
try await Task.sleep(for: .milliseconds(100))
}
func discoverApps() throws -> [[String: Any]] {
var entries: [String: [String: Any]] = [:]
Expand Down Expand Up @@ -767,14 +804,20 @@ final class Driver {
"sequence": ["completedSteps": completed, "totalSteps": steps.count, "error": String((message + " The remaining steps were not run; observe the app before continuing, and do not retry the batch.").prefix(2000))]]
}
for (index, step) in steps.enumerated() {
// Stop can arrive during the previous step's settle; no later step may start after it.
do { try checkCancellation() } catch { if completed == 0 { throw error }; return stopped("Stopped before step \(index + 1).") }
var params = step; params.bundleId = p.bundleId; params.snapshotId = p.snapshotId
// Stop can arrive during the previous step's render wait; no later step may start after it.
do { try checkCancellation() } catch {
// Before any step ran nothing was sent, so Stop fails the batch as cancelled.
if completed == 0 { throw error }
return stopped("Stopped before step \(index + 1).")
}
let before = uiStructure(pid: pid)
do { _ = try await act(Request(id: request.id, method: try required(step.action, "action"), params: params), app: app, bundle: bundle, snapshot: snapshot) }
do { _ = try await act(Request(id: request.id, method: try required(step.action, "action"), params: step), app: app, bundle: bundle, snapshot: snapshot) }
catch {
// A first step that sent nothing fails like the single action, keeping the snapshot valid.
if !inputDispatched { throw error }
if completed == 0 && !inputDispatched {
guard let error = error as? ComputerError else { throw error }
throw ComputerError(error.code, "Batch step 1 failed: " + error.message, dispatchState: error.dispatchState)
}
return stopped("Step \(index + 1) failed: " + ((error as? ComputerError)?.message ?? "its outcome is unknown."))
}
completed += 1
Expand Down Expand Up @@ -832,7 +875,11 @@ final class Driver {
// Activation can move focus, so whether the editor holds it is read after activating.
if attribute(root, kAXFocusedUIElementAttribute).map({ holdsFocus($0, element) }) != true {
try axCheck(AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue))
try await Task.sleep(for: .milliseconds(50))
// Focus can settle asynchronously; slow apps get 250 ms before any click is considered.
for _ in 0..<5 {
try await Task.sleep(for: .milliseconds(50))
if attribute(root, kAXFocusedUIElementAttribute).map({ holdsFocus($0, element) }) == true { break }
}
// Some apps (SwiftUI sheets such as Clock's city search) ignore an Accessibility focus
// request; in a foreground app the field is focused the way a user would, by clicking it.
if app.isActive, attribute(root, kAXFocusedUIElementAttribute).map({ holdsFocus($0, element) }) != true {
Expand Down Expand Up @@ -861,7 +908,7 @@ final class Driver {
do { try validateFocus(); break }
catch let error as ComputerError where error.code == "focus_changed" {
if attempt < 9 { try await Task.sleep(for: .milliseconds(50)) }
else if !app.isActive { throw ComputerError("activation_required", "No input was dispatched. This background app does not expose the exact keyboard focus target. Call activate_app for this bundle, then get_app_state and retry using the fresh editor reference.", dispatchState: "not_started") }
else if !app.isActive && !inputDispatched { throw ComputerError("activation_required", "No input was dispatched. This background app does not expose the exact keyboard focus target. Call activate_app for this bundle, then get_app_state and retry using the fresh editor reference.", dispatchState: "not_started") }
else { throw error }
}
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/native/computer-use/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ Coordinates in action inputs are window-local points; node frames are global scr

AXPress and AXSetValue are direct semantic operations. Keyboard events target the app PID. Cmd+A uses the focused editor's writable accessibility selection range where supported. Coordinate mouse input requires the target app in front; `activate_app` makes that focus change explicit and confirms it. Click/drag/scroll validate the foreground process, unchanged window geometry and the app that receives the point; the topmost window-list entry is not the click target because Notification Center and the Dock keep full-screen click-through windows. A hit on a window's own background counts as that window. Background tabs and minimized windows share bounds with the window in front, so they take no coordinate input. An action that opens UI (AXPress, AXPick, AXShowMenu) and times out while the app shows a sheet, modal or menu (`cannotComplete`) counts as dispatched; any other action that times out fails as uncertain. A menu-item press that leaves the item's own menu open with the app highlighting that item (Calendar on macOS 26) is completed with Return. Sheets, including Go to Folder and out-of-process open and save panels whose contents report no window, count as the window they are attached to. Menu item labels carry their shortcuts (for example ⇧⌘N). Some apps ignore background keyboard events. `dispatched:true,verified:false` means only dispatch succeeded; the agent must observe again to establish the outcome. No foreground activation fallback is hidden inside the helper. Native key codes currently assume the US layout for shortcuts; literal text uses Unicode events. Unsupported shortcuts fail explicitly.

`type_text` and `input_sequence` target a text control (text fields and areas, terminals, combo boxes, date and time pickers, or anything with an editable text selection), or a container whose focused editor already holds keyboard focus, such as a web composer; keystrokes are sent only while focus is in a text input. When an app ignores the Accessibility focus request (SwiftUI sheets such as Clock's city search), a foreground app's editor is clicked at its center to take focus, only if that point hit-tests to the editor inside its observed window; the usual focus checks then apply. `input_sequence` executes 1–32 key/text steps against one such element. It validates every step before dispatch and checks the same editor, window, and secure-input ancestry between steps. Enter can continue into another step when the same editor and window retain focus. Actual focus loss, Tab, or navigation shortcuts end the sequence before later input. Explicit `activateFirst: true` activates the target app inside the batch before input; it never enables automatic refocusing between steps. Partial execution reports completed steps and an error; callers must observe before retrying. `batch` runs 2–8 click, type_text, set_value, press_key, scroll or perform_action steps planned from one snapshot, each through its own action's checks; it stops at the first step that fails and after any step that opens or closes a window, sheet, menu or popover, reports progress the same way, and a first step that sent nothing is refused with `dispatchState: "not_started"`. Every mutation is followed by fresh state under the same authorization and queue entry; `observeAfter` optionally requests a screenshot. Observation failure is reported separately so the mutation is never replayed. The model receives a compact, bounded projection of the native snapshot; omitted nodes are reported explicitly.
`type_text` and `input_sequence` target a text control (text fields and areas, terminals, combo boxes, date and time pickers, or anything with an editable text selection), or a container whose focused editor already holds keyboard focus, such as a web composer; keystrokes are sent only while focus is in a text input. When an app ignores the Accessibility focus request (SwiftUI sheets such as Clock's city search), a foreground app's editor is clicked at its center to take focus after 250 ms without it, only if that point hit-tests to the editor (or a non-secure text control inside it) within its observed window; a click refused by the mouse guards sends nothing, the caret is moved to the end so typing inserts there, and the usual focus checks then apply. `input_sequence` executes 1–32 key/text steps against one such element. It validates every step before dispatch and checks the same editor, window, and secure-input ancestry between steps. Enter can continue into another step when the same editor and window retain focus. Actual focus loss, Tab, or navigation shortcuts end the sequence before later input. Explicit `activateFirst: true` activates the target app inside the sequence before input; it never enables automatic refocusing between steps. Partial execution reports completed steps and an error; callers must observe before retrying. `batch` runs 2–8 click, type_text, set_value, press_key, scroll or perform_action steps planned from one snapshot, each through its own action's checks; it stops at the first step that fails and after any step that changes the app's window structure (on-screen windows, which include sheets, menus and popovers, or the focused window's direct children), reports progress the same way, and a first step that sent nothing is refused with `dispatchState: "not_started"`. Every step is validated before the first one runs. The structure check is a heuristic: it misses modals inside web content and UI that appears after the step's render wait, and stops conservatively on tooltips and autocomplete popovers. Every mutation is followed by fresh state under the same authorization and queue entry; `observeAfter` optionally requests a screenshot. Observation failure is reported separately so the mutation is never replayed. The model receives a compact, bounded projection of the native snapshot; omitted nodes are reported explicitly.

## Cancellation

SIGTERM/SIGINT sets a cancellation flag. Traversal, typing and drag loops check it; mouse up is sent through a defer before returning from a cancelled drag. Keyboard down/up are paired in one function. The owner should close stdin and send SIGTERM, allowing a grace period before SIGKILL. Foreground drags also stop if the active app or window geometry changes; this does not detect every physical user input within the same app. Forced termination cannot guarantee a final release event; input routines deliberately avoid long waits with held buttons. Cancellation cannot undo an action already dispatched.

## Tests and limitations

Compile pure native tests with `swiftc -parse-as-library -D COMPUTER_USE_TEST ComputerUse.swift tests/NativeTests.swift -o native-tests`. They cover multi-display negative origins, coordinate bounds/nonfinite input, shortcut modifiers, invalid key handling and cooperative cancellation. Real status and app discovery outputs were validated against the generated worker Zod schema (156 apps, including 147 installed-only apps).
Compile pure native tests with `swiftc -parse-as-library -D COMPUTER_USE_TEST ComputerUse.swift tests/NativeTests.swift -o native-tests`. They cover multi-display negative origins, coordinate bounds/nonfinite input, shortcut modifiers, invalid key handling, input-sequence and batch validation, protected apps and cooperative cancellation. The live fixture also covers batches and click-to-focus for an editor that ignores Accessibility focus. Real status and app discovery outputs were validated against the generated worker Zod schema (156 apps, including 147 installed-only apps).

`python3 tests/protocol.py <compiled-helper>` checks persistent framing, bounded-request recovery, Unicode decoding, no-prompt status, invalid methods/permissions, permission gates and clean EOF. `tests/Fixture.swift` compiles into a disposable AppKit app with a text field, secure field, increment button/counter, slider, scroll area and a visual-only marker. `python3 tests/live.py <helper>` requires both grants and verifies actual outcomes. `--allow-missing-screen` explicitly reports screenshots blocked rather than passing them; `--existing-fixture` leaves an existing fixture running for coordinated model trials. Give the fixture bundle ID `com.mothership.computer-use-fixture` when packaging it for integration tests.

Expand Down
Loading
Loading