merge: T-2.1 WebSocketClient + FrameCodec + ResumeCursor

This commit is contained in:
jay 2026-05-15 18:34:16 +02:00
commit d6062000e8
4 changed files with 683 additions and 0 deletions

View File

@ -0,0 +1,190 @@
// FrameCodec.swift
// IC-1 WebSocket frame encoding/decoding.
//
// Wire format
// -----------
// Binary frame (server client): [seq: 8 bytes big-endian UInt64][raw ANSI bytes]
// Text frame (both directions) : JSON, type-discriminated by the "type" key.
//
// This file has no Starscream import pure Foundation only, fully unit-testable.
import Foundation
// MARK: - PiState
/// The reported state of the pi agent.
public enum PiState: String, Decodable, Sendable {
case thinking
case tool
case idle
case awaitingInput = "awaiting-input"
}
// MARK: - ServerToClient
/// JSON text frames sent from the server to the client.
public enum ServerToClient: Sendable {
/// Agent state update (thinking / tool / idle / awaiting-input).
case state(value: PiState, tool: String?, ts: Int)
/// Full-screen snapshot encoded as base64 ANSI bytes.
case snapshot(seq: UInt64, data: String)
/// Session metadata pushed on connect / rename.
case sessionMeta(name: String, description: String?, createdAt: String)
/// Protocol-level error from the server.
case error(code: String, message: String)
}
extension ServerToClient: Decodable {
private enum TypeKey: String, Decodable {
case state
case snapshot
case sessionMeta = "session-meta"
case error
}
private enum CodingKeys: String, CodingKey {
case type
case value, tool, ts // state
case seq, data // snapshot
case name, description, createdAt // session-meta
case code, message // error
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let typeKey = try container.decode(TypeKey.self, forKey: .type)
switch typeKey {
case .state:
let value = try container.decode(PiState.self, forKey: .value)
let tool = try container.decodeIfPresent(String.self, forKey: .tool)
let ts = try container.decode(Int.self, forKey: .ts)
self = .state(value: value, tool: tool, ts: ts)
case .snapshot:
let seq = try container.decode(UInt64.self, forKey: .seq)
let data = try container.decode(String.self, forKey: .data)
self = .snapshot(seq: seq, data: data)
case .sessionMeta:
let name = try container.decode(String.self, forKey: .name)
let description = try container.decodeIfPresent(String.self, forKey: .description)
let createdAt = try container.decode(String.self, forKey: .createdAt)
self = .sessionMeta(name: name, description: description, createdAt: createdAt)
case .error:
let code = try container.decode(String.self, forKey: .code)
let message = try container.decode(String.self, forKey: .message)
self = .error(code: code, message: message)
}
}
}
// MARK: - ClientToServer
/// JSON text frames sent from the client to the server.
public enum ClientToServer: Sendable {
/// Attach / resume from the given sequence number (nil = start fresh).
case resume(lastSeq: UInt64?)
/// Named key press (e.g. "escape", "up", "enter").
case key(name: String)
/// Literal text, delivered via send-keys -l.
case keys(data: String)
/// Text wrapped in bracketed-paste sequences.
case paste(data: String)
/// Request a full ANSI snapshot of the current pane.
case snapshotRequest
}
extension ClientToServer: Encodable {
private enum CodingKeys: String, CodingKey {
case type, lastSeq, name, data
}
public func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .resume(let lastSeq):
try c.encode("resume", forKey: .type)
// Encode null explicitly when lastSeq is nil so the server
// receives {"type":"resume","lastSeq":null} as the spec requires.
try c.encode(lastSeq, forKey: .lastSeq)
case .key(let name):
try c.encode("key", forKey: .type)
try c.encode(name, forKey: .name)
case .keys(let data):
try c.encode("keys", forKey: .type)
try c.encode(data, forKey: .data)
case .paste(let data):
try c.encode("paste", forKey: .type)
try c.encode(data, forKey: .data)
case .snapshotRequest:
try c.encode("snapshot-request", forKey: .type)
}
}
}
// MARK: - BinaryFrame
/// A decoded IC-1 binary frame.
///
/// Wire layout: `[seq: 8 bytes big-endian UInt64][raw ANSI bytes ]`
public struct BinaryFrame: Sendable {
/// Monotonically increasing chunk sequence number assigned by the server.
public let seq: UInt64
/// Raw ANSI terminal bytes for this chunk.
public let data: Data
/// Decodes a raw WebSocket binary message into a `BinaryFrame`.
///
/// Returns `nil` when `raw` is shorter than the 8-byte header.
public static func decode(_ raw: Data) -> BinaryFrame? {
guard raw.count >= 8 else { return nil }
// Read 8-byte big-endian UInt64 from the leading bytes.
let seqBytes = raw.prefix(8)
let seq = seqBytes.reduce(into: UInt64(0)) { acc, byte in
acc = (acc << 8) | UInt64(byte)
}
let payload = raw.dropFirst(8)
return BinaryFrame(seq: seq, data: Data(payload))
}
}
// MARK: - FrameCodec (static utilities)
/// Namespace for IC-1 frame encode/decode helpers.
public enum FrameCodec {
// Shared encoder / decoder both are safe to use from a single actor.
private static let encoder: JSONEncoder = {
let enc = JSONEncoder()
enc.outputFormatting = [] // compact, stable output
return enc
}()
private static let decoder = JSONDecoder()
/// Encodes a `ClientToServer` frame to a compact JSON string.
///
/// Throws if encoding fails (in practice this should never happen for
/// the well-typed cases defined above).
public static func encode(_ frame: ClientToServer) throws -> String {
let data = try encoder.encode(frame)
// JSON serialisation always produces valid UTF-8; the force-unwrap
// is safe and intentional a nil result here would be a Foundation bug.
return String(data: data, encoding: .utf8)! // swiftlint:disable:this force_unwrap
}
/// Decodes a JSON string into a `ServerToClient` frame.
///
/// Throws `DecodingError` for malformed or unknown payloads.
public static func decode(_ text: String) throws -> ServerToClient {
let data = Data(text.utf8)
return try decoder.decode(ServerToClient.self, from: data)
}
}

