|
|
@ -114,7 +114,7 @@ public final class FocusedInputSync { |
|
|
|
|
|
|
|
|
var targetElem: AXUIElement? |
|
|
var targetElem: AXUIElement? |
|
|
|
|
|
|
|
|
// 1. Try system-wide focused element |
|
|
|
|
|
|
|
|
// 1. System-wide focused element |
|
|
var focusedObj: CFTypeRef? |
|
|
var focusedObj: CFTypeRef? |
|
|
if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, |
|
|
if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, |
|
|
let obj = focusedObj { |
|
|
let obj = focusedObj { |
|
|
@ -127,7 +127,7 @@ public final class FocusedInputSync { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 2. Try App focused element |
|
|
|
|
|
|
|
|
// 2. App focused element |
|
|
if targetElem == nil { |
|
|
if targetElem == nil { |
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, |
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, |
|
|
let obj = focusedObj { |
|
|
let obj = focusedObj { |
|
|
@ -141,7 +141,7 @@ public final class FocusedInputSync { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 3. Try App focused window's focused element |
|
|
|
|
|
|
|
|
// 3. App focused window element |
|
|
if targetElem == nil { |
|
|
if targetElem == nil { |
|
|
var focusedWinObj: CFTypeRef? |
|
|
var focusedWinObj: CFTypeRef? |
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &focusedWinObj) == .success, |
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &focusedWinObj) == .success, |
|
|
@ -162,7 +162,7 @@ public final class FocusedInputSync { |
|
|
return (targetElem, appName) |
|
|
return (targetElem, appName) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/// Inspects current focused element and returns state snapshot if changed on Mac |
|
|
|
|
|
|
|
|
/// Inspects current focused element on Mac and returns state snapshot if user typed on Mac |
|
|
public func inspectCurrentState() -> MacInputState? { |
|
|
public func inspectCurrentState() -> MacInputState? { |
|
|
let now = Date().timeIntervalSince1970 |
|
|
let now = Date().timeIntervalSince1970 |
|
|
if isApplyingRemoteChange || now < remoteChangeExpiryTime { |
|
|
if isApplyingRemoteChange || now < remoteChangeExpiryTime { |
|
|
@ -243,19 +243,19 @@ public final class FocusedInputSync { |
|
|
return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision) |
|
|
return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/// Applies updated full text or inserts speech directly into active Mac input |
|
|
|
|
|
|
|
|
/// Perfectly mirrors the full text to the active Mac input box in real-time |
|
|
@discardableResult |
|
|
@discardableResult |
|
|
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = false) -> Bool { |
|
|
|
|
|
|
|
|
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool { |
|
|
isApplyingRemoteChange = true |
|
|
isApplyingRemoteChange = true |
|
|
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.60 |
|
|
|
|
|
|
|
|
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.40 |
|
|
|
|
|
|
|
|
defer { |
|
|
defer { |
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.60) { |
|
|
|
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.40) { |
|
|
self.isApplyingRemoteChange = false |
|
|
self.isApplyingRemoteChange = false |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 1. Clean and normalize text |
|
|
|
|
|
|
|
|
// Normalize text |
|
|
var cleanText = text.components(separatedBy: .newlines).joined(separator: " ") |
|
|
var cleanText = text.components(separatedBy: .newlines).joined(separator: " ") |
|
|
cleanText = cleanText.replacingOccurrences(of: "\t", with: " ") |
|
|
cleanText = cleanText.replacingOccurrences(of: "\t", with: " ") |
|
|
cleanText = cleanText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
|
|
cleanText = cleanText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
|
|
@ -265,50 +265,89 @@ public final class FocusedInputSync { |
|
|
lastObservedText = cleanText |
|
|
lastObservedText = cleanText |
|
|
lastObservedCursor = targetCursor |
|
|
lastObservedCursor = targetCursor |
|
|
|
|
|
|
|
|
let (_, appName) = getFocusedElement() |
|
|
|
|
|
|
|
|
let (elemOpt, appName) = getFocusedElement() |
|
|
lastObservedApp = appName |
|
|
lastObservedApp = appName |
|
|
|
|
|
|
|
|
print("FocusedInputSync: 🚀 Injecting text into '\(appName)' via Clean Quartz CGEvent (replace: \(isFullReplace), len: \(cleanText.count))") |
|
|
|
|
|
return pasteViaCleanKeystroke(cleanText, isFullReplace: isFullReplace) |
|
|
|
|
|
|
|
|
// Try fast direct native AX write if supported (TextEdit, Notes, Finder) |
|
|
|
|
|
if let elem = elemOpt { |
|
|
|
|
|
let setErr = AXUIElementSetAttributeValue(elem, kAXValueAttribute as CFString, cleanText as CFTypeRef) |
|
|
|
|
|
if setErr == .success { |
|
|
|
|
|
print("FocusedInputSync: ✅ Updated native AXValue for \(appName)") |
|
|
|
|
|
return true |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Universal Native Replace (Cmd+A -> Cmd+V / Backspace) |
|
|
|
|
|
print("FocusedInputSync: 🔄 Syncing full text to '\(appName)' (len: \(cleanText.count))") |
|
|
|
|
|
return executeCleanFullReplace(cleanText) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* Clean, Universal Quartz CGEvent Keystroke Engine |
|
|
|
|
|
* Sets pasteboard, sends clean Cmd+A (if replace) and Cmd+V (virtualKey 9 with .maskCommand). |
|
|
|
|
|
|
|
|
* 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 pasteViaCleanKeystroke(_ text: String, isFullReplace: Bool) -> Bool { |
|
|
|
|
|
let pasteboard = NSPasteboard.general |
|
|
|
|
|
pasteboard.clearContents() |
|
|
|
|
|
pasteboard.setString(text, forType: .string) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private func executeCleanFullReplace(_ text: String) -> Bool { |
|
|
let src = CGEventSource(stateID: .hidSystemState) |
|
|
let src = CGEventSource(stateID: .hidSystemState) |
|
|
let kVK_ANSI_A: CGKeyCode = 0 |
|
|
let kVK_ANSI_A: CGKeyCode = 0 |
|
|
let kVK_ANSI_V: CGKeyCode = 9 |
|
|
let kVK_ANSI_V: CGKeyCode = 9 |
|
|
|
|
|
let kVK_Delete: CGKeyCode = 51 |
|
|
|
|
|
|
|
|
if isFullReplace { |
|
|
|
|
|
|
|
|
if text.isEmpty { |
|
|
// Select all: Cmd + A |
|
|
// Select all: Cmd + A |
|
|
if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) { |
|
|
if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) { |
|
|
aDown.flags = .maskCommand |
|
|
aDown.flags = .maskCommand |
|
|
aDown.post(tap: .cghidEventTap) |
|
|
aDown.post(tap: .cghidEventTap) |
|
|
aDown.post(tap: .cgSessionEventTap) |
|
|
aDown.post(tap: .cgSessionEventTap) |
|
|
} |
|
|
} |
|
|
usleep(12000) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
usleep(8000) |
|
|
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) { |
|
|
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) { |
|
|
aUp.flags = .maskCommand |
|
|
aUp.flags = .maskCommand |
|
|
aUp.post(tap: .cghidEventTap) |
|
|
aUp.post(tap: .cghidEventTap) |
|
|
aUp.post(tap: .cgSessionEventTap) |
|
|
aUp.post(tap: .cgSessionEventTap) |
|
|
} |
|
|
} |
|
|
usleep(25000) // 25ms settling delay for selection |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
// Paste: Cmd + V |
|
|
|
|
|
|
|
|
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) { |
|
|
if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) { |
|
|
vDown.flags = .maskCommand |
|
|
vDown.flags = .maskCommand |
|
|
vDown.post(tap: .cghidEventTap) |
|
|
vDown.post(tap: .cghidEventTap) |
|
|
vDown.post(tap: .cgSessionEventTap) |
|
|
vDown.post(tap: .cgSessionEventTap) |
|
|
} |
|
|
} |
|
|
usleep(15000) |
|
|
|
|
|
|
|
|
usleep(12000) |
|
|
|
|
|
|
|
|
if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) { |
|
|
if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) { |
|
|
vUp.flags = .maskCommand |
|
|
vUp.flags = .maskCommand |
|
|
@ -316,7 +355,6 @@ public final class FocusedInputSync { |
|
|
vUp.post(tap: .cgSessionEventTap) |
|
|
vUp.post(tap: .cgSessionEventTap) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
print("FocusedInputSync: ✅ Clean Cmd+V Posted successfully for text: '\(text.prefix(25))...'") |
|
|
|
|
|
return true |
|
|
return true |
|
|
} |
|
|
} |
|
|
} |
|
|
} |