Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ If a grant still reads `NOT granted` after granting in the dialog, open **System

## Requirements

- macOS 14 (Sonoma) or later
- macOS 13 (Ventura) or later
- Apple Silicon (M1/M2/M3/M4) or Intel Mac (x86_64)
- 50 MB free disk space for the app bundle

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Keyboard is simpler. Every key goes through `CGEvent.postToPid` scoped to the na

## What it doesn't do

- Requires macOS 14 (Sonoma) or later. Works on Apple Silicon and Intel.
- Requires macOS 13 (Ventura) or later. Works on Apple Silicon and Intel.
- Not a VM. Cua Driver operates the real host, so grant Accessibility and Screen Recording with intent.
- No right-click on Chromium web content through pixel synthesis: the renderer-IPC filter drops right-click subtype on non-HID-tap paths. Use `right_click({pid, element_index})` on AX-addressable targets. See [Limits](/cua-driver/reference/limits).
- Canvas apps (Blender, Unity, games) need a brief frontmost activation because their event loops filter per-pid-routed events. Everything else stays backgrounded.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ let server = MCPServer(tools: ToolRegistry.default.allTools) { name, args in

## Minimum requirements

- macOS 14 (Sonoma) or later
- macOS 13 (Ventura) or later
- Swift 6.0+

## TCC permissions
Expand Down
2 changes: 1 addition & 1 deletion libs/cua-driver/App/CuaDriver/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
Expand Down
2 changes: 1 addition & 1 deletion libs/cua-driver/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "CuaDriver",
platforms: [
.macOS(.v14)
.macOS(.v13)
],
products: [
.executable(name: "cua-driver", targets: ["CuaDriverCLI"]),
Expand Down
5 changes: 3 additions & 2 deletions libs/cua-driver/Skills/cua-driver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ See `SKILL.md` for the main body.

## Prerequisites

1. **macOS 14 or newer** — the driver depends on SkyLight private SPIs
that were stabilized in Sonoma.
1. **macOS 13 or newer** — the driver depends on Accessibility,
ScreenCaptureKit, and SkyLight private SPIs available on Ventura
or later.
2. **`cua-driver` CLI + `CuaDriver.app`** — installable one-liner:
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh)"
Expand Down
14 changes: 11 additions & 3 deletions libs/cua-driver/Sources/CuaDriverCLI/BundleHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,25 @@ import Foundation
/// Subcommands may wrap this with additional gating (env vars, flags,
/// parent-pid checks, etc.) when their relaunch heuristics diverge.
func isExecutableInsideCuaDriverApp() -> Bool {
resolvedCuaDriverAppExecutablePath() != nil
}

/// Resolve the currently-running binary through symlinks and return the
/// installed `CuaDriver.app/Contents/MacOS/cua-driver` executable path when
/// the symlink points into a CuaDriver.app bundle. Returns nil for raw
/// `.build` executables and other non-app layouts.
func resolvedCuaDriverAppExecutablePath() -> String? {
// Prefer Foundation's executablePath (stable, absolute).
// Fall back to argv[0] when unset, which realpath() still
// resolves via $PATH lookup at the shell level — good enough
// for the cases we care about.
let candidate = Bundle.main.executablePath
?? CommandLine.arguments.first
?? ""
guard !candidate.isEmpty else { return false }
guard !candidate.isEmpty else { return nil }

var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
guard realpath(candidate, &buffer) != nil else { return false }
guard realpath(candidate, &buffer) != nil else { return nil }
let resolved = String(cString: buffer)
return resolved.contains("/CuaDriver.app/Contents/MacOS/")
return resolved.contains("/CuaDriver.app/Contents/MacOS/") ? resolved : nil
}
66 changes: 33 additions & 33 deletions libs/cua-driver/Sources/CuaDriverCLI/CuaDriverCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,8 @@ struct MCPCommand: ParsableCommand {
terminal — not to CuaDriver.app — so AX probes silently fail \
against the wrong bundle id. To sidestep this without breaking \
the stdio MCP transport, `mcp` detects the context, ensures a \
`cua-driver serve` daemon is running under LaunchServices \
(relaunching via `open -n -g -a CuaDriver --args serve` if not), \
`cua-driver serve` daemon is running under CuaDriver.app's bundle \
context (relaunching through the installed app executable if not), \
and proxies every MCP tool call through the daemon's Unix \
socket. Tool semantics are identical to the in-process path. \
Pass `--no-daemon-relaunch` (or set CUA_DRIVER_MCP_NO_RELAUNCH=1) \
Expand Down Expand Up @@ -410,27 +410,33 @@ struct MCPCommand: ParsableCommand {
// live NSApplication event loop to draw. When the cursor's
// never enabled, this costs us one idle run-loop.
AppKitBootstrap.runBlockingAppKitWith {
// Warm config before the TCC preflight: AX-only mode does not
// need Screen Recording, which matters on Ventura-compatible
// builds where pixel capture may be intentionally unavailable.
let config = await ConfigStore.shared.load()

// Preflight TCC grants. When both are already active this
// returns immediately; otherwise a small panel guides the
// user through granting them and we resume once everything
// flips green. User closing the panel without granting ->
// exit with a clear message.
let granted = await MainActor.run {
PermissionsGate.shared
}.ensureGranted()
let granted: Bool
if config.captureMode == .ax {
let status = await Permissions.currentStatus()
granted = status.accessibility
} else {
granted = await MainActor.run {
PermissionsGate.shared
}.ensureGranted()
}
if !granted {
FileHandle.standardError.write(
Data(
"cua-driver: required permissions (Accessibility + Screen Recording) not granted; MCP server exiting.\n"
"cua-driver: required permissions not granted; MCP server exiting.\n"
.utf8))
throw AppKitBootstrapError.permissionsDenied
}

// Same startup-warm as `serve`: surface any config decode
// warnings on the host's stderr before the first tool call
// hits the disk-read path.
let config = await ConfigStore.shared.load()

// Apply persisted agent-cursor preferences to the live
// singleton so stdio MCP sessions also honor the user's
// last-written state.
Expand Down Expand Up @@ -490,7 +496,7 @@ extension MCPCommand {
if !DaemonClient.isDaemonListening(socketPath: socketPath) {
FileHandle.standardError.write(
Data(
"cua-driver: mcp launched without CuaDriver.app's TCC grants; auto-launching the daemon via `open -n -g -a CuaDriver --args serve` and proxying MCP requests through it. Pass --no-daemon-relaunch to stay in-process.\n"
"cua-driver: mcp launched without CuaDriver.app's TCC grants; auto-launching the daemon through CuaDriver.app and proxying MCP requests through it. Pass --no-daemon-relaunch to stay in-process.\n"
.utf8))
try launchDaemonViaOpen()
try waitForDaemon(socketPath: socketPath, timeout: 10.0)
Expand Down Expand Up @@ -519,36 +525,30 @@ extension MCPCommand {
}
}

/// Spawn `/usr/bin/open -n -g -a CuaDriver --args serve`. Mirror of
/// `ServeCommand.relaunchViaOpen` minus the post-launch probe (we
/// poll separately via `waitForDaemon`, since the timeout there is
/// Spawn the installed CuaDriver.app executable with `serve --no-relaunch`.
/// Mirror of `ServeCommand.relaunchViaOpen` minus the post-launch probe
/// (we poll separately via `waitForDaemon`, since the timeout there is
/// MCP-specific).
fileprivate func launchDaemonViaOpen() throws {
guard let executablePath = resolvedCuaDriverAppExecutablePath() else {
FileHandle.standardError.write(
Data(
"cua-driver: installed CuaDriver.app executable not found. Check that `/Applications/CuaDriver.app` is installed, or pass --no-daemon-relaunch to bypass.\n"
.utf8))
throw ExitCode(1)
}

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
// -n: force a new instance. CuaDriver.app may already be
// running from a previous `mcp` (different MCP client
// session); without -n, `open -a` would re-use it and
// drop our `--args serve`, leaving no daemon up.
// -g: keep the new instance backgrounded. CuaDriver.app is
// LSUIElement=true anyway, but this makes that explicit.
process.arguments = ["-n", "-g", "-a", "CuaDriver", "--args", "serve"]
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = ["serve", "--no-relaunch"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
FileHandle.standardError.write(
Data(
"cua-driver: failed to exec `/usr/bin/open`: \(error). Pass --no-daemon-relaunch to bypass.\n"
.utf8))
throw ExitCode(1)
}
process.waitUntilExit()
if process.terminationStatus != 0 {
FileHandle.standardError.write(
Data(
"cua-driver: `open -n -g -a CuaDriver --args serve` exited \(process.terminationStatus). Check that `/Applications/CuaDriver.app` is installed, or pass --no-daemon-relaunch to bypass.\n"
"cua-driver: failed to exec CuaDriver.app daemon: \(error). Pass --no-daemon-relaunch to bypass.\n"
.utf8))
throw ExitCode(1)
}
Expand All @@ -569,7 +569,7 @@ extension MCPCommand {
}
FileHandle.standardError.write(
Data(
"cua-driver: daemon did not appear on \(socketPath) within \(Int(timeout))s. If this is the first launch, grant Accessibility + Screen Recording to CuaDriver.app in System Settings and retry. Pass --no-daemon-relaunch to stay in-process.\n"
"cua-driver: daemon did not appear on \(socketPath) within \(Int(timeout))s. If this is the first launch, grant Accessibility to CuaDriver.app in System Settings and retry. Pass --no-daemon-relaunch to stay in-process.\n"
.utf8))
throw ExitCode(1)
}
Expand Down
Loading