203 lines
6.6 KiB
Swift
203 lines
6.6 KiB
Swift
// WebSocketClient.swift
|
|
// Starscream wrapper that speaks the IC-1 WebSocket protocol.
|
|
//
|
|
// URL pattern: ws(s)://<host>/sessions/<id>/stream?token=<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<BinaryFrame, Never>()
|
|
|
|
/// Emits every successfully decoded JSON frame received from the server.
|
|
public let incomingJSON = PassthroughSubject<ServerToClient, Never>()
|
|
|
|
/// Tracks the current lifecycle state.
|
|
public let connectionState = CurrentValueSubject<ConnectionState, Never>(.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)
|
|
}
|
|
}
|
|
}
|