View File

@ -0,0 +1,65 @@
// ResumeCursor.swift
// Persists the last-seen IC-1 sequence number per session across app launches.
//
// Storage: UserDefaults (standard suite).
// Key schema: "ResumeCursor.<sessionId>"
//
// Thread-safety: UserDefaults is documented as thread-safe; this class adds no
// additional synchronisation. All callers are expected to be on the main actor
// in practice (alongside WebSocketClient), though no actor annotation is
// imposed here so tests can call freely.
import Foundation
/// Stores and retrieves the last acknowledged IC-1 sequence number for each
/// session, enabling resume-from-cursor on reconnect.
public final class ResumeCursor {
private let defaults: UserDefaults
private let keyPrefix = "ResumeCursor."
// MARK: - Init
/// Creates a cursor backed by `defaults` (defaults to `.standard`).
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
// MARK: - Public API
/// Returns the last persisted sequence number for `sessionId`, or `nil`
/// if no sequence has been stored (i.e. first-ever connection).
public func lastSeq(for sessionId: String) -> UInt64? {
let key = storageKey(for: sessionId)
// UserDefaults stores integer values as Int64 (signed). We use the raw
// bit pattern to round-trip UInt64 without loss, since UInt64.max
// exceeds Int.max on 64-bit platforms.
guard defaults.object(forKey: key) != nil else { return nil }
let raw = defaults.integer(forKey: key)
return UInt64(bitPattern: Int64(raw))
}
/// Persists `seq` as the latest acknowledged chunk for `sessionId`.
///
/// Called after successfully processing a `BinaryFrame` so that the cursor
/// always reflects a frame that the app has actually consumed.
public func update(sessionId: String, seq: UInt64) {
// Store as Int64 bit-pattern; see note in lastSeq(for:).
let raw = Int(Int64(bitPattern: seq))
defaults.set(raw, forKey: storageKey(for: sessionId))
}
/// Removes the stored cursor for `sessionId`.
///
/// Call this when a session is deleted or a full snapshot has been received
/// and the client no longer needs delta replay.
public func clear(sessionId: String) {
defaults.removeObject(forKey: storageKey(for: sessionId))
}
// MARK: - Private
private func storageKey(for sessionId: String) -> String {
keyPrefix + sessionId
}
}

View File

@ -0,0 +1,199 @@
// 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 }
Task { @MainActor [owner] in
owner.handle(event: event)
}
}
}

View File

