feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
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
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// Client → server WS frames (frozen contract, plan §3.1; mirrors
|
||||
/// `src/types.ts:87-92` `ClientMessage`). Immutable value type.
|
||||
///
|
||||
/// Server-side validation the caller must respect (the server SILENTLY DISCARDS
|
||||
/// invalid frames, `src/protocol.ts:45-86` — no error reply comes back):
|
||||
/// - `attach` must be the FIRST frame on a connection (src/server.ts:707-711).
|
||||
/// - `resize` cols/rows must be integers in `WireConstants.resizeRange` (1...1000).
|
||||
/// - `attach.cwd`, when present, must be an absolute path (`Validation.isAbsoluteCwd`).
|
||||
/// - `input.data` is raw keyboard bytes, passed through verbatim — never filtered.
|
||||
public enum ClientMessage: Sendable, Equatable {
|
||||
/// First frame. `sessionId == nil` spawns a new session (encoded as an
|
||||
/// explicit JSON `"sessionId":null` — the key is REQUIRED by the server,
|
||||
/// src/protocol.ts:132-134). `cwd` = "new tab here" spawn directory (M6).
|
||||
case attach(sessionId: UUID?, cwd: String?)
|
||||
/// Raw keyboard bytes, verbatim (invariant #9 — no content filtering).
|
||||
case input(data: String)
|
||||
/// Own message type so the server can `ioctl(TIOCSWINSZ)` → SIGWINCH.
|
||||
case resize(cols: Int, rows: Int)
|
||||
/// Resolve a held permission gate with allow. `mode` is only meaningful for
|
||||
/// a `plan` gate and is encoded as a TOP-LEVEL `mode` key — the server's WS
|
||||
/// wiring re-parses the raw frame for it (src/server.ts:91-102).
|
||||
case approve(mode: ApproveMode?)
|
||||
/// Resolve a held permission gate with deny.
|
||||
case reject
|
||||
}
|
||||
|
||||
/// Permission mode written back when resolving a `plan` gate. Raw values mirror
|
||||
/// the server whitelist `PERMISSION_MODES` (src/server.ts:76 / src/types.ts:365).
|
||||
///
|
||||
/// Note: the plan-gate three-way UI only ever sends `.acceptEdits` / `.default`
|
||||
/// (mirroring public/tabs.ts:345-347). Raw `.auto` is gated by ALLOW_AUTO_MODE
|
||||
/// (default false) and is server-downgraded to `default` otherwise
|
||||
/// (src/server.ts:765-766); it is reserved for a future permission-mode switcher.
|
||||
public enum ApproveMode: String, Sendable, CaseIterable {
|
||||
case `default`
|
||||
case acceptEdits
|
||||
case plan
|
||||
case auto
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// Thin HTTP I/O boundary (frozen contract, plan §3.1). `APIClient` builds
|
||||
/// `URLRequest`s (Origin stamped iff the endpoint is a mutating `G` route,
|
||||
/// plan §3.4 铁律) and sends them through this seam; the production
|
||||
/// implementation wraps `URLSession`, `FakeHTTPTransport` (TestSupport)
|
||||
/// queues canned responses.
|
||||
public protocol HTTPTransport: Sendable {
|
||||
/// Perform one HTTP exchange. Implementations throw on transport-level
|
||||
/// failure; non-2xx statuses are returned, not thrown — classification
|
||||
/// (e.g. `PairingError`) is the caller's job.
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// A paired web-terminal host (frozen contract, plan §3.1). The SINGLE point of
|
||||
/// derivation for the `Origin` header and the WS URL — hand-assembling either
|
||||
/// anywhere else is a review CRITICAL (plan §5.1 / T-iOS-9 安全注).
|
||||
///
|
||||
/// Derivations are computed once at init and stored immutably:
|
||||
/// - `originHeader` = `<scheme>://<host>[:<port>]`, omitting the scheme's
|
||||
/// default port (http/80, https/443), scheme+host lowercased — identical to
|
||||
/// browser Origin serialization. The server normalises BOTH sides via
|
||||
/// `new URL()` before comparing protocol/hostname/port (src/http/origin.ts:31-51).
|
||||
/// - `wsURL` = same host+port, scheme http→ws / https→wss, path replaced by
|
||||
/// `WireConstants.wsPath`; query/fragment/credentials dropped.
|
||||
public struct HostEndpoint: Sendable, Equatable, Codable {
|
||||
/// The URL the user dialed: `http(s)://<host>[:<port>]`. Any path, query,
|
||||
/// fragment or credentials it carries are ignored by the derivations.
|
||||
public let baseURL: URL
|
||||
/// Derived WS endpoint (`ws(s)://…/term`). Stored at init; contract-wise a
|
||||
/// read-only property, per plan §3.1.
|
||||
public let wsURL: URL
|
||||
/// Derived `Origin` header value — see type doc. Never hand-assemble.
|
||||
public let originHeader: String
|
||||
|
||||
private static let wsSchemeByHTTPScheme = ["http": "ws", "https": "wss"]
|
||||
private static let defaultPortByScheme = ["http": 80, "https": 443]
|
||||
|
||||
/// Validating init: the URL must be http(s) with a non-empty host, else nil
|
||||
/// (QR-scan payloads are untrusted external input — reject early, plan §5).
|
||||
public init?(baseURL: URL) {
|
||||
guard let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let wsScheme = Self.wsSchemeByHTTPScheme[scheme],
|
||||
let rawHost = components.host, !rawHost.isEmpty,
|
||||
let wsURL = Self.deriveWSURL(from: components, wsScheme: wsScheme)
|
||||
else { return nil }
|
||||
|
||||
self.baseURL = baseURL
|
||||
self.wsURL = wsURL
|
||||
self.originHeader = Self.deriveOrigin(
|
||||
scheme: scheme, host: rawHost.lowercased(), port: components.port
|
||||
)
|
||||
}
|
||||
|
||||
private static func deriveOrigin(scheme: String, host: String, port: Int?) -> String {
|
||||
// IPv6 literals need brackets in an Origin. URLComponents.host has
|
||||
// returned them both bare and pre-bracketed across Foundation versions —
|
||||
// wrap idempotently.
|
||||
let needsBrackets = host.contains(":") && !host.hasPrefix("[")
|
||||
let serializedHost = needsBrackets ? "[\(host)]" : host
|
||||
guard let port, port != defaultPortByScheme[scheme] else {
|
||||
return "\(scheme)://\(serializedHost)"
|
||||
}
|
||||
return "\(scheme)://\(serializedHost):\(port)"
|
||||
}
|
||||
|
||||
private static func deriveWSURL(from components: URLComponents, wsScheme: String) -> URL? {
|
||||
var wsComponents = components
|
||||
wsComponents.scheme = wsScheme
|
||||
wsComponents.path = WireConstants.wsPath
|
||||
wsComponents.query = nil
|
||||
wsComponents.fragment = nil
|
||||
wsComponents.user = nil
|
||||
wsComponents.password = nil
|
||||
return wsComponents.url
|
||||
}
|
||||
|
||||
// MARK: - Codable (persisted via HostRegistry/Keychain — re-validate on decode)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case baseURL
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let url = try container.decode(URL.self, forKey: .baseURL)
|
||||
guard let endpoint = HostEndpoint(baseURL: url) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .baseURL, in: container,
|
||||
debugDescription: "baseURL is not an http(s) URL with a host"
|
||||
)
|
||||
}
|
||||
self = endpoint
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(baseURL, forKey: .baseURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
/// Server → client WS frames (frozen contract, plan §3.1; mirrors
|
||||
/// `src/types.ts:109-120` `ServerMessage`). Decoded ONLY via
|
||||
/// `MessageCodec.decodeServer` — the server is an untrusted input source and
|
||||
/// malformed frames become `nil`, never a crash.
|
||||
public enum ServerMessage: Sendable, Equatable {
|
||||
/// Attach confirmation. ALWAYS adopt the server-issued id — attaching with
|
||||
/// an unknown UUID yields a fresh session id (src/session/manager.ts:108-174).
|
||||
case attached(sessionId: UUID)
|
||||
/// Opaque ANSI/UTF-8 bytes; ring-buffer replay and live stream share this shape.
|
||||
case output(data: String)
|
||||
/// Shell exit. `code == WireConstants.spawnFailedExitCode` (-1) means the
|
||||
/// spawn never succeeded (M4) and `reason` is required server-side.
|
||||
case exit(code: Int, reason: String?)
|
||||
/// Claude Code activity derived from hooks (H2/H3/B4). `pending` = a tool
|
||||
/// approval is held server-side; `gate` says which kind. Absent `pending`
|
||||
/// decodes as `false`; an unrecognized `gate` value decodes as `nil`
|
||||
/// (tolerated as absent so the pending signal is never lost).
|
||||
case status(ClaudeStatus, detail: String?, pending: Bool, gate: GateKind?)
|
||||
/// Latest statusLine telemetry broadcast (B2).
|
||||
case telemetry(StatusTelemetry)
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:97` `ClaudeStatus`. `unknown` = no hook signal yet;
|
||||
/// `stuck` (A5) = output silent past STUCK_TTL while not idle/exited.
|
||||
public enum ClaudeStatus: String, Sendable {
|
||||
case working, waiting, idle, unknown, stuck
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:101` `PermissionGate`. `plan` = ExitPlanMode three-way
|
||||
/// gate; `tool` = ordinary tool gate.
|
||||
public enum GateKind: String, Sendable {
|
||||
case tool, plan
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:406-416` `StatusTelemetry`: every metric optional,
|
||||
/// `at` (server receive time, ms since epoch) required. Decoding is tolerant —
|
||||
/// a wrong-typed OPTIONAL field is treated as absent (mirrors the server's own
|
||||
/// tolerant statusLine parsing); a missing/wrong-typed `at` fails the decode.
|
||||
public struct StatusTelemetry: Sendable, Equatable, Decodable {
|
||||
public let contextUsedPct: Double?
|
||||
public let costUsd: Double?
|
||||
public let linesAdded: Int?
|
||||
public let linesRemoved: Int?
|
||||
public let model: String?
|
||||
public let effort: String?
|
||||
public let pr: PrInfo?
|
||||
public let rate: RateInfo?
|
||||
/// Server receive timestamp (ms). REQUIRED — frames without it are dropped.
|
||||
public let at: Int
|
||||
|
||||
public init(
|
||||
contextUsedPct: Double? = nil,
|
||||
costUsd: Double? = nil,
|
||||
linesAdded: Int? = nil,
|
||||
linesRemoved: Int? = nil,
|
||||
model: String? = nil,
|
||||
effort: String? = nil,
|
||||
pr: PrInfo? = nil,
|
||||
rate: RateInfo? = nil,
|
||||
at: Int
|
||||
) {
|
||||
self.contextUsedPct = contextUsedPct
|
||||
self.costUsd = costUsd
|
||||
self.linesAdded = linesAdded
|
||||
self.linesRemoved = linesRemoved
|
||||
self.model = model
|
||||
self.effort = effort
|
||||
self.pr = pr
|
||||
self.rate = rate
|
||||
self.at = at
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case contextUsedPct, costUsd, linesAdded, linesRemoved
|
||||
case model, effort, pr, rate, at
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// `at` is the only required field; its absence invalidates the frame.
|
||||
at = try container.decode(Int.self, forKey: .at)
|
||||
// Optional fields: `try?` treats wrong-typed values as absent (tolerant).
|
||||
contextUsedPct = try? container.decode(Double.self, forKey: .contextUsedPct)
|
||||
costUsd = try? container.decode(Double.self, forKey: .costUsd)
|
||||
linesAdded = try? container.decode(Int.self, forKey: .linesAdded)
|
||||
linesRemoved = try? container.decode(Int.self, forKey: .linesRemoved)
|
||||
model = try? container.decode(String.self, forKey: .model)
|
||||
effort = try? container.decode(String.self, forKey: .effort)
|
||||
pr = try? container.decode(PrInfo.self, forKey: .pr)
|
||||
rate = try? container.decode(RateInfo.self, forKey: .rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:413` `StatusTelemetry.pr`.
|
||||
public struct PrInfo: Sendable, Equatable, Decodable {
|
||||
public let number: Int
|
||||
public let url: String
|
||||
public let reviewState: String?
|
||||
|
||||
public init(number: Int, url: String, reviewState: String? = nil) {
|
||||
self.number = number
|
||||
self.url = url
|
||||
self.reviewState = reviewState
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:414` `StatusTelemetry.rate`.
|
||||
public struct RateInfo: Sendable, Equatable, Decodable {
|
||||
public let fiveHourPct: Double?
|
||||
public let sevenDayPct: Double?
|
||||
|
||||
public init(fiveHourPct: Double? = nil, sevenDayPct: Double? = nil) {
|
||||
self.fiveHourPct = fiveHourPct
|
||||
self.sevenDayPct = sevenDayPct
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// The ONLY WS I/O boundary (frozen contract, plan §3.1/§3.2).
|
||||
/// `URLSessionTermTransport` (SessionCore, T-iOS-9) and `FakeTransport`
|
||||
/// (TestSupport, T-iOS-4) both implement this; `SessionEngine` cannot tell
|
||||
/// them apart.
|
||||
public protocol TermTransport: Sendable {
|
||||
/// Open a WS connection to `endpoint.wsURL`, stamping
|
||||
/// `Origin: endpoint.originHeader` on the upgrade (plan §5.1).
|
||||
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection
|
||||
}
|
||||
|
||||
/// One live WS connection, as immutable capability handles (frozen contract).
|
||||
public struct TransportConnection: Sendable {
|
||||
/// Server JSON text frames, in arrival order. Stream finish = clean close;
|
||||
/// stream throw = transport error (the two are distinguishable, T-iOS-9).
|
||||
public let frames: AsyncThrowingStream<String, any Error>
|
||||
/// Send one client JSON text frame (produced by `MessageCodec.encode`).
|
||||
public let send: @Sendable (String) async throws -> Void
|
||||
/// Close the connection (client detach — the server-side PTY keeps running).
|
||||
public let close: @Sendable () async -> Void
|
||||
|
||||
public init(
|
||||
frames: AsyncThrowingStream<String, any Error>,
|
||||
send: @escaping @Sendable (String) async throws -> Void,
|
||||
close: @escaping @Sendable () async -> Void
|
||||
) {
|
||||
self.frames = frames
|
||||
self.send = send
|
||||
self.close = close
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// One activity-timeline entry from `GET /live-sessions/:id/events` (frozen
|
||||
/// contract, plan §3.1; mirrors src/types.ts:428-433). The server derives
|
||||
/// `class` semantically (src/types.ts:423: tool/waiting/done/stuck/user) — the
|
||||
/// client never re-derives it from hook names. `class` stays a raw `String` so
|
||||
/// the SHAPE decodes even for future/unknown classes; consumers drop unknowns
|
||||
/// (use `decodeList`, which does exactly that).
|
||||
public struct TimelineEvent: Sendable, Equatable, Decodable {
|
||||
/// Server ingest timestamp (ms since epoch).
|
||||
public let at: Int
|
||||
/// Server-derived semantic class; see `knownClasses`.
|
||||
public let `class`: String
|
||||
/// Sanitized tool name (server-side: ≤200 chars, control chars stripped).
|
||||
public let toolName: String?
|
||||
/// Server-derived human phrase ("ran Bash", "edited 3 files").
|
||||
public let label: String
|
||||
|
||||
/// The server's `TimelineClass` union (src/types.ts:423).
|
||||
public static let knownClasses: Set<String> = ["tool", "waiting", "done", "stuck", "user"]
|
||||
|
||||
public init(at: Int, class className: String, toolName: String?, label: String) {
|
||||
self.at = at
|
||||
self.`class` = className
|
||||
self.toolName = toolName
|
||||
self.label = label
|
||||
}
|
||||
|
||||
/// True when `class` is one the server currently emits.
|
||||
public var hasKnownClass: Bool {
|
||||
Self.knownClasses.contains(`class`)
|
||||
}
|
||||
|
||||
/// Decode a `/events` response body. NEVER throws: the server is an
|
||||
/// untrusted input source — malformed entries and unknown-class entries are
|
||||
/// silently dropped; a non-array body yields `[]`.
|
||||
public static func decodeList(from data: Data) -> [TimelineEvent] {
|
||||
guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return entries.compactMap(\.value).filter(\.hasKnownClass)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-element tolerance shim: a malformed element becomes `nil` instead of
|
||||
/// failing the whole array decode.
|
||||
private struct LossyEntry: Decodable {
|
||||
let value: TimelineEvent?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? TimelineEvent(from: decoder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// Client-side tuning constants (frozen contract; SOLE value source is the
|
||||
/// plan §3.2.1 table — adding/changing a constant goes back through T-iOS-3).
|
||||
/// All named — no magic numbers anywhere downstream.
|
||||
public enum Tunables {
|
||||
/// WS keep-alive ping period. `URLSessionWebSocketTask` has NO automatic
|
||||
/// ping (plan §1) — `PingScheduler` drives an explicit `sendPing` at this
|
||||
/// interval so the terminal is never "looks connected but dead".
|
||||
public static let pingInterval: Duration = .seconds(25)
|
||||
|
||||
/// Consecutive missed pongs after which the connection is declared dead
|
||||
/// (T-iOS-5): 1 missed pong is tolerated, the 2nd is a disconnect signal.
|
||||
public static let pongMissLimit = 2
|
||||
|
||||
/// Foreground `/live-sessions` polling period. Mirrors the web launcher's
|
||||
/// refresh cadence (public/launcher.ts:30 `REFRESH_MS = 5000`).
|
||||
public static let listPollInterval: Duration = .seconds(5)
|
||||
|
||||
/// Telemetry chips grey out when `StatusTelemetry.at` is older than this.
|
||||
/// Mirrors public/tabs.ts:45 `STATUSLINE_TTL_MS` (= server default,
|
||||
/// src/config.ts:63). The server value is env-overridable at runtime while
|
||||
/// iOS bakes the default — accepted drift (plan §3.2.1).
|
||||
public static let telemetryStaleTtlMs: Int = 30_000
|
||||
|
||||
/// Away-digest banner auto-fade delay (T-iOS-14).
|
||||
public static let digestFadeDelay: Duration = .seconds(8)
|
||||
|
||||
/// Max session-title length after sanitisation (T-iOS-23; OSC titles are
|
||||
/// host/attacker-controlled input).
|
||||
public static let titleMaxLength = 256
|
||||
|
||||
/// `URLSessionWebSocketTask.maximumMessageSize`. The default (1 MiB) is too
|
||||
/// small: ring-buffer replay arrives as ONE full-snapshot frame
|
||||
/// (src/session/session.ts:165-171) and JSON escaping inflates control
|
||||
/// bytes to `\uXXXX` by 1-6x (src/protocol.ts:186), so worst case is
|
||||
/// ≈ 6 × SCROLLBACK_BYTES (default 2 MiB) plus frame envelope → 16 MiB.
|
||||
///
|
||||
/// COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client
|
||||
/// cannot discover at runtime (no config handshake). If the host raises it
|
||||
/// past ~2.7 MB (or replay is extremely escape-dense), `receive()` fails
|
||||
/// with NSPOSIXErrorDomain code 40 (ENOBUFS "Message too long") — that MUST
|
||||
/// be classified as the non-retryable `.replayTooLarge` failure and never
|
||||
/// fed into the backoff reconnect loop (plan §3.2 / T-iOS-9/10).
|
||||
public static let maxWSMessageBytes = 16 * 1024 * 1024
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Boundary validation, same rules as the server (frozen contract, plan §3.1).
|
||||
/// Sources: src/protocol.ts:22-23 (SESSION_ID_RE), :113-115 (dimensions),
|
||||
/// :142-149 (cwd). Pure functions, never throw.
|
||||
public enum Validation {
|
||||
/// Byte-identical port of the server's SESSION_ID_RE
|
||||
/// (`/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`):
|
||||
/// UUID v4 — 8-4-4-4-12 hex groups, version nibble '4', variant nibble
|
||||
/// 8/9/a/b, case-insensitive (M7). Vectors in ServerVectorTests pin the
|
||||
/// equivalence against a test-side mirror of the server regex.
|
||||
public static func isValidSessionId(_ candidate: String) -> Bool {
|
||||
let sessionIdRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
.ignoresCase()
|
||||
return candidate.wholeMatch(of: sessionIdRegex) != nil
|
||||
}
|
||||
|
||||
/// Mirrors the server's `isValidDimension` range check for `resize`
|
||||
/// (integers in `WireConstants.resizeRange`). Frames outside this range
|
||||
/// are silently discarded by the server — validate before sending.
|
||||
public static func isValidResize(cols: Int, rows: Int) -> Bool {
|
||||
WireConstants.resizeRange.contains(cols) && WireConstants.resizeRange.contains(rows)
|
||||
}
|
||||
|
||||
/// Mirrors the server's `attach.cwd` check: must start with "/"
|
||||
/// (src/protocol.ts:142-149; deeper normalisation stays server-side).
|
||||
public static func isAbsoluteCwd(_ candidate: String) -> Bool {
|
||||
candidate.hasPrefix("/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// Wire-level constants shared with the server (frozen contract, plan §3.1).
|
||||
public enum WireConstants {
|
||||
/// WS endpoint path (src/config.ts:41 `DEFAULT_WS_PATH`).
|
||||
public static let wsPath = "/term"
|
||||
|
||||
/// Soft-reset prefix the server prepends to ring-buffer replay as a safety
|
||||
/// net (src/types.ts:167-170 / M2). Clients may use it to recognise the
|
||||
/// replay boundary; never strip it — it is valid ANSI for the terminal.
|
||||
public static let replaySoftResetPrefix = "\u{1B}[0m"
|
||||
|
||||
/// `exit.code` value meaning the PTY spawn never succeeded (M4);
|
||||
/// `exit.reason` is required server-side in that case. Not a retryable state.
|
||||
public static let spawnFailedExitCode = -1
|
||||
|
||||
/// Valid `resize` cols/rows range, inclusive (src/protocol.ts:113-115).
|
||||
public static let resizeRange: ClosedRange<Int> = 1...1000
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// T-iOS-1 scaffold placeholder.
|
||||
///
|
||||
/// The frozen wire contract (`ClientMessage` / `ServerMessage` / `MessageCodec` /
|
||||
/// `Validation` / `WireConstants` / `Tunables` plus the shared I/O boundary types
|
||||
/// `HostEndpoint` / `TermTransport` / `HTTPTransport` / `TimelineEvent`) lands in
|
||||
/// T-iOS-3 and is owned exclusively by that task — do not add contract types here.
|
||||
public enum WireProtocolPackage {
|
||||
/// Package identity marker consumed by the scaffold smoke tests (downstream
|
||||
/// packages reference it to prove their dependency edge compiles).
|
||||
public static let packageName = "WireProtocol"
|
||||
}
|
||||
Reference in New Issue
Block a user