T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles Verify: independent agent re-ran all acceptance — 6/6 PASS
154 lines
6.7 KiB
Swift
154 lines
6.7 KiB
Swift
import Foundation
|
|
|
|
/// Pure static codec for the WS text-frame protocol (frozen contract, plan §3.1).
|
|
/// NEVER throws in either direction:
|
|
/// - `encode` is total — every `ClientMessage` has a JSON representation.
|
|
/// - `decodeServer` mirrors the server's "invalid frames are silently discarded"
|
|
/// resilience (src/protocol.ts:45-86, src/server.ts:698-701): malformed input → `nil`.
|
|
public enum MessageCodec {
|
|
// MARK: - encode (client → server)
|
|
|
|
/// Encode a `ClientMessage` as a JSON text frame accepted by the server's
|
|
/// `parseClientMessage` (src/protocol.ts). String escaping matches
|
|
/// `JSON.stringify` byte-for-byte (short escapes for \b \t \n \f \r, and
|
|
/// `\u00XX` lowercase-hex for other C0 control bytes) so a Swift client is
|
|
/// indistinguishable from the web client on the wire.
|
|
///
|
|
/// Key shapes (each pinned by CodecRoundtripTests):
|
|
/// - `attach` ALWAYS carries an explicit `sessionId` key — JSON `null` when
|
|
/// nil (the server requires the key's presence, src/protocol.ts:132-134);
|
|
/// UUIDs are lowercased (crypto.randomUUID style; SESSION_ID_RE is /i).
|
|
/// - `approve` puts `mode` as a TOP-LEVEL key: the server's WS wiring
|
|
/// re-parses the RAW frame for `obj['mode']` (src/server.ts:91-102).
|
|
/// - `resize` emits bare integers; `input.data` is escaped verbatim.
|
|
public static func encode(_ message: ClientMessage) -> String {
|
|
switch message {
|
|
case let .attach(sessionId, cwd):
|
|
return encodeAttach(sessionId: sessionId, cwd: cwd)
|
|
case let .input(data):
|
|
return "{\"type\":\"input\",\"data\":\(jsonStringLiteral(data))}"
|
|
case let .resize(cols, rows):
|
|
return "{\"type\":\"resize\",\"cols\":\(cols),\"rows\":\(rows)}"
|
|
case let .approve(mode):
|
|
guard let mode else { return "{\"type\":\"approve\"}" }
|
|
return "{\"type\":\"approve\",\"mode\":\"\(mode.rawValue)\"}"
|
|
case .reject:
|
|
return "{\"type\":\"reject\"}"
|
|
}
|
|
}
|
|
|
|
private static func encodeAttach(sessionId: UUID?, cwd: String?) -> String {
|
|
let sessionIdJSON = sessionId.map { "\"\($0.uuidString.lowercased())\"" } ?? "null"
|
|
let cwdPart = cwd.map { ",\"cwd\":\(jsonStringLiteral($0))" } ?? ""
|
|
return "{\"type\":\"attach\",\"sessionId\":\(sessionIdJSON)\(cwdPart)}"
|
|
}
|
|
|
|
/// JSON string literal with `JSON.stringify`-identical escaping.
|
|
private static func jsonStringLiteral(_ value: String) -> String {
|
|
var out = "\""
|
|
for scalar in value.unicodeScalars {
|
|
switch scalar {
|
|
case "\"": out += "\\\""
|
|
case "\\": out += "\\\\"
|
|
case "\u{08}": out += "\\b"
|
|
case "\t": out += "\\t"
|
|
case "\n": out += "\\n"
|
|
case "\u{0C}": out += "\\f"
|
|
case "\r": out += "\\r"
|
|
default:
|
|
if scalar.value < 0x20 {
|
|
out += String(format: "\\u%04x", Int(scalar.value))
|
|
} else {
|
|
out.unicodeScalars.append(scalar)
|
|
}
|
|
}
|
|
}
|
|
return out + "\""
|
|
}
|
|
|
|
// MARK: - decodeServer (server → client, untrusted)
|
|
|
|
/// Decode a server JSON text frame. Whitelist semantics, never throws:
|
|
/// bad JSON, non-object frames, unknown `type`, or missing/wrong-typed
|
|
/// REQUIRED fields → `nil` (drop the frame). Wrong-typed OPTIONAL fields
|
|
/// are tolerated as absent (see per-case doc on `ServerMessage`).
|
|
public static func decodeServer(_ text: String) -> ServerMessage? {
|
|
guard let data = text.data(using: .utf8),
|
|
let frame = try? JSONDecoder().decode(ServerFrame.self, from: data)
|
|
else { return nil }
|
|
return frame.message
|
|
}
|
|
}
|
|
|
|
// MARK: - Internal decoding scaffolding
|
|
|
|
/// Decodable shim so one JSONDecoder pass yields `ServerMessage?` without ever
|
|
/// surfacing a throw for merely-malformed content (only structurally non-JSON
|
|
/// input makes JSONDecoder itself throw, which `decodeServer` catches).
|
|
private struct ServerFrame: Decodable {
|
|
let message: ServerMessage?
|
|
|
|
private enum Keys: String, CodingKey {
|
|
case type, sessionId, data, code, reason, status, detail, pending, gate, telemetry
|
|
}
|
|
|
|
init(from decoder: any Decoder) throws {
|
|
guard let container = try? decoder.container(keyedBy: Keys.self),
|
|
let type = try? container.decode(String.self, forKey: .type)
|
|
else {
|
|
message = nil
|
|
return
|
|
}
|
|
message = Self.decodeBody(type: type, from: container)
|
|
}
|
|
|
|
private static func decodeBody(
|
|
type: String, from container: KeyedDecodingContainer<Keys>
|
|
) -> ServerMessage? {
|
|
switch type {
|
|
case "attached":
|
|
return decodeAttached(container)
|
|
case "output":
|
|
guard let data = try? container.decode(String.self, forKey: .data) else { return nil }
|
|
return .output(data: data)
|
|
case "exit":
|
|
return decodeExit(container)
|
|
case "status":
|
|
return decodeStatus(container)
|
|
case "telemetry":
|
|
guard let telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry)
|
|
else { return nil }
|
|
return .telemetry(telemetry)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// `attached.sessionId` must pass the server's own SESSION_ID_RE (M7) —
|
|
/// `UUID(uuidString:)` alone would accept non-v4 UUIDs.
|
|
private static func decodeAttached(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
|
|
guard let raw = try? container.decode(String.self, forKey: .sessionId),
|
|
Validation.isValidSessionId(raw),
|
|
let sessionId = UUID(uuidString: raw)
|
|
else { return nil }
|
|
return .attached(sessionId: sessionId)
|
|
}
|
|
|
|
private static func decodeExit(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
|
|
guard let code = try? container.decode(Int.self, forKey: .code) else { return nil }
|
|
let reason = try? container.decode(String.self, forKey: .reason)
|
|
return .exit(code: code, reason: reason)
|
|
}
|
|
|
|
private static func decodeStatus(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
|
|
guard let rawStatus = try? container.decode(String.self, forKey: .status),
|
|
let status = ClaudeStatus(rawValue: rawStatus)
|
|
else { return nil }
|
|
let detail = try? container.decode(String.self, forKey: .detail)
|
|
let pending = (try? container.decode(Bool.self, forKey: .pending)) ?? false
|
|
let gate = (try? container.decode(String.self, forKey: .gate))
|
|
.flatMap(GateKind.init(rawValue:))
|
|
return .status(status, detail: detail, pending: pending, gate: gate)
|
|
}
|
|
}
|