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:
@@ -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
|
||||
}
|
||||
|
||||
99
ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift
Normal file
99
ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift
Normal 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 64–160 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 64–160 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)。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
204
ios/Packages/APIClient/Sources/APIClient/Prefs.swift
Normal file
204
ios/Packages/APIClient/Sources/APIClient/Prefs.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
297
ios/Packages/APIClient/Sources/APIClient/Projects.swift
Normal file
297
ios/Packages/APIClient/Sources/APIClient/Projects.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user