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
205 lines
7.9 KiB
Swift
205 lines
7.9 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|