// WebSocketClient.swift // Starscream wrapper that speaks the IC-1 WebSocket protocol. // // URL pattern: ws(s):///sessions//stream?token= // // Design note: Starscream 4.0.x also declares a *protocol* named // `WebSocketClient` (the abstract socket interface). Our concrete class // shares that name. Inside this file we qualify Starscream's protocol as // `Starscream.WebSocketClient` to resolve the ambiguity; everywhere else in // the app our class is the only visible `WebSocketClient`. import Combine import Foundation import Starscream // MARK: - ConnectionState /// The lifecycle state of a `WebSocketClient` connection. public enum ConnectionState: Sendable { case disconnected case connecting case connected } // MARK: - WebSocketClientError /// Errors thrown by `WebSocketClient.send(_:)`. public enum WebSocketClientError: Error, Sendable { /// The socket is not in the `.connected` state. case notConnected /// JSON encoding of the outgoing frame failed. case encodingFailed(any Error) } // MARK: - WebSocketClient /// IC-1 WebSocket client. /// /// All public state is isolated to the main actor. /// Starscream's delegate callbacks (delivered on `DispatchQueue.main` by /// default) are hopped back through a `Task { @MainActor in … }` in /// `DelegateAdapter` before any mutation occurs, keeping the actor /// boundary explicit. @MainActor public final class WebSocketClient { // MARK: - Published subjects /// Emits every successfully decoded binary frame received from the server. public let incomingBinary = PassthroughSubject() /// Emits every successfully decoded JSON frame received from the server. public let incomingJSON = PassthroughSubject() /// Tracks the current lifecycle state. public let connectionState = CurrentValueSubject(.disconnected) // MARK: - Private state /// The active Starscream socket. `WebSocket` is the concrete Starscream /// class; no name collision here. private var socket: WebSocket? /// Non-isolated trampoline that holds a weak back-reference to us. private let delegateAdapter = DelegateAdapter() // MARK: - Init public init() { delegateAdapter.owner = self } // MARK: - Connect / Disconnect /// Opens a new WebSocket connection to `url`, tearing down any existing /// socket first. /// /// After the `connected` state is published, call /// `send(.resume(lastSeq:))` to attach the IC-1 stream. public func connect(url: URL) { socket?.disconnect() socket = nil var request = URLRequest(url: url) request.timeoutInterval = 10 let ws = WebSocket(request: request) // Starscream delivers delegate calls on DispatchQueue.main by default. ws.delegate = delegateAdapter socket = ws connectionState.send(.connecting) ws.connect() } /// Gracefully closes the current socket. public func disconnect() { socket?.disconnect() socket = nil connectionState.send(.disconnected) } // MARK: - Send /// Encodes `frame` as JSON and writes it to the open socket. /// /// - Throws: `WebSocketClientError.notConnected` when not connected, /// `WebSocketClientError.encodingFailed` on JSON encoding error. public func send(_ frame: ClientToServer) async throws { guard let socket, connectionState.value == .connected else { throw WebSocketClientError.notConnected } let json: String do { json = try FrameCodec.encode(frame) } catch { throw WebSocketClientError.encodingFailed(error) } socket.write(string: json) } // MARK: - Internal event handling (always on main actor) fileprivate func handle(event: WebSocketEvent) { switch event { case .connected: connectionState.send(.connected) case .disconnected(let reason, let code): // Both clean server-initiated and transport-level disconnects // collapse to the same client state. _ = (reason, code) connectionState.send(.disconnected) case .text(let string): do { let frame = try FrameCodec.decode(string) incomingJSON.send(frame) } catch { #if DEBUG print("[WebSocketClient] JSON decode error: \(error)\nPayload: \(string)") #endif } case .binary(let data): if let frame = BinaryFrame.decode(data) { incomingBinary.send(frame) } else { #if DEBUG print("[WebSocketClient] Binary frame too short (\(data.count) bytes) — ignored.") #endif } case .cancelled: connectionState.send(.disconnected) case .error(let error): #if DEBUG let desc = error.map { "\($0)" } ?? "unknown" print("[WebSocketClient] Socket error: \(desc)") #endif connectionState.send(.disconnected) case .peerClosed: connectionState.send(.disconnected) case .ping, .pong, .viabilityChanged, .reconnectSuggested: break } } } // MARK: - DelegateAdapter /// Non-isolated trampoline between Starscream's `WebSocketDelegate` callbacks /// and our `@MainActor`-isolated `WebSocketClient`. /// /// **Why a separate class?** /// `WebSocketDelegate` is not `@MainActor`, so Swift 6 strict-concurrency /// disallows directly satisfying it from a `@MainActor final class` without /// `nonisolated`. A nested non-isolated class that hops explicitly via /// `Task { @MainActor in … }` keeps the intent clear and compiler-clean. /// /// **Naming note:** Starscream 4.0.x declares `WebSocketClient` as a *protocol* /// (the abstract socket interface). The `didReceive` method's `client` parameter /// therefore has type `any Starscream.WebSocketClient`, qualified to disambiguate /// from our own concrete `WebSocketClient` class. private final class DelegateAdapter: WebSocketDelegate, @unchecked Sendable { weak var owner: WebSocketClient? func didReceive(event: WebSocketEvent, client: any Starscream.WebSocketClient) { guard let owner else { return } // DelegateAdapter is @unchecked Sendable; silence Sendable check on // Starscream's WebSocketEvent which doesn't conform but is safe here. nonisolated(unsafe) let e = event Task { @MainActor [owner] in owner.handle(event: e) } } }