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
298 lines
12 KiB
Swift
298 lines
12 KiB
Swift
import Foundation
|
|
import WireProtocol
|
|
|
|
// T-iOS-38 · Projects contract increments (both RO — NO Origin header):
|
|
// - `GET /projects` (src/server.ts:262-269) → `[ProjectInfo]`, mirrors
|
|
// src/types.ts:273-281 field-for-field. The server replies `[]` even on
|
|
// internal failure — a non-array body is therefore "not web-terminal".
|
|
// - `GET /projects/detail?path=` (src/server.ts:293-310) → `ProjectDetail`
|
|
// (src/types.ts:296-306). `path` percent-encoding happens ONCE, in the
|
|
// builder. 400/404/500 `{error}` map to explicit typed errors.
|
|
// Tolerant decoding at the untrusted server boundary (plan §4): unknown
|
|
// fields ignored, malformed entries dropped one by one, never crash.
|
|
|
|
// MARK: - ProjectSessionRef
|
|
|
|
/// One running session belonging to a project (src/types.ts:262-269; the
|
|
/// server mirrors LiveSessionInfo fields into this ref).
|
|
public struct ProjectSessionRef: Sendable, Equatable {
|
|
public let id: UUID
|
|
/// Derived label (last cwd segment, e.g. repo dir name); optional.
|
|
public let title: String?
|
|
public let status: ClaudeStatus
|
|
public let clientCount: Int
|
|
public let createdAt: Int
|
|
public let exited: Bool
|
|
|
|
public init(
|
|
id: UUID, title: String?, status: ClaudeStatus,
|
|
clientCount: Int, createdAt: Int, exited: Bool
|
|
) {
|
|
self.id = id
|
|
self.title = title
|
|
self.status = status
|
|
self.clientCount = clientCount
|
|
self.createdAt = createdAt
|
|
self.exited = exited
|
|
}
|
|
}
|
|
|
|
extension ProjectSessionRef: Decodable {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, title, status, clientCount, createdAt, exited
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(UUID.self, forKey: .id)
|
|
clientCount = try container.decode(Int.self, forKey: .clientCount)
|
|
createdAt = try container.decode(Int.self, forKey: .createdAt)
|
|
exited = try container.decode(Bool.self, forKey: .exited)
|
|
// Same tolerance as LiveSessionInfo: a future status value must not
|
|
// drop the ref; a wrong-typed title degrades to nil.
|
|
let rawStatus = try? container.decode(String.self, forKey: .status)
|
|
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
|
|
title = try? container.decode(String.self, forKey: .title)
|
|
}
|
|
}
|
|
|
|
// MARK: - ProjectInfo
|
|
|
|
/// A discovered project (git repo or recently-used cwd) — src/types.ts:273-281.
|
|
/// Note: there is NO `namespace` field on the wire; namespace grouping is a
|
|
/// web-client concept that only surfaces as `UiPrefs.collapsed` group-keys.
|
|
public struct ProjectInfo: Sendable, Equatable {
|
|
public let name: String
|
|
public let path: String
|
|
public let isGit: Bool
|
|
public let branch: String?
|
|
/// Uncommitted changes; only present when the server runs the dirty check.
|
|
public let dirty: Bool?
|
|
/// Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key.
|
|
public let lastActiveMs: Int?
|
|
public let sessions: [ProjectSessionRef]
|
|
|
|
public init(
|
|
name: String, path: String, isGit: Bool, branch: String?,
|
|
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef]
|
|
) {
|
|
self.name = name
|
|
self.path = path
|
|
self.isGit = isGit
|
|
self.branch = branch
|
|
self.dirty = dirty
|
|
self.lastActiveMs = lastActiveMs
|
|
self.sessions = sessions
|
|
}
|
|
}
|
|
|
|
extension ProjectInfo: Decodable {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, path, isGit, branch, dirty, lastActiveMs, sessions
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try container.decode(String.self, forKey: .name)
|
|
path = try container.decode(String.self, forKey: .path)
|
|
isGit = try container.decode(Bool.self, forKey: .isGit)
|
|
branch = try? container.decode(String.self, forKey: .branch)
|
|
dirty = try? container.decode(Bool.self, forKey: .dirty)
|
|
lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs)
|
|
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
|
|
}
|
|
|
|
/// Decode a `/projects` response body — non-array top level →
|
|
/// `invalidResponseBody`; malformed elements dropped one by one (same
|
|
/// pattern as `LiveSessionInfo.decodeList`).
|
|
static func decodeList(from data: Data) throws -> [ProjectInfo] {
|
|
guard let entries = try? JSONDecoder().decode([LossyBox<ProjectInfo>].self, from: data) else {
|
|
throw APIClientError.invalidResponseBody
|
|
}
|
|
return entries.compactMap(\.value)
|
|
}
|
|
}
|
|
|
|
// MARK: - WorktreeInfo / ProjectDetail
|
|
|
|
/// One `git worktree list --porcelain` entry (src/types.ts:284-292).
|
|
public struct WorktreeInfo: Sendable, Equatable {
|
|
public let path: String
|
|
/// Branch name; nil on detached HEAD.
|
|
public let branch: String?
|
|
public let head: String?
|
|
public let isMain: Bool
|
|
public let isCurrent: Bool
|
|
public let locked: Bool?
|
|
public let prunable: Bool?
|
|
|
|
public init(
|
|
path: String, branch: String?, head: String?,
|
|
isMain: Bool, isCurrent: Bool, locked: Bool?, prunable: Bool?
|
|
) {
|
|
self.path = path
|
|
self.branch = branch
|
|
self.head = head
|
|
self.isMain = isMain
|
|
self.isCurrent = isCurrent
|
|
self.locked = locked
|
|
self.prunable = prunable
|
|
}
|
|
}
|
|
|
|
extension WorktreeInfo: Decodable {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case path, branch, head, isMain, isCurrent, locked, prunable
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
path = try container.decode(String.self, forKey: .path)
|
|
branch = try? container.decode(String.self, forKey: .branch)
|
|
head = try? container.decode(String.self, forKey: .head)
|
|
isMain = (try? container.decode(Bool.self, forKey: .isMain)) ?? false
|
|
isCurrent = (try? container.decode(Bool.self, forKey: .isCurrent)) ?? false
|
|
locked = try? container.decode(Bool.self, forKey: .locked)
|
|
prunable = try? container.decode(Bool.self, forKey: .prunable)
|
|
}
|
|
}
|
|
|
|
/// Detailed view of one project (src/types.ts:296-306).
|
|
public struct ProjectDetail: Sendable, Equatable {
|
|
public let name: String
|
|
public let path: String
|
|
public let isGit: Bool
|
|
public let branch: String?
|
|
public let dirty: Bool?
|
|
public let worktrees: [WorktreeInfo]
|
|
public let sessions: [ProjectSessionRef]
|
|
public let hasClaudeMd: Bool
|
|
/// CLAUDE.md content (server-truncated for display) when present.
|
|
public let claudeMd: String?
|
|
|
|
public init(
|
|
name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?,
|
|
worktrees: [WorktreeInfo], sessions: [ProjectSessionRef],
|
|
hasClaudeMd: Bool, claudeMd: String?
|
|
) {
|
|
self.name = name
|
|
self.path = path
|
|
self.isGit = isGit
|
|
self.branch = branch
|
|
self.dirty = dirty
|
|
self.worktrees = worktrees
|
|
self.sessions = sessions
|
|
self.hasClaudeMd = hasClaudeMd
|
|
self.claudeMd = claudeMd
|
|
}
|
|
}
|
|
|
|
extension ProjectDetail: Decodable {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try container.decode(String.self, forKey: .name)
|
|
path = try container.decode(String.self, forKey: .path)
|
|
isGit = try container.decode(Bool.self, forKey: .isGit)
|
|
branch = try? container.decode(String.self, forKey: .branch)
|
|
dirty = try? container.decode(Bool.self, forKey: .dirty)
|
|
hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false
|
|
claudeMd = try? container.decode(String.self, forKey: .claudeMd)
|
|
worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees)
|
|
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
|
|
}
|
|
}
|
|
|
|
// MARK: - Lossy decoding helpers (shared per-element tolerance)
|
|
|
|
/// Per-element tolerance shim: a malformed element becomes nil instead of
|
|
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
|
|
struct LossyBox<Wrapped: Decodable>: Decodable {
|
|
let value: Wrapped?
|
|
|
|
init(from decoder: any Decoder) {
|
|
value = try? Wrapped(from: decoder)
|
|
}
|
|
}
|
|
|
|
enum LossyList {
|
|
/// Decode `[Element]` at `key`, dropping malformed elements; a missing or
|
|
/// wrong-typed array degrades to `[]`.
|
|
static func decode<Element: Decodable, Key: CodingKey>(
|
|
_ type: Element.Type,
|
|
in container: KeyedDecodingContainer<Key>,
|
|
forKey key: Key
|
|
) -> [Element] {
|
|
let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
|
|
return boxes.compactMap(\.value)
|
|
}
|
|
}
|
|
|
|
// MARK: - Routes + client calls
|
|
|
|
extension Endpoints {
|
|
/// `GET /projects` — RO, no Origin (src/server.ts:262-269).
|
|
static func projects() -> APIRoute {
|
|
APIRoute(method: .get, path: "/projects", originPolicy: .readOnly, body: nil)
|
|
}
|
|
|
|
/// `GET /projects/detail?path=` — RO, no Origin (src/server.ts:293-310).
|
|
/// The ONE place `path` gets percent-encoded (strict unreserved-only set —
|
|
/// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough).
|
|
/// nil = the path could not be encoded (surfaced as `.invalidRequest`).
|
|
static func projectDetail(path: String) -> APIRoute? {
|
|
guard let encoded = path.addingPercentEncoding(
|
|
withAllowedCharacters: unreservedCharacters
|
|
) else { return nil }
|
|
return APIRoute(
|
|
method: .get, path: "/projects/detail", originPolicy: .readOnly,
|
|
body: nil, percentEncodedQuery: "path=\(encoded)"
|
|
)
|
|
}
|
|
}
|
|
|
|
extension APIClient {
|
|
/// `GET /projects` — the host's discovered projects with their running
|
|
/// sessions merged in. RO → no Origin.
|
|
public func projects() async throws -> [ProjectInfo] {
|
|
let (data, response) = try await perform(Endpoints.projects())
|
|
guard response.statusCode == HTTPStatus.ok else {
|
|
throw APIClientError.unexpectedStatus(response.statusCode)
|
|
}
|
|
return try ProjectInfo.decodeList(from: data)
|
|
}
|
|
|
|
/// `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one
|
|
/// project. RO → no Origin. An empty path is rejected client-side
|
|
/// (mirror of the server's 400 rule) before any network I/O;
|
|
/// 400/404/500 map to `.projectPathInvalid` / `.projectNotFound` /
|
|
/// `.projectDetailUnavailable`.
|
|
public func projectDetail(path: String) async throws -> ProjectDetail {
|
|
guard !path.isEmpty else {
|
|
throw APIClientError.projectPathInvalid
|
|
}
|
|
guard let route = Endpoints.projectDetail(path: path) else {
|
|
throw APIClientError.invalidRequest
|
|
}
|
|
let (data, response) = try await perform(route)
|
|
switch response.statusCode {
|
|
case HTTPStatus.ok:
|
|
guard let detail = try? JSONDecoder().decode(ProjectDetail.self, from: data) else {
|
|
throw APIClientError.invalidResponseBody
|
|
}
|
|
return detail
|
|
case HTTPStatus.badRequest:
|
|
throw APIClientError.projectPathInvalid
|
|
case HTTPStatus.notFound:
|
|
throw APIClientError.projectNotFound
|
|
case HTTPStatus.internalServerError:
|
|
throw APIClientError.projectDetailUnavailable
|
|
default:
|
|
throw APIClientError.unexpectedStatus(response.statusCode)
|
|
}
|
|
}
|
|
}
|