feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract

T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
This commit is contained in:
Yaojia Wang
2026-07-05 13:34:01 +02:00
parent aa956fcbb4
commit 4871e8ac3d
20 changed files with 3319 additions and 48 deletions

View File

@@ -105,9 +105,9 @@ public struct APIClient: Sendable {
}
}
// MARK: - Internals
// MARK: - Internals (shared with the P1 feature files, T-iOS-38)
private func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
guard let request = route.urlRequest(for: endpoint) else {
throw APIClientError.invalidRequest
}
@@ -115,7 +115,7 @@ public struct APIClient: Sendable {
}
/// 200 ok; 404 `.sessionNotFound`; anything else `.unexpectedStatus`.
private static func requireOK(_ response: HTTPURLResponse) throws {
static func requireOK(_ response: HTTPURLResponse) throws {
switch response.statusCode {
case HTTPStatus.ok:
return
@@ -128,10 +128,12 @@ public struct APIClient: Sendable {
}
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
private enum HTTPStatus {
enum HTTPStatus {
static let ok = 200
static let noContent = 204
static let badRequest = 400
static let forbidden = 403
static let notFound = 404
static let tooManyRequests = 429
static let internalServerError = 500
}

View File

@@ -0,0 +1,99 @@
import Foundation
import WireProtocol
/// APNs device-token registration (T-iOS-38 · frozen wire shape, orchestrator
/// ruling mirrors the `POST /push/subscribe` conventions, src/server.ts:461-480):
///
/// - `POST /push/apns-token` body `{"token":"<64-160 hex chars, lowercase>"}`
/// **204** (idempotent upsert)
/// - `DELETE /push/apns-token` body **204** (idempotent; unknown token
/// still 204)
/// - Both are **G endpoints**: `Origin` guard (`requireAllowedOrigin`) 403.
/// - Server limits: 400 invalid token/shape · **429 rate limit 5/min/IP** ·
/// `express.json` body limit **8 KB** (SUBSCRIBE_RATE_MAX, src/server.ts:73).
///
/// The hex rule is enforced CLIENT-SIDE too (validate at the boundary, plan §4):
/// uppercase input is lowercase-normalized, anything not 64160 hex chars is
/// rejected with `.invalidApnsToken` BEFORE any network I/O.
enum ApnsTokenRule {
/// APNs device tokens are 32 bytes (64 hex chars) today; the frozen
/// shape allows up to 160 hex chars of forward headroom.
static let minHexLength = 64
static let maxHexLength = 160
/// ASCII-only hex alphabet deliberately NOT `Character.isHexDigit`
/// (which also accepts fullwidth digit forms the server regex rejects).
private static let hexDigits = Set("0123456789abcdef")
/// Lowercase-normalize and validate; nil = not a valid wire token.
static func normalize(_ raw: String) -> String? {
let lowered = raw.lowercased()
guard (minHexLength...maxHexLength).contains(lowered.count),
lowered.allSatisfy({ hexDigits.contains($0) })
else { return nil }
return lowered
}
}
extension Endpoints {
static let apnsTokenPath = "/push/apns-token"
/// `POST /push/apns-token` G. `normalized` MUST come from
/// `ApnsTokenRule.normalize` (single validation point).
static func registerApnsToken(normalized: String) throws -> APIRoute {
APIRoute(
method: .post, path: apnsTokenPath, originPolicy: .guarded,
body: try JSONEncoder().encode(ApnsTokenBody(token: normalized))
)
}
/// `DELETE /push/apns-token` G, idempotent (unknown token still 204).
static func unregisterApnsToken(normalized: String) throws -> APIRoute {
APIRoute(
method: .delete, path: apnsTokenPath, originPolicy: .guarded,
body: try JSONEncoder().encode(ApnsTokenBody(token: normalized))
)
}
private struct ApnsTokenBody: Encodable {
let token: String
}
}
extension APIClient {
/// Register this device's APNs token (idempotent upsert 204).
/// G endpoint Origin byte-equal to `endpoint.originHeader`.
/// Server limits (documented, enforced server-side): body 8 KB,
/// 5 requests/min/IP `.rateLimited`. Invalid hex is rejected
/// client-side (`.invalidApnsToken`) before any network I/O.
public func registerApnsToken(_ token: String) async throws {
try await sendApnsToken(token, build: Endpoints.registerApnsToken(normalized:))
}
/// Unregister a previously registered token (idempotent 204 even for an
/// unknown token). Same G guard and limits as `registerApnsToken`.
public func unregisterApnsToken(_ token: String) async throws {
try await sendApnsToken(token, build: Endpoints.unregisterApnsToken(normalized:))
}
private func sendApnsToken(
_ token: String,
build: (String) throws -> APIRoute
) async throws {
guard let normalized = ApnsTokenRule.normalize(token) else {
throw APIClientError.invalidApnsToken
}
let (_, response) = try await perform(try build(normalized))
switch response.statusCode {
case HTTPStatus.noContent:
return
case HTTPStatus.badRequest:
throw APIClientError.invalidApnsToken
case HTTPStatus.forbidden:
throw APIClientError.forbidden
case HTTPStatus.tooManyRequests:
throw APIClientError.rateLimited
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
}

View File

@@ -1,10 +1,11 @@
import Foundation
import WireProtocol
/// HTTP method whitelist for the six frozen endpoints (plan §3.4).
/// HTTP method whitelist for the frozen endpoints (plan §3.4 + T-iOS-38 P1 ).
enum HTTPMethod: String, Sendable {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
@@ -36,10 +37,29 @@ struct APIRoute: Sendable, Equatable {
let path: String
let originPolicy: OriginPolicy
let body: Data?
/// Pre-encoded query string (`key=<strictly-percent-encoded value>`), or
/// nil for no query. Percent-encoding happens ONCE, in the route builder
/// (T-iOS-38: `/projects/detail?path=`) never at call sites.
let percentEncodedQuery: String?
init(
method: HTTPMethod,
path: String,
originPolicy: OriginPolicy,
body: Data?,
percentEncodedQuery: String? = nil
) {
self.method = method
self.path = path
self.originPolicy = originPolicy
self.body = body
self.percentEncodedQuery = percentEncodedQuery
}
/// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
/// the path is REPLACED and query/fragment/credentials are dropped the
/// same derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
/// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
/// (dropped when nil), fragment/credentials are dropped the same
/// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
/// happens HERE and only here (single point; hand-stamping elsewhere is a
/// review CRITICAL, plan §5.1).
func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
@@ -51,6 +71,9 @@ struct APIRoute: Sendable, Equatable {
components.fragment = nil
components.user = nil
components.password = nil
if let percentEncodedQuery {
components.percentEncodedQuery = percentEncodedQuery
}
guard let url = components.url else { return nil }
var request = URLRequest(url: url)
@@ -66,12 +89,25 @@ struct APIRoute: Sendable, Equatable {
}
}
/// Builders for the six frozen endpoints (plan §3.4). Route table
/// (verified against src/server.ts):
/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 ). Route
/// table (verified against src/server.ts):
/// - RO `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609)
/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293)
/// · `GET /prefs` (:273)
/// - G `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20
/// shape, mirrors `/push/subscribe` :461-498)
/// P1 builders live beside their feature models: `ApnsToken.swift`,
/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner).
enum Endpoints {
/// Strict RFC 3986 unreserved set everything else gets percent-encoded.
/// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is
/// decoded as a SPACE by Express's qs parser, and `&`/`=` would split the
/// parameter strict encoding removes the whole ambiguity class.
static let unreservedCharacters = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
static func liveSessions() -> APIRoute {
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
}

View File

@@ -0,0 +1,87 @@
import Foundation
import WireProtocol
/// The four network tiers of plan §5.4's pairing/warning table PUBLIC so UI
/// layers (PairingViewModel's scheme+address , T-iOS-12) consume ONE
/// classification instead of re-deriving it (the W3 dedup this type exists for).
///
/// | tier | | §5.4 |
/// |---|---|---|
/// | `loopback` | localhost · 127.0.0.0/8 · ::1 | |
/// | `privateLAN` | RFC1918 (10/8, 172.16/12, 192.168/16) · link-local 169.254/16 · mDNS `.local` | ws:// |
/// | `tailscale` | CGNAT 100.64.0.0/10 · MagicDNS `*.ts.net` | WireGuard |
/// | `public` | /fail-safe | |
public enum HostNetworkTier: Sendable, Equatable, CaseIterable {
case loopback
case privateLAN
case tailscale
case `public`
}
/// Pure, stateless classifier over a URL host string (plan §5.4 tiers).
/// IPv6 note: only loopback `::1` is tiered; other IPv6 literals (incl. ULA
/// fd00::/8) fall to `.public` fail-safe over-warning, matching v1 scope
/// (the app dials IPv4 LAN / CGNAT / MagicDNS targets).
public enum HostClassifier {
private static let magicDNSSuffix = ".ts.net"
private static let mdnsSuffix = ".local"
private static let loopbackNames: Set<String> = ["localhost", "::1", "[::1]"]
/// Classify a bare host string (as returned by `URL.host` no scheme,
/// no port). Unknown/malformed input is `.public` by design (fail-safe).
public static func classify(host: String) -> HostNetworkTier {
let lowered = host.lowercased()
if loopbackNames.contains(lowered) {
return .loopback
}
if lowered.hasSuffix(magicDNSSuffix) {
return .tailscale
}
if lowered.hasSuffix(mdnsSuffix) {
return .privateLAN
}
guard let octets = ipv4Octets(lowered) else {
return .public
}
return classifyIPv4(octets)
}
/// Convenience for callers holding a `HostEndpoint` (the VM's shape).
public static func classify(endpoint: HostEndpoint) -> HostNetworkTier {
classify(host: endpoint.baseURL.host ?? "")
}
// MARK: - IPv4 (shared with PairingError's Local-Network diagnosis)
private static func classifyIPv4(_ octets: [Int]) -> HostNetworkTier {
switch (octets[0], octets[1]) {
case (127, _): // loopback 127/8
return .loopback
case (10, _), (192, 168): // RFC1918 10/8 · 192.168/16
return .privateLAN
case (172, 16...31): // RFC1918 172.16/12
return .privateLAN
case (169, 254): // link-local
return .privateLAN
case (100, 64...127): // CGNAT 100.64/10 (Tailscale)
return .tailscale
default:
return .public
}
}
private static let ipv4OctetCount = 4
private static let ipv4OctetRange = 0...255
/// Strict dotted-quad parse; anything else (hostnames, IPv6, out-of-range
/// octets, wrong arity) nil.
static func ipv4Octets(_ host: String) -> [Int]? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == ipv4OctetCount else { return nil }
let octets = parts.compactMap { Int($0) }
guard octets.count == ipv4OctetCount,
octets.allSatisfy({ ipv4OctetRange.contains($0) })
else { return nil }
return octets
}
}

View File

@@ -159,9 +159,26 @@ public enum APIClientError: Error, Equatable, Sendable {
/// token is missing / mismatched / STALE tokens are single-use and
/// expiring by design (SEC-C1/M1). User-facing in `message`.
case decisionRejected
/// 429: `POST /hook/decision` is limited to 10 requests/min/IP
/// (src/server.ts:72,504-508 fixed server security policy).
/// 429: the endpoint is rate-limited per IP by fixed server policy
/// `POST /hook/decision` 10/min (src/server.ts:72,504-508);
/// `POST|DELETE /push/apns-token` 5/min (mirrors `/push/subscribe`,
/// src/server.ts:73,461-466). Per-endpoint limits live in each call's
/// doc comment; the user copy stays limit-agnostic.
case rateLimited
/// The APNs device token is not 64160 hex chars (frozen T-iOS-20 wire
/// rule) rejected client-side BEFORE any network I/O, or echoed by the
/// server as a 400.
case invalidApnsToken
/// 400 from `GET /projects/detail` the `path` query parameter is
/// missing/empty (src/server.ts:295-298). Also raised client-side for an
/// empty path, before any network I/O.
case projectPathInvalid
/// 404 from `GET /projects/detail` no project at that path
/// (src/server.ts:301-304; moved/deleted/not a directory).
case projectNotFound
/// 500 from `GET /projects/detail` the server failed reading the repo
/// (src/server.ts:306-309, body `{error}`).
case projectDetailUnavailable
/// Any other non-success status code.
case unexpectedStatus(Int)
@@ -179,7 +196,15 @@ public enum APIClientError: Error, Equatable, Sendable {
case .decisionRejected:
"审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。"
case .rateLimited:
"操作过于频繁服务器限 10 次/分钟),请稍后再试。"
"操作过于频繁服务器已限流,请稍后再试。"
case .invalidApnsToken:
"推送注册令牌格式异常,请重启 App 重新注册推送。"
case .projectPathInvalid:
"项目路径为空或不合法。"
case .projectNotFound:
"项目不存在(路径可能已移动或删除)。"
case .projectDetailUnavailable:
"读取项目详情失败,请稍后再试。"
case .unexpectedStatus(let status):
"服务器返回了意外状态码 \(status)"
}

View File

@@ -146,42 +146,22 @@ extension PairingError {
(error as NSError).localizedDescription
}
/// LAN-class targets for the `localNetworkDenied` mapping: RFC1918,
/// link-local 169.254/16, loopback, CGNAT 100.64/10 (Tailscale), and
/// mDNS `.local` names. (T-iOS-12's warning TIERS are separate logic
/// this predicate only gates the Local-Network-permission diagnosis.)
/// LAN-class targets for the `localNetworkDenied` mapping a VIEW over
/// `HostClassifier` (T-iOS-38 dedup; plan §5.4 tiers are the single
/// classification source):
/// - `.loopback` / `.privateLAN` true (Local-Network-permission gated);
/// - `.tailscale` true only for CGNAT 100.64/10 IPs dialed on a local
/// interface; MagicDNS `*.ts.net` rides the WireGuard tunnel, so an
/// ENETDOWN there is NOT the Local Network permission false;
/// - `.public` false (never misdiagnose for public targets).
static func isPrivateOrLocalHost(_ host: String) -> Bool {
let lowered = host.lowercased()
if lowered == "localhost" || lowered.hasSuffix(".local") {
switch HostClassifier.classify(host: host) {
case .loopback, .privateLAN:
return true
}
guard let octets = ipv4Octets(lowered) else {
case .tailscale:
return HostClassifier.ipv4Octets(host.lowercased()) != nil
case .public:
return false
}
switch (octets[0], octets[1]) {
case (10, _), (127, _): // RFC1918 10/8 · loopback 127/8
return true
case (192, 168), (169, 254): // RFC1918 192.168/16 · link-local
return true
case (172, 16...31): // RFC1918 172.16/12
return true
case (100, 64...127): // CGNAT 100.64/10 (Tailscale)
return true
default:
return false
}
}
private static let ipv4OctetCount = 4
private static let ipv4OctetRange = 0...255
private static func ipv4Octets(_ host: String) -> [Int]? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == ipv4OctetCount else { return nil }
let octets = parts.compactMap { Int($0) }
guard octets.count == ipv4OctetCount,
octets.allSatisfy({ ipv4OctetRange.contains($0) })
else { return nil }
return octets
}
}

View File

@@ -0,0 +1,204 @@
import Foundation
import WireProtocol
// T-iOS-38 · cross-device Projects UI prefs (`GET /prefs` RO · `PUT /prefs` G):
// an OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web
// client loosely (public/prefs.ts sanitizePrefs / src/types.ts:493-496:
// `favourites: string[]`, `collapsed: {group-key: true}`); ALL OTHER top-level
// keys are preserved verbatim across decode mutate encode, so an iOS PUT
// can never clobber prefs written by the web client or a future server.
// (PUT replaces the WHOLE blob server-side, src/server.ts:278-288 key
// preservation is therefore correctness, not politeness.)
// MARK: - JSONValue (internal round-trip carrier)
/// Minimal JSON tree the storage format that lets unknown prefs keys
/// round-trip losslessly. Integers keep a dedicated case so `42` never
/// re-encodes as `42.0`.
enum JSONValue: Sendable, Equatable, Codable {
case null
case bool(Bool)
case integer(Int)
case number(Double)
case string(String)
case array([JSONValue])
case object([String: JSONValue])
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let bool = try? container.decode(Bool.self) {
self = .bool(bool)
} else if let int = try? container.decode(Int.self) {
self = .integer(int)
} else if let double = try? container.decode(Double.self) {
self = .number(double)
} else if let string = try? container.decode(String.self) {
self = .string(string)
} else if let array = try? container.decode([JSONValue].self) {
self = .array(array)
} else if let object = try? container.decode([String: JSONValue].self) {
self = .object(object)
} else {
throw DecodingError.dataCorruptedError(
in: container, debugDescription: "not a JSON value"
)
}
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .null: try container.encodeNil()
case .bool(let value): try container.encode(value)
case .integer(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .string(let value): try container.encode(value)
case .array(let value): try container.encode(value)
case .object(let value): try container.encode(value)
}
}
}
// MARK: - UiPrefs
/// The prefs blob. Immutable snapshot: `withFavourites`/`withCollapsed` return
/// NEW copies that replace exactly one known key and carry every other key
/// through untouched (THE round-trip correctness trap tested).
public struct UiPrefs: Sendable, Equatable {
/// Full decoded top-level object known keys included, unknown keys opaque.
private let storage: [String: JSONValue]
private enum Key {
static let favourites = "favourites"
static let collapsed = "collapsed"
}
init(storage: [String: JSONValue]) {
self.storage = storage
}
/// Fresh prefs (e.g. first write from a device that never fetched).
public init(favourites: [String] = [], collapsed: [String: Bool] = [:]) {
self.init(storage: [
Key.favourites: .array(favourites.map(JSONValue.string)),
Key.collapsed: .object(collapsed.mapValues(JSONValue.bool)),
])
}
// MARK: Known keys (sanitized views mirror public/prefs.ts sanitizePrefs)
/// Favourited project paths (): non-empty strings, de-duplicated,
/// original order kept. Wrong-typed entries are dropped, not fatal.
public var favourites: [String] {
guard case .array(let items)? = storage[Key.favourites] else { return [] }
var seen = Set<String>()
var out: [String] = []
for case .string(let path) in items where !path.isEmpty && seen.insert(path).inserted {
out.append(path)
}
return out
}
/// Namespace group-key collapsed. Only literal `true` values count
/// (expanded is the default; both web and server sanitizers agree).
public var collapsed: [String: Bool] {
guard case .object(let entries)? = storage[Key.collapsed] else { return [:] }
var out: [String: Bool] = [:]
for (key, value) in entries where !key.isEmpty {
if case .bool(true) = value { out[key] = true }
}
return out
}
// MARK: Immutable mutation (unknown keys carried through verbatim)
/// New snapshot with `favourites` replaced; every other key untouched.
public func withFavourites(_ favourites: [String]) -> UiPrefs {
var next = storage
next[Key.favourites] = .array(favourites.map(JSONValue.string))
return UiPrefs(storage: next)
}
/// New snapshot with `collapsed` replaced; every other key untouched.
public func withCollapsed(_ collapsed: [String: Bool]) -> UiPrefs {
var next = storage
next[Key.collapsed] = .object(collapsed.mapValues(JSONValue.bool))
return UiPrefs(storage: next)
}
// MARK: Wire round-trip
/// Decode a `/prefs` body. nil = top level is not a JSON object callers
/// surface `.invalidResponseBody` LOUDLY instead of degrading to empty
/// prefs (an empty-based PUT would wipe the server blob).
public static func decode(from data: Data) -> UiPrefs? {
guard let storage = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
return nil
}
return UiPrefs(storage: storage)
}
/// Encode the FULL blob (known + unknown keys) for `PUT /prefs`.
public func encodeBody() throws -> Data {
try JSONEncoder().encode(storage)
}
}
// MARK: - Routes + client calls
extension Endpoints {
static let prefsPath = "/prefs"
/// `GET /prefs` RO, no Origin (src/server.ts:273-275; no secrets, just
/// paths + booleans).
static func getPrefs() -> APIRoute {
APIRoute(method: .get, path: prefsPath, originPolicy: .readOnly, body: nil)
}
/// `PUT /prefs` G (CSRF Origin guard). Server-enforced body limit
/// **64 KB** (`express.json({limit:'64kb'})`, src/server.ts:278); the body
/// is sanitized server-side and echoed back as the 200 response.
static func putPrefs(_ prefs: UiPrefs) throws -> APIRoute {
APIRoute(
method: .put, path: prefsPath, originPolicy: .guarded,
body: try prefs.encodeBody()
)
}
}
extension APIClient {
/// `GET /prefs` the cross-device favourites/collapse blob. RO no
/// Origin. A non-object body throws `.invalidResponseBody` (never
/// silently degrades see `UiPrefs.decode`).
public func prefs() async throws -> UiPrefs {
let (data, response) = try await perform(Endpoints.getPrefs())
guard response.statusCode == HTTPStatus.ok else {
throw APIClientError.unexpectedStatus(response.statusCode)
}
guard let prefs = UiPrefs.decode(from: data) else {
throw APIClientError.invalidResponseBody
}
return prefs
}
/// `PUT /prefs` replace the whole blob (body 64 KB, server rule).
/// G Origin byte-equal; 403 = Origin guard. Returns the server's
/// sanitized echo treat IT as the new source of truth, not the input.
@discardableResult
public func putPrefs(_ prefs: UiPrefs) async throws -> UiPrefs {
let (data, response) = try await perform(try Endpoints.putPrefs(prefs))
switch response.statusCode {
case HTTPStatus.ok:
guard let echoed = UiPrefs.decode(from: data) else {
throw APIClientError.invalidResponseBody
}
return echoed
case HTTPStatus.forbidden:
throw APIClientError.forbidden
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
}

View File

@@ -0,0 +1,297 @@
import Foundation
import WireProtocol
// T-iOS-38 · Projects contract increments (both RO NO Origin header):
// - `GET /projects` (src/server.ts:262-269) `[ProjectInfo]`, mirrors
// src/types.ts:273-281 field-for-field. The server replies `[]` even on
// internal failure a non-array body is therefore "not web-terminal".
// - `GET /projects/detail?path=` (src/server.ts:293-310) `ProjectDetail`
// (src/types.ts:296-306). `path` percent-encoding happens ONCE, in the
// builder. 400/404/500 `{error}` map to explicit typed errors.
// Tolerant decoding at the untrusted server boundary (plan §4): unknown
// fields ignored, malformed entries dropped one by one, never crash.
// MARK: - ProjectSessionRef
/// One running session belonging to a project (src/types.ts:262-269; the
/// server mirrors LiveSessionInfo fields into this ref).
public struct ProjectSessionRef: Sendable, Equatable {
public let id: UUID
/// Derived label (last cwd segment, e.g. repo dir name); optional.
public let title: String?
public let status: ClaudeStatus
public let clientCount: Int
public let createdAt: Int
public let exited: Bool
public init(
id: UUID, title: String?, status: ClaudeStatus,
clientCount: Int, createdAt: Int, exited: Bool
) {
self.id = id
self.title = title
self.status = status
self.clientCount = clientCount
self.createdAt = createdAt
self.exited = exited
}
}
extension ProjectSessionRef: Decodable {
private enum CodingKeys: String, CodingKey {
case id, title, status, clientCount, createdAt, exited
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
clientCount = try container.decode(Int.self, forKey: .clientCount)
createdAt = try container.decode(Int.self, forKey: .createdAt)
exited = try container.decode(Bool.self, forKey: .exited)
// Same tolerance as LiveSessionInfo: a future status value must not
// drop the ref; a wrong-typed title degrades to nil.
let rawStatus = try? container.decode(String.self, forKey: .status)
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
title = try? container.decode(String.self, forKey: .title)
}
}
// MARK: - ProjectInfo
/// A discovered project (git repo or recently-used cwd) src/types.ts:273-281.
/// Note: there is NO `namespace` field on the wire; namespace grouping is a
/// web-client concept that only surfaces as `UiPrefs.collapsed` group-keys.
public struct ProjectInfo: Sendable, Equatable {
public let name: String
public let path: String
public let isGit: Bool
public let branch: String?
/// Uncommitted changes; only present when the server runs the dirty check.
public let dirty: Bool?
/// Newest `~/.claude/projects` mtime for this cwd (ms) the sort key.
public let lastActiveMs: Int?
public let sessions: [ProjectSessionRef]
public init(
name: String, path: String, isGit: Bool, branch: String?,
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef]
) {
self.name = name
self.path = path
self.isGit = isGit
self.branch = branch
self.dirty = dirty
self.lastActiveMs = lastActiveMs
self.sessions = sessions
}
}
extension ProjectInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, path, isGit, branch, dirty, lastActiveMs, sessions
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
path = try container.decode(String.self, forKey: .path)
isGit = try container.decode(Bool.self, forKey: .isGit)
branch = try? container.decode(String.self, forKey: .branch)
dirty = try? container.decode(Bool.self, forKey: .dirty)
lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs)
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
}
/// Decode a `/projects` response body non-array top level
/// `invalidResponseBody`; malformed elements dropped one by one (same
/// pattern as `LiveSessionInfo.decodeList`).
static func decodeList(from data: Data) throws -> [ProjectInfo] {
guard let entries = try? JSONDecoder().decode([LossyBox<ProjectInfo>].self, from: data) else {
throw APIClientError.invalidResponseBody
}
return entries.compactMap(\.value)
}
}
// MARK: - WorktreeInfo / ProjectDetail
/// One `git worktree list --porcelain` entry (src/types.ts:284-292).
public struct WorktreeInfo: Sendable, Equatable {
public let path: String
/// Branch name; nil on detached HEAD.
public let branch: String?
public let head: String?
public let isMain: Bool
public let isCurrent: Bool
public let locked: Bool?
public let prunable: Bool?
public init(
path: String, branch: String?, head: String?,
isMain: Bool, isCurrent: Bool, locked: Bool?, prunable: Bool?
) {
self.path = path
self.branch = branch
self.head = head
self.isMain = isMain
self.isCurrent = isCurrent
self.locked = locked
self.prunable = prunable
}
}
extension WorktreeInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case path, branch, head, isMain, isCurrent, locked, prunable
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
path = try container.decode(String.self, forKey: .path)
branch = try? container.decode(String.self, forKey: .branch)
head = try? container.decode(String.self, forKey: .head)
isMain = (try? container.decode(Bool.self, forKey: .isMain)) ?? false
isCurrent = (try? container.decode(Bool.self, forKey: .isCurrent)) ?? false
locked = try? container.decode(Bool.self, forKey: .locked)
prunable = try? container.decode(Bool.self, forKey: .prunable)
}
}
/// Detailed view of one project (src/types.ts:296-306).
public struct ProjectDetail: Sendable, Equatable {
public let name: String
public let path: String
public let isGit: Bool
public let branch: String?
public let dirty: Bool?
public let worktrees: [WorktreeInfo]
public let sessions: [ProjectSessionRef]
public let hasClaudeMd: Bool
/// CLAUDE.md content (server-truncated for display) when present.
public let claudeMd: String?
public init(
name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?,
worktrees: [WorktreeInfo], sessions: [ProjectSessionRef],
hasClaudeMd: Bool, claudeMd: String?
) {
self.name = name
self.path = path
self.isGit = isGit
self.branch = branch
self.dirty = dirty
self.worktrees = worktrees
self.sessions = sessions
self.hasClaudeMd = hasClaudeMd
self.claudeMd = claudeMd
}
}
extension ProjectDetail: Decodable {
private enum CodingKeys: String, CodingKey {
case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
path = try container.decode(String.self, forKey: .path)
isGit = try container.decode(Bool.self, forKey: .isGit)
branch = try? container.decode(String.self, forKey: .branch)
dirty = try? container.decode(Bool.self, forKey: .dirty)
hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false
claudeMd = try? container.decode(String.self, forKey: .claudeMd)
worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees)
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
}
}
// MARK: - Lossy decoding helpers (shared per-element tolerance)
/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
struct LossyBox<Wrapped: Decodable>: Decodable {
let value: Wrapped?
init(from decoder: any Decoder) {
value = try? Wrapped(from: decoder)
}
}
enum LossyList {
/// Decode `[Element]` at `key`, dropping malformed elements; a missing or
/// wrong-typed array degrades to `[]`.
static func decode<Element: Decodable, Key: CodingKey>(
_ type: Element.Type,
in container: KeyedDecodingContainer<Key>,
forKey key: Key
) -> [Element] {
let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
return boxes.compactMap(\.value)
}
}
// MARK: - Routes + client calls
extension Endpoints {
/// `GET /projects` RO, no Origin (src/server.ts:262-269).
static func projects() -> APIRoute {
APIRoute(method: .get, path: "/projects", originPolicy: .readOnly, body: nil)
}
/// `GET /projects/detail?path=` RO, no Origin (src/server.ts:293-310).
/// The ONE place `path` gets percent-encoded (strict unreserved-only set
/// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough).
/// nil = the path could not be encoded (surfaced as `.invalidRequest`).
static func projectDetail(path: String) -> APIRoute? {
guard let encoded = path.addingPercentEncoding(
withAllowedCharacters: unreservedCharacters
) else { return nil }
return APIRoute(
method: .get, path: "/projects/detail", originPolicy: .readOnly,
body: nil, percentEncodedQuery: "path=\(encoded)"
)
}
}
extension APIClient {
/// `GET /projects` the host's discovered projects with their running
/// sessions merged in. RO no Origin.
public func projects() async throws -> [ProjectInfo] {
let (data, response) = try await perform(Endpoints.projects())
guard response.statusCode == HTTPStatus.ok else {
throw APIClientError.unexpectedStatus(response.statusCode)
}
return try ProjectInfo.decodeList(from: data)
}
/// `GET /projects/detail?path=` branch/worktrees/CLAUDE.md for one
/// project. RO no Origin. An empty path is rejected client-side
/// (mirror of the server's 400 rule) before any network I/O;
/// 400/404/500 map to `.projectPathInvalid` / `.projectNotFound` /
/// `.projectDetailUnavailable`.
public func projectDetail(path: String) async throws -> ProjectDetail {
guard !path.isEmpty else {
throw APIClientError.projectPathInvalid
}
guard let route = Endpoints.projectDetail(path: path) else {
throw APIClientError.invalidRequest
}
let (data, response) = try await perform(route)
switch response.statusCode {
case HTTPStatus.ok:
guard let detail = try? JSONDecoder().decode(ProjectDetail.self, from: data) else {
throw APIClientError.invalidResponseBody
}
return detail
case HTTPStatus.badRequest:
throw APIClientError.projectPathInvalid
case HTTPStatus.notFound:
throw APIClientError.projectNotFound
case HTTPStatus.internalServerError:
throw APIClientError.projectDetailUnavailable
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
}

View File

@@ -0,0 +1,175 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// T-iOS-38 · APNs device-token orchestrator wire shape
/// - `POST /push/apns-token` body `{"token":"<64-160 hex>"}` 204 upsert
/// - `DELETE /push/apns-token` body 204 token 204
/// G Origin `endpoint.originHeader`
/// 400 token/ · 403 Origin · 429 5 //IP · body 8 KB
/// POST /push/subscribe src/server.ts:461-480,73
/// hex ****plan §4
struct ApnsTokenTests {
private static let base = "http://192.168.1.5:3000"
/// 64 hex APNs device token32
private static let validToken = String(repeating: "0123456789abcdef", count: 4)
private struct Fixture {
let endpoint: HostEndpoint
let http: FakeHTTPTransport
let client: APIClient
let url: URL
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(
endpoint: endpoint,
http: http,
client: APIClient(endpoint: endpoint, http: http),
url: try #require(URL(string: Self.base + "/push/apns-token"))
)
}
// MARK: - wire shape//Origin/body
@Test("register 为 POST /push/apns-token,G 端点:Origin 逐字符等于 endpoint.originHeader,body 恰为 {token}")
func registerBuildsFrozenWireShape() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
// Act
try await fixture.client.registerApnsToken(Self.validToken)
// Assert
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == fixture.url)
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
let body = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(Set(object.keys) == Set(["token"]))
#expect(object["token"] as? String == Self.validToken)
}
@Test("unregister 为 DELETE /push/apns-token,同 G 守卫同 body 形状,204 → 成功(幂等)")
func unregisterBuildsFrozenWireShape() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 204)
// Act
try await fixture.client.unregisterApnsToken(Self.validToken)
// Assert
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.httpMethod == "DELETE")
#expect(request.url == fixture.url)
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
let body = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(object["token"] as? String == Self.validToken)
}
// MARK: - hex
@Test("大写 hex 输入 → 联网前归一化为小写(服务器按小写归一存储)")
func uppercaseTokenIsLowercaseNormalizedBeforeNetwork() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
// Act
try await fixture.client.registerApnsToken(Self.validToken.uppercased())
// Assert
let request = try #require(await fixture.http.recordedRequests.first)
let body = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(object["token"] as? String == Self.validToken)
}
@Test("非法 token(过短/过长/非 hex/空/含空格) → invalidApnsToken 且零网络请求")
func invalidTokensAreRejectedBeforeAnyNetworkRequest() async throws {
// Arrange 63 ( 1)161 ( 1) hex
let fixture = try makeFixture()
let invalidTokens = [
String(repeating: "a", count: 63),
String(repeating: "a", count: 161),
String(repeating: "g", count: 64),
"",
String(repeating: "a", count: 32) + " " + String(repeating: "a", count: 31),
]
// Act + Assert register unregister
for token in invalidTokens {
await #expect(throws: APIClientError.invalidApnsToken) {
try await fixture.client.registerApnsToken(token)
}
await #expect(throws: APIClientError.invalidApnsToken) {
try await fixture.client.unregisterApnsToken(token)
}
}
#expect(await fixture.http.recordedRequests.isEmpty)
}
@Test("边界长度 64 与 160 位 hex 均接受(服务器规则 64-160)")
func boundaryLengthTokensAreAccepted() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
// Act
try await fixture.client.registerApnsToken(String(repeating: "f", count: 64))
try await fixture.client.registerApnsToken(String(repeating: "f", count: 160))
// Assert
#expect(await fixture.http.recordedRequests.count == 2)
}
// MARK: -
@Test("400(服务器判 token 非法) → invalidApnsToken;403(Origin 守卫) → forbidden")
func status400And403MapToTypedErrors() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 400)
await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 403)
// Act + Assert
await #expect(throws: APIClientError.invalidApnsToken) {
try await fixture.client.registerApnsToken(Self.validToken)
}
await #expect(throws: APIClientError.forbidden) {
try await fixture.client.unregisterApnsToken(Self.validToken)
}
}
@Test("429 → rateLimited(≤5 次/分/IP,对齐 push/subscribe 限频);500 → unexpectedStatus")
func status429And500MapToTypedErrors() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 429)
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 500)
// Act + Assert
await #expect(throws: APIClientError.rateLimited) {
try await fixture.client.registerApnsToken(Self.validToken)
}
await #expect(throws: APIClientError.unexpectedStatus(500)) {
try await fixture.client.registerApnsToken(Self.validToken)
}
}
@Test("invalidApnsToken 有非空 UI 话术(plan §4 错误处理全面显式)")
func invalidApnsTokenHasNonEmptyMessage() {
#expect(!APIClientError.invalidApnsToken.message.isEmpty)
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
import Testing
import WireProtocol
import APIClient
/// T-iOS-38 · host plan §5.4 API
/// loopback / privateLAN / tailscale / publicW3 PairingViewModel
/// `isPrivateOrLocalHost` internal
/// VM VM
/// ** @testable** API public
struct HostClassificationTests {
@Test("loopback 层:localhost/127.0.0.0\u{2044}8/IPv6 ::1(裸与带括号)")
func loopbackTierMatchesLocalTargets() {
// Arrange
let hosts = ["localhost", "LOCALHOST", "127.0.0.1", "127.8.8.8", "::1", "[::1]"]
// Act + Assert
for host in hosts {
#expect(HostClassifier.classify(host: host) == .loopback, "\(host) 应为 loopback")
}
}
@Test("privateLAN 层:RFC1918 三段 + link-local 169.254\u{2044}16 + mDNS .local")
func privateLANTierMatchesRFC1918AndLinkLocal() {
// Arrange
let hosts = [
"10.0.0.5", "192.168.0.9", "172.16.0.1", "172.31.255.255",
"169.254.1.1", "mac-mini.local", "Mac-Mini.LOCAL",
]
// Act + Assert
for host in hosts {
#expect(HostClassifier.classify(host: host) == .privateLAN, "\(host) 应为 privateLAN")
}
}
@Test("tailscale 层:CGNAT 100.64.0.0\u{2044}10 与 MagicDNS *.ts.net")
func tailscaleTierMatchesCGNATAndMagicDNS() {
// Arrange
let hosts = [
"100.64.0.1", "100.100.1.1", "100.127.255.255",
"mac.tailnet.ts.net", "foo.TS.NET",
]
// Act + Assert
for host in hosts {
#expect(HostClassifier.classify(host: host) == .tailscale, "\(host) 应为 tailscale")
}
}
@Test("public 层:公网 IP/域名/越界 CIDR/畸形 IPv4 一律最强警告层")
func publicTierIsTheFailSafeDefault() {
// Arrange :172.32 172.16/12;100.128 100.63 100.64/10
let hosts = [
"8.8.8.8", "203.0.113.7", "example.com", "tsnet.example.com",
"172.32.0.1", "100.128.0.1", "100.63.255.255", "256.1.1.1", "1.2.3", "",
]
// Act + Assert public(fail-safe:,plan §5.4)
for host in hosts {
#expect(HostClassifier.classify(host: host) == .public, "\(host) 应为 public")
}
}
@Test("classify(endpoint:) 便捷入口与 host 字符串版一致(VM 只有 HostEndpoint 可用)")
func endpointConvenienceMatchesHostClassification() throws {
// Arrange
let vectors: [(url: String, tier: HostNetworkTier)] = [
("http://127.0.0.1:3000", .loopback),
("http://192.168.1.5:3000", .privateLAN),
("http://100.100.1.1:3000", .tailscale),
("https://mac.tailnet.ts.net", .tailscale),
("https://example.com", .public),
]
// Act + Assert
for vector in vectors {
let url = try #require(URL(string: vector.url))
let endpoint = try #require(HostEndpoint(baseURL: url))
#expect(HostClassifier.classify(endpoint: endpoint) == vector.tier, "\(vector.url)")
}
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// T-iOS-38 · `GET /prefs`(RO, Origin) / `PUT /prefs`(G,Origin;body 64 KB,
/// src/server.ts:278)prefs "" JSON
/// favourites/collapsed web public/prefs.ts sanitizePrefs
/// **** iOS PUT
/// web/
struct PrefsRoundTripTests {
private static let base = "http://192.168.1.5:3000"
/// blobstring/number/bool/null/ array/object
private static let blobWithUnknownKeys = """
{"favourites":["/Users/dev/a"],"collapsed":{"grp":true},\
"theme":"dark","pollSeconds":42,"beta":false,"legacy":null,\
"futureList":[1,"two",{"deep":true}],"futureObj":{"x":{"y":[3]}}}
"""
private struct Fixture {
let endpoint: HostEndpoint
let http: FakeHTTPTransport
let client: APIClient
let url: URL
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(
endpoint: endpoint,
http: http,
client: APIClient(endpoint: endpoint, http: http),
url: try #require(URL(string: Self.base + "/prefs"))
)
}
private func decodePrefs(_ json: String) throws -> UiPrefs {
try #require(UiPrefs.decode(from: Data(json.utf8)))
}
/// encode
private func reparse(_ data: Data) throws -> NSDictionary {
try #require(try JSONSerialization.jsonObject(with: data) as? NSDictionary)
}
// MARK: - Origin iff-G
@Test("Origin iff-G:GET /prefs 无 Origin;PUT /prefs 带 Origin(逐字符相等)+JSON Content-Type")
func prefsOriginAppearsIffPut() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.url, body: Data(Self.blobWithUnknownKeys.utf8))
await fixture.http.queueSuccess(
method: "PUT", url: fixture.url, body: Data(#"{"favourites":[],"collapsed":{}}"#.utf8)
)
// Act
_ = try await fixture.client.prefs()
_ = try await fixture.client.putPrefs(UiPrefs())
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 2)
let get = try #require(requests.first)
#expect(get.httpMethod == "GET")
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
let put = try #require(requests.last)
#expect(put.httpMethod == "PUT")
#expect(put.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
#expect(put.value(forHTTPHeaderField: "Content-Type") == "application/json")
}
// MARK: - web sanitizePrefs
@Test("已知键解码:favourites 只留非空字符串并去重;collapsed 只留 true 项")
func knownKeysAreSanitizedLikeWebClient() throws {
// Arrange /// true
let json = """
{"favourites":["/a",42,"","/a","/b"],\
"collapsed":{"g1":true,"g2":false,"g3":"yes","":true}}
"""
// Act
let prefs = try decodePrefs(json)
// Assert
#expect(prefs.favourites == ["/a", "/b"])
#expect(prefs.collapsed == ["g1": true])
}
@Test("已知键缺失/类型错 → 空缺省;空构造与 memberwise 构造可用")
func missingKnownKeysDegradeToEmpty() throws {
// Act
let empty = try decodePrefs(#"{"favourites":"not-an-array"}"#)
let constructed = UiPrefs(favourites: ["/x"], collapsed: ["g": true])
// Assert
#expect(empty.favourites.isEmpty)
#expect(empty.collapsed.isEmpty)
#expect(constructed.favourites == ["/x"])
#expect(constructed.collapsed == ["g": true])
}
// MARK: -
@Test("关键陷阱:解码含未知键的 blob → withFavourites 改已知键 → 重编码,未知键逐字节存活")
func unknownKeysSurviveFavouritesMutationRoundTrip() throws {
// Arrange
let original = try decodePrefs(Self.blobWithUnknownKeys)
// Act favourites
let mutated = original.withFavourites(["/Users/dev/a", "/Users/dev/b"])
let reencoded = try reparse(try mutated.encodeBody())
// Assert
#expect(mutated.favourites == ["/Users/dev/a", "/Users/dev/b"])
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a", "/Users/dev/b"])
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true])
#expect(reencoded["theme"] as? String == "dark")
#expect(reencoded["pollSeconds"] as? Int == 42)
#expect(reencoded["beta"] as? Bool == false)
#expect(reencoded["legacy"] is NSNull)
#expect(reencoded["futureList"] as? NSArray == [1, "two", ["deep": true]] as NSArray)
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
// plan §4
#expect(original.favourites == ["/Users/dev/a"])
}
@Test("withCollapsed 同样保留未知键与 favourites;往返不放大不丢失")
func unknownKeysSurviveCollapsedMutationRoundTrip() throws {
// Arrange
let original = try decodePrefs(Self.blobWithUnknownKeys)
// Act
let mutated = original.withCollapsed(["grp": true, "new-group": true])
let reencoded = try reparse(try mutated.encodeBody())
// Assert
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true, "new-group": true])
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a"])
#expect(reencoded["theme"] as? String == "dark")
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
// 8
#expect(reencoded.count == 8)
}
// MARK: - echo/
@Test("putPrefs 200 → 返回服务器回显的(已净化)prefs;403(Origin 守卫) → forbidden")
func putPrefsReturnsServerEchoAndMapsForbidden() async throws {
// Arrange blob(src/server.ts:283 res.json(prefsStore.get()))
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "PUT", url: fixture.url,
body: Data(#"{"favourites":["/kept"],"collapsed":{}}"#.utf8)
)
await fixture.http.queueSuccess(method: "PUT", url: fixture.url, status: 403)
// Act
let echoed = try await fixture.client.putPrefs(UiPrefs(favourites: ["/kept", ""]))
// Assert
#expect(echoed.favourites == ["/kept"])
await #expect(throws: APIClientError.forbidden) {
_ = try await fixture.client.putPrefs(UiPrefs())
}
}
@Test("GET /prefs 顶层非对象(数组/HTML) → invalidResponseBody(绝不静默降级为空——防止后续 PUT 清空服务器 prefs)")
func nonObjectPrefsBodyFailsLoudlyInsteadOfDegradingToEmpty() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.url, body: Data("[]".utf8))
await fixture.http.queueSuccess(url: fixture.url, body: Data("<html>".utf8))
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.prefs()
}
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.prefs()
}
}
}

