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

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