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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ jobs:
# non-mutating path.
run: python3 scripts/preflight.py

linux-build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Swift
uses: swift-actions/setup-swift@v2
with:
swift-version: "6.0.3"

- name: Cache SwiftPM
uses: actions/cache@v4
with:
path: |
.build
~/.cache/org.swift.swiftpm
key: ${{ runner.os }}-swiftpm-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ runner.os }}-swiftpm-

- name: Preflight (Linux, no GUI/TCC)
run: python3 scripts/preflight.py --bin .build/debug/computer-use-mcp

# Non-blocking style check: does not gate build-and-test. Prefer the
# toolchain-bundled `swift format lint` over adding a SwiftLint dependency.
lint:
Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ server. Point Claude Code, Cursor, Codex CLI, Gemini CLI, or your own agent at i
and the agent can **see and operate the apps on your Mac — in the background,
without hijacking your cursor or stealing focus.**

> **Status:** pre-1.0, used in production by the authors. macOS only. Runs while
> the Mac is unlocked (the system is kept from idle-sleeping during active
> sessions; if the screen locks, mutating tools pause with a recoverable error
> until you unlock).
> **Status:** pre-1.0, used in production by the authors. The interaction engine
> runs on macOS while the Linux build, tests, CLI, and daemon plumbing are
> supported. On macOS it runs while the Mac is unlocked (the system is kept from
> idle-sleeping during active sessions; if the screen locks, mutating tools pause
> with a recoverable error until you unlock).

<!--
HERO MEDIA PLACEHOLDER.
Expand Down Expand Up @@ -417,6 +418,8 @@ Two ways to keep large windows from blowing up the tree:
(Input Monitoring is *not* required). Run `computer-use-mcp health_report` to
inspect current identity/permission state, or `computer-use-mcp doctor --prompt`
when you intentionally want macOS prompts.
- Linux: Swift 6.0.3 for build, tests, CLI, and daemon use; no GUI permissions
are required.

## Safety

Expand Down Expand Up @@ -518,8 +521,9 @@ productionization checklist.
`allow_global_cursor: true` as an explicit fallback.
- Menu key-equivalents (e.g. `cmd+a`) are reliable when the app is the key window;
some apps ignore them when targeted purely in the background.
- macOS only (the engine is built on Accessibility, ScreenCaptureKit, and
CoreGraphics). The protocol/tool layer is OS-agnostic.
- The interaction engine is macOS-only (Accessibility, ScreenCaptureKit, and
CoreGraphics). Linux tools that require those capabilities return structured
unsupported errors; the protocol/tool layer is OS-agnostic.

## Development

Expand Down
9 changes: 9 additions & 0 deletions Sources/ComputerUseFixture/ComputerUseFixtureApp.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
import AppKit
import SwiftUI

Expand Down Expand Up @@ -96,3 +97,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.async { NSApp.deactivate() }
}
}
#else
import Foundation

@main
struct ComputerUseFixtureApp {
static func main() {}
}
#endif
2 changes: 2 additions & 0 deletions Sources/ComputerUseFixture/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
import SwiftUI

struct ContentView: View {
Expand Down Expand Up @@ -79,3 +80,4 @@ struct ContentView: View {
.background(RoundedRectangle(cornerRadius: 8).fill(Color.gray.opacity(0.08)))
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/ComputerUseFixture/FixtureComponents.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
import AppKit
import SwiftUI

Expand Down Expand Up @@ -79,3 +80,4 @@ final class LiarButtonView: NSView {
withAttributes: attrs)
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/ComputerUseFixture/KeystrokeInput.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
import AppKit
import SwiftUI

Expand Down Expand Up @@ -68,3 +69,4 @@ final class KeystrokeView: NSView {
display.draw(at: NSPoint(x: 6, y: (bounds.height - 18) / 2), withAttributes: attrs)
}
}
#endif
4 changes: 3 additions & 1 deletion Sources/ComputerUseFixture/RowListPane.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
import AppKit
import SwiftUI

Expand Down Expand Up @@ -54,7 +55,7 @@ struct AppKitRowList: NSViewRepresentable {

func updateNSView(_ nsView: NSScrollView, context: Context) {}

final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
func numberOfRows(in tableView: NSTableView) -> Int { rowCount }

func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
Expand All @@ -72,3 +73,4 @@ struct AppKitRowList: NSViewRepresentable {
}
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/ComputerUseFixture/ScrollProbePane.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
import AppKit
import SwiftUI

Expand Down Expand Up @@ -71,3 +72,4 @@ final class InertScrollTargetView: NSView {
}
}
}
#endif
2 changes: 2 additions & 0 deletions Sources/ComputerUseFixture/WebPane.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
import SwiftUI
import WebKit

