You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
446 lines
19 KiB
446 lines
19 KiB
import Cocoa
|
|
import ApplicationServices
|
|
import CoreGraphics
|
|
|
|
public struct MacInputState: Codable {
|
|
public let source: String
|
|
public let app: String
|
|
public let text: String
|
|
public let cursor: Int
|
|
public let selection: Int
|
|
public let revision: Int64
|
|
public let timestamp: Double
|
|
|
|
public init(app: String, text: String, cursor: Int, selection: Int, revision: Int64) {
|
|
self.source = "mac"
|
|
self.app = app
|
|
self.text = text
|
|
self.cursor = cursor
|
|
self.selection = selection
|
|
self.revision = revision
|
|
self.timestamp = Date().timeIntervalSince1970
|
|
}
|
|
}
|
|
|
|
public final class FocusedInputSync {
|
|
public static let shared = FocusedInputSync()
|
|
|
|
private let systemWideElement: AXUIElement
|
|
private var isApplyingRemoteChange: Bool = false
|
|
private var lastObservedText: String = ""
|
|
private var lastObservedCursor: Int = -1
|
|
private var lastObservedApp: String = ""
|
|
private var localRevision: Int64 = 0
|
|
private var remoteChangeExpiryTime: Double = 0
|
|
|
|
// Known Web, Electron, Terminal, and Custom UI apps that do NOT accept direct AXValue writes
|
|
// but require fast, reliable native keystroke paste (Cmd+V / Cmd+A + Cmd+V)
|
|
private let pastePreferredApps: Set<String> = [
|
|
"antigravity", "antigravity helper", "google chrome", "chromium", "brave browser",
|
|
"arc", "microsoft edge", "firefox", "safari", "code", "cursor", "visual studio code",
|
|
"slack", "discord", "telegram", "whatsapp", "signal", "ghostty", "iterm2", "iterm",
|
|
"terminal", "alacritty", "kitty", "jetbrains", "idea", "webstorm", "pycharm",
|
|
"datagrip", "sublime text", "notion", "obsidian", "linear", "warp"
|
|
]
|
|
|
|
private init() {
|
|
self.systemWideElement = AXUIElementCreateSystemWide()
|
|
enableGlobalAccessibility()
|
|
}
|
|
|
|
private func enableGlobalAccessibility() {
|
|
AXUIElementSetAttributeValue(systemWideElement, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
|
|
AXUIElementSetAttributeValue(systemWideElement, "AXManualAccessibility" as CFString, kCFBooleanTrue)
|
|
}
|
|
|
|
public func isAppPastePreferred(_ appName: String) -> Bool {
|
|
let lower = appName.lowercased()
|
|
for pref in pastePreferredApps {
|
|
if lower.contains(pref) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Resolves the actual user-facing application, bypassing system overlays like UserNotificationCenter
|
|
public func getRealFrontmostApp() -> NSRunningApplication? {
|
|
// 1. If standard frontmost app is a real regular user app, return it
|
|
if let front = NSWorkspace.shared.frontmostApplication,
|
|
front.activationPolicy == .regular,
|
|
let bundleId = front.bundleIdentifier,
|
|
!bundleId.contains("notificationcenter"),
|
|
!bundleId.contains("controlcenter"),
|
|
!bundleId.contains("WindowManager"),
|
|
!bundleId.contains("Soniox") {
|
|
return front
|
|
}
|
|
|
|
// 2. Otherwise, find top on-screen Layer 0 window from CGWindowList
|
|
let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
|
|
|
|
for win in windowList {
|
|
let layer = win[kCGWindowLayer as String] as? Int ?? -1
|
|
let pid = win[kCGWindowOwnerPID as String] as? pid_t ?? 0
|
|
let bounds = win[kCGWindowBounds as String] as? [String: Any] ?? [:]
|
|
let width = bounds["Width"] as? CGFloat ?? 0
|
|
let height = bounds["Height"] as? CGFloat ?? 0
|
|
|
|
// Only consider standard app windows (layer 0, reasonable size)
|
|
if layer == 0 && width > 100 && height > 100 {
|
|
if let app = NSRunningApplication(processIdentifier: pid),
|
|
app.activationPolicy == .regular,
|
|
let bundleId = app.bundleIdentifier,
|
|
!bundleId.contains("notificationcenter"),
|
|
!bundleId.contains("controlcenter"),
|
|
!bundleId.contains("WindowManager"),
|
|
!bundleId.contains("Soniox") {
|
|
return app
|
|
}
|
|
}
|
|
}
|
|
|
|
return NSWorkspace.shared.frontmostApplication
|
|
}
|
|
|
|
private func findFocusedDescendant(_ elem: AXUIElement, depth: Int = 0) -> AXUIElement? {
|
|
if depth > 12 { return nil }
|
|
|
|
var isFocusedObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &isFocusedObj) == .success,
|
|
let isFocused = isFocusedObj as? Bool, isFocused {
|
|
var roleObj: CFTypeRef?
|
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = (roleObj as? String) ?? ""
|
|
if role != "AXWindow" && role != "AXApplication" && role != "AXGroup" && role != "AXSplitGroup" && role != "AXScrollArea" {
|
|
return elem
|
|
}
|
|
}
|
|
|
|
var childrenObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success,
|
|
let children = childrenObj as? [AXUIElement] {
|
|
for child in children {
|
|
if let found = findFocusedDescendant(child, depth: depth + 1) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func getFocusedElement() -> (AXUIElement?, String) {
|
|
guard let frontApp = getRealFrontmostApp() else { return (nil, "App") }
|
|
let appName = frontApp.localizedName ?? "App"
|
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
|
|
|
|
AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
|
|
AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue)
|
|
|
|
var targetElem: AXUIElement?
|
|
|
|
// 1. Try system-wide focused element
|
|
var focusedObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
|
|
let obj = focusedObj {
|
|
let elem = obj as! AXUIElement
|
|
var roleObj: CFTypeRef?
|
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = (roleObj as? String) ?? ""
|
|
if role != "AXWindow" && role != "AXApplication" {
|
|
targetElem = elem
|
|
}
|
|
}
|
|
|
|
// 2. Try App focused element
|
|
if targetElem == nil {
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
|
|
let obj = focusedObj {
|
|
let elem = obj as! AXUIElement
|
|
var roleObj: CFTypeRef?
|
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = (roleObj as? String) ?? ""
|
|
if role != "AXWindow" && role != "AXApplication" {
|
|
targetElem = elem
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Try App focused window's focused element
|
|
if targetElem == nil {
|
|
var focusedWinObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &focusedWinObj) == .success,
|
|
let win = focusedWinObj {
|
|
var winFocObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue((win as! AXUIElement), kAXFocusedUIElementAttribute as CFString, &winFocObj) == .success,
|
|
let obj = winFocObj {
|
|
targetElem = (obj as! AXUIElement)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Recursive search in tree
|
|
if targetElem == nil {
|
|
targetElem = findFocusedDescendant(appElem)
|
|
}
|
|
|
|
return (targetElem, appName)
|
|
}
|
|
|
|
/// Inspects the current focused element and returns a state snapshot if changed on Mac
|
|
public func inspectCurrentState() -> MacInputState? {
|
|
let now = Date().timeIntervalSince1970
|
|
if isApplyingRemoteChange || now < remoteChangeExpiryTime {
|
|
return nil
|
|
}
|
|
|
|
let (elemOpt, appName) = getFocusedElement()
|
|
|
|
// If app changed
|
|
let appChanged = (appName != lastObservedApp)
|
|
if appChanged {
|
|
lastObservedApp = appName
|
|
}
|
|
|
|
guard let elem = elemOpt else {
|
|
// When in opaque Electron/Antigravity containers, do not emit ghost empty text
|
|
if appChanged && !lastObservedText.isEmpty {
|
|
localRevision += 1
|
|
return MacInputState(app: appName, text: lastObservedText, cursor: lastObservedCursor, selection: 0, revision: localRevision)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Check role
|
|
var roleObj: CFTypeRef?
|
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = roleObj as? String ?? ""
|
|
let isTextRole = (role == "AXTextField" || role == "AXTextArea" || role == "AXComboBox" || role == "AXSearchField")
|
|
|
|
// Extract text
|
|
var text = ""
|
|
var valObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success,
|
|
let val = valObj {
|
|
if let str = val as? String {
|
|
text = str
|
|
} else if let attrStr = val as? NSAttributedString {
|
|
text = attrStr.string
|
|
}
|
|
}
|
|
|
|
if text.isEmpty && isTextRole {
|
|
var countObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(elem, kAXNumberOfCharactersAttribute as CFString, &countObj) == .success,
|
|
let count = countObj as? Int, count > 0 {
|
|
var range = CFRange(location: 0, length: count)
|
|
if let axRange = AXValueCreate(.cfRange, &range) {
|
|
var strObj: CFTypeRef?
|
|
if AXUIElementCopyParameterizedAttributeValue(elem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &strObj) == .success,
|
|
let str = strObj as? String {
|
|
text = str
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ghost empty suppression: If an opaque web app reports empty string, but we had existing text,
|
|
// and element is NOT an explicit empty native text field, ignore the empty read.
|
|
if text.isEmpty && !lastObservedText.isEmpty && !isTextRole && isAppPastePreferred(appName) {
|
|
return nil
|
|
}
|
|
|
|
// Extract cursor & selection
|
|
var cursor = text.count
|
|
var selLen = 0
|
|
var rangeObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, &rangeObj) == .success,
|
|
let axRange = rangeObj {
|
|
var range = CFRange()
|
|
if AXValueGetValue(axRange as! AXValue, .cfRange, &range) {
|
|
cursor = range.location
|
|
selLen = range.length
|
|
}
|
|
}
|
|
|
|
// Check if text or cursor actually changed on Mac
|
|
if text == lastObservedText && cursor == lastObservedCursor && !appChanged {
|
|
return nil
|
|
}
|
|
|
|
lastObservedText = text
|
|
lastObservedCursor = cursor
|
|
lastObservedApp = appName
|
|
localRevision += 1
|
|
|
|
return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision)
|
|
}
|
|
|
|
/// Applies updated full text or inserts at cursor position directly into active Mac input
|
|
@discardableResult
|
|
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool {
|
|
isApplyingRemoteChange = true
|
|
// 750ms quiet window to prevent self-echo and race conditions
|
|
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.75
|
|
|
|
defer {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
|
self.isApplyingRemoteChange = false
|
|
}
|
|
}
|
|
|
|
// 1. Clean and normalize text
|
|
var cleanText = text.components(separatedBy: .newlines).joined(separator: " ")
|
|
cleanText = cleanText.replacingOccurrences(of: "\t", with: " ")
|
|
cleanText = cleanText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
cleanText = cleanText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let targetCursor = cursor ?? cleanText.count
|
|
lastObservedText = cleanText
|
|
lastObservedCursor = targetCursor
|
|
|
|
let (elemOpt, appName) = getFocusedElement()
|
|
lastObservedApp = appName
|
|
|
|
// If the target app is a Web/Electron/IDE application (e.g. Antigravity, Chrome, VS Code, Discord, Slack)
|
|
// or no native AX element was resolved, execute process-targeted synthetic paste!
|
|
if isAppPastePreferred(appName) || elemOpt == nil {
|
|
print("FocusedInputSync: 🎯 Target app '\(appName)' is Web/Electron. Executing Process-Targeted Paste.")
|
|
return pasteViaKeystroke(cleanText, appName: appName, isFullReplace: isFullReplace)
|
|
}
|
|
|
|
// For Native AppKit/Cocoa apps (e.g. TextEdit, Notes, Finder):
|
|
if let elem = elemOpt {
|
|
if isFullReplace {
|
|
let setErr = AXUIElementSetAttributeValue(elem, kAXValueAttribute as CFString, cleanText as CFTypeRef)
|
|
if setErr == .success {
|
|
if let cursor = cursor {
|
|
var range = CFRange(location: min(cursor, cleanText.count), length: 0)
|
|
if let axRange = AXValueCreate(.cfRange, &range) {
|
|
AXUIElementSetAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, axRange)
|
|
}
|
|
}
|
|
print("FocusedInputSync: ✅ Updated native AXValue for \(appName)")
|
|
return true
|
|
}
|
|
} else {
|
|
let setSelErr = AXUIElementSetAttributeValue(elem, kAXSelectedTextAttribute as CFString, cleanText as CFTypeRef)
|
|
if setSelErr == .success {
|
|
print("FocusedInputSync: ✅ Inserted native text via AXSelectedText for \(appName)")
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reliable fallback for any situation
|
|
return pasteViaKeystroke(cleanText, appName: appName, isFullReplace: isFullReplace)
|
|
}
|
|
|
|
/**
|
|
* Ultra-reliable Process-Targeted synthetic keystroke injection (AppleScript System Events + Quartz HID)
|
|
* Forces frontmost focus on the target app process and injects Cmd+V directly into its input element.
|
|
*/
|
|
private func pasteViaKeystroke(_ text: String, appName: String, isFullReplace: Bool) -> Bool {
|
|
let pasteboard = NSPasteboard.general
|
|
|
|
// 1. Snapshot previous clipboard to restore after paste
|
|
let oldString = pasteboard.string(forType: .string)
|
|
|
|
// 2. Set new speech text to pasteboard
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
|
|
// 3. Ensure target app is activated
|
|
if let realApp = getRealFrontmostApp() {
|
|
realApp.activate(options: [])
|
|
usleep(25000) // 25ms focus settling
|
|
}
|
|
|
|
// 4. Engine 1: Process-Targeted AppleScript System Events
|
|
var appleScriptSuccess = false
|
|
let safeAppName = appName.replacingOccurrences(of: "\"", with: "\\\"")
|
|
|
|
let scriptSource = isFullReplace ? """
|
|
tell application "\(safeAppName)" to activate
|
|
tell application "System Events"
|
|
tell process "\(safeAppName)"
|
|
set frontmost to true
|
|
keystroke "a" using command down
|
|
delay 0.03
|
|
keystroke "v" using command down
|
|
end tell
|
|
end tell
|
|
""" : """
|
|
tell application "\(safeAppName)" to activate
|
|
tell application "System Events"
|
|
tell process "\(safeAppName)"
|
|
set frontmost to true
|
|
keystroke "v" using command down
|
|
end tell
|
|
end tell
|
|
"""
|
|
|
|
if let script = NSAppleScript(source: scriptSource) {
|
|
var errorDict: NSDictionary?
|
|
script.executeAndReturnError(&errorDict)
|
|
if errorDict == nil {
|
|
appleScriptSuccess = true
|
|
print("FocusedInputSync: ✅ Process-Targeted AppleScript Injected into '\(safeAppName)' (replace: \(isFullReplace))")
|
|
} else {
|
|
print("FocusedInputSync: ⚠️ Targeted AppleScript warning: \(errorDict ?? [:]), trying general System Events")
|
|
// General fallback without process qualification
|
|
let generalScriptSource = isFullReplace ? "tell application \"System Events\" to {keystroke \"a\" using command down, delay 0.03, keystroke \"v\" using command down}" : "tell application \"System Events\" to keystroke \"v\" using command down"
|
|
if let genScript = NSAppleScript(source: generalScriptSource) {
|
|
var genErr: NSDictionary?
|
|
genScript.executeAndReturnError(&genErr)
|
|
if genErr == nil {
|
|
appleScriptSuccess = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Engine 2: Quartz HID Event Tap Fallback (if AppleScript didn't run)
|
|
if !appleScriptSuccess {
|
|
let src = CGEventSource(stateID: .hidSystemState)
|
|
|
|
if isFullReplace {
|
|
// Select all: Cmd + A (virtualKey 0 = 'a')
|
|
if let aDown = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: true) {
|
|
aDown.flags = .maskCommand
|
|
aDown.post(tap: .cghidEventTap)
|
|
}
|
|
usleep(15000)
|
|
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: false) {
|
|
aUp.flags = .maskCommand
|
|
aUp.post(tap: .cghidEventTap)
|
|
}
|
|
usleep(40000)
|
|
}
|
|
|
|
// Paste: Cmd + V (virtualKey 9 = 'v')
|
|
if let vDown = CGEvent(keyboardEventSource: src, virtualKey: 9, keyDown: true) {
|
|
vDown.flags = .maskCommand
|
|
vDown.post(tap: .cghidEventTap)
|
|
}
|
|
usleep(15000)
|
|
if let vUp = CGEvent(keyboardEventSource: src, virtualKey: 9, keyDown: false) {
|
|
vUp.flags = .maskCommand
|
|
vUp.post(tap: .cghidEventTap)
|
|
}
|
|
print("FocusedInputSync: ✅ Injected via Quartz HID Event Tap (replace: \(isFullReplace))")
|
|
}
|
|
|
|
// 6. Asynchronously restore previous clipboard content after 600ms
|
|
if let previousText = oldString, previousText != text {
|
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.60) {
|
|
if pasteboard.string(forType: .string) == text {
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(previousText, forType: .string)
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|