fix(ios,android): close the acceptance gaps the review found, add Android CI

- 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.
This commit is contained in:
Yaojia Wang
2026-07-30 16:46:20 +02:00
parent 284cfd193a
commit 5cc755b0b6
14 changed files with 1120 additions and 102 deletions

View File

@@ -22,6 +22,11 @@ import WireProtocol
/// (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;
@@ -73,10 +78,7 @@ struct AppEnvironment: Sendable {
// 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 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
@@ -129,6 +131,52 @@ struct AppEnvironment: Sendable {
)
}
/// 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.