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.
384 lines
15 KiB
384 lines
15 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
|
|
|
|
private init() {
|
|
self.systemWideElement = AXUIElementCreateSystemWide()
|
|
enableGlobalAccessibility()
|
|
}
|
|
|
|
private func enableGlobalAccessibility() {
|
|
AXUIElementSetAttributeValue(systemWideElement, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
|
|
AXUIElementSetAttributeValue(systemWideElement, "AXManualAccessibility" as CFString, kCFBooleanTrue)
|
|
}
|
|
|
|
/// Resolves the actual user-facing application, bypassing system overlays
|
|
public func getRealFrontmostApp() -> NSRunningApplication? {
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
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. 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. 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. App focused window 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 current focused element on Mac and returns state snapshot if user typed on Mac
|
|
public func inspectCurrentState() -> MacInputState? {
|
|
let now = Date().timeIntervalSince1970
|
|
if isApplyingRemoteChange || now < remoteChangeExpiryTime {
|
|
return nil
|
|
}
|
|
|
|
let (elemOpt, appName) = getFocusedElement()
|
|
let appChanged = (appName != lastObservedApp)
|
|
if appChanged {
|
|
lastObservedApp = appName
|
|
}
|
|
|
|
guard let elem = elemOpt else {
|
|
if appChanged && !lastObservedText.isEmpty {
|
|
localRevision += 1
|
|
return MacInputState(app: appName, text: lastObservedText, cursor: lastObservedCursor, selection: 0, revision: localRevision)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var roleObj: CFTypeRef?
|
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = roleObj as? String ?? ""
|
|
let isTextRole = (role == "AXTextField" || role == "AXTextArea" || role == "AXComboBox" || role == "AXSearchField")
|
|
|
|
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 for non-native containers
|
|
if text.isEmpty && !lastObservedText.isEmpty && !isTextRole {
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// Perfectly mirrors the full text to the active Mac input box in real-time
|
|
@discardableResult
|
|
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool {
|
|
isApplyingRemoteChange = true
|
|
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.25
|
|
|
|
defer {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
|
self.isApplyingRemoteChange = false
|
|
}
|
|
}
|
|
|
|
// Normalize line endings and preserve intentional multiline text (\n)
|
|
var cleanText = text.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
|
cleanText = cleanText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let targetCursor = cursor ?? cleanText.count
|
|
lastObservedText = cleanText
|
|
lastObservedCursor = targetCursor
|
|
|
|
let (_, appName) = getFocusedElement()
|
|
lastObservedApp = appName
|
|
|
|
// Universal Quartz CGEvent Keystroke Engine (Cmd+A -> Cmd+V / Backspace or pure Cmd+V)
|
|
// Works 100% reliably across native, web, and Electron/Chromium apps (e.g. Antigravity, VS Code, Slack, Firefox)
|
|
print("FocusedInputSync: 🚀 Injecting text into '\(appName)' (replace: \(isFullReplace), len: \(cleanText.count), lines: \(cleanText.components(separatedBy: "\n").count))")
|
|
if isFullReplace {
|
|
return executeCleanFullReplace(cleanText)
|
|
} else {
|
|
return pasteOnlyViaCleanKeystroke(cleanText)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inserts speech directly at active cursor via clean Cmd+V
|
|
*/
|
|
private func pasteOnlyViaCleanKeystroke(_ text: String) -> Bool {
|
|
guard !text.isEmpty else { return true }
|
|
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
|
|
let src = CGEventSource(stateID: .hidSystemState)
|
|
let kVK_ANSI_V: CGKeyCode = 9
|
|
|
|
// Paste: Cmd + V
|
|
if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) {
|
|
vDown.flags = .maskCommand
|
|
vDown.post(tap: .cghidEventTap)
|
|
vDown.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(12000)
|
|
|
|
if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) {
|
|
vUp.flags = .maskCommand
|
|
vUp.post(tap: .cghidEventTap)
|
|
vUp.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(10000)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Replaces the entire content of active input box cleanly in real-time.
|
|
* If text is empty: Cmd+A -> Backspace.
|
|
* If text is non-empty: Cmd+A -> Cmd+V.
|
|
*/
|
|
private func executeCleanFullReplace(_ text: String) -> Bool {
|
|
let src = CGEventSource(stateID: .hidSystemState)
|
|
let kVK_ANSI_A: CGKeyCode = 0
|
|
let kVK_ANSI_V: CGKeyCode = 9
|
|
let kVK_Delete: CGKeyCode = 51
|
|
|
|
if text.isEmpty {
|
|
// Select all: Cmd + A
|
|
if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) {
|
|
aDown.flags = .maskCommand
|
|
aDown.post(tap: .cghidEventTap)
|
|
aDown.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(8000)
|
|
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) {
|
|
aUp.flags = .maskCommand
|
|
aUp.post(tap: .cghidEventTap)
|
|
aUp.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(15000)
|
|
|
|
// Backspace to clear
|
|
if let delDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: true) {
|
|
delDown.post(tap: .cghidEventTap)
|
|
delDown.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(8000)
|
|
if let delUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: false) {
|
|
delUp.post(tap: .cghidEventTap)
|
|
delUp.post(tap: .cgSessionEventTap)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Set clipboard
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
|
|
// 1. Select all: Cmd + A
|
|
if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) {
|
|
aDown.flags = .maskCommand
|
|
aDown.post(tap: .cghidEventTap)
|
|
aDown.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(8000)
|
|
|
|
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) {
|
|
aUp.flags = .maskCommand
|
|
aUp.post(tap: .cghidEventTap)
|
|
aUp.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(18000) // 18ms for selection to settle
|
|
|
|
// 2. Paste: Cmd + V
|
|
if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) {
|
|
vDown.flags = .maskCommand
|
|
vDown.post(tap: .cghidEventTap)
|
|
vDown.post(tap: .cgSessionEventTap)
|
|
}
|
|
usleep(12000)
|
|
|
|
if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) {
|
|
vUp.flags = .maskCommand
|
|
vUp.post(tap: .cghidEventTap)
|
|
vUp.post(tap: .cgSessionEventTap)
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|