185 lines
6.5 KiB
Swift
185 lines
6.5 KiB
Swift
// SessionConnection.swift
|
|
// IC-2.1 — one WebSocket connection per session.
|
|
//
|
|
// Lifecycle:
|
|
// init → resume(from:) → [stream data] → suspend() → resume(from:) → …
|
|
//
|
|
// URL pattern: ws://<host>:<port>/sessions/<id>/stream?token=<bearerToken>
|
|
// (TLS pinning is wired in a follow-up task; plain `ws://` for now.)
|
|
|
|
import Combine
|
|
import Foundation
|
|
|
|
// MARK: - SessionConnection
|
|
|
|
/// Manages a single IC-1 WebSocket session stream.
|
|
///
|
|
/// Conforms to `ObservableObject` so SwiftUI views can react to
|
|
/// `connectionState` changes without manually subscribing to Combine.
|
|
///
|
|
/// All mutable state is main-actor-isolated. Callers on background contexts
|
|
/// must dispatch accordingly (normal for `@MainActor` types).
|
|
@MainActor
|
|
public final class SessionConnection: ObservableObject {
|
|
|
|
// MARK: - Identity
|
|
|
|
/// The session identifier used in the URL path and scrollback file name.
|
|
public let id: String
|
|
|
|
// MARK: - Combine publishers
|
|
|
|
/// Emits raw ANSI bytes in arrival order (binary frames, header stripped).
|
|
public let stream = PassthroughSubject<Data, Never>()
|
|
|
|
/// Emits every JSON frame received from the server.
|
|
public let stateEvents = PassthroughSubject<ServerToClient, Never>()
|
|
|
|
/// Emits snapshot content ready to feed directly into SwiftTerm:
|
|
/// ESC[H + ESC[2J (clear+home) followed by the pane's current content.
|
|
public let snapshots = PassthroughSubject<Data, Never>()
|
|
|
|
/// Tracks the WebSocket lifecycle.
|
|
@Published public private(set) var connectionState: ConnectionState = .disconnected
|
|
|
|
// MARK: - Scrollback
|
|
|
|
/// Persistent rolling ANSI cache for this session.
|
|
public private(set) var scrollback: ScrollbackCache
|
|
|
|
// MARK: - Private
|
|
|
|
private let credential: SidecarCredential
|
|
private var client: WebSocketClient?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// MARK: - Init
|
|
|
|
/// Creates a `SessionConnection` for `id` authenticated with `credential`.
|
|
///
|
|
/// Does **not** open a WebSocket. Call `resume(from:)` to connect.
|
|
init(id: String, credential: SidecarCredential) {
|
|
self.id = id
|
|
self.credential = credential
|
|
self.scrollback = ScrollbackCache(sessionId: id)
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Opens (or re-opens) the WebSocket and sends a `resume` frame.
|
|
///
|
|
/// - Parameter lastSeq: The last acknowledged sequence number, or `nil`
|
|
/// to request replay from the beginning.
|
|
public func resume(from lastSeq: UInt64?) async {
|
|
guard let url = streamURL else {
|
|
#if DEBUG
|
|
print("[SessionConnection] Could not construct stream URL for session \(id) — aborting resume.")
|
|
#endif
|
|
return
|
|
}
|
|
|
|
// Tear down any existing connection cleanly before reconnecting.
|
|
await suspend()
|
|
|
|
let ws = WebSocketClient()
|
|
client = ws
|
|
|
|
// Mirror WebSocketClient's connection state into our @Published property.
|
|
ws.connectionState
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] state in
|
|
self?.connectionState = state
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// Binary frames → scrollback + downstream `stream` subject.
|
|
ws.incomingBinary
|
|
.sink { [weak self] frame in
|
|
guard let self else { return }
|
|
self.scrollback.append(frame.data)
|
|
self.stream.send(frame.data)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// JSON frames → `stateEvents` + snapshot converter.
|
|
ws.incomingJSON
|
|
.sink { [weak self] frame in
|
|
guard let self else { return }
|
|
if case .snapshot(_, let base64) = frame {
|
|
// Decode base64 → text, prepend clear+home, normalise line endings.
|
|
if let raw = Data(base64Encoded: base64),
|
|
let text = String(data: raw, encoding: .utf8) {
|
|
let header = "\u{1B}[H\u{1B}[2J" // cursor home + clear screen
|
|
let body = text.replacingOccurrences(of: "\n", with: "\r\n")
|
|
self.snapshots.send(Data((header + body).utf8))
|
|
}
|
|
}
|
|
self.stateEvents.send(frame)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// Once connected, send the appropriate opening frame.
|
|
//
|
|
// • lastSeq == nil → fresh attach: live output already flows; caller
|
|
// will send resize then snapshotRequest. No frame sent here.
|
|
// • lastSeq != nil → reconnect after gap: replay missed output.
|
|
ws.connectionState
|
|
.filter { $0 == .connected }
|
|
.first()
|
|
.sink { [weak self, weak ws, lastSeq] _ in
|
|
guard let self, let ws else { return }
|
|
if let seq = lastSeq {
|
|
Task { @MainActor [self, ws, seq] in
|
|
try? await ws.send(.resume(lastSeq: seq))
|
|
_ = self
|
|
}
|
|
}
|
|
// fresh connect: caller drives resize → snapshotRequest
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
ws.connect(url: url)
|
|
}
|
|
|
|
/// Sends a frame to the server.
|
|
///
|
|
/// - Throws: `WebSocketClientError.notConnected` if there is no active
|
|
/// socket, or `WebSocketClientError.encodingFailed` on serialisation
|
|
/// failure.
|
|
public func send(_ frame: ClientToServer) async throws {
|
|
guard let client else {
|
|
throw WebSocketClientError.notConnected
|
|
}
|
|
try await client.send(frame)
|
|
}
|
|
|
|
/// Closes the WebSocket but keeps local state (scrollback + cursor).
|
|
public func suspend() async {
|
|
client?.disconnect()
|
|
client = nil
|
|
cancellables.removeAll()
|
|
connectionState = .disconnected
|
|
}
|
|
|
|
// MARK: - URL construction
|
|
|
|
/// Builds `ws://<host>:<port>/sessions/<id>/stream?token=<bearerToken>`.
|
|
///
|
|
/// Returns `nil` if `URLComponents` cannot produce a valid URL (should
|
|
/// never happen in practice with well-formed credentials).
|
|
///
|
|
/// Note: plain `ws://` is used for now; TLS + cert-pinning wired in
|
|
/// the T-2.5 follow-up task.
|
|
private var streamURL: URL? {
|
|
var components = URLComponents()
|
|
components.scheme = "ws"
|
|
components.host = credential.host
|
|
components.port = credential.port
|
|
components.path = "/sessions/\(id)/stream"
|
|
components.queryItems = [
|
|
URLQueryItem(name: "token", value: credential.bearerToken)
|
|
]
|
|
return components.url
|
|
}
|
|
}
|