@ -0,0 +1,229 @@
// FrameCodecTests.swift
// Unit tests for BinaryFrame, ClientToServer encoding, and ServerToClient decoding.
//
// All tests are pure (no network, no Starscream, no async) FrameCodec.swift
// imports only Foundation, making this test target dependency-free.
import XCTest
@testable import piRemote
final class FrameCodecTests: XCTestCase {
// -------------------------------------------------------------------------
// MARK: 1. BinaryFrame.decode
// -------------------------------------------------------------------------
/// A well-formed binary frame: 8-byte big-endian seq = 1, payload = "hello".
func testBinaryFrameDecode_knownBytes() throws {
// seq = 1 in big-endian: 00 00 00 00 00 00 00 01
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]
let payload = Array("hello".utf8)
let raw = Data(seqBytes + payload)
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
XCTAssertEqual(frame.seq, 1)
XCTAssertEqual(frame.data, Data("hello".utf8))
}
/// Large sequence numbers (UInt64.max) must round-trip through the header.
func testBinaryFrameDecode_maxSeq() throws {
// seq = UInt64.max = FF FF FF FF FF FF FF FF
let seqBytes: [UInt8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
let raw = Data(seqBytes) // empty payload is valid
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
XCTAssertEqual(frame.seq, UInt64.max)
XCTAssertTrue(frame.data.isEmpty)
}
/// A multi-byte big-endian seq: 0x0000_0001_0000_0000 = 4_294_967_296
func testBinaryFrameDecode_bigEndianOrdering() throws {
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
let raw = Data(seqBytes + [0xAB, 0xCD])
let frame = try XCTUnwrap(BinaryFrame.decode(raw))
XCTAssertEqual(frame.seq, 4_294_967_296)
XCTAssertEqual(frame.data, Data([0xAB, 0xCD]))
}
/// Frames shorter than 8 bytes must be rejected (returns nil).
func testBinaryFrameDecode_tooShort_returnsNil() {
let raw = Data([0x00, 0x01, 0x02])
XCTAssertNil(BinaryFrame.decode(raw))
}
/// Exactly 8 bytes is valid (empty payload, seq depends on content).
func testBinaryFrameDecode_exactlyEightBytes_emptyPayload() throws {
let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A] // seq = 42
let frame = try XCTUnwrap(BinaryFrame.decode(Data(seqBytes)))
XCTAssertEqual(frame.seq, 42)
XCTAssertTrue(frame.data.isEmpty)
}
// -------------------------------------------------------------------------
// MARK: 2. ClientToServer JSON encoding
// -------------------------------------------------------------------------
func testEncode_resume_withLastSeq() throws {
let json = try FrameCodec.encode(.resume(lastSeq: 99))
let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
XCTAssertEqual(obj["type"] as? String, "resume")
XCTAssertEqual(obj["lastSeq"] as? Int, 99)
}
func testEncode_resume_nilLastSeq_encodesNull() throws {
let json = try FrameCodec.encode(.resume(lastSeq: nil))
// Verify "lastSeq" key exists and its value is JSON null.
let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
XCTAssertEqual(obj["type"] as? String, "resume")
// JSON null comes back as NSNull in Foundation.
XCTAssertTrue(obj["lastSeq"] is NSNull, "Expected lastSeq to be null, got \(String(describing: obj["lastSeq"]))")
}
func testEncode_key() throws {
let json = try FrameCodec.encode(.key(name: "escape"))
XCTAssertEqual(json, #"{"type":"key","name":"escape"}"#)
}
func testEncode_keys() throws {
let json = try FrameCodec.encode(.keys(data: "hello world"))
let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
XCTAssertEqual(obj["type"] as? String, "keys")
XCTAssertEqual(obj["data"] as? String, "hello world")
}
func testEncode_paste() throws {
let json = try FrameCodec.encode(.paste(data: "pasted text\nwith newline"))
let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
XCTAssertEqual(obj["type"] as? String, "paste")
XCTAssertEqual(obj["data"] as? String, "pasted text\nwith newline")
}
func testEncode_snapshotRequest() throws {
let json = try FrameCodec.encode(.snapshotRequest)
XCTAssertEqual(json, #"{"type":"snapshot-request"}"#)
}
// -------------------------------------------------------------------------
// MARK: 3. ServerToClient JSON decoding
// -------------------------------------------------------------------------
func testDecode_state_idle() throws {
let payload = #"{"type":"state","value":"idle","ts":1716000000}"#
let frame = try FrameCodec.decode(payload)
guard case .state(let value, let tool, let ts) = frame else {
return XCTFail("Expected .state, got \(frame)")
}
XCTAssertEqual(value, .idle)
XCTAssertNil(tool)
XCTAssertEqual(ts, 1_716_000_000)
}
func testDecode_state_tool_withToolName() throws {
let payload = #"{"type":"state","value":"tool","tool":"bash","ts":42}"#
let frame = try FrameCodec.decode(payload)
guard case .state(let value, let tool, let ts) = frame else {
return XCTFail("Expected .state, got \(frame)")
}
XCTAssertEqual(value, .tool)
XCTAssertEqual(tool, "bash")
XCTAssertEqual(ts, 42)
}
func testDecode_state_awaitingInput() throws {
let payload = #"{"type":"state","value":"awaiting-input","ts":0}"#
let frame = try FrameCodec.decode(payload)
guard case .state(let value, _, _) = frame else {
return XCTFail("Expected .state, got \(frame)")
}
XCTAssertEqual(value, .awaitingInput)
}
func testDecode_snapshot() throws {
let payload = #"{"type":"snapshot","seq":1234,"data":"SGVsbG8="}"#
let frame = try FrameCodec.decode(payload)
guard case .snapshot(let seq, let data) = frame else {
return XCTFail("Expected .snapshot, got \(frame)")
}
XCTAssertEqual(seq, 1234)
XCTAssertEqual(data, "SGVsbG8=")
}
func testDecode_sessionMeta_withDescription() throws {
let payload = #"{"type":"session-meta","name":"my-session","description":"A test session","createdAt":"2026-05-15T10:00:00Z"}"#
let frame = try FrameCodec.decode(payload)
guard case .sessionMeta(let name, let description, let createdAt) = frame else {
return XCTFail("Expected .sessionMeta, got \(frame)")
}
XCTAssertEqual(name, "my-session")
XCTAssertEqual(description, "A test session")
XCTAssertEqual(createdAt, "2026-05-15T10:00:00Z")
}
func testDecode_sessionMeta_withoutDescription() throws {
let payload = #"{"type":"session-meta","name":"bare","createdAt":"2026-01-01T00:00:00Z"}"#
let frame = try FrameCodec.decode(payload)
guard case .sessionMeta(let name, let description, _) = frame else {
return XCTFail("Expected .sessionMeta, got \(frame)")
}
XCTAssertEqual(name, "bare")
XCTAssertNil(description)
}
func testDecode_error() throws {
let payload = #"{"type":"error","code":"auth_failed","message":"Invalid token"}"#
let frame = try FrameCodec.decode(payload)
guard case .error(let code, let message) = frame else {
return XCTFail("Expected .error, got \(frame)")
}
XCTAssertEqual(code, "auth_failed")
XCTAssertEqual(message, "Invalid token")
}
/// Unknown type keys must throw rather than silently produce garbage.
func testDecode_unknownType_throws() {
let payload = #"{"type":"tree","nodes":[]}"#
XCTAssertThrowsError(try FrameCodec.decode(payload))
}
// -------------------------------------------------------------------------
// MARK: 4. ResumeCursor (bonus no I/O, uses in-memory UserDefaults suite)
// -------------------------------------------------------------------------
func testResumeCursor_roundTrip() {
let suiteName = "FrameCodecTests.\(UUID())"
// Using a named suite isolates this test from real app defaults.
let defaults = UserDefaults(suiteName: suiteName)!
let cursor = ResumeCursor(defaults: defaults)
XCTAssertNil(cursor.lastSeq(for: "s1"), "Fresh cursor should be nil")
cursor.update(sessionId: "s1", seq: 999)
XCTAssertEqual(cursor.lastSeq(for: "s1"), 999)
cursor.update(sessionId: "s1", seq: UInt64.max)
XCTAssertEqual(cursor.lastSeq(for: "s1"), UInt64.max)
cursor.clear(sessionId: "s1")
XCTAssertNil(cursor.lastSeq(for: "s1"), "Cursor should be nil after clear")
// Cleanup: remove the test suite.
defaults.removePersistentDomain(forName: suiteName)
}
func testResumeCursor_isolatedPerSession() {
let suiteName = "FrameCodecTests.\(UUID())"
let defaults = UserDefaults(suiteName: suiteName)!
let cursor = ResumeCursor(defaults: defaults)
cursor.update(sessionId: "alpha", seq: 10)
cursor.update(sessionId: "beta", seq: 20)
XCTAssertEqual(cursor.lastSeq(for: "alpha"), 10)
XCTAssertEqual(cursor.lastSeq(for: "beta"), 20)
cursor.clear(sessionId: "alpha")
XCTAssertNil(cursor.lastSeq(for: "alpha"))
XCTAssertEqual(cursor.lastSeq(for: "beta"), 20, "Clearing alpha must not affect beta")
defaults.removePersistentDomain(forName: suiteName)
}
}