|
|
|
@ -1,5 +1,6 @@ |
|
|
|
import Cocoa |
|
|
|
import ApplicationServices |
|
|
|
import CoreGraphics |
|
|
|
|
|
|
|
public struct MacInputState: Codable { |
|
|
|
public let source: String |
|
|
|
@ -42,7 +43,44 @@ public final class FocusedInputSync { |
|
|
|
AXUIElementSetAttributeValue(systemWideElement, "AXManualAccessibility" as CFString, kCFBooleanTrue) |
|
|
|
} |
|
|
|
|
|
|
|
private func findFocusedDescendant(_ elem: AXUIElement) -> AXUIElement? { |
|
|
|
/// 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 { |
|
|
|
@ -58,7 +96,7 @@ public final class FocusedInputSync { |
|
|
|
if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success, |
|
|
|
let children = childrenObj as? [AXUIElement] { |
|
|
|
for child in children { |
|
|
|
if let found = findFocusedDescendant(child) { |
|
|
|
if let found = findFocusedDescendant(child, depth: depth + 1) { |
|
|
|
return found |
|
|
|
} |
|
|
|
} |
|
|
|
@ -66,8 +104,8 @@ public final class FocusedInputSync { |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
public func getFocusedElement() -> (AXUIElement, String)? { |
|
|
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { 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) |
|
|
|
|
|
|
|
@ -76,7 +114,7 @@ public final class FocusedInputSync { |
|
|
|
|
|
|
|
var targetElem: AXUIElement? |
|
|
|
|
|
|
|
// 1. Try system wide focused element |
|
|
|
// 1. System-wide focused element |
|
|
|
var focusedObj: CFTypeRef? |
|
|
|
if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, |
|
|
|
let obj = focusedObj { |
|
|
|
@ -89,7 +127,7 @@ public final class FocusedInputSync { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 2. Try App focused element |
|
|
|
// 2. App focused element |
|
|
|
if targetElem == nil { |
|
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, |
|
|
|
let obj = focusedObj { |
|
|
|
@ -103,27 +141,53 @@ public final class FocusedInputSync { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 3. Recursive search in tree |
|
|
|
// 3. App focused window element |
|
|
|
if targetElem == nil { |
|
|
|
targetElem = findFocusedDescendant(appElem) |
|
|
|
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) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if let elem = targetElem { |
|
|
|
return (elem, appName) |
|
|
|
// 4. Recursive search in tree |
|
|
|
if targetElem == nil { |
|
|
|
targetElem = findFocusedDescendant(appElem) |
|
|
|
} |
|
|
|
return nil |
|
|
|
|
|
|
|
return (targetElem, appName) |
|
|
|
} |
|
|
|
|
|
|
|
/// Inspects the current focused element and returns a state snapshot if changed |
|
|
|
/// 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 |
|
|
|
} |
|
|
|
|
|
|
|
guard let (elem, appName) = getFocusedElement() else { 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") |
|
|
|
|
|
|
|
// Extract text |
|
|
|
var text = "" |
|
|
|
var valObj: CFTypeRef? |
|
|
|
if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success, |
|
|
|
@ -135,7 +199,7 @@ public final class FocusedInputSync { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if text.isEmpty { |
|
|
|
if text.isEmpty && isTextRole { |
|
|
|
var countObj: CFTypeRef? |
|
|
|
if AXUIElementCopyAttributeValue(elem, kAXNumberOfCharactersAttribute as CFString, &countObj) == .success, |
|
|
|
let count = countObj as? Int, count > 0 { |
|
|
|
@ -150,7 +214,11 @@ public final class FocusedInputSync { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Extract cursor & selection |
|
|
|
// 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? |
|
|
|
@ -163,8 +231,7 @@ public final class FocusedInputSync { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Check if text or cursor actually changed on Mac |
|
|
|
if text == lastObservedText && cursor == lastObservedCursor && appName == lastObservedApp { |
|
|
|
if text == lastObservedText && cursor == lastObservedCursor && !appChanged { |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
@ -176,84 +243,141 @@ public final class FocusedInputSync { |
|
|
|
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 |
|
|
|
/// 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.45 // 450ms quiet window |
|
|
|
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.25 |
|
|
|
|
|
|
|
defer { |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { |
|
|
|
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) |
|
|
|
// 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 |
|
|
|
|
|
|
|
guard let (elem, appName) = getFocusedElement() else { |
|
|
|
// Fallback: clipboard paste |
|
|
|
lastObservedApp = NSWorkspace.shared.frontmostApplication?.localizedName ?? "App" |
|
|
|
return pasteViaKeystroke(cleanText, isFullReplace: isFullReplace) |
|
|
|
} |
|
|
|
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 { |
|
|
|
// Try setting AXValue directly |
|
|
|
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) |
|
|
|
return executeCleanFullReplace(cleanText) |
|
|
|
} else { |
|
|
|
return pasteOnlyViaCleanKeystroke(cleanText) |
|
|
|
} |
|
|
|
} |
|
|
|
print("FocusedInputSync: ✅ Updated AXValue for \(appName)") |
|
|
|
return true |
|
|
|
|
|
|
|
/** |
|
|
|
* 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) |
|
|
|
} |
|
|
|
} else { |
|
|
|
// Try setting selected text |
|
|
|
let setSelErr = AXUIElementSetAttributeValue(elem, kAXSelectedTextAttribute as CFString, cleanText as CFTypeRef) |
|
|
|
if setSelErr == .success { |
|
|
|
print("FocusedInputSync: ✅ Inserted text via AXSelectedText for \(appName)") |
|
|
|
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) |
|
|
|
|
|
|
|
return pasteViaKeystroke(cleanText, isFullReplace: isFullReplace) |
|
|
|
// 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 |
|
|
|
} |
|
|
|
|
|
|
|
private func pasteViaKeystroke(_ text: String, isFullReplace: Bool) -> Bool { |
|
|
|
// Set clipboard |
|
|
|
let pasteboard = NSPasteboard.general |
|
|
|
pasteboard.clearContents() |
|
|
|
pasteboard.setString(text, forType: .string) |
|
|
|
|
|
|
|
let src = CGEventSource(stateID: .hidSystemState) |
|
|
|
// 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 isFullReplace { |
|
|
|
// Select all: Cmd + A |
|
|
|
let aDown = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: true) |
|
|
|
aDown?.flags = .maskCommand |
|
|
|
let aUp = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: false) |
|
|
|
aDown?.post(tap: .cghidEventTap) |
|
|
|
aUp?.post(tap: .cghidEventTap) |
|
|
|
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 |
|
|
|
|
|
|
|
usleep(20000) |
|
|
|
// 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) |
|
|
|
|
|
|
|
// Paste: Cmd + V |
|
|
|
let vDown = CGEvent(keyboardEventSource: src, virtualKey: 9, keyDown: true) |
|
|
|
vDown?.flags = .maskCommand |
|
|
|
let vUp = CGEvent(keyboardEventSource: src, virtualKey: 9, keyDown: false) |
|
|
|
vDown?.post(tap: .cghidEventTap) |
|
|
|
vUp?.post(tap: .cghidEventTap) |
|
|
|
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 |
|
|
|
} |
|
|
|
|