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.
 
 
 
 

205 lines
7.7 KiB

import Foundation
import Cocoa
public final class RelayClient: NSObject, URLSessionWebSocketDelegate {
public static let shared = RelayClient()
// Connects through SSH local port forward 18999 -> Linux Server 8999
private let primaryUrl = URL(string: "ws://127.0.0.1:18999/ws/mac")!
private var webSocketTask: URLSessionWebSocketTask?
private var urlSession: URLSession!
private var isRunning = false
public private(set) var isConnected = false
private var reconnectTimer: Timer?
private var pingTimer: Timer?
private var monitorTimerSource: DispatchSourceTimer?
private let monitorQueue = DispatchQueue(label: "com.soniox.macsync.monitor", qos: .userInteractive)
public var onRemoteUpdateReceived: ((String, Int?, Bool) -> Void)?
private var lastInjectedText: String = ""
private var lastInjectedTime: Double = 0
override private init() {
super.init()
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
self.urlSession = URLSession(configuration: config, delegate: self, delegateQueue: .main)
}
public func start() {
guard !isRunning else { return }
isRunning = true
print("RelayClient: Starting real-time bi-directional sync engine...")
connect()
startInputMonitoring()
}
public func stop() {
isRunning = false
reconnectTimer?.invalidate()
reconnectTimer = nil
pingTimer?.invalidate()
pingTimer = nil
monitorTimerSource?.cancel()
monitorTimerSource = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
isConnected = false
}
private func connect() {
guard isRunning else { return }
webSocketTask?.cancel()
var request = URLRequest(url: primaryUrl)
request.timeoutInterval = 6
request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) SonioxVoice/1.0", forHTTPHeaderField: "User-Agent")
webSocketTask = urlSession.webSocketTask(with: request)
webSocketTask?.resume()
listenForMessages()
startPingTimer()
}
private func listenForMessages() {
webSocketTask?.receive { [weak self] result in
guard let self = self, self.isRunning else { return }
switch result {
case .success(let message):
self.isConnected = true
switch message {
case .string(let text):
self.handleMessageString(text)
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
self.handleMessageString(text)
}
@unknown default:
break
}
self.listenForMessages()
case .failure(let error):
print("RelayClient: Connection error: \(error.localizedDescription)")
self.isConnected = false
self.scheduleReconnect()
}
}
}
private func handleMessageString(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
let action = json["action"] as? String ?? json["type"] as? String ?? ""
let source = json["source"] as? String ?? ""
// Ignore echo messages originated by Mac itself
if source == "mac" { return }
let updateText = json["text"] as? String ?? json["insert_text"] as? String ?? ""
let cursor = json["cursor"] as? Int ?? json["cursor_pos"] as? Int
if action == "sync_state" || action == "phone_input_edit" || action == "update_input" || action == "force_replace" || action == "insert_speech" || action == "paste" {
let now = Date().timeIntervalSince1970
// Drop rapid duplicate transmissions within 150ms (unless force_replace)
if updateText == lastInjectedText && (now - lastInjectedTime) < 0.15 && action != "force_replace" {
return
}
lastInjectedText = updateText
lastInjectedTime = now
let isFullReplace = (action != "insert_speech" && action != "speech_insert")
print("RelayClient: ⚡ Applying phone edit: '\(updateText.prefix(30))...' (replace: \(isFullReplace), len: \(updateText.count))")
DispatchQueue.main.async {
self.onRemoteUpdateReceived?(updateText, cursor, isFullReplace)
}
}
}
private func startInputMonitoring() {
monitorTimerSource?.cancel()
let timer = DispatchSource.makeTimerSource(queue: monitorQueue)
// Poll every 50ms on dedicated user-interactive queue
timer.schedule(deadline: .now(), repeating: .milliseconds(50))
timer.setEventHandler { [weak self] in
guard let self = self, self.isRunning, self.isConnected else { return }
if let state = FocusedInputSync.shared.inspectCurrentState() {
self.sendStateToRelay(state)
}
}
timer.resume()
self.monitorTimerSource = timer
}
private func sendStateToRelay(_ state: MacInputState) {
guard let task = webSocketTask else { return }
let payload: [String: Any] = [
"type": "sync_state",
"source": "mac",
"app": state.app,
"text": state.text,
"cursor": state.cursor,
"selection": state.selection,
"revision": state.revision,
"timestamp": state.timestamp
]
if let data = try? JSONSerialization.data(withJSONObject: payload),
let jsonStr = String(data: data, encoding: .utf8) {
task.send(.string(jsonStr)) { error in
if let error = error {
print("Error sending sync_state:", error)
}
}
}
}
private func startPingTimer() {
pingTimer?.invalidate()
let timer = Timer(timeInterval: 10.0, repeats: true) { [weak self] _ in
guard let self = self, self.isRunning else { return }
self.webSocketTask?.send(.string("{\"type\":\"ping\"}")) { _ in }
}
RunLoop.main.add(timer, forMode: .common)
self.pingTimer = timer
}
private func scheduleReconnect() {
guard isRunning else { return }
pingTimer?.invalidate()
pingTimer = nil
if reconnectTimer == nil || !(reconnectTimer?.isValid ?? false) {
let timer = Timer(timeInterval: 2.0, repeats: false) { [weak self] _ in
self?.reconnectTimer = nil
self?.connect()
}
RunLoop.main.add(timer, forMode: .common)
self.reconnectTimer = timer
}
}
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
print("RelayClient: 🟢 Persistent WebSocket Connected to Server Gateway!")
self.isConnected = true
}
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
print("RelayClient: 🔴 WebSocket Closed (code: \(closeCode.rawValue))")
self.isConnected = false
self.scheduleReconnect()
}
}