let data = FileHandle.standardInput.readDataToEndOfFile()
guard let request = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let action = request["action"] as? String,
let issuedAt = request["issued_at"] as? Double,
let callerPID = request["caller_pid"] as? Int32, callerPID > 0,
let lockPath = request["lock_path"] as? String,
["type_text", "key", "scroll", "click", "drag", "status"].contains(action) else { fail("invalid background input request") }
guard CGPreflightPostEventAccess() else { fail("Accessibility input permission is required", code: 3) }
// The process that posts events owns the lock, even if its caller is killed.
let lockFD = open(lockPath, O_CREAT | O_RDWR | O_NOFOLLOW, mode_t(0o600))
guard lockFD >= 0 else { fail("cannot open input lock") }
let acquiredLock = flock(lockFD, LOCK_EX | LOCK_NB) == 0
let inputBusy = !acquiredLock && errno == EWOULDBLOCK
guard acquiredLock || (action == "status" && inputBusy) else {
close(lockFD)
fail("another input action is running for this app; observe before retrying")
}
defer { close(lockFD) }
// Status is an observation, not a reservation across subsequent AX queries.
if action == "status" && acquiredLock {
guard flock(lockFD, LOCK_UN) == 0 else { fail("cannot release status probe lock") }
}
let keyboard = ["type_text", "key"].contains(action)
let startedAt = ProcessInfo.processInfo.systemUptime
let frontBefore = NSWorkspace.shared.frontmostApplication?.processIdentifier ?? 0
var eventsSent = 0
var interruption: String?
// Optional native identity avoids relying on AX titles that dialogs may omit.
typealias GetAXWindow = @convention(c) (AXUIElement, UnsafeMutablePointer<CGWindowID>) -> AXError
let axWindowSymbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "_AXUIElementGetWindow")
func focusedWindowMatches() -> Bool {
// Inactive Spaces may omit AXWindows while still exposing AXFocusedWindow.
guard let raw = attribute(application, kAXFocusedWindowAttribute),
CFGetTypeID(raw) == AXUIElementGetTypeID() else { return false }
let focused = raw as! AXUIElement
var owner: pid_t = 0
guard AXUIElementGetPid(focused, &owner) == .success, owner == pid,
let frame = bounds(focused), sameBounds(frame, expectedBounds) else { return false }
if let symbol = axWindowSymbol {
var observedID: CGWindowID = 0
let lookup = unsafeBitCast(symbol, to: GetAXWindow.self)
// A failed or conflicting native identity must not fall back to a title match.
return lookup(focused, &observedID) == .success && observedID != 0 && observedID == windowID
}
return legacyKeyboardWindowMatches(focused, frame)
}
func legacyKeyboardWindowMatches(_ focused: AXUIElement, _ frame: CGRect) -> Bool {
guard text(attribute(focused, kAXTitleAttribute)) == expectedTitle else { return false }
let rows = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
let matches = rows.filter { row in
guard (row[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == pid,
row[kCGWindowName as String] as? String == expectedTitle,
let rawBounds = row[kCGWindowBounds as String] as? NSDictionary,
let candidate = CGRect(dictionaryRepresentation: rawBounds) else { return false }
return sameBounds(candidate, frame)
}
return matches.count == 1 && (matches[0][kCGWindowNumber as String] as? NSNumber)?.uint32Value == windowID
}
func readinessIssue(forKeyboard: Bool) -> String? {
if kill(callerPID, 0) != 0 { return "input caller exited; background input was stopped" }
let age = Date().timeIntervalSince1970 - issuedAt
if age < 0 || age > 300 { return "input target expired; take a new snapshot" }
if ProcessInfo.processInfo.systemUptime - startedAt > 20 { return "input time limit reached; inspect before retrying" }
if NSWorkspace.shared.frontmostApplication?.processIdentifier == pid {
return "target app is in the foreground; background input was stopped"
}
if !catalogWindowMatches(allowOffscreen: true, exactBounds: true) {
return "window changed; take a new snapshot"
}
if forKeyboard && !focusedWindowMatches() {
return "target is not the app's verified keyboard window; input was not redirected"
}
return nil
}
func maySend() -> Bool {
if let issue = readinessIssue(forKeyboard: keyboard) {
if eventsSent == 0 { fail(issue) }
interruption = issue
return false
}
return true
}
// Optional platform functionality. Its presence proves a route, not app acceptance.
typealias SetWindowLocation = @convention(c) (CGEvent, CGFloat, CGFloat) -> Void
let framework = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY)
let windowLocationSymbol = framework.flatMap { dlsym($0, "CGEventSetWindowLocation") }
let routingUnavailable = "background window-coordinate routing is unavailable on this system"
if action == "status" {
let busyReason = inputBusy ? "another input action is running for this app" : nil
func routeStatus(_ issue: String?) -> [String: Any] {
var result: [String: Any] = ["dispatch_ready": issue == nil]
if let issue { result["reason"] = issue }
return result
}
emit([
"window_id": windowID,
"input_busy": inputBusy,
"keyboard": routeStatus(busyReason ?? readinessIssue(forKeyboard: true)),
"pointer": routeStatus(busyReason ?? readinessIssue(forKeyboard: false) ?? (windowLocationSymbol == nil ? routingUnavailable : nil)),
"application_acceptance": "unverified",
])
exit(0)
}
guard let source = CGEventSource(stateID: .privateState) else { fail("cannot create private input source") }
func configure(_ event: CGEvent, flags: CGEventFlags = []) {
event.flags = flags
event.setIntegerValueField(.eventSourceUserData, value: 0x43554549)
event.setIntegerValueField(.eventTargetUnixProcessID, value: Int64(pid))
}
func point(_ xName: String = "x", _ yName: String = "y") -> CGPoint {
guard let localX = request[xName] as? Double, let localY = request[yName] as? Double,
localX.isFinite, localY.isFinite, localX >= 0, localY >= 0,
localX < width, localY < height else { fail("input point must be inside the window frame") }
return CGPoint(x: localX, y: localY)
}
func routePointer(_ event: CGEvent, at point: CGPoint) {
guard let symbol = windowLocationSymbol else { fail(routingUnavailable) }
configure(event)
event.location = CGPoint(x: x + point.x, y: y + point.y)
unsafeBitCast(symbol, to: SetWindowLocation.self)(event, point.x, point.y)
for (field, value): (UInt32, Int64) in [(51, Int64(windowID)), (58, 1), (7, 3)] {
guard let key = CGEventField(rawValue: field) else { fail("window routing field unavailable") }
event.setIntegerValueField(key, value: value)
}
event.setIntegerValueField(.mouseEventWindowUnderMousePointer, value: Int64(windowID))
event.setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: Int64(windowID))
}
func mouseEvent(_ type: CGEventType, at point: CGPoint, button: CGMouseButton = .left, count: Int64 = 1) -> CGEvent {
guard let event = CGEvent(mouseEventSource: source, mouseType: type,
mouseCursorPosition: .zero, mouseButton: button) else { fail("cannot create pointer event") }
routePointer(event, at: point)
event.setIntegerValueField(.mouseEventClickState, value: count)
event.setDoubleValueField(.mouseEventPressure, value: type == .leftMouseUp || type == .rightMouseUp ? 0 : 1)
return event
}
func click() {
let location = point()
guard let name = request["button"] as? String, ["left", "right"].contains(name),
let count = request["count"] as? Int, (1...2).contains(count) else { fail("invalid click") }
let right = name == "right"
// Prepare every pair before starting; allocation failure must not leave a down event.
let pairs = (1...count).map { ordinal in (
mouseEvent(right ? .rightMouseDown : .leftMouseDown, at: location, button: right ? .right : .left, count: Int64(ordinal)),
mouseEvent(right ? .rightMouseUp : .leftMouseUp, at: location, button: right ? .right : .left, count: Int64(ordinal))
) }
for (down, up) in pairs {
guard maySend() else { break }
down.postToPid(pid)
up.postToPid(pid)
eventsSent += 2
if count == 2 { Thread.sleep(forTimeInterval: 0.05) }
}
}
func drag() {
let start = point(), end = point("to_x", "to_y")
guard let duration = request["duration_ms"] as? Int, (50...2000).contains(duration) else { fail("invalid drag duration") }
let steps = max(2, duration / 20)
let down = mouseEvent(.leftMouseDown, at: start)
var release = mouseEvent(.leftMouseUp, at: start)
let sequence = (1...steps).map { index -> (CGEvent, CGEvent) in
let fraction = Double(index) / Double(steps)
let location = CGPoint(x: start.x + (end.x - start.x) * fraction, y: start.y + (end.y - start.y) * fraction)
return (mouseEvent(.leftMouseDragged, at: location), mouseEvent(.leftMouseUp, at: location))
}
guard maySend() else { return }
down.postToPid(pid)
eventsSent += 1
// Always release the original receiver at the last delivered point after a detected interruption.
defer { release.postToPid(pid); eventsSent += 1 }
for (event, up) in sequence {
Thread.sleep(forTimeInterval: Double(duration) / Double(steps) / 1000)
guard maySend() else { break }
event.postToPid(pid)
eventsSent += 1
release = up
}
}
func keyPair(code: CGKeyCode, unicode: String = "", flags: CGEventFlags = []) -> Bool {
// Prepare both events before sending either. No checks or AX calls separate down and up.
guard let down = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: true),
let up = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: false) else {
if eventsSent == 0 { fail("cannot create keyboard events") }
interruption = "keyboard event creation failed"
return false
}
for event in [down, up] {
configure(event, flags: flags)
let units = Array(unicode.utf16)
if !units.isEmpty { event.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units) }
}
guard maySend() else { return false }
down.postToPid(pid)
up.postToPid(pid)
eventsSent += 2
return true
}
switch action {
case "type_text":
guard let value = request["text"] as? String, !value.isEmpty,
value.utf16.count <= 1024,
value.unicodeScalars.allSatisfy({ $0.properties.generalCategory != .control }) else {
fail("invalid text input")
}
for character in value {
if !keyPair(code: 0, unicode: String(character)) { break }
Thread.sleep(forTimeInterval: 0.002)
}
case "key":
guard let code = request["key_code"] as? UInt16, let flags = request["flags"] as? UInt64,
flags & ~UInt64(0x1E0000) == 0 else { fail("invalid key input") }
_ = keyPair(code: code, flags: CGEventFlags(rawValue: flags))
case "scroll":
let location = point()
guard let dx = request["delta_x"] as? Int32, let dy = request["delta_y"] as? Int32,
(-4096...4096).contains(dx), (-4096...4096).contains(dy), dx != 0 || dy != 0 else {
fail("invalid scroll input")
}
guard let event = CGEvent(scrollWheelEvent2Source: source, units: .pixel, wheelCount: 2,
wheel1: dy, wheel2: dx, wheel3: 0) else { fail("cannot create scroll event") }
routePointer(event, at: location)
if maySend() {
event.postToPid(pid)
eventsSent += 1
}
case "click": click()
case "drag": drag()
default: fail("unsupported background input")
}
let frontAfter = NSWorkspace.shared.frontmostApplication?.processIdentifier ?? 0
var result: [String: Any] = [
"action": action, "window_id": windowID,
"status": interruption == nil ? "sent_unverified" : "partially_sent",
"events_sent": eventsSent,
"frontmost_pid_before": frontBefore, "frontmost_pid_after": frontAfter,
"foreground_changed": frontBefore != frontAfter,
]
if let interruption { result["interruption"] = interruption }
emit(result)