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].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: 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( _ type: Element.Type, in container: KeyedDecodingContainer, forKey key: Key ) -> [Element] { let boxes = (try? container.decode([LossyBox].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) } } }