Browse Source

fix(sync): pure clean Quartz Cmd+V insertion for voice typing without Cmd+A clobber

main
Ali Alavi 21 hours ago
parent
commit
3202d75da1
  1. 3
      android/app/src/main/java/com/soniox/remotemic/MainActivity.kt
  2. 40
      android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt
  3. 75
      mac/src/FocusedInputSync.swift
  4. 8
      mac/src/RelayClient.swift

3
android/app/src/main/java/com/soniox/remotemic/MainActivity.kt

@ -252,7 +252,8 @@ class MainActivity : AppCompatActivity() {
AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)")
streamDictationClient?.sendLocalSyncState(mergedText, newCursor)
// Send speech directly to Mac cursor (Cmd+V)
streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor)
} }
private fun setupUI() { private fun setupUI() {

40
android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt

@ -146,7 +146,6 @@ class StreamDictationClient(
activeWebSocket = null activeWebSocket = null
mainHandler.post { onConnectionStateChanged(false) } mainHandler.post { onConnectionStateChanged(false) }
// Exponential backoff: 800ms, 1600ms, 3200ms
retryAttempt++ retryAttempt++
val delayMs = minOf(800L * (1L shl minOf(retryAttempt, 3)), 5000L) val delayMs = minOf(800L * (1L shl minOf(retryAttempt, 3)), 5000L)
mainHandler.postDelayed({ connectWebSocket() }, delayMs) mainHandler.postDelayed({ connectWebSocket() }, delayMs)
@ -155,7 +154,44 @@ class StreamDictationClient(
} }
/** /**
* Broadcasts phone's updated text & cursor to Mac in sub-15ms
* Sends speech chunk directly for cursor insertion on Mac (Cmd+V)
*/
fun sendSpeechInsert(speechText: String, cursor: Int) {
if (!isConnected.get() || activeWebSocket == null) {
connectWebSocket()
}
val payload = JSONObject().apply {
put("type", "insert_speech")
put("action", "insert_speech")
put("source", "android")
put("text", speechText)
put("cursor", cursor)
put("timestamp", System.currentTimeMillis() / 1000.0)
}.toString()
activeWebSocket?.send(payload)
}
/**
* Broadcasts phone's full updated text & cursor to Mac for replace (Cmd+A + Cmd+V)
*/
fun sendForceReplace(text: String, cursor: Int, selection: Int = 0) {
if (!isConnected.get() || activeWebSocket == null) {
connectWebSocket()
}
val payload = JSONObject().apply {
put("type", "update_input")
put("action", "update_input")
put("source", "android")
put("text", text)
put("cursor", cursor)
put("selection", selection)
put("timestamp", System.currentTimeMillis() / 1000.0)
}.toString()
activeWebSocket?.send(payload)
}
/**
* Broadcasts phone's updated text & cursor to Mac
*/ */
fun sendLocalSyncState(text: String, cursor: Int, selection: Int = 0) { fun sendLocalSyncState(text: String, cursor: Int, selection: Int = 0) {
if (!isConnected.get() || activeWebSocket == null) { if (!isConnected.get() || activeWebSocket == null) {

75
mac/src/FocusedInputSync.swift

@ -245,7 +245,7 @@ public final class FocusedInputSync {
/// Applies updated full text or inserts speech directly into active Mac input /// Applies updated full text or inserts speech directly into active Mac input
@discardableResult @discardableResult
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool {
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = false) -> Bool {
isApplyingRemoteChange = true isApplyingRemoteChange = true
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.60 remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.60
@ -268,70 +268,55 @@ public final class FocusedInputSync {
let (_, appName) = getFocusedElement() let (_, appName) = getFocusedElement()
lastObservedApp = appName lastObservedApp = appName
print("FocusedInputSync: 🚀 Injecting text into '\(appName)' via Ultra-Reliable Quartz CGEvent (replace: \(isFullReplace))")
return pasteViaKeystroke(cleanText, isFullReplace: isFullReplace)
print("FocusedInputSync: 🚀 Injecting text into '\(appName)' via Clean Quartz CGEvent (replace: \(isFullReplace), len: \(cleanText.count))")
return pasteViaCleanKeystroke(cleanText, isFullReplace: isFullReplace)
} }
/** /**
* Rock-Solid Quartz CGEvent Keystroke Engine
* Uses full modifier sequence: Cmd Down -> Key Down -> Key Up -> Cmd Up.
* Works on 100% of macOS applications with 0 permission hurdles (uses Accessibility).
* Clean, Universal Quartz CGEvent Keystroke Engine
* Sets pasteboard, sends clean Cmd+A (if replace) and Cmd+V (virtualKey 9 with .maskCommand).
*/ */
private func pasteViaKeystroke(_ text: String, isFullReplace: Bool) -> Bool {
private func pasteViaCleanKeystroke(_ text: String, isFullReplace: Bool) -> Bool {
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.setString(text, forType: .string) pasteboard.setString(text, forType: .string)
let src = CGEventSource(stateID: .hidSystemState) let src = CGEventSource(stateID: .hidSystemState)
let kVK_Command: CGKeyCode = 55
let kVK_ANSI_A: CGKeyCode = 0 let kVK_ANSI_A: CGKeyCode = 0
let kVK_ANSI_V: CGKeyCode = 9 let kVK_ANSI_V: CGKeyCode = 9
if isFullReplace { if isFullReplace {
// Full Cmd + A sequence
let cmdDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_Command, keyDown: true)
cmdDown?.flags = .maskCommand
cmdDown?.post(tap: .cghidEventTap)
usleep(8000)
let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true)
aDown?.flags = .maskCommand
aDown?.post(tap: .cghidEventTap)
// 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(12000) usleep(12000)
let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false)
aUp?.flags = .maskCommand
aUp?.post(tap: .cghidEventTap)
usleep(8000)
let cmdUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_Command, keyDown: false)
cmdUp?.flags = []
cmdUp?.post(tap: .cghidEventTap)
usleep(30000) // 30ms settling delay for selection
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) {
aUp.flags = .maskCommand
aUp.post(tap: .cghidEventTap)
aUp.post(tap: .cgSessionEventTap)
}
usleep(25000) // 25ms settling delay for selection
} }
// Full Cmd + V sequence
let cmdDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_Command, keyDown: true)
cmdDown?.flags = .maskCommand
cmdDown?.post(tap: .cghidEventTap)
usleep(8000)
let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true)
vDown?.flags = .maskCommand
vDown?.post(tap: .cghidEventTap)
// 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(15000) usleep(15000)
let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false)
vUp?.flags = .maskCommand
vUp?.post(tap: .cghidEventTap)
usleep(8000)
let cmdUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_Command, keyDown: false)
cmdUp?.flags = []
cmdUp?.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)
}
print("FocusedInputSync: ✅ CGEvent Cmd+V Posted successfully!")
print("FocusedInputSync: ✅ Clean Cmd+V Posted successfully for text: '\(text.prefix(25))...'")
return true return true
} }
} }

