saltylab-ios/SulTee/SulTee/WebSocketClient.swift
sl-ios 433d85754f fix: switch WebSocket to Tailscale IP, add configurable Orin URL (Issue #709)
- Default URL updated from ws://192.168.86.158:9090 (LAN) to ws://100.64.0.2:9090 (Tailscale)
- URL persisted in UserDefaults under key "orinURL" — survives app restarts
- WebSocketClient.url is now mutable so it can be updated without recreation
- SensorManager.updateURL(_:) applies a new URL when not streaming
- ContentView: editable text field for Orin address with Apply button, disabled while streaming
- Connection banner shows the active URL instead of hardcoded string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:09:22 -04:00

115 lines
3.4 KiB
Swift

import Foundation
import UIKit
/// Thin WebSocket wrapper around URLSessionWebSocketTask.
/// Reconnects automatically on disconnect.
final class WebSocketClient: NSObject, ObservableObject {
enum ConnectionState {
case disconnected, connecting, connected
}
@Published var state: ConnectionState = .disconnected
var url: URL
private var session: URLSession!
private var task: URLSessionWebSocketTask?
private var shouldRun = false
init(url: URL) {
self.url = url
super.init()
self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}
func connect() {
shouldRun = true
guard state == .disconnected else { return }
openConnection()
}
func disconnect() {
shouldRun = false
task?.cancel(with: .normalClosure, reason: nil)
task = nil
DispatchQueue.main.async { self.state = .disconnected }
}
func send(_ message: [String: Any]) {
guard state == .connected,
let data = try? JSONSerialization.data(withJSONObject: message),
let json = String(data: data, encoding: .utf8) else { return }
task?.send(.string(json)) { error in
if let error {
print("[WebSocket] send error: \(error)")
}
}
}
// MARK: - Private
private func openConnection() {
DispatchQueue.main.async { self.state = .connecting }
task = session.webSocketTask(with: url)
task?.resume()
scheduleReceive()
}
private func scheduleReceive() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .success(let message):
self.handle(message)
self.scheduleReceive()
case .failure(let error):
print("[WebSocket] receive error: \(error)")
self.reconnectIfNeeded()
}
}
}
private func handle(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .string(let text):
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return }
if type == "haptic" {
DispatchQueue.main.async {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
}
default:
break
}
}
private func reconnectIfNeeded() {
DispatchQueue.main.async { self.state = .disconnected }
guard shouldRun else { return }
DispatchQueue.global().asyncAfter(deadline: .now() + 2) { [weak self] in
self?.openConnection()
}
}
}
// MARK: - URLSessionWebSocketDelegate
extension WebSocketClient: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?) {
DispatchQueue.main.async { self.state = .connected }
}
func urlSession(_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?) {
reconnectIfNeeded()
}
}