- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped — recorded as a withKnownIssue rather than fixed. Height now derives from the content size category (and tracks live changes via registerForTraitChanges); the known-issue marker is gone, replaced by positive assertions including a 12-category no-clip sweep and a guard that stays red if anyone writes the constant back. Honest tradeoff: the keycap font is clamped at .accessibility2, the same policy the design system already applies to dense content, because an unclamped AX5 bar would eat the terminal. A test pins the clamp so the two cannot drift. - Thumbnails silently 401'd on a token-gated host: the pipeline built its own transport with no token source, and by design never throws, so every preview degraded to a placeholder with no signal. Assembled from AppEnvironment now. - Android had zero CI while the token wave shipped 24 files of secret-handling code. The instrumented leg is workflow_dispatch-only and says why in the file: no one has ever seen it green on a runner, and a required leg nobody trusts just produces a false green. - Android persisted a validated token before the host probe succeeded, stranding a secret for a host that never paired. App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
306 lines
16 KiB
Swift
306 lines
16 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.
|
|
/// - 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<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)
|
|
}
|
|
}
|