pi-remote-ios/Sources/Core/Network/WebSocketClient.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)
}
}
}