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. /// - The one other `URLSession` in the app belongs to `thumbnailTransport()` /// below — assembled HERE, from these same providers, for the same reasons /// (F1: the session-thumbnail pipeline is built by a SwiftUI `@State` /// initializer that has no environment to read, and used to build a /// credential-less transport of its own). /// - 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 identityProvider = keychainIdentityProvider() 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 ) } /// C-iOS-2 · The lazy, Keychain-backed device-identity provider: resolved /// per TLS client-certificate challenge (never snapshotted), so a certificate /// imported mid-run is presented on the NEXT handshake without a relaunch. /// ONE definition, shared by `production()` and `thumbnailTransport()`. static func keychainIdentityProvider() -> @Sendable () -> ClientIdentity? { let identityStore = KeychainClientIdentityStore() return { identityStore.loadedIdentityOrNil() } } /// F1 · The HTTP transport `SessionThumbnailPipeline.live()` fetches /// `GET /live-sessions/:id/preview` over — assembled HERE, at the /// composition root, from the SAME two credential providers `production()` /// installs on the app-wide transport: the lazy mTLS identity AND the /// per-origin access token. /// /// WHY IT EXISTS (the bug it fixes): the pipeline used to build its own /// `URLSessionHTTPTransport` with an identity provider but **no token /// source**, so on a `WEBTERM_TOKEN`-gated host every preview came back 401. /// The pipeline never throws by design — a failed preview IS a placeholder — /// so the whole session list silently degraded to placeholder thumbnails with /// nothing on screen saying why. It is assembled here rather than in the /// component because the pipeline is created by a SwiftUI `@State` /// initializer that has no `AppEnvironment` to read from. /// /// It is a SEPARATE `URLSession` from `production().http` for the same reason /// (no environment at that call site), and that costs nothing: both are /// `.ephemeral` (preview bytes are raw ring-buffer terminal output and must /// never touch a disk cache — T-iOS-19) and both resolve their credentials /// per request, so neither can hold a stale token. /// /// - Parameters: /// - hostStore: where the per-host tokens live — Keychain in production, /// an in-memory double in tests. /// - identityProvider: the mTLS identity source (see above). static func thumbnailTransport( hostStore: any HostStore = KeychainHostStore(), identityProvider: @escaping @Sendable () -> ClientIdentity? = AppEnvironment.keychainIdentityProvider() ) -> URLSessionHTTPTransport { let tokens = AccessTokenSource(store: hostStore) return URLSessionHTTPTransport( identityProvider: identityProvider, tokenForOrigin: { origin in await tokens.token(forOrigin: origin) } ) } /// 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 { 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) } }