// 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 /// Notify the sidecar of the client's terminal dimensions so tmux can /// resize the window to match. Send on connect and on every layout change. case resize(cols: Int, rows: Int) } extension ClientToServer: Encodable { private enum CodingKeys: String, CodingKey { case type, lastSeq, name, data, cols, rows } 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) case .resize(let cols, let rows): try c.encode("resize", forKey: .type) try c.encode(cols, forKey: .cols) try c.encode(rows, forKey: .rows) } } } // 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) } }