Files
web-terminal/ios/App/WebTerm/Wiring/AppEnvironment.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

258 lines
13 KiB
Swift

import APIClient
import ClientTLS
import Foundation
import HostRegistry
import SessionCore
import UIKit
import WireProtocol
/// T-iOS-15 · Production dependency graph (composition root). One immutable
/// value assembled at launch; every screen/VM receives its dependencies from
/// here no ad-hoc URLSession/keychain access anywhere else in the App layer.
///
/// Assembly security audit (task , verified at this single point):
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
/// SessionListViewModel's client, probe's kill round-trip inside
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own
/// EXCEPT the access-token `Cookie` (C1) see its type doc for why that one
/// belongs at the transport and cannot bypass the Origin rule.
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
/// HTTP: APIClient's route builder) nothing here hand-assembles one.
/// - Secrets: hosts (and their access tokens) in `KeychainHostStore`
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
/// UserDefaults carries only the non-secret per-host lastSessionId. A token
/// never reaches a log line, a URL query, or an error description.
/// - No debug ATS overrides exist (project.yml declares NO
/// NSAllowsArbitraryLoads in any configuration only the five §5.2 CIDR
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
/// `UIScreen.isCaptured` detection is SKIPPED per plan (accepted residual
/// risk, local trust model).
struct AppEnvironment: Sendable {
let hostStore: any HostStore
let lastSessionStore: any LastSessionStore
let http: any HTTPTransport
/// The WS transport used when no per-host factory is injected: it carries
/// NO access token, so it is correct only for a host that has none. Every
/// terminal goes through `makeTermTransport(for:)`, which prefers
/// `termTransportFactory` production always installs one.
let termTransport: any TermTransport
/// Injected into `PairingViewModel` production is `runPairingProbe`
/// over the real transports (two-step: RO GET, then WS attach + guarded
/// kill; only runs after the user's explicit confirm, T-iOS-12), plus the
/// `POST /auth` token probe and the host-removal side effect (C1).
let probe: PairingViewModel.Probe
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
/// `var` + default so the memberwise init stays source-compatible for
/// pre-P1 call sites while tests can inject an in-memory fake.
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
/// E1 · Builds the WS transport for ONE host, so the upgrade can carry that
/// host's access token (§1.1). nil `termTransport` for every host (the
/// App-layer tests' `FakeTransport`); production installs the real factory.
var termTransportFactory: (@Sendable (HostRegistry.Host) -> any TermTransport)?
/// The WS transport a terminal on `host` must dial.
///
/// E1 (HIGH) · This exists because the access-token cookie is PER HOST: the
/// app previously shared one transport whose token was resolved
/// host-independently, which returned nil for the mixed fleet the token
/// feature is for (a tokened tunnel host beside an open LAN host) the
/// upgrade omitted the cookie, the server 401'd, and that failure is
/// terminal with no in-app remedy. Android never had the bug because
/// `OkHttpTermTransport` resolves `tokens.tokenFor(endpoint)`; this is the
/// same rule.
func makeTermTransport(for host: HostRegistry.Host) -> any TermTransport {
termTransportFactory?(host) ?? termTransport
}
static func production() -> AppEnvironment {
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
// identity LAZILY from the keychain on each connect/challenge, injected
// into BOTH transports + the probe as a provider. This way a certificate
// imported from the "" screen takes effect on the NEXT connection
// without relaunching a snapshot captured here would stay stale. A
// missing cert is the normal pre-install state ( nil); genuine faults
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
let identityStore = KeychainClientIdentityStore()
let identityProvider: @Sendable () -> ClientIdentity? = {
identityStore.loadedIdentityOrNil()
}
let hostStore = KeychainHostStore()
// C1 · ONE access-token source for the whole app, reading the same
// Keychain records the pairing flow writes. Both transports consult it
// per request/connect, so a token typed mid-run applies immediately
// no relaunch, and no token snapshot captured at composition.
let tokens = AccessTokenSource(store: hostStore)
let http = URLSessionHTTPTransport(
identityProvider: identityProvider,
tokenForOrigin: { origin in await tokens.token(forOrigin: origin) }
)
// E1 · ONE transport per host: the upgrade's Cookie is this host's token
// and never another's. The provider is re-consulted on every connect
// (rotation applies on the next reconnect, no relaunch).
let termTransportFactory: @Sendable (HostRegistry.Host) -> any TermTransport = { host in
URLSessionTermTransport(
identityProvider: identityProvider,
tokenProvider: { tokens.wsToken(for: host) }
)
}
return AppEnvironment(
hostStore: hostStore,
lastSessionStore: UserDefaultsLastSessionStore(),
http: http,
termTransport: URLSessionTermTransport(identityProvider: identityProvider),
probe: PairingViewModel.Probe(
verifyHost: { endpoint, token in
// The candidate token has to reach BOTH probe legs. HTTP
// takes it as a parameter (APIClient stamps the Cookie); the
// WS leg gets a PROBE-SCOPED transport carrying exactly this
// candidate the frozen `TermTransport.connect` has no
// credential parameter, and the host is not paired yet, so
// the shared transport could not resolve it from the store.
let ws = URLSessionTermTransport(
identityProvider: identityProvider,
tokenProvider: { token?.rawValue }
)
return await runPairingProbe(
endpoint: endpoint, http: http, ws: ws,
accessToken: token?.rawValue
)
},
validateToken: { endpoint, token in
await probeAccessToken(endpoint: endpoint, http: http, token: token)
},
unregisterPush: { host in
await PushHostDeregistration.run(for: host)
}
),
termTransportFactory: termTransportFactory
)
}
/// Away-digest source for a session engine: wraps `APIClient.events` per
/// host (the engine never holds an HTTP client plan §3.2). The token rides
/// along at the transport, so this stays token-free.
func makeEventsSource(endpoint: HostEndpoint)
-> @Sendable (UUID) async throws -> [TimelineEvent] {
let client = APIClient(endpoint: endpoint, http: http)
return { id in try await client.events(id: id) }
}
}
// MARK: - Access-token source (C1 · ios-completion §1.1)
/// The app's single read path for per-host access tokens.
///
/// Reads the `HostStore` (Keychain) on demand rather than caching a value at
/// composition: pairing writes a token into the same records, and a snapshot
/// taken at launch would make "type the token, then connect" require a relaunch.
/// A Keychain read is a sub-millisecond `SecItemCopyMatching` + JSON decode, so
/// per-request resolution is affordable at the app's polling cadence.
///
/// SECURITY (§5.3): the token is returned as a `String` for exactly one use a
/// `Cookie` header value. It is never logged, never interpolated into a URL, and
/// the values it returns are `AccessToken`-validated (§1.1 charset), which is
/// what makes header injection impossible.
final class AccessTokenSource: @unchecked Sendable {
private let store: any HostStore
private let lock = NSLock()
/// Origin token, rebuilt from the store on every `token(forOrigin:)`.
/// See `wsToken(for:)`. Guarded by `lock`; `nonisolated` state is the reason
/// this type is `@unchecked Sendable` (an actor cannot serve the WS
/// provider, which is synchronous).
private var snapshot: [String: String] = [:]
init(store: any HostStore) {
self.store = store
}
/// This host's token, matched by ORIGIN (`HostEndpoint.originHeader` the
/// single derivation point, plan §5.1), or nil when the host has none / is
/// unknown / the store read fails. A failed read degrades to "no token"
/// rather than throwing: the request then gets the server's 401, which the
/// UI already explains, instead of the app breaking outright.
func token(forOrigin origin: String) async -> String? {
let hosts = (try? await store.loadAll()) ?? []
updateSnapshot(from: hosts)
return hosts.first { $0.endpoint.originHeader == origin }?.accessToken?.rawValue
}
/// The token a WS upgrade to `host` must present (§1.1) for the one caller
/// that can be given neither an `await` nor a parameter: SessionCore's
/// frozen `tokenProvider: @Sendable () -> String?`, which the per-host
/// transport closes over (`AppEnvironment.makeTermTransport(for:)`).
///
/// Two reads, in this order, and both are THIS host's own value no answer
/// derived from any other host can ever be returned (§5.3):
/// 1. the latest value the store returned for this origin (so a token
/// rotated mid-run applies on the next connect, no relaunch);
/// 2. the `host` record itself, which the coordinator read from the same
/// Keychain store when the session was opened this is what makes the
/// FIRST connect correct even before any HTTP request has warmed the
/// snapshot (a cold-start terminal must not depend on that race), and
/// what keeps a failed store read from silently dropping the cookie.
///
/// Consequence of (2), stated plainly: a token DELETED from the store keeps
/// riding until this terminal is reopened. That is still this host's own
/// value the server just answers 401 if it is no longer valid and it is
/// the price of never dropping the cookie on a cold connect.
func wsToken(for host: HostRegistry.Host) -> String? {
let origin = host.endpoint.originHeader
return lock.withLock { snapshot[origin] } ?? host.accessToken?.rawValue
}
private func updateSnapshot(from hosts: [HostRegistry.Host]) {
let resolved = hosts.reduce(into: [String: String]()) { map, host in
guard let token = host.accessToken?.rawValue else { return }
map[host.endpoint.originHeader] = token
}
lock.withLock { snapshot = resolved }
}
}
/// `POST /auth` (§1.1) as the pairing flow needs it: the four documented
/// outcomes, or a typed transport failure. Lives here (composition root) because
/// it is the seam between `APIClient`'s throwing API and the VM's total switch.
private func probeAccessToken(
endpoint: HostEndpoint,
http: any HTTPTransport,
token: AccessToken
) async -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
do {
let client = APIClient(endpoint: endpoint, http: http)
return .success(try await client.probeAccessToken(token.rawValue))
} catch APIClientError.malformedToken {
return .failure(.malformed)
} catch let apiError as APIClientError {
// `.message` is user-facing copy about the STATUS, never about the token.
return .failure(.unreachable(apiError.message))
} catch {
return .failure(.unreachable((error as NSError).localizedDescription))
}
}
// MARK: - Host removal APNs de-registration (C1 · fixes the dead hook)
/// Bridge from "the user removed a host" to `PushRegistrar.handleHostRemoved`.
///
/// The registrar owns the in-memory APNs device token (deliberately never
/// persisted iOS re-delivers it on every registration), so the de-registration
/// can only be done by the LIVE instance. That instance is created and held by
/// `PushAppDelegate` (`makePushWiring`), which the system builds after this
/// composition root hence the resolution happens at CALL time through
/// `UIApplication.shared.delegate`, the platform's own object, rather than any
/// singleton of ours.
///
/// nil delegate / nil registrar (unit tests, XCUITest, a build where push was
/// never wired) is a deliberate no-op: removal must never depend on push.
enum PushHostDeregistration {
@MainActor
static func run(for host: HostRegistry.Host) async {
guard let delegate = UIApplication.shared.delegate as? PushAppDelegate,
let registrar = delegate.registrar else {
return
}
await registrar.handleHostRemoved(host)
}
}