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
221 lines
9.2 KiB
Swift
221 lines
9.2 KiB
Swift
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 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)
|
||
|
||
/// 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)。"
|
||
}
|
||
}
|
||
}
|