Soniox Mobile to Mac - Real-time Voice Dictation & Remote Input Control
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

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
}
}