diff --git a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt index a01d3ed..15a6eb1 100644 --- a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt +++ b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt @@ -252,7 +252,8 @@ class MainActivity : AppCompatActivity() { AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") - streamDictationClient?.sendLocalSyncState(mergedText, newCursor) + // Send speech directly to Mac cursor (Cmd+V) + streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor) } private fun setupUI() { diff --git a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt index beec0dc..ec762e0 100644 --- a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt +++ b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt @@ -146,7 +146,6 @@ class StreamDictationClient( activeWebSocket = null mainHandler.post { onConnectionStateChanged(false) } - // Exponential backoff: 800ms, 1600ms, 3200ms retryAttempt++ val delayMs = minOf(800L * (1L shl minOf(retryAttempt, 3)), 5000L) 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) { if (!isConnected.get() || activeWebSocket == null) { diff --git a/mac/src/FocusedInputSync.swift b/mac/src/FocusedInputSync.swift index 9832de7..0c05547 100644 --- a/mac/src/FocusedInputSync.swift +++ b/mac/src/FocusedInputSync.swift @@ -245,7 +245,7 @@ public final class FocusedInputSync { /// Applies updated full text or inserts speech directly into active Mac input @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 remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.60 @@ -268,70 +268,55 @@ public final class FocusedInputSync { let (_, appName) = getFocusedElement() 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 pasteboard.clearContents() pasteboard.setString(text, forType: .string) let src = CGEventSource(stateID: .hidSystemState) - let kVK_Command: CGKeyCode = 55 let kVK_ANSI_A: CGKeyCode = 0 let kVK_ANSI_V: CGKeyCode = 9 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) - 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) - 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 } } diff --git a/mac/src/RelayClient.swift b/mac/src/RelayClient.swift index 12a4f33..361a184 100644 --- a/mac/src/RelayClient.swift +++ b/mac/src/RelayClient.swift @@ -72,7 +72,7 @@ public final class RelayClient: NSObject, URLSessionWebSocketDelegate { case .success(let message): self.isConnected = true switch message { - case .string(let text): + case .string(let text): self.handleMessageString(text) case .data(let data): 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 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 { self.onRemoteUpdateReceived?(updateText, cursor, isFullReplace) }