Files
Yaojia Wang cbaa08daba 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
2026-07-04 21:19:30 +02:00

111 lines
4.3 KiB
Swift

import Foundation
/// Test-side mini-port of the server's `parseClientMessage` (src/protocol.ts:45-176)
/// plus `parseApproveMode` (src/server.ts:94-102). Used by the roundtrip property
/// test and the cross-implementation vector tests: `MessageCodec.encode` output is
/// fed through THIS parser, which itself is pinned against the vectors transcribed
/// from test/protocol.test.ts so the Swift encoder and the TS server parser
/// cannot drift silently (the live tripwire is T-iOS-16's real-server CI).
enum ServerViewMessage: Equatable {
case attach(sessionId: String?, cwd: String?)
case input(data: String)
case resize(cols: Int, rows: Int)
case approve
case reject
}
enum ServerViewParser {
/// Mirror of src/protocol.ts SESSION_ID_RE (UUID v4, /i).
private static let sessionIdPattern =
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
/// Mirror of src/server.ts:76 PERMISSION_MODES.
static let permissionModes: Set<String> = ["default", "acceptEdits", "plan", "auto"]
/// Mirror of src/protocol.ts:27 ALLOWED_TYPES.
private static let allowedTypes: Set<String> = ["attach", "input", "resize", "approve", "reject"]
// MARK: - parseClientMessage mirror (never throws; invalid nil)
static func parse(_ raw: String) -> ServerViewMessage? {
guard let data = raw.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let obj = parsed as? [String: Any],
let type = obj["type"] as? String,
allowedTypes.contains(type)
else { return nil }
switch type {
case "resize": return parseResize(obj)
case "input": return parseInput(obj)
case "approve": return .approve
case "reject": return .reject
default: return parseAttach(obj)
}
}
/// Mirror of src/server.ts:94-102 reads `mode` from the RAW frame's top level.
static func topLevelMode(_ raw: String) -> String? {
guard let data = raw.data(using: .utf8),
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let mode = obj["mode"] as? String,
permissionModes.contains(mode)
else { return nil }
return mode
}
// MARK: - Per-type validators (src/protocol.ts:90-176)
private static func parseResize(_ obj: [String: Any]) -> ServerViewMessage? {
guard let cols = integerDimension(obj["cols"]),
let rows = integerDimension(obj["rows"])
else { return nil }
return .resize(cols: cols, rows: rows)
}
/// Mirror of isValidDimension: number, integer, 1...1000 (src/protocol.ts:113-115).
private static func integerDimension(_ value: Any?) -> Int? {
guard let number = value as? NSNumber, !isJSONBoolean(number) else { return nil }
let doubleValue = number.doubleValue
guard doubleValue == doubleValue.rounded(.towardZero),
doubleValue >= 1, doubleValue <= 1000
else { return nil }
return number.intValue
}
private static func isJSONBoolean(_ number: NSNumber) -> Bool {
CFGetTypeID(number) == CFBooleanGetTypeID()
}
private static func parseInput(_ obj: [String: Any]) -> ServerViewMessage? {
guard let data = obj["data"] as? String else { return nil }
return .input(data: data)
}
private static func parseAttach(_ obj: [String: Any]) -> ServerViewMessage? {
// sessionId key must be explicitly present (src/protocol.ts:132-134).
guard obj.index(forKey: "sessionId") != nil else { return nil }
var cwd: String?
if let rawCwd = obj["cwd"] {
guard let cwdString = rawCwd as? String, cwdString.hasPrefix("/") else { return nil }
cwd = cwdString
}
if obj["sessionId"] is NSNull {
return .attach(sessionId: nil, cwd: cwd)
}
guard let sessionId = obj["sessionId"] as? String, isServerValidSessionId(sessionId) else {
return nil
}
return .attach(sessionId: sessionId, cwd: cwd)
}
static func isServerValidSessionId(_ candidate: String) -> Bool {
candidate.range(
of: sessionIdPattern,
options: [.regularExpression, .caseInsensitive]
) != nil
}
}