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