Files
web-terminal/ios/Packages/APIClient/Sources/APIClient/APIClient.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

140 lines
5.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import WireProtocol
/// Typed client for the server's HTTP surface (frozen contract, plan §3.4).
///
/// **Origin plan §3.4/§5.1**: only the two G (state-changing)
/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do.
/// Stamping lives in ONE place `APIRoute.urlRequest(for:)` and the value is
/// single-point derived by `HostEndpoint` (never hand-assembled).
///
/// The server is an UNTRUSTED input source at this boundary (plan §4): bodies
/// are decoded tolerantly (malformed entries dropped), statuses are mapped to
/// explicit `APIClientError`s, and nothing here ever crashes on bad input.
public struct APIClient: Sendable {
public let endpoint: HostEndpoint
private let http: any HTTPTransport
public init(endpoint: HostEndpoint, http: any HTTPTransport) {
self.endpoint = endpoint
self.http = http
}
// MARK: - RO (read-only NO Origin header)
/// `GET /live-sessions` (src/server.ts:257-259) the discovery list every
/// device polls (`Tunables.listPollInterval` cadence, T-iOS-13).
public func liveSessions() async throws -> [LiveSessionInfo] {
let (data, response) = try await perform(Endpoints.liveSessions())
guard response.statusCode == HTTPStatus.ok else {
throw APIClientError.unexpectedStatus(response.statusCode)
}
return try LiveSessionInfo.decodeList(from: data)
}
/// `GET /live-sessions/:id/preview` (src/server.ts:314-326) ring-buffer
/// tail for a read-only thumbnail; no attach, no client registered.
public func preview(id: UUID) async throws -> SessionPreview {
let (data, response) = try await perform(Endpoints.preview(id: id))
try Self.requireOK(response)
guard let preview = try? JSONDecoder().decode(SessionPreview.self, from: data) else {
throw APIClientError.invalidResponseBody
}
return preview
}
/// `GET /live-sessions/:id/events` (src/server.ts:528-540) the A4
/// activity timeline. Decoded via WireProtocol's tolerant helper: unknown
/// `class` entries are dropped, a non-array body yields `[]` (the server
/// itself replies `[]` when timeline capture is disabled).
public func events(id: UUID) async throws -> [TimelineEvent] {
let (data, response) = try await perform(Endpoints.events(id: id))
try Self.requireOK(response)
return TimelineEvent.decodeList(from: data)
}
/// `GET /config/ui` (src/server.ts:609-612) `{allowAutoMode}`. Reserved
/// for a future permission-mode switcher (plan §3.4); the plan-gate
/// three-way UI does not consume it.
public func uiConfig() async throws -> UiConfig {
let (data, response) = try await perform(Endpoints.uiConfig())
try Self.requireOK(response)
guard let config = try? JSONDecoder().decode(UiConfig.self, from: data) else {
throw APIClientError.invalidResponseBody
}
return config
}
// MARK: - G (state-changing Origin required, byte-equal)
/// `DELETE /live-sessions/:id` (src/server.ts:354-358). 403 = the Origin
/// guard rejected us (src/server.ts:332-339); 404 = session already gone.
public func killSession(id: UUID) async throws {
let (_, response) = try await perform(Endpoints.killSession(id: id))
switch response.statusCode {
case HTTPStatus.noContent:
return
case HTTPStatus.notFound:
throw APIClientError.sessionNotFound
case HTTPStatus.forbidden:
throw APIClientError.forbidden
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
/// `POST /hook/decision` (src/server.ts:503-525) resolve a held remote
/// approval with a single-use capability `token` (arrives via push payload
/// only; NEVER persist it, plan §5.3). Server limits: body 4 KB
/// (src/server.ts:503), 10 requests/min/IP (src/server.ts:72,504-508).
/// 403 `.decisionRejected` (token expired / mismatched / already used).
public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws {
let route = try Endpoints.hookDecision(
sessionId: sessionId, decision: decision, token: token
)
let (_, response) = try await perform(route)
switch response.statusCode {
case HTTPStatus.noContent:
return
case HTTPStatus.forbidden:
throw APIClientError.decisionRejected
case HTTPStatus.tooManyRequests:
throw APIClientError.rateLimited
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
// MARK: - Internals (shared with the P1 feature files, T-iOS-38)
func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
guard let request = route.urlRequest(for: endpoint) else {
throw APIClientError.invalidRequest
}
return try await http.send(request)
}
/// 200 ok; 404 `.sessionNotFound`; anything else `.unexpectedStatus`.
static func requireOK(_ response: HTTPURLResponse) throws {
switch response.statusCode {
case HTTPStatus.ok:
return
case HTTPStatus.notFound:
throw APIClientError.sessionNotFound
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
}
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
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
}