8
mac/src/RelayClient.swift

@ -72,7 +72,7 @@ public final class RelayClient: NSObject, URLSessionWebSocketDelegate {
case .success(let message): case .success(let message):
self.isConnected = true self.isConnected = true
switch message { switch message {
case .string(let text):
case .string(let text):
self.handleMessageString(text) self.handleMessageString(text)
case .data(let data): case .data(let data):
if let text = String(data: data, encoding: .utf8) { if let text = String(data: data, encoding: .utf8) {
@ -106,9 +106,9 @@ public final class RelayClient: NSObject, URLSessionWebSocketDelegate {
let updateText = json["text"] as? String ?? json["insert_text"] as? String ?? "" let updateText = json["text"] as? String ?? json["insert_text"] as? String ?? ""
let cursor = json["cursor"] as? Int ?? json["cursor_pos"] as? Int let cursor = json["cursor"] as? Int ?? json["cursor_pos"] as? Int
if action == "paste" || action == "set_text" || action == "update_input" || action == "phone_input_edit" || action == "sync_state" {
let isFullReplace = (action != "insert_at_cursor")
print("RelayClient: ⚡ Received remote Android update: '\(updateText.prefix(30))...' (replace: \(isFullReplace), cursor: \(cursor ?? -1))")
if action == "paste" || action == "set_text" || action == "update_input" || action == "phone_input_edit" || action == "sync_state" || action == "insert_speech" {
let isFullReplace = (action == "update_input" || action == "set_text" || action == "force_replace")
print("RelayClient: ⚡ Received remote update: '\(updateText.prefix(30))...' (replace: \(isFullReplace), cursor: \(cursor ?? -1))")
DispatchQueue.main.async { DispatchQueue.main.async {
self.onRemoteUpdateReceived?(updateText, cursor, isFullReplace) self.onRemoteUpdateReceived?(updateText, cursor, isFullReplace)
} }

Loading…
Cancel
Save