feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter.
This commit is contained in:
@@ -3,23 +3,46 @@ import WireProtocol
|
||||
|
||||
/// Typed client for the server's HTTP surface (frozen contract, plan §3.4).
|
||||
///
|
||||
/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: only the two G (state-changing)
|
||||
/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do.
|
||||
/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is
|
||||
/// single-point derived by `HostEndpoint` (never hand-assembled).
|
||||
/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: `Origin: endpoint.originHeader` is
|
||||
/// stamped **iff** the route is G (state-changing); RO GETs never carry it.
|
||||
/// Stamping lives in ONE place — `APIRoute.urlRequest(for:accessToken:)` — and
|
||||
/// the value is single-point derived by `HostEndpoint` (never hand-assembled).
|
||||
/// The optional access-token `Cookie` is stamped at that same single point and is
|
||||
/// **orthogonal**: it never replaces Origin (ios-completion §1.1).
|
||||
///
|
||||
/// The server is an UNTRUSTED input source at this boundary (plan §4): bodies
|
||||
/// are decoded tolerantly (malformed entries dropped), statuses are mapped to
|
||||
/// explicit `APIClientError`s, and nothing here ever crashes on bad input.
|
||||
public struct APIClient: Sendable {
|
||||
public struct APIClient: Sendable, CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public let endpoint: HostEndpoint
|
||||
private let http: any HTTPTransport
|
||||
/// The host's optional shared access token (`WEBTERM_TOKEN`, ios-completion
|
||||
/// §1.1). SECRET: private, never printed (see `description`), never put in a
|
||||
/// URL, only ever leaving as a `Cookie` header stamped in `APIRoute`.
|
||||
/// nil = the host has no token configured (LAN zero-config).
|
||||
private let accessToken: String?
|
||||
|
||||
public init(endpoint: HostEndpoint, http: any HTTPTransport) {
|
||||
public init(endpoint: HostEndpoint, http: any HTTPTransport, accessToken: String? = nil) {
|
||||
self.endpoint = endpoint
|
||||
self.http = http
|
||||
self.accessToken = accessToken
|
||||
}
|
||||
|
||||
/// Whether this client carries an access token — PRESENCE only. There is
|
||||
/// deliberately no getter for the value: the token leaves this type solely
|
||||
/// as a `Cookie` header (plan §5: never log, never a URL, never a report).
|
||||
public var hasAccessToken: Bool { accessToken != nil }
|
||||
|
||||
/// Redacted on purpose: the default reflection-based description of a
|
||||
/// struct holding a secret would print it into any log line that
|
||||
/// interpolates the client.
|
||||
public var description: String {
|
||||
"APIClient(origin: \(endpoint.originHeader), accessToken: "
|
||||
+ (accessToken == nil ? "none)" : "<redacted>)")
|
||||
}
|
||||
|
||||
public var debugDescription: String { description }
|
||||
|
||||
// MARK: - RO (read-only — NO Origin header)
|
||||
|
||||
/// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every
|
||||
@@ -107,11 +130,35 @@ public struct APIClient: Sendable {
|
||||
|
||||
// MARK: - Internals (shared with the P1 feature files, T-iOS-38)
|
||||
|
||||
/// The ONE request choke point. Two structural guarantees live here:
|
||||
/// - a configured token is shape-validated before it can reach a header
|
||||
/// (`.malformedToken`, fail-fast — never silently send an unauthenticated
|
||||
/// request and let the user read the 401 as "server is down");
|
||||
/// - a 401 becomes the typed `.unauthorized` (ios-completion §1.1) for every
|
||||
/// route except the two families that define their own 401
|
||||
/// (`UnauthorizedPolicy.routeDefined`).
|
||||
func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let request = route.urlRequest(for: endpoint) else {
|
||||
let token = try validatedAccessToken()
|
||||
guard let request = route.urlRequest(for: endpoint, accessToken: token) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
return try await http.send(request)
|
||||
let (data, response) = try await http.send(request)
|
||||
if response.statusCode == HTTPStatus.unauthorized,
|
||||
route.unauthorizedPolicy == .accessTokenGate {
|
||||
throw APIClientError.unauthorized
|
||||
}
|
||||
return (data, response)
|
||||
}
|
||||
|
||||
/// nil when no token is configured; throws `.malformedToken` when one is
|
||||
/// configured but violates the frozen charset/length rule (which is also
|
||||
/// what makes CRLF header injection impossible).
|
||||
private func validatedAccessToken() throws -> String? {
|
||||
guard let accessToken else { return nil }
|
||||
guard AccessTokenRule.isWellFormed(accessToken) else {
|
||||
throw APIClientError.malformedToken
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
/// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`.
|
||||
@@ -125,6 +172,44 @@ public struct APIClient: Sendable {
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/projects/*` three-prong contract, shared by `detail`/`log`/`pr`
|
||||
/// (src/server.ts:1033-1042 and friends): `path` missing/empty → 400,
|
||||
/// non-git dir → 404, read failure → 500. `notFound` is a parameter because
|
||||
/// `/projects/worktree/state`'s 404 means "not a worktree", not "no project".
|
||||
static func requireGitReadOK(
|
||||
_ response: HTTPURLResponse, notFound: APIClientError = .projectNotFound
|
||||
) throws {
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
return
|
||||
case HTTPStatus.badRequest:
|
||||
throw APIClientError.projectPathInvalid
|
||||
case HTTPStatus.notFound:
|
||||
throw notFound
|
||||
case HTTPStatus.internalServerError:
|
||||
throw APIClientError.gitDataUnavailable
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of the server's own `path` guard, applied BEFORE any network I/O
|
||||
/// (validate at the boundary, plan §4) — every `/projects/*` route rejects
|
||||
/// an empty path with 400, so there is nothing to learn from the round trip.
|
||||
static func requireNonEmptyPath(_ path: String) throws {
|
||||
guard !path.isEmpty else {
|
||||
throw APIClientError.projectPathInvalid
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a single JSON object body, or `.invalidResponseBody`.
|
||||
static func decodeObject<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||
guard let value = try? JSONDecoder().decode(T.self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
|
||||
@@ -132,8 +217,12 @@ enum HTTPStatus {
|
||||
static let ok = 200
|
||||
static let noContent = 204
|
||||
static let badRequest = 400
|
||||
static let unauthorized = 401
|
||||
static let forbidden = 403
|
||||
static let notFound = 404
|
||||
static let conflict = 409
|
||||
static let payloadTooLarge = 413
|
||||
static let tooManyRequests = 429
|
||||
static let internalServerError = 500
|
||||
static let serviceUnavailable = 503
|
||||
}
|
||||
|
||||
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal file
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · optional shared access token (`WEBTERM_TOKEN`) — ios-completion §1.1
|
||||
// FROZEN contract, cross-checked against `src/http/auth.ts` + `src/server.ts`.
|
||||
//
|
||||
// Server facts:
|
||||
// | cookie name | `webterm_auth` | auth.ts:30 |
|
||||
// | login endpoint | `POST /auth` | server.ts:393 |
|
||||
// | request body | `{"token":"<t>"}` + JSON C-T | server.ts:396 |
|
||||
// | `Accept` | MUST NOT contain `text/html` | server.ts:366 |
|
||||
// | valid | 204 **with** `Set-Cookie` | server.ts:412 |
|
||||
// | wrong token | 401 `{"error":"invalid token"}` | server.ts:418 |
|
||||
// | rate limited | 429 (10/min/IP) | server.ts:399 |
|
||||
// | auth DISABLED | 204 **without** `Set-Cookie` | server.ts:404 |
|
||||
//
|
||||
// IMPLEMENTATION DECISION (frozen): a native client KNOWS its token, so it
|
||||
// hand-writes `Cookie: webterm_auth=<t>` and NEVER parses `Set-Cookie` nor
|
||||
// relies on a cookie jar (URLSession/OkHttp jars behave inconsistently on a WS
|
||||
// upgrade and are hard to test). Hand-written header == the same pattern as the
|
||||
// hand-written `Origin`, pinned by pure-function unit tests.
|
||||
//
|
||||
// HONEST BOUNDARY (src/http/auth.ts header): the token is a bar-raiser, NOT a
|
||||
// TLS substitute. On bare `ws://`/`http://` it travels in cleartext and is
|
||||
// replayable by a LAN sniffer; it only meaningfully hardens the TLS-terminated
|
||||
// relay/tunnel path.
|
||||
|
||||
/// The auth cookie, single-point (name + value assembly).
|
||||
enum AuthCookie {
|
||||
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30).
|
||||
static let name = "webterm_auth"
|
||||
/// Response header the probe reads. The client checks its PRESENCE only and
|
||||
/// never parses its value — the token it would echo is already known.
|
||||
static let setCookieHeader = "Set-Cookie"
|
||||
|
||||
/// `webterm_auth=<token>`. Callers MUST pass a token that already satisfies
|
||||
/// `AccessTokenRule.isWellFormed` — the charset check is what guarantees no
|
||||
/// CR/LF (header injection) and no `;` (cookie splitting) can appear here.
|
||||
static func headerValue(for token: String) -> String {
|
||||
"\(name)=\(token)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The frozen token shape: 16–512 characters from `[A-Za-z0-9._~+/=-]`
|
||||
/// (CLAUDE.md / `src/config.ts` — the server REFUSES TO START with anything
|
||||
/// else, so a shape-violating token can never be the right one).
|
||||
enum AccessTokenRule {
|
||||
static let minLength = 16
|
||||
static let maxLength = 512
|
||||
/// URL/cookie-safe set, byte-for-byte the server's `[A-Za-z0-9._~+/=-]`.
|
||||
private static let allowed = Set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-"
|
||||
)
|
||||
|
||||
static func isWellFormed(_ token: String) -> Bool {
|
||||
(minLength...maxLength).contains(token.count) && token.allSatisfy(allowed.contains)
|
||||
}
|
||||
}
|
||||
|
||||
/// The four outcomes of the `POST /auth` pairing-time probe (ios-completion
|
||||
/// §1.1). They are RESULTS, not errors: three of the four are perfectly normal
|
||||
/// states the pairing UI must tell apart.
|
||||
public enum AccessTokenProbeResult: Sendable, Equatable {
|
||||
/// 204 **with** `Set-Cookie` — the token is correct; persist it (Keychain).
|
||||
case valid
|
||||
/// 204 **without** `Set-Cookie` — this host has auth DISABLED. It is NOT
|
||||
/// "authenticated": nothing was verified and nothing should be persisted.
|
||||
case authDisabled
|
||||
/// 401 — wrong token.
|
||||
case invalidToken
|
||||
/// 429 — 10 attempts/min/IP exceeded; ask the user to wait.
|
||||
case rateLimited
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
static let authPath = "/auth"
|
||||
|
||||
/// `POST /auth` with body `{"token":…}`.
|
||||
///
|
||||
/// - `.guarded`: this is a state-changing POST (it mints a session cookie),
|
||||
/// so it stamps `Origin` like every other write. The server does not
|
||||
/// Origin-check `/auth` today — keeping the rule uniform costs one header
|
||||
/// and avoids a special case that would rot the moment it does.
|
||||
/// - `.routeDefined`: this route's own 401 means "wrong token", not "the
|
||||
/// gate rejected you" — the probe maps it to `.invalidToken`.
|
||||
static func auth(token: String) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: .post, path: authPath, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(AuthTokenBody(token: token)),
|
||||
unauthorizedPolicy: .routeDefined
|
||||
)
|
||||
}
|
||||
|
||||
private struct AuthTokenBody: Encodable {
|
||||
let token: String
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// One-shot pairing-time probe of a candidate access token
|
||||
/// (`POST /auth`, ios-completion §1.1).
|
||||
///
|
||||
/// The token travels ONLY in the JSON body — never in a URL query (the
|
||||
/// server's `?token=` bootstrap exists for browsers, which strip it from
|
||||
/// history afterwards; a native client has no reason to put a secret in a
|
||||
/// URL that lands in logs). A shape-violating candidate is rejected here,
|
||||
/// before any network I/O (`.malformedToken`).
|
||||
///
|
||||
/// Returns one of the four frozen outcomes; any other status throws
|
||||
/// `.unexpectedStatus` rather than guessing.
|
||||
public func probeAccessToken(_ token: String) async throws -> AccessTokenProbeResult {
|
||||
guard AccessTokenRule.isWellFormed(token) else {
|
||||
throw APIClientError.malformedToken
|
||||
}
|
||||
let (_, response) = try await perform(try Endpoints.auth(token: token))
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.noContent:
|
||||
// THE distinction the whole feature hinges on: a 204 without a
|
||||
// Set-Cookie means the host never enabled auth. Reporting that as
|
||||
// "authenticated" would persist a token that gates nothing and
|
||||
// teach the user the host is protected when it is not.
|
||||
return response.value(forHTTPHeaderField: AuthCookie.setCookieHeader) == nil
|
||||
? .authDisabled : .valid
|
||||
case HTTPStatus.unauthorized:
|
||||
return .invalidToken
|
||||
case HTTPStatus.tooManyRequests:
|
||||
return .rateLimited
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,28 @@ enum OriginPolicy: Sendable, Equatable {
|
||||
case guarded
|
||||
}
|
||||
|
||||
/// How a 401 on this route must be READ (ios-completion §1.1). The access-token
|
||||
/// gate answers 401 for any unauthed request (src/server.ts:459 `authGate` step
|
||||
/// 5), so on almost every route 401 means "token missing/wrong" → typed
|
||||
/// `.unauthorized`. Two route families define their OWN 401 and must not be
|
||||
/// swallowed by that rule — declared here, at the route, so the exception is
|
||||
/// visible instead of hidden in a call site.
|
||||
enum UnauthorizedPolicy: Sendable, Equatable {
|
||||
/// Default — a 401 can only be the access-token gate.
|
||||
case accessTokenGate
|
||||
/// The route owns its 401: `POST /auth`'s wrong-token answer
|
||||
/// (src/server.ts:418) and the git-write family, where the server
|
||||
/// CLASSIFIES a host-side git credential failure as 401
|
||||
/// (src/http/git-ops.ts:108 "Push authentication required on the host.").
|
||||
case routeDefined
|
||||
}
|
||||
|
||||
/// Header/content-type names used by the builder (no magic strings inline).
|
||||
enum HeaderName {
|
||||
static let origin = "Origin"
|
||||
static let contentType = "Content-Type"
|
||||
static let accept = "Accept"
|
||||
static let cookie = "Cookie"
|
||||
}
|
||||
|
||||
enum ContentTypeValue {
|
||||
@@ -41,28 +59,43 @@ struct APIRoute: Sendable, Equatable {
|
||||
/// nil for no query. Percent-encoding happens ONCE, in the route builder
|
||||
/// (T-iOS-38: `/projects/detail?path=`) — never at call sites.
|
||||
let percentEncodedQuery: String?
|
||||
/// See `UnauthorizedPolicy`. Defaults to the gate reading.
|
||||
let unauthorizedPolicy: UnauthorizedPolicy
|
||||
|
||||
init(
|
||||
method: HTTPMethod,
|
||||
path: String,
|
||||
originPolicy: OriginPolicy,
|
||||
body: Data?,
|
||||
percentEncodedQuery: String? = nil
|
||||
percentEncodedQuery: String? = nil,
|
||||
unauthorizedPolicy: UnauthorizedPolicy = .accessTokenGate
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.originPolicy = originPolicy
|
||||
self.body = body
|
||||
self.percentEncodedQuery = percentEncodedQuery
|
||||
self.unauthorizedPolicy = unauthorizedPolicy
|
||||
}
|
||||
|
||||
/// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
|
||||
/// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
|
||||
/// (dropped when nil), fragment/credentials are dropped — the same
|
||||
/// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
|
||||
/// happens HERE and only here (single point; hand-stamping elsewhere is a
|
||||
/// review CRITICAL, plan §5.1).
|
||||
func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
|
||||
/// derivation philosophy as `HostEndpoint.wsURL`.
|
||||
///
|
||||
/// **Every header this client sends is stamped HERE and only here** (single
|
||||
/// point; hand-stamping elsewhere is a review CRITICAL, plan §5.1):
|
||||
/// - `Origin` **iff** `.guarded` — the security split;
|
||||
/// - `Cookie: webterm_auth=<t>` iff a token is configured — ORTHOGONAL to
|
||||
/// the Origin rule (ios-completion §1.1: the token never REPLACES Origin,
|
||||
/// both travel together, on RO and G alike);
|
||||
/// - `Accept: application/json` always — an `Accept` containing `text/html`
|
||||
/// makes the server treat the request as a browser navigation and answer
|
||||
/// 302→/login instead of 401/204 (src/server.ts:366-369,459).
|
||||
///
|
||||
/// `accessToken` MUST already be shape-validated (`AccessTokenRule`); the
|
||||
/// charset check is what makes a header-injection value impossible here.
|
||||
func urlRequest(for endpoint: HostEndpoint, accessToken: String? = nil) -> URLRequest? {
|
||||
guard var components = URLComponents(
|
||||
url: endpoint.baseURL, resolvingAgainstBaseURL: true
|
||||
) else { return nil }
|
||||
@@ -78,9 +111,15 @@ struct APIRoute: Sendable, Equatable {
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.accept)
|
||||
if originPolicy == .guarded {
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin)
|
||||
}
|
||||
if let accessToken {
|
||||
request.setValue(
|
||||
AuthCookie.headerValue(for: accessToken), forHTTPHeaderField: HeaderName.cookie
|
||||
)
|
||||
}
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType)
|
||||
@@ -89,17 +128,24 @@ struct APIRoute: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route
|
||||
/// table (verified against src/server.ts):
|
||||
/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
|
||||
/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609)
|
||||
/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293)
|
||||
/// · `GET /prefs` (:273)
|
||||
/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
|
||||
/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20
|
||||
/// shape, mirrors `/push/subscribe` :461-498)
|
||||
/// P1 builders live beside their feature models: `ApnsToken.swift`,
|
||||
/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner).
|
||||
/// Builders for the frozen endpoints (plan §3.4 · T-iOS-38 P1 · B1 增量). Route
|
||||
/// table (verified against src/server.ts at the line numbers shown):
|
||||
/// - RO — `GET /live-sessions` (:485) · `GET /live-sessions/:id/preview` (:585)
|
||||
/// · `GET /live-sessions/:id/events` (:984) · `GET /config/ui` (:1320)
|
||||
/// · `GET /projects` (:507) · `GET /projects/detail?path=` (:564)
|
||||
/// · `GET /prefs` (:518) · `GET /projects/log?path=[&n=]` (:1030)
|
||||
/// · `GET /projects/pr?path=` (:1058)
|
||||
/// · `GET /projects/worktree/state?path=` (:542) · `GET /sessions` (:479)
|
||||
/// - G — `DELETE /live-sessions/:id` (:689) · `POST /hook/decision` (:959)
|
||||
/// · `PUT /prefs` (:523) · `POST|DELETE /push/apns-token` (:871/:892)
|
||||
/// · `POST /auth` (:393) · `POST /live-sessions/:id/queue` (:605)
|
||||
/// · `POST /projects/git/stage|commit|push|fetch` (:1184/:1219/:1256/:1290)
|
||||
/// · `POST /projects/worktree` (:1095) · `DELETE /projects/worktree` (:1124)
|
||||
/// · `POST /projects/worktree/prune` (:1154)
|
||||
/// Builders live beside their feature models: `ApnsToken.swift`,
|
||||
/// `Projects.swift`, `Prefs.swift`, `AccessToken.swift`, `GitLog.swift`,
|
||||
/// `PrStatus.swift`, `WorktreeState.swift`, `GitWrite.swift`, `History.swift`,
|
||||
/// `FollowupQueue.swift`.
|
||||
enum Endpoints {
|
||||
/// Strict RFC 3986 unreserved set — everything else gets percent-encoded.
|
||||
/// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is
|
||||
@@ -108,6 +154,21 @@ enum Endpoints {
|
||||
static let unreservedCharacters = CharacterSet(
|
||||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
|
||||
/// THE single percent-encoding choke point for every query value in this
|
||||
/// package (`?path=`, and anything added later). nil = not encodable →
|
||||
/// callers surface `.invalidRequest` instead of building a broken URL.
|
||||
static func percentEncode(_ value: String) -> String? {
|
||||
value.addingPercentEncoding(withAllowedCharacters: unreservedCharacters)
|
||||
}
|
||||
|
||||
/// `path=<strictly-encoded>` — the query shared by every `/projects/*` read
|
||||
/// route (`detail`, `log`, `pr`, `worktree/state`). One builder, so a new
|
||||
/// route cannot re-introduce the `+`-decodes-to-space class of bug.
|
||||
static func pathQuery(_ path: String) -> String? {
|
||||
percentEncode(path).map { "path=\($0)" }
|
||||
}
|
||||
|
||||
static func liveSessions() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
@@ -155,7 +216,7 @@ enum Endpoints {
|
||||
/// Server session ids are lowercase `crypto.randomUUID()` strings and
|
||||
/// `:id` route params are matched as EXACT strings — always serialize
|
||||
/// lowercase (same rule as `MessageCodec`'s attach encoding).
|
||||
private static func pathId(_ id: UUID) -> String {
|
||||
static func pathId(_ id: UUID) -> String {
|
||||
id.uuidString.lowercased()
|
||||
}
|
||||
|
||||
|
||||
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal file
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `POST /live-sessions/:id/queue` (src/server.ts:605-643) — **G**.
|
||||
// State-changing (it causes shell input on the next idle) → Origin guard + per-IP
|
||||
// rate limit. Body: `{text, appendEnter?}`.
|
||||
//
|
||||
// BYTE-SHUTTLE (the project's central invariant): the bytes are stored and later
|
||||
// injected VERBATIM. The server never parses them as a shell command, and
|
||||
// neither does this client — `text` is passed through untouched, exactly like a
|
||||
// keystroke. The FE owns the Enter decision (`appendEnter` → a trailing `\r`,
|
||||
// 0x0D — never `\n`), so the stored entry is byte-identical to what typing it
|
||||
// would have produced.
|
||||
|
||||
/// `POST /live-sessions/:id/queue` 200 body — the queue's new depth.
|
||||
struct QueueDepth: Decodable {
|
||||
let length: Int
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `POST /live-sessions/:id/queue` — G. `appendEnter` is always serialized
|
||||
/// (the server reads `=== true`, so an explicit `false` is honest and
|
||||
/// symmetric rather than relying on an absent-key default).
|
||||
static func enqueueFollowup(
|
||||
sessionId: UUID, text: String, appendEnter: Bool
|
||||
) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: .post, path: "/live-sessions/\(pathId(sessionId))/queue",
|
||||
originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(
|
||||
FollowupBody(text: text, appendEnter: appendEnter)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private struct FollowupBody: Encodable {
|
||||
let text: String
|
||||
let appendEnter: Bool
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// Enqueue a follow-up prompt, fired into the PTY on the session's next idle
|
||||
/// (w2). Returns the queue's NEW depth.
|
||||
///
|
||||
/// G → `Origin` byte-equal. Server-enforced limits (theirs, documented for
|
||||
/// callers): body ≤ 16 KB, `text` + optional `\r` ≤ `queueItemMaxBytes`
|
||||
/// (→ 413), depth ≤ `queueMaxItems` (→ 409, never a silent drop), per-IP
|
||||
/// rate limit (→ 429), `QUEUE_ENABLED=0` (→ 503).
|
||||
/// An empty `text` is rejected before any network I/O (mirrors the 400 rule).
|
||||
@discardableResult
|
||||
public func enqueueFollowup(
|
||||
sessionId: UUID, text: String, appendEnter: Bool
|
||||
) async throws -> Int {
|
||||
guard !text.isEmpty else {
|
||||
throw APIClientError.queueTextInvalid
|
||||
}
|
||||
let route = try Endpoints.enqueueFollowup(
|
||||
sessionId: sessionId, text: text, appendEnter: appendEnter
|
||||
)
|
||||
let (data, response) = try await perform(route)
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
return try Self.decodeObject(QueueDepth.self, from: data).length
|
||||
case HTTPStatus.badRequest:
|
||||
throw APIClientError.queueTextInvalid
|
||||
case HTTPStatus.forbidden:
|
||||
throw APIClientError.forbidden
|
||||
case HTTPStatus.notFound:
|
||||
throw APIClientError.sessionNotFound
|
||||
case HTTPStatus.conflict:
|
||||
throw APIClientError.queueFull
|
||||
case HTTPStatus.payloadTooLarge:
|
||||
throw APIClientError.queueTextTooLarge
|
||||
case HTTPStatus.tooManyRequests:
|
||||
throw APIClientError.rateLimited
|
||||
case HTTPStatus.serviceUnavailable:
|
||||
throw APIClientError.queueDisabled
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal file
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /projects/log?path=[&n=]` (src/server.ts:1030-1056) — RO, NO Origin.
|
||||
// Response = `src/types.ts:757-772` `GitLogResult`. Always 200 on a valid git
|
||||
// dir (a git failure degrades to an empty commit list server-side); `path`
|
||||
// missing → 400, non-git dir → 404, read failure → 500.
|
||||
|
||||
/// One commit from `git log` (src/types.ts:757-763). `hash` and `at` are
|
||||
/// REQUIRED — a commit without them cannot be rendered or opened, so the entry
|
||||
/// is dropped while its siblings survive. `at` = `%ct * 1000` (epoch ms, an
|
||||
/// integer — unlike the `stat()`-derived timestamps elsewhere).
|
||||
///
|
||||
/// Every field is INERT display text: render as plain text, never autolink,
|
||||
/// never pass to a shell.
|
||||
public struct CommitLogEntry: Sendable, Equatable {
|
||||
public let hash: String
|
||||
public let at: Int
|
||||
public let subject: String
|
||||
/// w6/G4: reachable from HEAD but not from `@{u}`. nil = the server did not
|
||||
/// say (no upstream to compare against) — which is NOT the same as `false`.
|
||||
public let unpushed: Bool?
|
||||
|
||||
public init(hash: String, at: Int, subject: String = "", unpushed: Bool? = nil) {
|
||||
self.hash = hash
|
||||
self.at = at
|
||||
self.subject = subject
|
||||
self.unpushed = unpushed
|
||||
}
|
||||
}
|
||||
|
||||
extension CommitLogEntry: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case hash, at, subject, unpushed
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
hash = try container.decode(String.self, forKey: .hash)
|
||||
at = try container.decode(Int.self, forKey: .at)
|
||||
subject = (try? container.decode(String.self, forKey: .subject)) ?? ""
|
||||
unpushed = try? container.decode(Bool.self, forKey: .unpushed)
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /projects/log` result (src/types.ts:765-772).
|
||||
public struct GitLogResult: Sendable, Equatable {
|
||||
public let commits: [CommitLogEntry]
|
||||
/// More commits exist beyond the server's cap.
|
||||
public let truncated: Bool
|
||||
/// w6/G4: upstream short name, used to label the pushed/unpushed boundary.
|
||||
/// nil ⇒ nothing to compare against, so NO boundary may be drawn.
|
||||
public let upstream: String?
|
||||
|
||||
public init(commits: [CommitLogEntry], truncated: Bool = false, upstream: String? = nil) {
|
||||
self.commits = commits
|
||||
self.truncated = truncated
|
||||
self.upstream = upstream
|
||||
}
|
||||
}
|
||||
|
||||
extension GitLogResult: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case commits, truncated, upstream
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
commits = LossyList.decode(CommitLogEntry.self, in: container, forKey: .commits)
|
||||
truncated = (try? container.decode(Bool.self, forKey: .truncated)) ?? false
|
||||
upstream = try? container.decode(String.self, forKey: .upstream)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// Mirror of `src/http/git-log.ts:32` `GIT_LOG_MAX` — the server's `?n=`
|
||||
/// clamp ceiling. Clamping client-side too keeps the URL honest about what
|
||||
/// will come back (the server re-clamps regardless).
|
||||
static let gitLogMaxCount = 50
|
||||
static let gitLogMinCount = 1
|
||||
|
||||
/// `GET /projects/log?path=[&n=]` — RO, no Origin. nil = `path` could not be
|
||||
/// percent-encoded. A nil `n` omits the parameter (server default applies).
|
||||
static func gitLog(path: String, n: Int?) -> APIRoute? {
|
||||
guard var query = pathQuery(path) else { return nil }
|
||||
if let n {
|
||||
query += "&n=\(min(max(n, gitLogMinCount), gitLogMaxCount))"
|
||||
}
|
||||
return APIRoute(
|
||||
method: .get, path: "/projects/log", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /projects/log?path=[&n=]` — the repo's recent commits. RO → no
|
||||
/// Origin. `n` is clamped to `1...50`; nil leaves it to the server.
|
||||
/// 400/404/500 → `.projectPathInvalid` / `.projectNotFound` /
|
||||
/// `.gitDataUnavailable`; an empty path is rejected before any network I/O.
|
||||
public func gitLog(path: String, n: Int? = nil) async throws -> GitLogResult {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
guard let route = Endpoints.gitLog(path: path, n: n) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await perform(route)
|
||||
try Self.requireGitReadOK(response)
|
||||
return try Self.decodeObject(GitLogResult.self, from: data)
|
||||
}
|
||||
}
|
||||
377
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
377
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
@@ -0,0 +1,377 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · the seven **G** (state-changing) git/worktree routes — the highest-risk
|
||||
// channel in the app. Every one of them: `Origin` (CSRF) → `gitOpsEnabled` /
|
||||
// `worktreeEnabled` kill-switch (403) → per-IP rate limit (429) →
|
||||
// `isValidGitDir` three-prong (404). Sources:
|
||||
// POST /projects/git/stage src/server.ts:1184-1218 {path,files,stage}
|
||||
// POST /projects/git/commit src/server.ts:1219-1255 {path,message}
|
||||
// POST /projects/git/push src/server.ts:1256-1289 {path}
|
||||
// POST /projects/git/fetch src/server.ts:1290-1319 {path}
|
||||
// POST /projects/worktree src/server.ts:1095-1123 {path,branch[,base]}
|
||||
// DELETE /projects/worktree src/server.ts:1124-1153 {path,worktreePath,force}
|
||||
// POST /projects/worktree/prune src/server.ts:1154-1183 {path}
|
||||
//
|
||||
// The remote/branch/refspec of push and fetch are ALWAYS derived server-side —
|
||||
// this client cannot point them at an arbitrary URL, and never force-pushes.
|
||||
// Failure bodies carry the server's already-classified, already-sanitized `error`
|
||||
// string only (never raw git stderr, SEC-M10): the client shows it verbatim.
|
||||
|
||||
/// Outcome of one guarded git write. Three shapes, because the server gives
|
||||
/// exactly three:
|
||||
/// - `.ok` — 200 with the route's payload;
|
||||
/// - `.rejected` — a 4xx/5xx carrying the server's SAFE `error` message, to be
|
||||
/// displayed INERTLY. **403 is overloaded** (the Origin guard AND the feature
|
||||
/// kill-switch both answer 403) and the client cannot tell them apart by
|
||||
/// status, so it surfaces the message instead of inventing a typed variant;
|
||||
/// - `.rateLimited` — 429. Do NOT auto-retry (that is what the limiter is for).
|
||||
public enum GitWriteOutcome<Payload: Sendable & Equatable>: Sendable, Equatable {
|
||||
case ok(Payload)
|
||||
case rejected(status: Int, message: String?)
|
||||
case rateLimited
|
||||
}
|
||||
|
||||
/// A guarded write's 200 payload. `degraded` is what a 200 with a missing or
|
||||
/// garbled body decodes to: the write ALREADY HAPPENED, so a bad body must not
|
||||
/// be reported as a failure — and the fallback must not be a crash path either.
|
||||
protocol GitWritePayload: Decodable, Sendable, Equatable {
|
||||
static var degraded: Self { get }
|
||||
}
|
||||
|
||||
// MARK: - Per-route 200 payloads
|
||||
|
||||
/// `POST /projects/git/stage` → `{ok,staged,count}`.
|
||||
public struct StageResult: Sendable, Equatable, Decodable {
|
||||
/// true = files were staged (`git add`); false = unstaged (`git restore --staged`).
|
||||
public let staged: Bool
|
||||
public let count: Int
|
||||
|
||||
public init(staged: Bool = false, count: Int = 0) {
|
||||
self.staged = staged
|
||||
self.count = count
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case staged, count }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
staged = (try? container.decode(Bool.self, forKey: .staged)) ?? false
|
||||
count = LossyNumber.int(in: container, forKey: .count) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/commit` → `{ok,commit}` (short sha; `""` is possible).
|
||||
public struct CommitResult: Sendable, Equatable, Decodable {
|
||||
public let commit: String
|
||||
|
||||
public init(commit: String = "") {
|
||||
self.commit = commit
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case commit }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
commit = (try? container.decode(String.self, forKey: .commit)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/push` → `{ok,branch,remote}` (both server-derived).
|
||||
public struct PushResult: Sendable, Equatable, Decodable {
|
||||
public let branch: String?
|
||||
public let remote: String?
|
||||
|
||||
public init(branch: String? = nil, remote: String? = nil) {
|
||||
self.branch = branch
|
||||
self.remote = remote
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case branch, remote }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
remote = try? container.decode(String.self, forKey: .remote)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/fetch` → `{ok,remote,lastFetchMs}`. `lastFetchMs` is the
|
||||
/// post-fetch `FETCH_HEAD` mtime — fractional (Double), same as `SyncState`.
|
||||
/// nil means the mtime could not be read: the UI must NOT then claim the
|
||||
/// `behind` count was freshly verified.
|
||||
public struct FetchResult: Sendable, Equatable, Decodable {
|
||||
public let remote: String?
|
||||
public let lastFetchMs: Double?
|
||||
|
||||
public init(remote: String? = nil, lastFetchMs: Double? = nil) {
|
||||
self.remote = remote
|
||||
self.lastFetchMs = lastFetchMs
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case remote, lastFetchMs }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
remote = try? container.decode(String.self, forKey: .remote)
|
||||
lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/worktree` → `{ok,path,branch}` (git's canonical values).
|
||||
public struct CreateWorktreeResult: Sendable, Equatable, Decodable {
|
||||
public let path: String?
|
||||
public let branch: String?
|
||||
|
||||
public init(path: String? = nil, branch: String? = nil) {
|
||||
self.path = path
|
||||
self.branch = branch
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case path, branch }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /projects/worktree` → `{ok,path}` (the canonical path removed).
|
||||
public struct RemoveWorktreeResult: Sendable, Equatable, Decodable {
|
||||
public let path: String?
|
||||
|
||||
public init(path: String? = nil) {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case path }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
path = try? container.decode(String.self, forKey: .path)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/worktree/prune` → `{ok,pruned}`. An empty list means
|
||||
/// "nothing to prune" — the route is idempotent.
|
||||
public struct PruneWorktreesResult: Sendable, Equatable, Decodable {
|
||||
public let pruned: [String]
|
||||
|
||||
public init(pruned: [String] = []) {
|
||||
self.pruned = pruned
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case pruned }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
pruned = (try? container.decode([String].self, forKey: .pruned)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// All-defaults fallbacks, declared next to nothing else so they stay one line
|
||||
// each and cannot drift from the memberwise defaults above.
|
||||
extension StageResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension CommitResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension PushResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension FetchResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension CreateWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension RemoveWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension PruneWorktreesResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
|
||||
/// Shape of a failure body: the worktree routes emit `{error}`, git-ops
|
||||
/// `{ok:false,error}`. Both carry `error` as a SAFE string.
|
||||
private struct GitErrorBody: Decodable {
|
||||
let error: String?
|
||||
}
|
||||
|
||||
// MARK: - Request bodies (frozen field-for-field against src/server.ts)
|
||||
|
||||
private struct StageBody: Encodable {
|
||||
let path: String
|
||||
let files: [String]
|
||||
let stage: Bool
|
||||
}
|
||||
|
||||
private struct CommitBody: Encodable {
|
||||
let path: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
/// `{path}` — push · fetch · worktree prune all take exactly this.
|
||||
private struct RepoPathBody: Encodable {
|
||||
let path: String
|
||||
}
|
||||
|
||||
private struct CreateWorktreeBody: Encodable {
|
||||
let path: String
|
||||
let branch: String
|
||||
/// Omitted from the JSON when nil — the server reads a missing `base` as
|
||||
/// "branch from HEAD", and an explicit `null`/`""` is NOT the same thing.
|
||||
let base: String?
|
||||
}
|
||||
|
||||
private struct RemoveWorktreeBody: Encodable {
|
||||
let path: String
|
||||
let worktreePath: String
|
||||
let force: Bool
|
||||
}
|
||||
|
||||
// MARK: - Routes
|
||||
|
||||
extension Endpoints {
|
||||
/// Every guarded git write shares this shape: JSON body + `Origin`, and
|
||||
/// `.routeDefined` 401 — `src/http/git-ops.ts:108` classifies a HOST-side
|
||||
/// git credential failure as 401 ("Push authentication required on the
|
||||
/// host."), which must not be mistaken for the access-token gate telling us
|
||||
/// to enter a token.
|
||||
private static func gitWriteRoute<Body: Encodable>(
|
||||
_ method: HTTPMethod, _ path: String, _ body: Body
|
||||
) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: method, path: path, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(body),
|
||||
unauthorizedPolicy: .routeDefined
|
||||
)
|
||||
}
|
||||
|
||||
static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage))
|
||||
}
|
||||
|
||||
static func gitCommit(path: String, message: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message))
|
||||
}
|
||||
|
||||
static func gitPush(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/push", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func gitFetch(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/fetch", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute {
|
||||
try gitWriteRoute(
|
||||
.post, "/projects/worktree",
|
||||
CreateWorktreeBody(path: path, branch: branch, base: base)
|
||||
)
|
||||
}
|
||||
|
||||
/// DELETE **with** a JSON body — the server reads `express.json` here
|
||||
/// (src/server.ts:1124), so this is the wire shape, unusual as it looks.
|
||||
static func removeWorktree(path: String, worktreePath: String, force: Bool) throws -> APIRoute {
|
||||
try gitWriteRoute(
|
||||
.delete, "/projects/worktree",
|
||||
RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force)
|
||||
)
|
||||
}
|
||||
|
||||
static func pruneWorktrees(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/worktree/prune", RepoPathBody(path: path))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client calls
|
||||
|
||||
extension APIClient {
|
||||
/// Stage (`stage: true` → `git add`) or unstage (`false` →
|
||||
/// `git restore --staged`) specific files. The file list is capped and
|
||||
/// realpath-contained server-side; body limit 64 KB.
|
||||
public func gitStage(
|
||||
path: String, files: [String], stage: Bool
|
||||
) async throws -> GitWriteOutcome<StageResult> {
|
||||
try await performGitWrite(path: path, StageResult.self) {
|
||||
try Endpoints.gitStage(path: path, files: files, stage: stage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit the STAGED changes. `message` is length-capped server-side and
|
||||
/// passed as a single `-m <msg>` argv — never a shell string, never a
|
||||
/// pathspec. An empty message comes back as `.rejected(400, …)`.
|
||||
public func gitCommit(
|
||||
path: String, message: String
|
||||
) async throws -> GitWriteOutcome<CommitResult> {
|
||||
try await performGitWrite(path: path, CommitResult.self) {
|
||||
try Endpoints.gitCommit(path: path, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current branch to its existing upstream, or `-u <sole-remote>
|
||||
/// <branch>` when it has none. Never a force-push; the remote is derived
|
||||
/// server-side. Tighter rate limit than stage/commit (network-bound).
|
||||
public func gitPush(path: String) async throws -> GitWriteOutcome<PushResult> {
|
||||
try await performGitWrite(path: path, PushResult.self) {
|
||||
try Endpoints.gitPush(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh remote-tracking refs (`refs/remotes` only — never a pull) so the
|
||||
/// panel's `behind` stops being a stale guess.
|
||||
public func gitFetch(path: String) async throws -> GitWriteOutcome<FetchResult> {
|
||||
try await performGitWrite(path: path, FetchResult.self) {
|
||||
try Endpoints.gitFetch(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a git worktree (`base` nil ⇒ branch from HEAD). The only
|
||||
/// write-to-disk feature; gated by `WORKTREE_ENABLED` (403 when off).
|
||||
public func createWorktree(
|
||||
path: String, branch: String, base: String?
|
||||
) async throws -> GitWriteOutcome<CreateWorktreeResult> {
|
||||
try await performGitWrite(path: path, CreateWorktreeResult.self) {
|
||||
try Endpoints.createWorktree(path: path, branch: branch, base: base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a worktree. Destructive: a dirty worktree needs `force: true`
|
||||
/// (otherwise the server answers 409 with a safe message), and the main
|
||||
/// worktree can never be removed (400).
|
||||
public func removeWorktree(
|
||||
path: String, worktreePath: String, force: Bool
|
||||
) async throws -> GitWriteOutcome<RemoveWorktreeResult> {
|
||||
try await performGitWrite(path: path, RemoveWorktreeResult.self) {
|
||||
try Endpoints.removeWorktree(path: path, worktreePath: worktreePath, force: force)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prune stale worktree registrations (idempotent — an empty `pruned` list
|
||||
/// means there was nothing to reclaim).
|
||||
public func pruneWorktrees(path: String) async throws -> GitWriteOutcome<PruneWorktreesResult> {
|
||||
try await performGitWrite(path: path, PruneWorktreesResult.self) {
|
||||
try Endpoints.pruneWorktrees(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The ONE place a guarded git write is executed and its status mapped, so
|
||||
/// all seven routes cannot drift apart: empty path rejected before any
|
||||
/// network I/O → 200 decoded (a garbled payload degrades to defaults rather
|
||||
/// than throwing — the write already happened) → 429 `.rateLimited` →
|
||||
/// everything else `.rejected` with the server's safe message.
|
||||
private func performGitWrite<Payload: GitWritePayload>(
|
||||
path: String,
|
||||
_ payload: Payload.Type,
|
||||
route build: () throws -> APIRoute
|
||||
) async throws -> GitWriteOutcome<Payload> {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
let (data, response) = try await perform(try build())
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
let decoded = try? JSONDecoder().decode(Payload.self, from: data)
|
||||
return .ok(decoded ?? Payload.degraded)
|
||||
case HTTPStatus.tooManyRequests:
|
||||
return .rateLimited
|
||||
default:
|
||||
return .rejected(
|
||||
status: response.statusCode, message: Self.decodeGitError(from: data)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's SAFE `error` string, or nil when the body is empty/
|
||||
/// unparseable — the client never invents a reason.
|
||||
private static func decodeGitError(from data: Data) -> String? {
|
||||
(try? JSONDecoder().decode(GitErrorBody.self, from: data))?.error
|
||||
}
|
||||
}
|
||||
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal file
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /sessions` (src/server.ts:479-481) — RO, NO Origin. Response =
|
||||
// `src/http/history.ts:13-19` `HistorySession[]`: the host's most recently
|
||||
// modified Claude Code session files, i.e. the `claude --resume` picker's data
|
||||
// (T-iOS-32).
|
||||
//
|
||||
// SECURITY (Sec H3, accepted upstream risk — documented, not introduced here):
|
||||
// this route is UNAUTHENTICATED on a token-less host and returns session cwds
|
||||
// plus the first ~120 chars of each first prompt. That matches the app's threat
|
||||
// model (the whole app hands a shell to anyone who can reach the port; deploy
|
||||
// behind Tailscale) — but it means the CLIENT must treat every field as INERT
|
||||
// display text: never autolink, never interpolate into a shell command.
|
||||
|
||||
/// One past Claude Code session (src/http/history.ts:13-19).
|
||||
///
|
||||
/// `id` stays a **String**, not a UUID: it is the `.jsonl` filename stem the
|
||||
/// host will pass to `claude --resume <id>` verbatim. Parsing it as a UUID would
|
||||
/// buy nothing and would DROP any session whose file was renamed — while the id
|
||||
/// still resumes fine. Required + non-empty, though: an entry without one cannot
|
||||
/// be resumed, so it is dropped rather than rendered as a dead row.
|
||||
public struct HistorySession: Sendable, Equatable {
|
||||
public let id: String
|
||||
/// The session's working directory (`""` when the jsonl had none).
|
||||
public let cwd: String
|
||||
/// Last cwd segment, for display (`"unknown"` server-side when cwd is empty).
|
||||
public let project: String
|
||||
/// The jsonl's mtime in ms. `fs.stat().mtimeMs` is FRACTIONAL on a real host
|
||||
/// (e.g. `1785390645813.5327`), so this is a Double — decoding it as Int
|
||||
/// would fail and silently drop every entry (src/http/history.ts:105).
|
||||
public let mtimeMs: Double
|
||||
/// First user prompt, whitespace-collapsed and truncated to 120 chars
|
||||
/// server-side. INERT text.
|
||||
public let preview: String
|
||||
|
||||
public init(id: String, cwd: String = "", project: String = "", mtimeMs: Double, preview: String = "") {
|
||||
self.id = id
|
||||
self.cwd = cwd
|
||||
self.project = project
|
||||
self.mtimeMs = mtimeMs
|
||||
self.preview = preview
|
||||
}
|
||||
}
|
||||
|
||||
extension HistorySession: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, cwd, project, mtimeMs, preview
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let rawId = try container.decode(String.self, forKey: .id)
|
||||
guard !rawId.isEmpty else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .id, in: container,
|
||||
debugDescription: "empty session id is not resumable"
|
||||
)
|
||||
}
|
||||
id = rawId
|
||||
mtimeMs = try container.decode(Double.self, forKey: .mtimeMs)
|
||||
cwd = (try? container.decode(String.self, forKey: .cwd)) ?? ""
|
||||
project = (try? container.decode(String.self, forKey: .project)) ?? ""
|
||||
preview = (try? container.decode(String.self, forKey: .preview)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `GET /sessions` — RO, no Origin (src/server.ts:479).
|
||||
static func claudeSessions() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/sessions", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /sessions` — the host's recent Claude Code sessions, newest first
|
||||
/// (the server sorts by mtime and caps at 50). RO → no Origin.
|
||||
///
|
||||
/// Malformed entries are dropped one by one; a non-array body throws
|
||||
/// `.invalidResponseBody`. The server answers `[]` (never an error) when
|
||||
/// `~/.claude/projects` is missing, so an empty list means "no history",
|
||||
/// not "failed".
|
||||
public func claudeSessions() async throws -> [HistorySession] {
|
||||
let (data, response) = try await perform(Endpoints.claudeSessions())
|
||||
guard response.statusCode == HTTPStatus.ok else {
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
return try LossyList.decodeBody(HistorySession.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,37 @@ public enum APIClientError: Error, Equatable, Sendable {
|
||||
/// 500 from `GET /projects/detail` — the server failed reading the repo
|
||||
/// (src/server.ts:306-309, body `{error}`).
|
||||
case projectDetailUnavailable
|
||||
/// **401 from the access-token gate** (src/server.ts:459) — the host has
|
||||
/// `WEBTERM_TOKEN` set and this request carried no (or a wrong) `webterm_auth`
|
||||
/// cookie. Distinct from a transport failure ON PURPOSE (ios-completion
|
||||
/// §1.1): the UI must offer "enter the access token", not "retry".
|
||||
case unauthorized
|
||||
/// A configured access token violates the frozen shape (16–512 chars of
|
||||
/// `[A-Za-z0-9._~+/=-]`). Raised BEFORE any network I/O — a shape-invalid
|
||||
/// token can never match a server token (the server refuses to start with
|
||||
/// one), and refusing it here is also what makes header injection
|
||||
/// impossible.
|
||||
case malformedToken
|
||||
/// 500 from a read-only git side-channel (`/projects/log`, `/projects/pr`,
|
||||
/// `/projects/worktree/state`) — the host failed to read git state.
|
||||
case gitDataUnavailable
|
||||
/// 404 from `GET /projects/worktree/state` (src/server.ts:549-552) — that
|
||||
/// path is not a worktree of the repo (removed / never existed).
|
||||
case worktreeNotFound
|
||||
/// 503 from `POST /live-sessions/:id/queue` — `QUEUE_ENABLED=0` on the host
|
||||
/// (src/server.ts:608-611). A configuration state, not a failure to retry.
|
||||
case queueDisabled
|
||||
/// 409 from `POST /live-sessions/:id/queue` — the queue is at
|
||||
/// `queueMaxItems` (src/server.ts:637-641). Never silently dropped.
|
||||
case queueFull
|
||||
/// 400 from `POST /live-sessions/:id/queue` — empty text or a malformed
|
||||
/// session id (src/server.ts:620-627). Also raised client-side for empty
|
||||
/// text before any network I/O.
|
||||
case queueTextInvalid
|
||||
/// 413 from `POST /live-sessions/:id/queue` — text (plus the optional
|
||||
/// trailing `\r`) exceeds the host's `queueItemMaxBytes`
|
||||
/// (src/server.ts:632-635).
|
||||
case queueTextTooLarge
|
||||
/// Any other non-success status code.
|
||||
case unexpectedStatus(Int)
|
||||
|
||||
@@ -213,6 +244,22 @@ public enum APIClientError: Error, Equatable, Sendable {
|
||||
"项目不存在(路径可能已移动或删除)。"
|
||||
case .projectDetailUnavailable:
|
||||
"读取项目详情失败,请稍后再试。"
|
||||
case .unauthorized:
|
||||
"该主机启用了访问令牌,请填写正确的令牌后重试。"
|
||||
case .malformedToken:
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A-Z a-z 0-9 . _ ~ + / = -。"
|
||||
case .gitDataUnavailable:
|
||||
"读取 git 状态失败,请稍后再试。"
|
||||
case .worktreeNotFound:
|
||||
"该 worktree 已不存在(可能已被删除或清理)。"
|
||||
case .queueDisabled:
|
||||
"该主机关闭了排队注入功能(QUEUE_ENABLED=0)。"
|
||||
case .queueFull:
|
||||
"排队已满,请先等前面的任务发出去。"
|
||||
case .queueTextInvalid:
|
||||
"要排队的内容为空或会话标识不合法。"
|
||||
case .queueTextTooLarge:
|
||||
"内容过长,超出了主机允许的单条上限。"
|
||||
case .unexpectedStatus(let status):
|
||||
"服务器返回了意外状态码 \(status)。"
|
||||
}
|
||||
|
||||
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal file
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /projects/pr?path=` (src/server.ts:1058-1076) — RO, NO Origin.
|
||||
// Response = `src/types.ts:617-648` `PrStatus`.
|
||||
//
|
||||
// Out-of-band side-channel: the host spawns its own `gh` CLI, which makes a
|
||||
// NETWORK call to GitHub with the host's credential. This route NEVER accepts or
|
||||
// forwards a token, and `GH_ENABLED=0` disables it entirely. A valid git dir
|
||||
// ALWAYS answers 200: every degrade (gh missing / unauthed / no PR / disabled)
|
||||
// lives in `availability`, not in the HTTP status — so the client renders one
|
||||
// chip instead of branching on errors.
|
||||
|
||||
/// Why a `PrStatus` has (or lacks) PR data (src/types.ts:619-625).
|
||||
/// An unknown/future wire value degrades to `.error`: a new server availability
|
||||
/// must never make the chip crash or hide the row.
|
||||
public enum PrAvailability: String, Sendable, Equatable, CaseIterable {
|
||||
/// A PR exists for the current branch; the sibling fields are populated.
|
||||
case ok
|
||||
/// gh works but the branch has no PR (or no remote / default repo).
|
||||
case noPr = "no-pr"
|
||||
/// The `gh` binary is not on the host's PATH (ENOENT).
|
||||
case notInstalled = "not-installed"
|
||||
/// gh is present but not logged in (needs `gh auth login`).
|
||||
case unauthenticated
|
||||
/// `GH_ENABLED=0` — the feature is off and gh is never spawned.
|
||||
case disabled
|
||||
/// gh spawned but failed for another reason (timeout, …). Also the
|
||||
/// unknown/missing fallback.
|
||||
case error
|
||||
}
|
||||
|
||||
/// Rolled-up CI check counts from gh's `statusCheckRollup`
|
||||
/// (src/types.ts:628-633).
|
||||
public struct PrCheckSummary: Sendable, Equatable {
|
||||
public let total: Int
|
||||
public let passing: Int
|
||||
public let failing: Int
|
||||
public let pending: Int
|
||||
|
||||
public init(total: Int = 0, passing: Int = 0, failing: Int = 0, pending: Int = 0) {
|
||||
self.total = total
|
||||
self.passing = passing
|
||||
self.failing = failing
|
||||
self.pending = pending
|
||||
}
|
||||
}
|
||||
|
||||
extension PrCheckSummary: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case total, passing, failing, pending
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
total = LossyNumber.int(in: container, forKey: .total) ?? 0
|
||||
passing = LossyNumber.int(in: container, forKey: .passing) ?? 0
|
||||
failing = LossyNumber.int(in: container, forKey: .failing) ?? 0
|
||||
pending = LossyNumber.int(in: container, forKey: .pending) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /projects/pr` result (src/types.ts:635-648). Everything except
|
||||
/// `availability` is present only when `availability == .ok`.
|
||||
///
|
||||
/// `state` / `mergeable` stay RAW inert strings (the server already lower-cases
|
||||
/// gh's `OPEN`/`MERGEABLE`): they are display text, and inventing an enum here
|
||||
/// would just add a second place for a future gh value to break.
|
||||
public struct PrStatus: Sendable, Equatable {
|
||||
public let availability: PrAvailability
|
||||
public let number: Int?
|
||||
public let title: String?
|
||||
public let url: String?
|
||||
public let state: String?
|
||||
public let isDraft: Bool?
|
||||
public let mergeable: String?
|
||||
public let headRefName: String?
|
||||
public let baseRefName: String?
|
||||
public let checks: PrCheckSummary?
|
||||
|
||||
public init(
|
||||
availability: PrAvailability = .error,
|
||||
number: Int? = nil,
|
||||
title: String? = nil,
|
||||
url: String? = nil,
|
||||
state: String? = nil,
|
||||
isDraft: Bool? = nil,
|
||||
mergeable: String? = nil,
|
||||
headRefName: String? = nil,
|
||||
baseRefName: String? = nil,
|
||||
checks: PrCheckSummary? = nil
|
||||
) {
|
||||
self.availability = availability
|
||||
self.number = number
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.state = state
|
||||
self.isDraft = isDraft
|
||||
self.mergeable = mergeable
|
||||
self.headRefName = headRefName
|
||||
self.baseRefName = baseRefName
|
||||
self.checks = checks
|
||||
}
|
||||
}
|
||||
|
||||
extension PrStatus: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case availability, number, title, url, state, isDraft, mergeable
|
||||
case headRefName, baseRefName, checks
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let rawAvailability = try? container.decode(String.self, forKey: .availability)
|
||||
availability = rawAvailability.flatMap(PrAvailability.init(rawValue:)) ?? .error
|
||||
number = LossyNumber.int(in: container, forKey: .number)
|
||||
title = try? container.decode(String.self, forKey: .title)
|
||||
url = try? container.decode(String.self, forKey: .url)
|
||||
state = try? container.decode(String.self, forKey: .state)
|
||||
isDraft = try? container.decode(Bool.self, forKey: .isDraft)
|
||||
mergeable = try? container.decode(String.self, forKey: .mergeable)
|
||||
headRefName = try? container.decode(String.self, forKey: .headRefName)
|
||||
baseRefName = try? container.decode(String.self, forKey: .baseRefName)
|
||||
checks = try? container.decode(PrCheckSummary.self, forKey: .checks)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `GET /projects/pr?path=` — RO, no Origin. nil = `path` not encodable.
|
||||
static func projectPr(path: String) -> APIRoute? {
|
||||
pathQuery(path).map { query in
|
||||
APIRoute(
|
||||
method: .get, path: "/projects/pr", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /projects/pr?path=` — the current branch's PR + CI rollup. RO → no
|
||||
/// Origin. 400/404/500 → `.projectPathInvalid` / `.projectNotFound` /
|
||||
/// `.gitDataUnavailable`; an empty path is rejected before any network I/O.
|
||||
/// Note that a REACHABLE-but-degraded gh is a 200 with a non-`ok`
|
||||
/// `availability`, NOT an error.
|
||||
public func prStatus(path: String) async throws -> PrStatus {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
guard let route = Endpoints.projectPr(path: path) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await perform(route)
|
||||
try Self.requireGitReadOK(response)
|
||||
return try Self.decodeObject(PrStatus.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ public struct ProjectSessionRef: Sendable, Equatable {
|
||||
public let clientCount: Int
|
||||
public let createdAt: Int
|
||||
public let exited: Bool
|
||||
/// w6/G7: where the session runs — lets the UI attribute it to a worktree
|
||||
/// (src/types.ts:353). Optional additive field; nil on pre-w6 servers.
|
||||
public let cwd: String?
|
||||
|
||||
public init(
|
||||
id: UUID, title: String?, status: ClaudeStatus,
|
||||
clientCount: Int, createdAt: Int, exited: Bool
|
||||
clientCount: Int, createdAt: Int, exited: Bool, cwd: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -34,12 +37,13 @@ public struct ProjectSessionRef: Sendable, Equatable {
|
||||
self.clientCount = clientCount
|
||||
self.createdAt = createdAt
|
||||
self.exited = exited
|
||||
self.cwd = cwd
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectSessionRef: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, status, clientCount, createdAt, exited
|
||||
case id, title, status, clientCount, createdAt, exited, cwd
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -53,6 +57,7 @@ extension ProjectSessionRef: Decodable {
|
||||
let rawStatus = try? container.decode(String.self, forKey: .status)
|
||||
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
|
||||
title = try? container.decode(String.self, forKey: .title)
|
||||
cwd = try? container.decode(String.self, forKey: .cwd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +74,22 @@ public struct ProjectInfo: Sendable, Equatable {
|
||||
/// 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.
|
||||
/// Derived from `fs.stat().mtimeMs`, so it arrives FRACTIONAL on a real host
|
||||
/// and is decoded via `LossyNumber.int` (see there for why a plain
|
||||
/// `decode(Int.self)` silently nils this field).
|
||||
public let lastActiveMs: Int?
|
||||
/// W3 sync chip — commits on HEAD not on `@{u}` (src/types.ts:366).
|
||||
public let ahead: Int?
|
||||
/// W3 sync chip — commits on `@{u}` not on HEAD (src/types.ts:367).
|
||||
public let behind: Int?
|
||||
/// HEAD commit time in ms (`git log -1 --format=%ct * 1000`, an integer).
|
||||
public let lastCommitMs: Int?
|
||||
public let sessions: [ProjectSessionRef]
|
||||
|
||||
public init(
|
||||
name: String, path: String, isGit: Bool, branch: String?,
|
||||
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef]
|
||||
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef],
|
||||
ahead: Int? = nil, behind: Int? = nil, lastCommitMs: Int? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
@@ -82,6 +97,9 @@ public struct ProjectInfo: Sendable, Equatable {
|
||||
self.branch = branch
|
||||
self.dirty = dirty
|
||||
self.lastActiveMs = lastActiveMs
|
||||
self.ahead = ahead
|
||||
self.behind = behind
|
||||
self.lastCommitMs = lastCommitMs
|
||||
self.sessions = sessions
|
||||
}
|
||||
}
|
||||
@@ -89,6 +107,7 @@ public struct ProjectInfo: Sendable, Equatable {
|
||||
extension ProjectInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, isGit, branch, dirty, lastActiveMs, sessions
|
||||
case ahead, behind, lastCommitMs
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -98,7 +117,10 @@ extension ProjectInfo: Decodable {
|
||||
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)
|
||||
lastActiveMs = LossyNumber.int(in: container, forKey: .lastActiveMs)
|
||||
ahead = LossyNumber.int(in: container, forKey: .ahead)
|
||||
behind = LossyNumber.int(in: container, forKey: .behind)
|
||||
lastCommitMs = LossyNumber.int(in: container, forKey: .lastCommitMs)
|
||||
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
|
||||
}
|
||||
|
||||
@@ -106,10 +128,7 @@ extension ProjectInfo: Decodable {
|
||||
/// `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)
|
||||
try LossyList.decodeBody(ProjectInfo.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +183,11 @@ public struct ProjectDetail: Sendable, Equatable {
|
||||
public let isGit: Bool
|
||||
public let branch: String?
|
||||
public let dirty: Bool?
|
||||
/// w6/G1: `git status --porcelain` line count, same gate as `dirty`
|
||||
/// (src/types.ts:388). nil ⇒ the host has the dirty check off, NOT "clean".
|
||||
public let dirtyCount: Int?
|
||||
/// w6/G1: upstream sync state; nil for a non-git dir (src/types.ts:389).
|
||||
public let sync: SyncState?
|
||||
public let worktrees: [WorktreeInfo]
|
||||
public let sessions: [ProjectSessionRef]
|
||||
public let hasClaudeMd: Bool
|
||||
@@ -173,13 +197,16 @@ public struct ProjectDetail: Sendable, Equatable {
|
||||
public init(
|
||||
name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?,
|
||||
worktrees: [WorktreeInfo], sessions: [ProjectSessionRef],
|
||||
hasClaudeMd: Bool, claudeMd: String?
|
||||
hasClaudeMd: Bool, claudeMd: String?,
|
||||
dirtyCount: Int? = nil, sync: SyncState? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.isGit = isGit
|
||||
self.branch = branch
|
||||
self.dirty = dirty
|
||||
self.dirtyCount = dirtyCount
|
||||
self.sync = sync
|
||||
self.worktrees = worktrees
|
||||
self.sessions = sessions
|
||||
self.hasClaudeMd = hasClaudeMd
|
||||
@@ -190,6 +217,7 @@ public struct ProjectDetail: Sendable, Equatable {
|
||||
extension ProjectDetail: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd
|
||||
case dirtyCount, sync
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -199,6 +227,8 @@ extension ProjectDetail: Decodable {
|
||||
isGit = try container.decode(Bool.self, forKey: .isGit)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
dirty = try? container.decode(Bool.self, forKey: .dirty)
|
||||
dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount)
|
||||
sync = try? container.decode(SyncState.self, forKey: .sync)
|
||||
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)
|
||||
@@ -206,31 +236,6 @@ extension ProjectDetail: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -240,17 +245,17 @@ extension Endpoints {
|
||||
}
|
||||
|
||||
/// `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`).
|
||||
/// `path` is encoded by the shared `pathQuery` choke point (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)"
|
||||
)
|
||||
pathQuery(path).map { query in
|
||||
APIRoute(
|
||||
method: .get, path: "/projects/detail", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal file
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
// Shared tolerant-decoding helpers for the UNTRUSTED server boundary (plan §4).
|
||||
// ONE home for all of them: unknown fields ignored, malformed elements dropped
|
||||
// one by one, wrong-typed optionals degraded to nil — never a crash, never a
|
||||
// whole-list failure because of a single bad entry.
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Decode a whole top-level `[Element]` body, dropping malformed elements.
|
||||
/// A non-array top level throws `.invalidResponseBody` — that is the
|
||||
/// "this port speaks HTTP but is not web-terminal" signal, not a degrade.
|
||||
static func decodeBody<Element: Decodable>(
|
||||
_ type: Element.Type, from data: Data
|
||||
) throws -> [Element] {
|
||||
guard let boxes = try? JSONDecoder().decode([LossyBox<Element>].self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return boxes.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
enum LossyNumber {
|
||||
/// Decode an integer-valued field that the server may serialize as a
|
||||
/// FRACTIONAL JSON number.
|
||||
///
|
||||
/// This is not paranoia: every `*Ms` field derived from `fs.stat().mtimeMs`
|
||||
/// (`ProjectInfo.lastActiveMs` — src/http/projects.ts:171,
|
||||
/// `HistorySession.mtimeMs` — src/http/history.ts:105,
|
||||
/// `SyncState.lastFetchMs` — src/http/projects.ts:170) carries sub-millisecond
|
||||
/// precision on APFS: a real body reads `1785390645813.5327`. A plain
|
||||
/// `decode(Int.self)` FAILS on that, so a bare `try?` would silently null the
|
||||
/// field (or drop the whole entry) against every real server.
|
||||
static func int<Key: CodingKey>(
|
||||
in container: KeyedDecodingContainer<Key>, forKey key: Key
|
||||
) -> Int? {
|
||||
if let exact = try? container.decode(Int.self, forKey: key) { return exact }
|
||||
guard let fractional = try? container.decode(Double.self, forKey: key),
|
||||
fractional.isFinite,
|
||||
fractional >= Double(Int.min), fractional <= Double(Int.max)
|
||||
else { return nil }
|
||||
return Int(fractional)
|
||||
}
|
||||
}
|
||||
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal file
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /projects/worktree/state?path=` (src/server.ts:542-562) — RO, NO
|
||||
// Origin. Response = `src/types.ts:392-408` (`SyncState` / `WorktreeState`),
|
||||
// fetched lazily per worktree row (w6/G7) because listing N worktrees eagerly
|
||||
// would spend a git spawn per row on data nothing renders.
|
||||
|
||||
/// Upstream sync state for one repo or worktree (src/types.ts:392-398).
|
||||
///
|
||||
/// EVERY field degrades independently, and each absence is a NORMAL state: no
|
||||
/// upstream, detached HEAD, empty repo and never-fetched all leave their field
|
||||
/// nil. Two rules the UI must honour (they are the reason this type is all
|
||||
/// optionals instead of zeros):
|
||||
/// - `ahead` is always trustworthy (local refs only);
|
||||
/// - `behind` is only as fresh as `lastFetchMs`, because `@{u}` is a locally
|
||||
/// cached remote ref that only a fetch moves — a stale `behind: 0` must NEVER
|
||||
/// render as "in sync";
|
||||
/// - `upstream == nil` means "nothing to compare against", which is NOT
|
||||
/// "nothing to push".
|
||||
public struct SyncState: Sendable, Equatable {
|
||||
/// e.g. `origin/develop`; nil ⇒ the branch tracks nothing.
|
||||
public let upstream: String?
|
||||
/// Commits on HEAD not on `@{u}`.
|
||||
public let ahead: Int?
|
||||
/// Commits on `@{u}` not on HEAD — trust only with a fresh `lastFetchMs`.
|
||||
public let behind: Int?
|
||||
/// `FETCH_HEAD` mtime (ms); nil ⇒ never fetched. Comes from
|
||||
/// `fs.stat().mtimeMs`, so it is FRACTIONAL on a real host — hence Double,
|
||||
/// not Int (src/http/projects.ts readLastFetchMs).
|
||||
public let lastFetchMs: Double?
|
||||
/// HEAD is not on a branch ⇒ no branch, no ahead/behind.
|
||||
public let detached: Bool?
|
||||
|
||||
public init(
|
||||
upstream: String? = nil, ahead: Int? = nil, behind: Int? = nil,
|
||||
lastFetchMs: Double? = nil, detached: Bool? = nil
|
||||
) {
|
||||
self.upstream = upstream
|
||||
self.ahead = ahead
|
||||
self.behind = behind
|
||||
self.lastFetchMs = lastFetchMs
|
||||
self.detached = detached
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncState: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case upstream, ahead, behind, lastFetchMs, detached
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
upstream = try? container.decode(String.self, forKey: .upstream)
|
||||
ahead = LossyNumber.int(in: container, forKey: .ahead)
|
||||
behind = LossyNumber.int(in: container, forKey: .behind)
|
||||
lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs)
|
||||
detached = try? container.decode(Bool.self, forKey: .detached)
|
||||
}
|
||||
}
|
||||
|
||||
/// The git state of ONE worktree (src/types.ts:402-408).
|
||||
public struct WorktreeState: Sendable, Equatable {
|
||||
public let path: String
|
||||
public let branch: String?
|
||||
public let sync: SyncState?
|
||||
/// `git status --porcelain` line count; nil ⇒ the host has the dirty check
|
||||
/// disabled (`PROJECT_DIRTY_CHECK=0`), which is NOT "clean".
|
||||
public let dirtyCount: Int?
|
||||
|
||||
public init(path: String, branch: String? = nil, sync: SyncState? = nil, dirtyCount: Int? = nil) {
|
||||
self.path = path
|
||||
self.branch = branch
|
||||
self.sync = sync
|
||||
self.dirtyCount = dirtyCount
|
||||
}
|
||||
}
|
||||
|
||||
extension WorktreeState: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path, branch, sync, dirtyCount
|
||||
}
|
||||
|
||||
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)
|
||||
sync = try? container.decode(SyncState.self, forKey: .sync)
|
||||
dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `GET /projects/worktree/state?path=` — RO, no Origin.
|
||||
static func worktreeState(path: String) -> APIRoute? {
|
||||
pathQuery(path).map { query in
|
||||
APIRoute(
|
||||
method: .get, path: "/projects/worktree/state", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /projects/worktree/state?path=` — branch + sync + dirty count for
|
||||
/// ONE worktree row. RO → no Origin. 400 → `.projectPathInvalid`,
|
||||
/// **404 → `.worktreeNotFound`** (this route's 404 means "not a worktree of
|
||||
/// this repo", not "no such project"), 500 → `.gitDataUnavailable`.
|
||||
/// An empty path is rejected before any network I/O.
|
||||
public func worktreeState(path: String) async throws -> WorktreeState {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
guard let route = Endpoints.worktreeState(path: path) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await perform(route)
|
||||
try Self.requireGitReadOK(response, notFound: .worktreeNotFound)
|
||||
return try Self.decodeObject(WorktreeState.self, from: data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user