Expand Down Expand Up @@ -46,3 +47,4 @@ struct WebPane: NSViewRepresentable {
</html>
"""
}
#endif
4 changes: 3 additions & 1 deletion Sources/computer-use-mcp/Core/AX.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Thin helpers over the C Accessibility (AXUIElement) API.

import Foundation
#if os(macOS)
import AppKit
import ApplicationServices
import Foundation
#endif

/// Process-wide AX messaging timeout (seconds). An unresponsive app then
/// fails each AX call after this bound instead of stalling tool calls for the
Expand Down
4 changes: 3 additions & 1 deletion Sources/computer-use-mcp/Core/ActionChains.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
// chains can be added later without new plumbing. Inspired by
// lahfir/agent-desktop's chain_defs.rs, adapted to our verifier.

import Foundation
#if os(macOS)
import ApplicationServices
import CoreGraphics
import Foundation
#endif

// MARK: - Chain definition (data-driven)

Expand Down
4 changes: 3 additions & 1 deletion Sources/computer-use-mcp/Core/ActionOutcome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
//
// See docs/outcome-contract.md for the design and the per-tool trap matrix.

import ApplicationServices
import Foundation
import MCP
#if os(macOS)
import ApplicationServices
#endif

let actionOutcomeMetaKey = "computer-use-mcp/outcome"

Expand Down
11 changes: 10 additions & 1 deletion Sources/computer-use-mcp/Core/AppResolver.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Resolve a target app by name or bundle identifier, and enumerate
// controllable apps.

import AppKit
import Foundation
#if os(macOS)
import AppKit
#endif

struct ResolvedApp {
let pid: pid_t
Expand Down Expand Up @@ -61,10 +63,17 @@ private final class RunningApplicationsCache: @unchecked Sendable {
/// pid size silently dropped the oldest three quarters of the list and made
/// login-time apps unresolvable.
func allProcessIDs() -> [pid_t] {
#if os(macOS)
var pids = [pid_t](repeating: 0, count: 8192)
let returned = proc_listallpids(&pids, Int32(pids.count * MemoryLayout<pid_t>.size))
guard returned > 0 else { return [] }
return pids.prefix(min(Int(returned), pids.count)).filter { $0 > 0 }
#else
(try? FileManager.default.contentsOfDirectory(atPath: "/proc"))?.compactMap { entry in
guard entry.allSatisfy(\.isNumber), let pid = Int32(entry), pid > 0 else { return nil }
return pid
} ?? []
#endif
}

func resolveApp(_ identifier: String) throws -> ResolvedApp {
Expand Down
6 changes: 4 additions & 2 deletions Sources/computer-use-mcp/Core/FocusTelemetry.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import AppKit
import ApplicationServices
import Foundation
import MCP
#if os(macOS)
import AppKit
import ApplicationServices
#endif

private let focusTelemetryMetaKey = "computer-use-mcp/focus"
private let deliveryTelemetryMetaKey = "computer-use-mcp/delivery"
Expand Down
3 changes: 2 additions & 1 deletion Sources/computer-use-mcp/Core/GeometrySanitization.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CoreGraphics

import Foundation

/// Geometry from AX/CoreGraphics/JavaScript can cross process boundaries with
/// NaN or infinite components. Swift traps when such values are converted to
Expand Down
4 changes: 3 additions & 1 deletion Sources/computer-use-mcp/Core/HitTest.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Coordinate → element hit-testing, the fallback when a target is not (or is
// wrongly) represented in the accessibility tree.

import ApplicationServices
import Foundation
#if os(macOS)
import ApplicationServices
#endif

/// The accessibility element at a global screen point, restricted to the
/// target app. Returns nil when nothing (or another app's window) is there.
Expand Down
6 changes: 5 additions & 1 deletion Sources/computer-use-mcp/Core/Input.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if os(macOS)
// Background-safe synthetic input delivery.
//
// The ladder, in order of preference, for delivering a mouse/scroll/key event
Expand All @@ -15,10 +16,12 @@
// there is no reliable success signal, so escalation to Tier 4 is never
// automatic — it requires the caller's explicit opt-in.

import Foundation
#if os(macOS)
import AppKit
import ApplicationServices
import CoreGraphics
import Foundation
#endif

// Private SPI mapping an AX window element to its CGWindowID, used as the
// NSEvent windowNumber. Undocumented but stable; always guard the result.
Expand Down Expand Up @@ -495,3 +498,4 @@ func deliverKey(_ chord: KeyChord, context: DeliveryContext, targetAppIsActive:
}
return mode
}
#endif
4 changes: 3 additions & 1 deletion Sources/computer-use-mcp/Core/InterferenceGuard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
// delivery (private-source events posted to a pid or window) does not update —
// the agent cannot trip its own guard.

import CoreGraphics
import Foundation
import MCP
#if os(macOS)
import CoreGraphics
#endif

/// Pure yield decision, separated from system state for tests.
func interferenceShouldYield(
Expand Down
6 changes: 5 additions & 1 deletion Sources/computer-use-mcp/Core/Keymap.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
#if os(macOS)
// Parse xdotool-style key strings ("cmd+shift+s", "Return", "a") into a
// CGKeyCode + modifier flags. Named keys use layout-independent virtual
// keycodes; printable characters are translated against the current keyboard
// layout via UCKeyTranslate.

import Foundation
#if os(macOS)
import Carbon.HIToolbox
import CoreGraphics
import Foundation
#endif

struct KeyChord {
let keyCode: CGKeyCode
Expand Down Expand Up @@ -138,3 +141,4 @@ enum Keymap {
return String(utf16CodeUnits: chars, count: length)
}
}
#endif
10 changes: 9 additions & 1 deletion Sources/computer-use-mcp/Core/OCR.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
// accessibility tree is missing or empty — custom-drawn UIs, games,
// remote-desktop and virtualization windows.

import CoreGraphics
import Foundation
#if os(macOS)
import CoreGraphics
import Vision
#endif

struct OCRLine: Sendable {
let text: String
/// x, y, w, h in screenshot pixels (top-left origin).
let box: [Int]
}

#if os(macOS)
func recognizeText(inPNG pngData: Data, pixelWidth: Int, pixelHeight: Int) async throws -> [OCRLine] {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
Expand All @@ -31,6 +34,11 @@ func recognizeText(inPNG pngData: Data, pixelWidth: Int, pixelHeight: Int) async
}
}.value
}
#else
func recognizeText(inPNG pngData: Data, pixelWidth: Int, pixelHeight: Int) async throws -> [OCRLine] {
throw ToolError.failed("OCR is unsupported on Linux.")
}
#endif

/// Vision boxes are normalized with a bottom-left origin; screenshots use
/// top-left pixel coordinates.
Expand Down
6 changes: 4 additions & 2 deletions Sources/computer-use-mcp/Core/PointTarget.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// Shared resolution for pointer tools (click, scroll): turn the tool args into
// a concrete (AXUIElement, global point, delivery context).

import ApplicationServices
import CoreGraphics
import Foundation
import MCP
#if os(macOS)
import ApplicationServices
import CoreGraphics
#endif

struct PointTarget {
/// The element under the point (hit-tested or resolved from an id), if any.
Expand Down
4 changes: 3 additions & 1 deletion Sources/computer-use-mcp/Core/SafetyPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
// COMPUTER_USE_MCP_CONFIRM_APPS=a,b,c apps where every action needs confirm
// COMPUTER_USE_MCP_DESTRUCTIVE=pat,pat extra destructive label substrings

import Foundation
#if os(macOS)
import ApplicationServices
import Carbon.HIToolbox
import CoreGraphics
import Foundation
#endif
import MCP

struct SafetyError: Error, CustomStringConvertible {
Expand Down
Loading
Loading