Files
web-terminal/ios/Packages/APIClient/Sources/APIClient/Models.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

221 lines
9.2 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
// MARK: - LiveSessionInfo
/// One running (or just-exited) server session from `GET /live-sessions`
/// (read-only discovery NO Origin header, src/server.ts:257-259). Mirrors
/// `src/types.ts:246-256` field-for-field; `telemetry` is `?: | null` there
/// and stays optional here.
///
/// Decoding is tolerant at the UNTRUSTED server boundary (plan §4):
/// - identity/geometry (`id`/`createdAt`/`clientCount`/`exited`/`cols`/`rows`)
/// are required an entry missing them is dropped by `decodeList`;
/// - an unrecognized `status` string maps to `.unknown` a future server
/// status value must not make a running session invisible in the list;
/// - wrong-typed/absent `cwd`/`telemetry` degrade to `nil`, never fail the entry.
public struct LiveSessionInfo: Sendable, Equatable {
public let id: UUID
/// Server `Date.now()` at spawn (ms since epoch).
public let createdAt: Int
/// Devices currently attached (JOIN/mirror semantics, src/session/manager.ts).
public let clientCount: Int
public let status: ClaudeStatus
public let exited: Bool
public let cwd: String?
/// Current PTY size (latest-writer-wins on the server).
public let cols: Int
public let rows: Int
/// Latest statusLine telemetry, if any (B2).
public let telemetry: StatusTelemetry?
/// Server ms timestamp of the last PTY output (== createdAt until first
/// output). OPTIONAL additive field (T-iOS-37, src/types.ts:259-261)
/// pre-P1 servers omit it; nil means "no unread data source" (T-iOS-23).
public let lastOutputAt: Int?
public init(
id: UUID,
createdAt: Int,
clientCount: Int,
status: ClaudeStatus,
exited: Bool,
cwd: String?,
cols: Int,
rows: Int,
telemetry: StatusTelemetry?,
lastOutputAt: Int? = nil
) {
self.id = id
self.createdAt = createdAt
self.clientCount = clientCount
self.status = status
self.exited = exited
self.cwd = cwd
self.cols = cols
self.rows = rows
self.telemetry = telemetry
self.lastOutputAt = lastOutputAt
}
}
extension LiveSessionInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case id, createdAt, clientCount, status, exited, cwd, cols, rows, telemetry
case lastOutputAt
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
createdAt = try container.decode(Int.self, forKey: .createdAt)
clientCount = try container.decode(Int.self, forKey: .clientCount)
exited = try container.decode(Bool.self, forKey: .exited)
cols = try container.decode(Int.self, forKey: .cols)
rows = try container.decode(Int.self, forKey: .rows)
// Unknown/missing status .unknown, never drop the whole entry.
let rawStatus = try? container.decode(String.self, forKey: .status)
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
// Optional/nullable fields: wrong type degrades to nil (tolerant).
cwd = try? container.decode(String.self, forKey: .cwd)
telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry)
lastOutputAt = try? container.decode(Int.self, forKey: .lastOutputAt)
}
/// Decode a `/live-sessions` response body.
/// - Non-JSON / non-array top level `APIClientError.invalidResponseBody`
/// (the pairing probe maps this to `httpOkButNotWebTerminal` "?").
/// - Malformed elements are dropped one by one, mirroring
/// `TimelineEvent.decodeList`'s per-element tolerance.
static func decodeList(from data: Data) throws -> [LiveSessionInfo] {
guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else {
throw APIClientError.invalidResponseBody
}
return entries.compactMap(\.value)
}
/// Per-element tolerance shim: a malformed element becomes `nil` instead of
/// failing the whole array decode (same pattern as WireProtocol's helper).
private struct LossyEntry: Decodable {
let value: LiveSessionInfo?
init(from decoder: any Decoder) {
value = try? LiveSessionInfo(from: decoder)
}
}
}
// MARK: - SessionPreview
/// `GET /live-sessions/:id/preview` response (src/server.ts:314-326): the tail
/// of the session's ring buffer for a read-only thumbnail. `data` is opaque
/// ANSI/UTF-8 feed it to a terminal, never parse it. Read-only NO Origin.
public struct SessionPreview: Sendable, Equatable, Decodable {
public let id: UUID
public let cols: Int
public let rows: Int
public let data: String
public init(id: UUID, cols: Int, rows: Int, data: String) {
self.id = id
self.cols = cols
self.rows = rows
self.data = data
}
}
// MARK: - UiConfig
/// `GET /config/ui` response (src/server.ts:609-612, src/types.ts:503).
/// Reserved for a future permission-mode switcher (plan §3.4) it filters the
/// high-risk raw `auto` mode by `allowAutoMode`; the plan-gate three-way UI
/// does NOT consume this.
public struct UiConfig: Sendable, Equatable, Decodable {
public let allowAutoMode: Bool
public init(allowAutoMode: Bool) {
self.allowAutoMode = allowAutoMode
}
}
// MARK: - HookDecision
/// The two verdicts `POST /hook/decision` accepts anything else is a 400
/// (src/server.ts:513-517).
public enum HookDecision: String, Sendable, Equatable, CaseIterable {
case allow, deny
}
// MARK: - APIClientError
/// Typed failures for `APIClient` calls (explicit error handling, plan §4).
/// Transport-level errors thrown by `HTTPTransport.send` propagate UNwrapped
/// `PairingError.classify` reads their NSError shape during pairing.
public enum APIClientError: Error, Equatable, Sendable {
/// The request could not be built from the endpoint (should never happen
/// for a validated `HostEndpoint`; surfaced instead of force-unwrapping).
case invalidRequest
/// A 2xx arrived but the body is not the endpoint's shape. For
/// `/live-sessions` this is the pairing probe's "the port speaks HTTP but
/// is not web-terminal" signal.
case invalidResponseBody
/// 404 on a `/live-sessions/:id/*` route the session is gone
/// (exited / reaped / already killed).
case sessionNotFound
/// 403 from a G route's Origin guard (src/server.ts:332-339).
case forbidden
/// 403 from `POST /hook/decision` (src/server.ts:522-525): the capability
/// token is missing / mismatched / STALE tokens are single-use and
/// expiring by design (SEC-C1/M1). User-facing in `message`.
case decisionRejected
/// 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)
/// User-facing message (UI plan §4 ).
public var message: String {
switch self {
case .invalidRequest:
"无法构造请求(主机地址异常)。"
case .invalidResponseBody:
"服务器响应不是预期格式——端口对吗?"
case .sessionNotFound:
"会话已不存在(可能已退出或被清理)。"
case .forbidden:
"服务器拒绝了此来源Origin 校验未通过)。"
case .decisionRejected:
"审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。"
case .rateLimited:
"操作过于频繁,服务器已限流,请稍后再试。"
case .invalidApnsToken:
"推送注册令牌格式异常,请重启 App 重新注册推送。"
case .projectPathInvalid:
"项目路径为空或不合法。"
case .projectNotFound:
"项目不存在(路径可能已移动或删除)。"
case .projectDetailUnavailable:
"读取项目详情失败,请稍后再试。"
case .unexpectedStatus(let status):
"服务器返回了意外状态码 \(status)"
}
}
}