Files
web-terminal/ios/Packages/APIClient/Sources/APIClient/Prefs.swift
Yaojia Wang 4871e8ac3d 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
2026-07-05 13:34:01 +02:00

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)
}
}
}