View File

@@ -0,0 +1,289 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// T-iOS-38 · Projects RO Origin
/// - `GET /projects`src/server.ts:262-269 `[ProjectInfo]`src/types.ts:273-281
/// name/path/isGit ,branch/dirty/lastActiveMs ,sessions ProjectSessionRef[]
/// namespace namespace web , prefs.collapsed key
/// - `GET /projects/detail?path=`src/server.ts:293-310 `ProjectDetail`
/// path percent-encoding ** builder **400/404/500 `{error}`
/// crash
struct ProjectsTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
private static let fullProjectJSON = """
{"name":"web-terminal","path":"/Users/dev/web-terminal","isGit":true,\
"branch":"main","dirty":true,"lastActiveMs":1720000000000,\
"sessions":[{"id":"\(sessionIdString)","title":"web-terminal","status":"working",\
"clientCount":2,"createdAt":1719990000000,"exited":false}]}
"""
private static let minimalProjectJSON = """
{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[]}
"""
private struct Fixture {
let http: FakeHTTPTransport
let client: APIClient
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http))
}
private func routeURL(_ pathAndQuery: String) throws -> URL {
try #require(URL(string: Self.base + pathAndQuery))
}
private func fetchProjects(_ fixture: Fixture, body: String) async throws -> [ProjectInfo] {
await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data(body.utf8))
return try await fixture.client.projects()
}
// MARK: - Origin iff-G RO Origin
@Test("Origin iff-G(RO 侧):GET /projects 与 GET /projects/detail 均不带 Origin")
func projectsEndpointsAreReadOnlyWithoutOrigin() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data("[]".utf8))
await fixture.http.queueSuccess(
url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
body: Data(Self.detailJSON.utf8)
)
// Act
_ = try await fixture.client.projects()
_ = try await fixture.client.projectDetail(path: "/Users/dev/notes")
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 2)
for request in requests {
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
}
}
// MARK: - /projects
@Test("ProjectInfo 全字段样本解码(src/types.ts:273-281 实际形状,含 sessions ref)")
func projectInfoDecodesFullSample() async throws {
// Act
let projects = try await fetchProjects(try makeFixture(), body: "[\(Self.fullProjectJSON)]")
// Assert
let expected = ProjectInfo(
name: "web-terminal",
path: "/Users/dev/web-terminal",
isGit: true,
branch: "main",
dirty: true,
lastActiveMs: 1_720_000_000_000,
sessions: [ProjectSessionRef(
id: try #require(UUID(uuidString: Self.sessionIdString)),
title: "web-terminal",
status: .working,
clientCount: 2,
createdAt: 1_719_990_000_000,
exited: false
)]
)
#expect(projects == [expected])
}
@Test("可选字段缺省(branch/dirty/lastActiveMs)与未知字段(向前兼容)均容忍,不丢条目")
func optionalFieldsDegradeAndUnknownFieldsAreIgnored() async throws {
// Arrange
let body = """
[{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[],\
"futureField":{"nested":true},"favouriteRank":3}]
"""
// Act
let projects = try await fetchProjects(try makeFixture(), body: body)
// Assert
let project = try #require(projects.first)
#expect(project.branch == nil)
#expect(project.dirty == nil)
#expect(project.lastActiveMs == nil)
#expect(project.sessions.isEmpty)
}
@Test("畸形条目(缺必填/类型错/非对象)逐条丢弃,合法条目保留,不 crash")
func malformedProjectEntriesAreDroppedWhileValidOnesSurvive() async throws {
// Arrange
let body = """
[\(Self.fullProjectJSON),42,{"name":"x"},\
{"name":1,"path":"/p","isGit":true,"sessions":[]},\(Self.minimalProjectJSON)]
"""
// Act
let projects = try await fetchProjects(try makeFixture(), body: body)
// Assert
#expect(projects.map(\.name) == ["web-terminal", "notes"])
}
@Test("session ref:未知 status → .unknown;畸形 ref 丢弃但项目保留;sessions 缺失 → 空数组")
func sessionRefsAreTolerantlyDecoded() async throws {
// Arrange
let body = """
[{"name":"a","path":"/a","isGit":true,"sessions":[\
{"id":"\(Self.sessionIdString)","status":"hyperdrive","clientCount":0,\
"createdAt":1,"exited":false},\
{"id":"not-a-uuid","status":"idle","clientCount":0,"createdAt":2,"exited":true}]},\
{"name":"b","path":"/b","isGit":false}]
"""
// Act
let projects = try await fetchProjects(try makeFixture(), body: body)
// Assert
#expect(projects.count == 2)
let first = try #require(projects.first)
#expect(first.sessions.count == 1) // UUID ref
#expect(first.sessions.first?.status == .unknown)
#expect(first.sessions.first?.title == nil)
#expect(projects.last?.sessions.isEmpty == true) // sessions []
}
@Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus")
func projectsRejectsNonArrayBodyAndBadStatus() async throws {
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await self.fetchProjects(try self.makeFixture(), body: #"{"error":"x"}"#)
}
// Act + Assert 200
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: try routeURL("/projects"), status: 500)
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await fixture.client.projects()
}
}
// MARK: - /projects/detailpercent-encoding
@Test("detail 的 path percent-encoding 只在 builder 一处:空格/+/&/=/非 ASCII 全部严格编码")
func detailPathIsStrictlyPercentEncodedOnceInBuilder() async throws {
// Arrange '+' %2B(Express qs '+' );α = %CE%B1
let fixture = try makeFixture()
let rawPath = "/Users/dev/my proj+α&x=1"
let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1%26x%3D1"
let expectedURL = try routeURL("/projects/detail?path=\(encoded)")
await fixture.http.queueSuccess(url: expectedURL, body: Data(Self.detailJSON.utf8))
// Act
_ = try await fixture.client.projectDetail(path: rawPath)
// Assert FakeHTTPTransport URL ,
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.url == expectedURL)
}
// MARK: - /projects/detail
private static let detailJSON = """
{"name":"notes","path":"/Users/dev/notes","isGit":true,"branch":"main","dirty":false,\
"worktrees":[{"path":"/Users/dev/notes","branch":"main","head":"abc1234",\
"isMain":true,"isCurrent":true},\
{"path":"/Users/dev/notes-wt","isMain":false,"isCurrent":false,"locked":true}],\
"sessions":[],"hasClaudeMd":true,"claudeMd":"# Notes"}
"""
@Test("ProjectDetail 全字段解码(worktrees 含可选字段缺省;src/types.ts:296-306)")
func projectDetailDecodesFullSample() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
body: Data(Self.detailJSON.utf8)
)
// Act
let detail = try await fixture.client.projectDetail(path: "/Users/dev/notes")
// Assert
#expect(detail.name == "notes")
#expect(detail.isGit)
#expect(detail.branch == "main")
#expect(detail.dirty == false)
#expect(detail.hasClaudeMd)
#expect(detail.claudeMd == "# Notes")
#expect(detail.worktrees.count == 2)
let second = try #require(detail.worktrees.last)
#expect(second.branch == nil) // detached HEAD branch
#expect(second.locked == true)
#expect(second.prunable == nil)
#expect(!second.isMain)
}
@Test("detail 容忍:hasClaudeMd 缺失 → false;畸形 worktree 条目丢弃;200 但 body 畸形 → invalidResponseBody")
func projectDetailToleratesDegradedShapes() async throws {
// Arrange worktrees path
let fixture = try makeFixture()
let degraded = """
{"name":"a","path":"/a","isGit":false,\
"worktrees":[{"isMain":true},{"path":"/a","isMain":true,"isCurrent":true}],"sessions":[]}
"""
let url = try routeURL("/projects/detail?path=%2Fa")
await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
await fixture.http.queueSuccess(url: url, body: Data("[1,2]".utf8))
// Act
let detail = try await fixture.client.projectDetail(path: "/a")
// Assert
#expect(detail.hasClaudeMd == false)
#expect(detail.claudeMd == nil)
#expect(detail.worktrees.count == 1)
// Act + Assert 200
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.projectDetail(path: "/a")
}
}
@Test("detail 400/404/500 {error} → projectPathInvalid/projectNotFound/projectDetailUnavailable,各有非空话术")
func projectDetailMapsErrorStatusesToTypedErrors() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/detail?path=%2Fgone")
await fixture.http.queueSuccess(url: url, status: 400, body: Data(#"{"error":"path query parameter is required"}"#.utf8))
await fixture.http.queueSuccess(url: url, status: 404, body: Data(#"{"error":"project not found"}"#.utf8))
await fixture.http.queueSuccess(url: url, status: 500, body: Data(#"{"error":"failed to read project detail"}"#.utf8))
// Act + Assert
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.projectDetail(path: "/gone")
}
await #expect(throws: APIClientError.projectNotFound) {
_ = try await fixture.client.projectDetail(path: "/gone")
}
await #expect(throws: APIClientError.projectDetailUnavailable) {
_ = try await fixture.client.projectDetail(path: "/gone")
}
for error in [APIClientError.projectPathInvalid, .projectNotFound, .projectDetailUnavailable] {
#expect(!error.message.isEmpty)
}
}
@Test("空 path → projectPathInvalid,联网前拒绝(镜像服务器 400 规则)")
func emptyDetailPathIsRejectedBeforeNetwork() async throws {
// Arrange
let fixture = try makeFixture()
// Act + Assert
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.projectDetail(path: "")
}
#expect(await fixture.http.recordedRequests.isEmpty)
}
}

View File

@@ -63,6 +63,12 @@ targets:
properties:
CFBundleDisplayName: WebTerm
UILaunchScreen: {}
# Deep-link scheme (P1 T-iOS-22): webterminal://open?host=<id>&join=<uuid>
# Registered here by the orchestrator (project.yml coordination point);
# all parsing/validation lives in DeepLinkRouter (untrusted input).
CFBundleURLTypes:
- CFBundleURLName: com.yaojia.webterm.deeplink
CFBundleURLSchemes: [webterminal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft