From 5cc755b0b65cb5a18a89a79c746a33cd9ab7245e Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 16:46:20 +0200 Subject: [PATCH] fix(ios,android): close the acceptance gaps the review found, add Android CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .github/workflows/android.yml | 176 +++++++++++++ android/.gitignore | 3 + .../webterm/viewmodels/PairingViewModel.kt | 107 ++++++-- .../viewmodels/PairingTokenViewModelTest.kt | 107 ++++++++ docs/PLAN_IOS_CLIENT.md | 23 +- docs/PROGRESS_LOG.md | 29 +++ ios/App/WebTerm/Components/KeyBar.swift | 168 +++++++++++-- .../WebTerm/Components/SessionThumbnail.swift | 31 ++- ios/App/WebTerm/Wiring/AppEnvironment.swift | 56 ++++- .../WebTermTests/DynamicTypeLayoutTests.swift | 173 ++++++++++--- ios/App/WebTermTests/KeyBarTests.swift | 96 ++++++++ .../SessionThumbnailTokenTests.swift | 231 ++++++++++++++++++ ios/README.md | 10 +- ios/project.yml | 12 +- 14 files changed, 1120 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 ios/App/WebTermTests/SessionThumbnailTokenTests.swift diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..99e0469 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,176 @@ +# Android CI (F2). Until this file existed the Android client had ZERO CI — 10 modules, +# 691 JVM tests and the whole `WEBTERM_TOKEN` secret-handling wave (Tink AEAD under an +# AndroidKeystore master key, the POST /auth probe, cookie stamping on every route and on +# the WS upgrade) were gated by nothing at all. Modelled on ios.yml: same `on:` shape +# (push + pull_request, path-filtered), same "one job per verification layer" structure, +# and — like ios.yml — deliberately NO `concurrency:` group, so the two workflows behave +# the same way on rapid pushes. +# +# ── HONESTY NOTE (read before trusting a green badge) ──────────────────────────────────── +# This repository's only remote is a self-hosted Gitea instance, so GitHub Actions has +# NEVER run here and this file has never been executed by the platform. What IS verified: +# every command below was run locally on macOS against the same Gradle build +# (`./gradlew test :app:assembleDebug koverVerify koverLog` + the androidTest compile), +# and the YAML parses. What is NOT verified: the runner-side pieces — action resolution, +# the preinstalled Android SDK layout on `ubuntu-latest`, and the emulator leg. +# +# ── Why no `src/**` path trigger (ios.yml HAS one) ─────────────────────────────────────── +# ios.yml watches `src/**` because it owns `integration-tests`, a Swift layer that boots the +# real Node server and would catch client↔server protocol drift. Android has no equivalent +# leg (android/README.md, "DEFERRED — end-to-end access token": nothing here starts a server +# with WEBTERM_TOKEN set and completes a cookie-gated WS upgrade). Triggering on `src/**` +# would therefore burn runner minutes without checking a single contract. When an Android +# server-contract leg is added, add `src/**` here at the same time. +name: android + +on: + push: + paths: + - "android/**" + - ".github/workflows/android.yml" + pull_request: + paths: + - "android/**" + - ".github/workflows/android.yml" + # Manual trigger for the instrumented (device) leg below, which is dispatch-only. + workflow_dispatch: + +jobs: + # Layer 1 — everything that runs without a device. This is exactly the green gate + # android/README.md documents ("./gradlew test :app:assembleDebug koverVerify"), plus a + # compile-only pass over the instrumented sources so they cannot silently rot. + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 45 + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + + # jvmToolchain(17) is declared by every module; Gradle resolves it from the + # installed JDKs, so 17 must actually be present (the runner image's default may + # be a different major). + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + + # :app/:terminal-view/:host-registry/:client-tls-android compile against SDK 36 with + # build-tools 36.0.0 (android/README.md "Android SDK setup"). The runner image ships an + # Android SDK, but which platforms it holds drifts image to image — so install what the + # build actually needs instead of assuming, and FAIL LOUDLY if there is no sdkmanager. + - name: Install the Android SDK packages this build compiles against + run: | + # `set -eu` WITHOUT pipefail on purpose: `yes | sdkmanager --licenses` kills `yes` + # with SIGPIPE (141) as soon as the prompts stop, and pipefail would report that + # normal ending as a failed step. + set -eu + sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sdkmanager_bin" ]; then + sdkmanager_bin="$(ls -1 "$ANDROID_HOME"/cmdline-tools/*/bin/sdkmanager 2>/dev/null | tail -n 1 || true)" + fi + if [ ! -x "$sdkmanager_bin" ]; then + echo "::error title=No sdkmanager::none found under $ANDROID_HOME/cmdline-tools — the Android SDK is not usable on this runner" >&2 + exit 1 + fi + yes | "$sdkmanager_bin" --licenses > /dev/null + "$sdkmanager_bin" "platforms;android-36" "build-tools;36.0.0" "platform-tools" + + # 10 modules of JVM unit tests (JUnit 5 + coroutines-test + Turbine + MockK). Includes + # the Android-library modules' testDebugUnitTest and :app's — no device needed. + - name: JVM unit tests (all modules) + run: ./gradlew test + + # The APK build is its own step: a Kotlin/KSP/Hilt/Compose breakage that unit tests do + # not reach (resources, manifest merge, DI graph assembly) must not hide behind them. + - name: Debug APK builds + run: ./gradlew :app:assembleDebug + + # Coverage floor: >=80% line coverage on the four modules that apply the Kover plugin + # (:wire-protocol, :session-core, :api-client, :client-tls). koverLog prints the actual + # percentages into the run log so a near-miss is visible before it becomes a failure. + # The Android-framework modules are NOT gated (no Kover on them) — a known gap. + - name: Coverage gate (Kover, >= 80% on the four gated modules) + run: ./gradlew koverVerify koverLog + + # The instrumented sources cannot RUN here, but they must keep COMPILING: they are the + # only assertion that the access token is ciphertext at rest (TinkAccessTokenStoreTest) + # and that the AndroidKeyStore import path works, so a silent compile break would delete + # that coverage without anyone noticing. + - name: Instrumented test sources still compile + run: ./gradlew :host-registry:assembleDebugAndroidTest :client-tls-android:assembleDebugAndroidTest + + - name: Upload test + coverage reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-reports + path: | + android/*/build/reports/tests/** + android/*/build/test-results/** + android/*/build/reports/kover/** + retention-days: 7 + if-no-files-found: ignore + + # Layer 2 — instrumented (device) tests. DISPATCH-ONLY, on purpose. + # + # What is at stake: `TinkAccessTokenStoreTest` is the ONLY test anywhere that proves the + # frozen at-rest properties of the access token (ios-completion §1.1) — that a fresh store + # instance decrypts it after a cold start, and that the bytes on disk are ciphertext rather + # than the token in the clear. Tink's AEAD + the AndroidKeystore-wrapped master key have no + # JVM double, so this can only run on a device or emulator. + # + # Why it is not on push: this workflow has never executed on any runner (see the honesty + # note at the top), and an emulator leg is the single most fragile thing in Android CI — + # it needs KVM, a system image download, and an AVD boot. Wiring an unvalidated, ~15-minute, + # KVM-dependent job into every push would either block the repo on a leg nobody has seen + # pass, or push people to make it non-blocking — and a leg that cannot fail is a false green + # (the same trap ios.yml's `ios17-floor-tests` comment calls out). + # + # THEREFORE: run it by hand from the Actions tab (workflow_dispatch) whenever the token + # store, DataStore stores or the AndroidKeyStore importer change. Once it has been seen + # green on a real runner, promote it by deleting the `if:` below. + # + # MANUAL EQUIVALENT (the documented path today — android/README.md "DEFERRED — instrumented + # tests", DEVICE_QA_CHECKLIST.md): with a device/emulator attached, + # cd android && ANDROID_HOME= ./gradlew :host-registry:connectedDebugAndroidTest \ + # :client-tls-android:connectedDebugAndroidTest + # (StrongBox falls back to software keys on an emulator, so hardware-backed key claims still + # need real hardware.) + instrumented-tests: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + - name: Enable KVM for the emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + # API 29 == the app's minSdk: the deployment floor is where at-rest crypto behaviour is + # most likely to differ (mirrors ios.yml's iOS 17 floor leg). + - name: connectedDebugAndroidTest on an API 29 emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: google_apis + arch: x86_64 + working-directory: android + script: ./gradlew :host-registry:connectedDebugAndroidTest :client-tls-android:connectedDebugAndroidTest + - name: Upload instrumented reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-instrumented-reports + path: android/*/build/reports/androidTests/** + retention-days: 7 + if-no-files-found: ignore diff --git a/android/.gitignore b/android/.gitignore index 2c52a7b..4e0833d 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -40,3 +40,6 @@ service-account*.json # Kover / test reports **/kover/ + +# Kotlin 2.x build side-effect (created by any ./gradlew invocation) +.kotlin/ diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt index 2d889fe..16f4ae9 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt @@ -3,10 +3,12 @@ package wang.yaojia.webterm.viewmodels import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import wang.yaojia.webterm.api.pairing.AuthProbeResult import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult @@ -56,6 +58,14 @@ import java.util.UUID * continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the * user on the confirm step with a typed [TokenError] and NEVER reaches the network probe. * + * ### RULE 6 — a pairing that does not end in [PairingUiState.Paired] strands no secret (F2) + * Storing before probing means the store can be left holding a token for a host that never paired (wrong + * port, unreachable, user backs out). So every non-paired exit — probe failure, a throw, and cancellation + * by [reset] or by a superseding attempt — rolls the store back to exactly what it held before this + * attempt ([TokenAttempt]): the previous token for a re-pair, nothing at all for a first pairing. The + * rollback runs under [NonCancellable] so an abandoned attempt still cleans up, and the next attempt + * `join`s the one it supersedes so the two never race on the same key. + * * The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in * per call, so no state snapshot, log or crash report can carry it. * @@ -161,8 +171,12 @@ public class PairingViewModel( return } val scope = scope ?: return - probeJob?.cancel() + val superseded = probeJob + superseded?.cancel() probeJob = scope.launch { + // Wait out the attempt this one replaces: its RULE 6 rollback must finish before we read or + // write the same store key, or the two attempts race and the loser's undo wins. + superseded?.join() // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard. if (tier.isCertGated && !hasDeviceCert()) { _uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier) @@ -170,20 +184,36 @@ public class PairingViewModel( } _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) // RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store). - if (accessToken.isNotBlank() && !establishToken(candidate, accessToken)) return@launch - when (val result = prober.probe(candidate.endpoint)) { - is PairingProbeResult.Success -> onProbeSuccess(candidate) - is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error) + val attempt = when { + accessToken.isBlank() -> TokenAttempt.NONE + else -> establishToken(candidate, accessToken) ?: return@launch } + // RULE 6 — anything short of Paired puts the store back the way this attempt found it. + val paired = try { + when (val result = prober.probe(candidate.endpoint)) { + is PairingProbeResult.Success -> onProbeSuccess(candidate) + is PairingProbeResult.Failure -> { + onProbeFailure(candidate, result.error) + false + } + } + } catch (error: Throwable) { + // Cancelled (reset / a newer attempt) or a transport throw — the rule is the same, and + // NonCancellable is what lets an already-cancelled attempt still clean up after itself. + withContext(NonCancellable) { rollBackToken(candidate, attempt) } + throw error + } + if (!paired) rollBackToken(candidate, attempt) } } /** * Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host. - * Returns true when pairing may continue (accepted-and-stored, OR the host has no auth at all); - * false after publishing the matching [TokenError] / failure state. + * Returns the [TokenAttempt] to undo should pairing not complete (RULE 6) — [TokenAttempt.NONE] when + * the host turned out to have no auth at all — or **null** after publishing the matching + * [TokenError] / failure state, meaning "stop, do not probe". */ - private suspend fun establishToken(candidate: Candidate, token: String): Boolean { + private suspend fun establishToken(candidate: Candidate, token: String): TokenAttempt? { val outcome = try { authProber.validate(candidate.endpoint, token) } catch (cancel: CancellationException) { @@ -191,13 +221,13 @@ public class PairingViewModel( } catch (error: Throwable) { // A network-level failure is not a token problem — classify it like any other probe error. onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint)) - return false + return null } return when (outcome) { AuthProbeResult.Accepted -> storeToken(candidate, token) // 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated") // and carry on — an open host pairs exactly as it did before this feature. - AuthProbeResult.AuthDisabled -> true + AuthProbeResult.AuthDisabled -> TokenAttempt.NONE AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID) AuthProbeResult.RateLimited -> failToken(candidate, TokenError.TOO_MANY_ATTEMPTS) AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED) @@ -205,26 +235,49 @@ public class PairingViewModel( } } - private suspend fun storeToken(candidate: Candidate, token: String): Boolean { - val stored = runCatching { tokenStore.put(candidate.endpoint, token) } - return when { - stored.getOrNull() == true -> true + /** Persist [token], remembering what the store held before so RULE 6 can put it back. */ + private suspend fun storeToken(candidate: Candidate, token: String): TokenAttempt? { + return try { + val previous = tokenStore.tokenFor(candidate.endpoint) // put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT). - stored.isSuccess -> failToken(candidate, TokenError.MALFORMED) + if (!tokenStore.put(candidate.endpoint, token)) failToken(candidate, TokenError.MALFORMED) + else TokenAttempt(stored = true, previous = previous) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { // A throw ⇒ encrypted storage failed; never continue with a token we could not keep. - else -> failToken(candidate, TokenError.STORE_FAILED) + failToken(candidate, TokenError.STORE_FAILED) + } + } + + /** + * Undo [attempt]'s write: restore the previous token (a failed re-pair must not cost the user the + * one that was working) or drop the key entirely when this host had none. Never publishes state — + * the caller has already published the reason pairing stopped. + */ + private suspend fun rollBackToken(candidate: Candidate, attempt: TokenAttempt) { + if (!attempt.stored) return + try { + val previous = attempt.previous + if (previous == null) tokenStore.remove(candidate.endpoint) + else tokenStore.put(candidate.endpoint, previous) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + // Best effort: encrypted storage just failed on the undo. There is no recovery beyond this, + // and surfacing it would replace the pairing error the user actually needs to read. } } /** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */ - private fun failToken(candidate: Candidate, error: TokenError): Boolean { + private fun failToken(candidate: Candidate, error: TokenError): TokenAttempt? { _uiState.value = PairingUiState.Confirming( endpoint = candidate.endpoint, name = candidate.name, tier = candidate.tier, tokenError = error, ) - return false + return null } private fun onProbeFailure(candidate: Candidate, error: PairingError) { @@ -242,7 +295,8 @@ public class PairingViewModel( ) } - private suspend fun onProbeSuccess(candidate: Candidate) { + /** Persist the paired host. Returns false when it could not be paired (so RULE 6 undoes the token). */ + private suspend fun onProbeSuccess(candidate: Candidate): Boolean { val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl) if (host == null) { // Unreachable in practice (the endpoint already validated), but never persist a null host. @@ -253,10 +307,11 @@ public class PairingViewModel( error = PairingError.HttpOkButNotWebTerminal, message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier), ) - return + return false } hostStore.upsert(host) _uiState.value = PairingUiState.Paired(host) + return true } private fun defaultName(endpoint: HostEndpoint): String = @@ -428,6 +483,18 @@ public enum class TokenError { /** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */ private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) +/** + * What one pairing attempt wrote to the access-token store, and therefore what has to be put back if the + * attempt does not end in [PairingUiState.Paired] (RULE 6). [previous] is what the store held for this + * host beforehand — non-null only for a re-pair, whose working token a failed attempt must not cost. + */ +private data class TokenAttempt(val stored: Boolean, val previous: String?) { + companion object { + /** No token was written (none typed, or the host has no auth) — nothing to undo. */ + val NONE: TokenAttempt = TokenAttempt(stored = false, previous = null) + } +} + private fun PairingUiState.candidate(): Candidate? = when (this) { is PairingUiState.Confirming -> Candidate(endpoint, name, tier) is PairingUiState.Probing -> Candidate(endpoint, name, tier) diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt index 732b99c..79e128e 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt @@ -1,7 +1,9 @@ package wang.yaojia.webterm.viewmodels +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf @@ -25,6 +27,9 @@ import java.io.IOException * - a wrong token / rate-limit keeps the user ON the confirm step with a typed [TokenError] (the field * is right there) instead of dropping them into a generic failure; * - a host that answers 401 to the probe asks for a token ([TokenError.REQUIRED]); + * - **no stranded secret** (F2): the flip side of storing before probing is that a pairing which does + * not reach [PairingUiState.Paired] — failed probe, or abandoned mid-probe — must roll the store back + * to exactly what it held before, restoring a re-paired host's previous token rather than clobbering it; * - the token NEVER appears in the UI state (no accidental leak into a state dump / crash report). */ @OptIn(ExperimentalCoroutinesApi::class) @@ -33,6 +38,12 @@ class PairingTokenViewModelTest { private companion object { const val LAN = "http://10.0.0.5:3000" const val TOKEN = "0123456789abcdefTOKEN" + + /** The token an already-paired host holds, so a failed re-pair can be shown not to clobber it. */ + const val PREVIOUS_TOKEN = "0123456789abcdefOLD1" + + /** A cert-gated native-tunnel host (`WarningTier.TUNNEL`). */ + const val TUNNEL = "https://star.terminal.yaojia.wang" } /** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */ @@ -238,6 +249,102 @@ class PairingTokenViewModelTest { assertEquals(TOKEN, tokens.tokenFor(endpoint())) } + // ── a pairing that does not succeed must not strand the secret (F2) ───────────────────────── + // + // The token is stored BEFORE the probe on purpose (the probe's own HTTP+WS legs read it back out + // of the store to authenticate). The flip side is that every path which does NOT end in `Paired` + // has to put the store back the way it found it — otherwise a mistyped port leaves a live token + // on disk for a host that was never paired, and nothing ever removes it. + + @Test + fun `a probe failure after the token was accepted leaves no stranded token`() = + runTest(UnconfinedTestDispatcher()) { + val tokens = InMemoryAccessTokenStore() + var tokenVisibleToProbe: String? = null + val prober = PairingProber { endpoint -> + tokenVisibleToProbe = tokens.tokenFor(endpoint) + PairingProbeResult.Failure(PairingError.HostUnreachable("connect refused")) + } + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertEquals(TOKEN, tokenVisibleToProbe, "the probe's legs must still authenticate from the store") + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + assertNull(tokens.tokenFor(endpoint()), "a host that never paired must not keep a stored token") + } + + @Test + fun `a failed re-pair restores the token the host was already paired with`() = + runTest(UnconfinedTestDispatcher()) { + val tokens = InMemoryAccessTokenStore() + tokens.put(endpoint(), PREVIOUS_TOKEN) + val prober = FakeProber { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) } + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertEquals( + PREVIOUS_TOKEN, + tokens.tokenFor(endpoint()), + "a failed re-pair rolls back to the token that was already working, it does not clobber it", + ) + } + + /** + * The one test that needs a REAL suspension point mid-probe, so it runs on the default + * [kotlinx.coroutines.test.StandardTestDispatcher] rather than the unconfined dispatcher the rest of + * the file uses. Note `runCurrent()`, not `advanceUntilIdle()`: the probe runs in `backgroundScope`, + * and `advanceUntilIdle` stops as soon as no FOREGROUND work is left — it would never run the probe + * at all (verified: the coroutine did not start). + */ + @Test + fun `abandoning an in-flight probe rolls the freshly stored token back`() = runTest { + val probeReached = CompletableDeferred() + val neverAnswers = CompletableDeferred() + val tokens = InMemoryAccessTokenStore() + val vm = newVm( + prober = { probeReached.complete(Unit); neverAnswers.await() }, + authProber = FakeAuthProber { AuthProbeResult.Accepted }, + tokenStore = tokens, + ) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + runCurrent() + + assertTrue(probeReached.isCompleted, "the probe must be in flight for this to test anything") + assertEquals(TOKEN, tokens.tokenFor(endpoint()), "the in-flight probe reads the candidate token") + + vm.reset() // the user backs out while the probe hangs + runCurrent() + + assertNull(tokens.tokenFor(endpoint()), "an abandoned pairing must not leave the secret behind") + } + + @Test + fun `a cert-gated tunnel host is refused before the token is validated or stored`() = + runTest(UnconfinedTestDispatcher()) { + val tokens = InMemoryAccessTokenStore() + val authProber = FakeAuthProber { AuthProbeResult.Accepted } + val prober = FakeProber { PairingProbeResult.Success(it) } + val vm = newVm(prober, authProber, tokens) // hasDeviceCert = false + + vm.bind(backgroundScope) + + vm.onManualEntry(TUNNEL) + vm.confirm(accessToken = TOKEN) + + assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value) + assertTrue(authProber.calls.isEmpty(), "the cert-gate refuses BEFORE any network I/O") + assertNull(tokens.tokenFor(endpoint(TUNNEL)), "nothing is stored for a host that was never probed") + } + // ── secrecy ───────────────────────────────────────────────────────────────────────────────── @Test diff --git a/docs/PLAN_IOS_CLIENT.md b/docs/PLAN_IOS_CLIENT.md index 94de47d..8125922 100644 --- a/docs/PLAN_IOS_CLIENT.md +++ b/docs/PLAN_IOS_CLIENT.md @@ -540,10 +540,10 @@ W5 验收(report-only, 并行) | T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift`、`Components/TelemetryChips.swift`、`ViewModels/SessionListViewModel.swift` + Tests | | T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift`、`ViewModels/GateViewModel.swift` + `GateViewModelTests` | | T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` | -| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **10** `@Test` + `scripts/coverage-gate.sh` + `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)。**未核实**:GH Actions 平台侧的运行结果(本环境 `gh` 未登录) | +| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **26** `@Test`(10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫)+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS)+ `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml`。`ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**:GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea,两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 | | T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 | | T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名(P0 收官条目)。**缺口**:F-iOS 的真机项(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED,手工清单在 LOG | -| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核(Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。**缺口**:P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` 尚未在**产物层**复核;release ipa 层核对仍未做(免费 team 无分发通道) | +| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核(Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**:release ipa 层核对仍未做(免费 team 无分发通道) | | T-iOS-20 server APNs | `[x]` | `src/push/apns.ts` + `test/push-apns.test.ts`(含本地 h2c 假 APNs 的双 e2e;具体测试数以 `npm test` 输出为准,LOG 记 65)。**DEFERRED**:真机端到端需付费账号 | | T-iOS-37 server lastOutputAt | `[x]` | `src/types.ts:315` 可选字段 + `src/session/manager.ts:197` 映射 + 测试 | | T-iOS-38 APIClient P1 契约 | `[x]` | `APIClient/{ApnsToken,Projects,Prefs}.swift` + `ApnsTokenTests`/`ProjectsTests`/`PrefsRoundTripTests` | @@ -560,9 +560,9 @@ W5 验收(report-only, 并行) | T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 | | T-iOS-32 Worktree + `--resume` 历史 | `[~]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift`、`ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**缺口**:Accept 的"端到端一次"(对真服务器真建/真删 worktree)无自动化腿、本环境也未手工跑 | | T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) | -| T-iOS-34 主题 + Dynamic Type | `[~]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。**缺口(已实测、未修)**:AX5 下键栏需 ~108.24pt 而 `KeyBarMetrics.barHeight` 固定 52pt → 键帽裁切,以 `withKnownIssue` 钉住(`KeyBar.swift` 不在该任务 Owns) | +| T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言(AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XS–XL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3–AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 | | T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift` 的 `.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) | -| T-iOS-36 P2 验收 | `[~]` | 本波 Wave D 的验收 agent 正在执行;**结果与真实数字以 `PROGRESS_LOG.md` 的该条目为准**,本文不预写结论 | +| T-iOS-36 P2 验收 | `[x]` | 两轮独立复验(Wave D + 收尾波)均已跑完,**真实数字以 `PROGRESS_LOG.md` 的该条目为准**:452 包测 / 550 App 测(iPhone 16 Pro 与 iPad Pro 11" M4 各一遍,**0 known issue**)/ 26 集成测 / 覆盖率门 5/5 / 真机构建 `BUILD SUCCEEDED` 且产物无 `aps-environment`。P2 五项逐条走查见本节上方各行 | **说明**:上表的"测试数"是 `@Test` **声明**静态计数(`grep -rh '@Test'`),与 commit `850531f`/`a5fa843` 里实跑数字一致; 参数化用例实跑会更多。App bundle 侧共 **532** 条 `@Test` 声明。 @@ -588,10 +588,15 @@ W5 验收(report-only, 并行) 另补 iPad UI-test 腿(同 commit)。 - **签名解锁**:`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关 (commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`,7 天临时 profile)。 -- **已知未闭合缺口(供后续任务领走)**:① AX5 键栏裁切(见 T-iOS-34 行);② **无端到端令牌腿**—— - `IntegrationTests` 与模拟器内 live-server smoke 都不带 `WEBTERM_TOKEN` 起服务器(grep 无 `WEBTERM_TOKEN`/`webterm_auth`), - 令牌路径目前只有单元级覆盖;③ T-iOS-19 的产物层复核未覆盖 P2 新增的两条 usage description; - ④ worktree 建/删的**端到端**一次(T-iOS-32 Accept)既无自动化腿也未手工跑。 +- **收尾波(2026-07-30)闭合了其中三条**:① AX5 键栏裁切 → 已修(见 T-iOS-34 行); + ② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举) + 跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/ + 令牌不替代 Origin(合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态; + 另加 `TokenPolicyDriftTests.swift` **8 例**漂移守卫(运行期读 `src/config.ts` 与 `src/http/auth.ts` 的真字面量, + 与三份 Swift 谓词逐条比对,含变异测试证明守卫自身够响);③ 两条 usage description 已在产物层复核(见 T-iOS-19 行)。 +- **剩余未闭合**:release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME); + Android instrumented 腿(`TinkAccessTokenStoreTest` 是令牌静态加密的唯一证明,需真机或模拟器,CI 上暂设为 + `workflow_dispatch` 触发——理由写在 `android.yml` 文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿)。 ### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天 @@ -920,7 +925,7 @@ W5 验收(report-only, 并行) - **T-iOS-33** · 终端内搜索 `[x]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮。 - **T-iOS-34** · 主题 + Dynamic Type `[~]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量(与同波任务文件不相交,开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版。 - **落地**: `DesignSystem/{AppTheme,TerminalPalette}.swift` + `Screens/SettingsScreen.swift`(齿轮入口在 `ProjectsToolbarItem` 内,stack 与 split 两根视图共享)+ Tokens/Typography 浅色档;`AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。默认仍是深色(零回归)。 - - **缺口(已实测、未修)**: AX5 下键栏两行需 **108.24pt**,`KeyBarMetrics.barHeight` 固定 **52pt**(44+8)→ 键帽裁切;以 `withKnownIssue` 钉在 `DynamicTypeLayoutTests`(修好后该用例会报 "known issue was not recorded",提醒删标记)。`Components/KeyBar.swift` 不在该任务 Owns,需另派。 + - **缺口已闭合(收尾波,2026-07-30)**: `KeyBarMetrics.barHeight` 从常量 52pt 改为 `max(minHitTarget, keycapHeight(category)) + sm8`,`intrinsicContentSize`/init frame/`apply(contentSizeCategory:)` 三处同源,并经 iOS 17 `registerForTraitChanges` 支持运行期改档。`withKnownIssue` 已删。**取舍**:键帽字体封顶 `.accessibility2`(不封顶时 AX5 需 108.24pt,会把终端吃掉),沿用 DS 对密集内容的既有策略;实测条高 XS–XL 52 / XXL 55 / XXXL 59 / AccM 68 / AccL–AX5 77。 - **T-iOS-35** · web `?join=` 互通(分享 QR 双向)`[x]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量(`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话。 - **落地**: `DeepLinkRouter` 增 `.joinShared` 分支 + `DeepLinkJoinTests` 19 测(含把原"scheme 不是 webterminal"的拒绝理由改写为"web 形状只许单一 `join` 键");主机身份只经 `HostStore`+`HostEndpoint.originHeader` 解析,未配对 origin 一律走配对页且 hint 不回显链接内容。 - **DEFERRED**: 用真手机相机扫 web 分享 QR 的目视走查(模拟器无相机)。 diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 1f036d1..3f060a5 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,35 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 📱 [x] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`) + +- **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。 +- **编排**: 契约先冻结在 [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §1(orchestrator 亲自核服务端源码),再派 3 个 Workflow 共 22 个 agent。**A 串行 → B 并行 5 → C 串行 4 → D 并行 3 → E 条件 → 收尾波 4**。C 波刻意串行:新增 `.swift` 必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行必互踩。 + - 中途整个 C+D 波(7 个 agent)被 provider 侧 `529 Overloaded`/`ECONNRESET` 打挂;`resumeFromRunId` 把 A/B 从缓存回放、只重跑失败的,零返工。 +- **冻结契约(§1.1,踩过的坑都在里面)**: cookie 名 `webterm_auth`;`POST /auth` 体 `{"token":"…"}`;**`Accept` 头不能含 `text/html`**——含了会被当表单走 302 而不是 204/401;**204 但无 `Set-Cookie` = 服务端根本没开鉴权**,绝不能据此认为"已认证";原生客户端**自己手写 `Cookie` 头**,不解析 `Set-Cookie`、不用系统 cookie jar(URLSession/OkHttp 的 jar 在 WS upgrade 上行为不一致且难测);**令牌不替代 Origin**,两者都要带。 +- **真机构建解锁(最高杠杆,一次关掉 7 项验收阻塞)**: `DEVELOPMENT_TEAM=C738Z66SRW` + **target 级** `CODE_SIGN_IDENTITY`(废弃串 `"iPhone Developer"` 是 XcodeGen 的 product-type preset 在 target 层注入的,project 级怎么写都盖不住)→ 免费个人 team 上 `** BUILD SUCCEEDED **`,`-allowProvisioningUpdates` 现场签发 7 天 profile。**`aps-environment` 做成 env 开关且默认不挂**(`WEBTERM_PUSH_ENTITLEMENTS`),并**反向验证过必要性**:打开后真机构建报 `Personal development teams … do not support the Push Notifications capability`。 +- **顺手挖出一个会卡死全波的坑**: SwiftTerm 用浮动版本 `from: 1.13.0`,而 `.xcodeproj` 是 gitignore 的 → **任何人一 `xcodegen generate` 就解析到 1.15.0**,它在 1.14.0 新增的 `public var hasActiveSelection` 与 `TerminalScreen.swift` 本地同名属性冲突,且**加 `override` 修不了**(上游是 `public` 不是 `open`)。A1 先钉死版本止血,C3 删掉本地属性改用上游的、再抬到 1.15.0 根治。 +- **交付**(全部实测,非引用): + + | | 之前 | 之后 | + |---|---|---| + | 包测试 | 310 | **452** | + | App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) | + | 集成测试 | 10 | **26** | + | Android 测试 | 687 | **691** | + | ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) | + | 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) | + | 真机构建 | `BUILD FAILED` | **`BUILD SUCCEEDED`** | + + - **令牌**: 两端全链路(APIClient `/auth` 探针 + Keychain/Keystore 存储 + WS upgrade 带 Cookie + 401 终态不进退避环)。 + - **git 面板 parity**: iOS 补齐 `/projects/{log,pr,git/*,worktree*}` + `/sessions` + 队列——这是与 Android 和 web 差了两个月的那一块。 + - **P2 全 5 项**: 语音 PTT(epoch 防误发)、终端内搜索、worktree 生命周期、主题 + Dynamic Type、web `?join=` 互通。 + - **CI**: 修好 iOS 三条腿(缺 `npm ci` 会**硬失败**不是 skip / 缺 iPad UI 腿 / iOS 17 缺 runtime 时**静默报绿**),新建 `android.yml`(此前 Android 零 CI)。 +- **复核抓到并已修的 2 个 HIGH**: ① iOS 的 WS 令牌是**与主机无关**地解析的,混合机群(一台开令牌一台没开)下令牌门主机永远开不了终端,且 App 内无解 → 改为每主机一个 transport;② Android 把主机自身的 git 凭据 401(`src/http/git-ops.ts:108`)误报成"你的访问令牌错了",因为通用 401 映射跑在路由映射前面 → git 写路由标 `ROUTE_DEFINED`,保住服务端原文案。 +- **orchestrator 的两个裁定**: ① 令牌策略在 iOS 有 3 份、Android 1 份,**不解冻 `WireProtocol` 去收敛**(它是冻结的跨语言契约,共享密钥的 helper 不该进去),改为在 `IntegrationTests` 加漂移守卫——它是全树唯一能同时依赖三个包的目标;② `PairingProbe.swift` 越界改动是我特批的(B1 已收工释放该包),复核把它记为 LOW,已知悉。 +- **诚实的未闭合项**: release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);APNs 端到端(需付费账号);**两个 workflow 从未在任何 runner 上跑过**——本仓库唯一 remote 是自建 Gitea,GH Actions 从来没执行过,本地已逐条复跑其命令;Android instrumented 腿(令牌静态加密的唯一证明)设为 `workflow_dispatch` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。 +- **一个诚实的取舍**: 键栏在 AX3–AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。 + ### 🌿 [x] w6 项目详情 Git 面板 — G1–G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`) - **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`。 diff --git a/ios/App/WebTerm/Components/KeyBar.swift b/ios/App/WebTerm/Components/KeyBar.swift index f3ff05c..3cf3a75 100644 --- a/ios/App/WebTerm/Components/KeyBar.swift +++ b/ios/App/WebTerm/Components/KeyBar.swift @@ -115,15 +115,89 @@ enum KeyBarLayout { /// Layout constants — all composed from the frozen `DS` scale (no literals). /// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar /// stays on-grid with the rest of the app. -private enum KeyBarMetrics { - /// A hit-target-tall row plus a little breathing room (44 + 8). - static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8 +/// +/// T-iOS-34 acceptance ("亮暗主题 + **最大字号不破版**") · The bar height used +/// to be the CONSTANT 44 + 8 = 52pt while the keycap it draws is two lines of +/// Dynamic Type text — at AX5 that keycap needs ~108pt, so more than half of it +/// was clipped. The height is now DERIVED from the keycap at the user's text +/// size, with two bounds that pull in opposite directions and are both required: +/// - a 44pt FLOOR, so the small sizes keep exactly today's 52pt bar and the HIG +/// hit target survives (zero regression); +/// - a CEILING on the text itself (`typeCeiling`), so the bar cannot grow +/// without limit. This bar is an `inputAccessoryView` riding on top of the +/// soft keyboard: an unclamped AX5 keycap turns a 52pt strip into a ~108pt one +/// and, with the keyboard already up, eats the terminal it exists to drive. +/// Clamping the FONT (not the frame) is what keeps that honest — the height +/// below is computed from the SAME clamped fonts the keycaps render with, so +/// nothing is ever clipped at any size. +/// +/// `internal`, not `private`: the acceptance is a MEASUREMENT +/// (`DynamicTypeLayoutTests` compares these against the real UIKit fonts and +/// against a real `KeyBarView`), and a private constant could only be +/// re-implemented in the test — i.e. not tested at all. +enum KeyBarMetrics { static let buttonSpacing: CGFloat = DS.Space.xs4 static let contentInset: CGFloat = DS.Space.sm8 static let buttonInsets = NSDirectionalEdgeInsets( top: DS.Space.xs4, leading: DS.Space.md12, bottom: DS.Space.xs4, trailing: DS.Space.md12 ) + + /// Dynamic Type ceiling for the keycap text — the UIKit currency of the DS's + /// dense-content policy `DS.Typography.numericClamp` (`.accessibility2`, + /// whose `UIContentSizeCategory` twin is `.accessibilityLarge`). The two are + /// pinned equal by a test so they cannot drift apart. + /// + /// Prose in this app scales all the way to AX5 and nothing caps it; a + /// 17-keycap control strip docked to the keyboard is the same dense case as + /// the telemetry chips — past this point extra size only costs terminal rows. + static let typeCeiling: UIContentSizeCategory = .accessibilityLarge + + /// The category the keycaps actually render at: the user's, capped at + /// `typeCeiling`. + static func effectiveCategory( + _ category: UIContentSizeCategory + ) -> UIContentSizeCategory { + category > typeCeiling ? typeCeiling : category + } + + /// Traits carrying the effective category — the `compatibleWith:` argument + /// for every font below. `UIFont.preferredFont(forTextStyle:)` WITHOUT it + /// reads the app-wide category and would ignore the clamp entirely. + static func traits(_ category: UIContentSizeCategory) -> UITraitCollection { + UITraitCollection(preferredContentSizeCategory: effectiveCategory(category)) + } + + /// Glyph line ("Esc", "^C", "⇧Tab"): monospaced footnote, so the glyphs stay + /// on a common width grid. + static func glyphFont(_ category: UIContentSizeCategory) -> UIFont { + UIFont.monospacedSystemFont( + ofSize: UIFont.preferredFont( + forTextStyle: .footnote, compatibleWith: traits(category) + ).pointSize, + weight: .semibold + ) + } + + /// Caption line ("中断", "模式"): caption2, secondary label colour. + static func captionFont(_ category: UIContentSizeCategory) -> UIFont { + UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits(category)) + } + + /// What ONE keycap needs at `category`: its two text lines plus the button's + /// own vertical content insets. Rounded up — a fractional shortfall is still + /// a clipped descender. + static func keycapHeight(_ category: UIContentSizeCategory) -> CGFloat { + (glyphFont(category).lineHeight + captionFont(category).lineHeight + + buttonInsets.top + buttonInsets.bottom).rounded(.up) + } + + /// The bar's height at `category`: whatever the keycap needs, never below the + /// 44pt hit target, plus `sm8` of breathing room. At the standard sizes the + /// keycap is smaller than 44 ⇒ 44 + 8 = 52pt, identical to the old constant. + static func barHeight(_ category: UIContentSizeCategory) -> CGFloat { + max(DS.Layout.minHitTarget, keycapHeight(category)) + DS.Space.sm8 + } } // MARK: - KeyBarView (inputAccessoryView) @@ -140,19 +214,47 @@ final class KeyBarView: UIInputView { /// The 🎤 push-to-talk key, nil unless voice handlers were supplied. private(set) var voiceButton: UIButton? + /// The Dynamic Type category the bar is currently laid out for (T-iOS-34). + /// Test-visible: the acceptance is "the keycap the bar DRAWS fits the bar", + /// which is only measurable if the category the bar drew at is knowable. + private(set) var contentSizeCategory: UIContentSizeCategory + private let voice: KeyBarVoiceHandlers? - /// - Parameter voice: press/release outlets for the 🎤 key (T-iOS-31). - /// nil ⇒ no mic key at all (mirrors the web bar, which only renders it - /// when speech is supported AND a trigger is supplied). - init(voice: KeyBarVoiceHandlers? = nil) { + /// - Parameters: + /// - voice: press/release outlets for the 🎤 key (T-iOS-31). nil ⇒ no mic + /// key at all (mirrors the web bar, which only renders it when speech is + /// supported AND a trigger is supplied). + /// - contentSizeCategory: the text size to lay out for. Defaults to the + /// app's own — the same value `UIFont.preferredFont(forTextStyle:)` + /// reads — and is re-read from the traits whenever the user changes it. + /// Injectable so the AX5 acceptance can be measured without driving + /// Settings (a `UITraitCollection.performAsCurrent` wrapper would NOT + /// work: the un-`compatibleWith:` font API ignores it, which is exactly + /// the trap the old measurement fell into). + init( + voice: KeyBarVoiceHandlers? = nil, + contentSizeCategory: UIContentSizeCategory = + UIApplication.shared.preferredContentSizeCategory + ) { self.voice = voice + self.contentSizeCategory = contentSizeCategory super.init( - frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight), + frame: CGRect( + x: 0, y: 0, width: 0, + height: KeyBarMetrics.barHeight(contentSizeCategory) + ), inputViewStyle: .keyboard ) allowsSelfSizing = true buildBar() + // Live text-size changes: re-render the keycaps at the new size and let + // `allowsSelfSizing` re-measure the bar. Without this the bar would keep + // whatever height it was born with until the terminal is reopened. + registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { + (bar: KeyBarView, _: UITraitCollection) in + bar.apply(contentSizeCategory: bar.traitCollection.preferredContentSizeCategory) + } } @available(*, unavailable, message: "KeyBarView is code-built only") @@ -161,7 +263,29 @@ final class KeyBarView: UIInputView { } override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight) + CGSize( + width: UIView.noIntrinsicMetric, + height: KeyBarMetrics.barHeight(contentSizeCategory) + ) + } + + /// Re-render every keycap at `category` and re-measure the bar. A no-op when + /// the category did not actually change (trait callbacks fire for unrelated + /// trait changes too). + func apply(contentSizeCategory category: UIContentSizeCategory) { + guard category != contentSizeCategory else { return } + contentSizeCategory = category + for (button, spec) in zip(keyButtons, KeyBarLayout.buttons) { + button.configuration = keycapConfiguration( + label: spec.label, caption: spec.caption, isPrimary: spec.isPrimary + ) + } + voiceButton?.configuration = keycapConfiguration( + label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption, + isPrimary: false + ) + frame.size.height = KeyBarMetrics.barHeight(category) + invalidateIntrinsicContentSize() } private func buildBar() { @@ -239,25 +363,35 @@ final class KeyBarView: UIInputView { /// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is /// the UIKit twin of DS.Palette.textSecondary — the DS UIColor surface only /// vends the accent, so native system labels stand in at this boundary.) - private func makeKeycap( - label: String, caption: String, title: String, isPrimary: Bool - ) -> UIButton { + /// + /// Both fonts come from `KeyBarMetrics`, resolved `compatibleWith:` the + /// bar's current (clamped) category — the SAME resolution `barHeight` is + /// computed from, which is what makes "the bar always fits its keycaps" true + /// by construction rather than by a hand-tuned constant. + private func keycapConfiguration( + label: String, caption: String, isPrimary: Bool + ) -> UIButton.Configuration { var config: UIButton.Configuration = isPrimary ? .tinted() : .gray() config.cornerStyle = .fixed config.background.cornerRadius = DS.Radius.sm8 var attributedLabel = AttributedString(label) - attributedLabel.font = UIFont.monospacedSystemFont( - ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize, - weight: .semibold - ) + attributedLabel.font = KeyBarMetrics.glyphFont(contentSizeCategory) config.attributedTitle = attributedLabel var attributedCaption = AttributedString(caption) - attributedCaption.font = UIFont.preferredFont(forTextStyle: .caption2) + attributedCaption.font = KeyBarMetrics.captionFont(contentSizeCategory) attributedCaption.foregroundColor = .secondaryLabel config.attributedSubtitle = attributedCaption config.titleAlignment = .center config.contentInsets = KeyBarMetrics.buttonInsets + return config + } + private func makeKeycap( + label: String, caption: String, title: String, isPrimary: Bool + ) -> UIButton { + let config = keycapConfiguration( + label: label, caption: caption, isPrimary: isPrimary + ) let button = UIButton(configuration: config) if isPrimary { button.tintColor = DS.Palette.accentUIColor() diff --git a/ios/App/WebTerm/Components/SessionThumbnail.swift b/ios/App/WebTerm/Components/SessionThumbnail.swift index 4b05e45..d9ad78b 100644 --- a/ios/App/WebTerm/Components/SessionThumbnail.swift +++ b/ios/App/WebTerm/Components/SessionThumbnail.swift @@ -1,5 +1,4 @@ import APIClient -import ClientTLS import Foundation import os import SwiftUI @@ -226,19 +225,25 @@ final class SessionThumbnailPipeline { cache = SessionThumbnailCache(maxEntries: maxCacheEntries) } - /// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存 - /// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份, - /// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。 - /// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复: - /// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。 - static func live() -> SessionThumbnailPipeline { - let identityStore = KeychainClientIdentityStore() - let transport = URLSessionHTTPTransport(identityProvider: { - identityStore.loadedIdentityOrNil() - }) - return SessionThumbnailPipeline( + /// 生产装配:取数走**组合根装配**的那条带凭证的传输 + /// (`AppEnvironment.thumbnailTransport()`)—— ephemeral URLSession(preview + /// 字节可能含屏上密钥,内存缓存 only,同 T-iOS-19 对 RO GET 的裁定) + /// + C-iOS-2 的设备证书身份(否则隧道主机的缩略图会被 nginx 拒握手) + /// + **C1 每主机访问令牌 Cookie**。两个凭证都按请求/按握手现取, + /// 中途导入的证书、中途输入的令牌下一次取数即生效,无需重启。 + /// + /// F1(本次修复):这里原先自建一个**没有令牌源**的传输,于是在开了 + /// `WEBTERM_TOKEN` 的主机上 `GET /live-sessions/:id/preview` 一律 401; + /// 而本管线按设计永不抛错(失败即占位图),401 就被静默降级成 + /// **每一张**缩略图都是占位符,屏幕上没有任何原因可查。 + /// + /// - Parameter http: 取数传输。默认即上述生产装配;测试注入替身。 + static func live( + http: any HTTPTransport = AppEnvironment.thumbnailTransport() + ) -> SessionThumbnailPipeline { + SessionThumbnailPipeline( loader: { request in - try await APIClient(endpoint: request.endpoint, http: transport) + try await APIClient(endpoint: request.endpoint, http: http) .preview(id: request.sessionId) }, renderer: { data, cols, rows in diff --git a/ios/App/WebTerm/Wiring/AppEnvironment.swift b/ios/App/WebTerm/Wiring/AppEnvironment.swift index 3d9d1d2..f9bf51e 100644 --- a/ios/App/WebTerm/Wiring/AppEnvironment.swift +++ b/ios/App/WebTerm/Wiring/AppEnvironment.swift @@ -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. diff --git a/ios/App/WebTermTests/DynamicTypeLayoutTests.swift b/ios/App/WebTermTests/DynamicTypeLayoutTests.swift index 3a06ba0..e7557fd 100644 --- a/ios/App/WebTermTests/DynamicTypeLayoutTests.swift +++ b/ios/App/WebTermTests/DynamicTypeLayoutTests.swift @@ -103,26 +103,105 @@ struct DynamicTypeLayoutTests { #expect(size.height.isFinite) } - // MARK: - F29 · KeyBar(UIKit,固定条高 vs 放大的键帽)—— 已知缺口 + // MARK: - F29 · KeyBar(UIKit inputAccessoryView):条高跟随键帽 + + /// F1 · 这里原是 `withKnownIssue` 记录的**已知缺口**:`KeyBarMetrics.barHeight` + /// 固定 52pt(44 + 8),而 AX5 下一枚键帽要 ~108pt(footnote 行高 52.51 + + /// caption2 行高 47.73 + 上下 4+4 内衬)——超过一半被裁,T-iOS-34 的验收 + /// 「最大字号不破版」实际是 FAIL。现已修好:条高由**键帽实际画出来的高度** + /// 推出(下限 44pt 命中目标,字号在 DS 的密集内容上限处封顶),所以本组 + /// 断言全部是真绿,`withKnownIssue` 标记已删除。 + @Test("KeyBar 在 AX5 下条高容得下它真正画出的键帽(原缺口:裁掉一半以上)") + func keyBarFitsItsKeycapsAtLargestType() { + let measured = KeyBarProbe.measure(at: KeyBarProbe.largestCategory) + + #expect( + measured.keycap <= measured.barHeight + LayoutProbe.epsilon, + "AX5 键帽需 \(measured.keycap)pt,条高只有 \(measured.barHeight)pt" + ) + #expect( + measured.typographic <= measured.barHeight + LayoutProbe.epsilon, + "AX5 排版需求 \(measured.typographic)pt,条高只有 \(measured.barHeight)pt" + ) + } + + @Test("KeyBar 在全部 12 个字号档都不裁切,且条高始终 ≥ 44pt 命中目标") + func keyBarFitsItsKeycapsAtEveryContentSize() { + for category in KeyBarProbe.allCategories { + let measured = KeyBarProbe.measure(at: category) - /// 量到的事实(iPhone 16 Pro 模拟器):AX5 下一个键帽需要 - /// **108.24pt**(footnote 行高 52.51 + caption2 行高 47.73 + 上下 4+4 内衬), - /// 而 `KeyBarMetrics.barHeight` 是**固定 52pt**(44 + 8)——超过一半被裁。 - /// - /// 修法(一行):`barHeight` 改为随 Dynamic Type 缩放,例如 - /// `UIFontMetrics(forTextStyle: .footnote).scaledValue(for: DS.Layout.minHitTarget + DS.Space.sm8)`, - /// 并让 `intrinsicContentSize` 用同一个值。 - /// **`Components/KeyBar.swift` 不在 C4 的 Owns 里**,故这里只用 - /// `withKnownIssue` 把缺口钉成机器可见的记录:修好之后本用例会报 - /// "known issue was not recorded",提醒把这个标记删掉。 - @Test("KeyBar 键帽在 AX5 下超出固定条高(已知缺口:KeyBar.swift 属他人 Owns)") - func keyBarKeycapExceedsBarHeightAtLargestType() { - withKnownIssue("KeyBarMetrics.barHeight 固定 52pt,AX5 键帽需约 108pt → 裁切") { - let requirement = KeyBarProbe.largestTypeRequirement() #expect( - requirement.needed <= requirement.barHeight, - "AX5 键帽需 \(requirement.needed)pt,条高只有 \(requirement.barHeight)pt" + measured.keycap <= measured.barHeight + LayoutProbe.epsilon, + "\(category.rawValue):键帽 \(measured.keycap)pt > 条高 \(measured.barHeight)pt" ) + #expect( + measured.typographic <= measured.barHeight + LayoutProbe.epsilon, + "\(category.rawValue):排版需求 \(measured.typographic)pt > 条高 \(measured.barHeight)pt" + ) + #expect( + measured.barHeight >= DS.Layout.minHitTarget, + "\(category.rawValue):条高 \(measured.barHeight)pt 低于命中目标" + ) + } + } + + /// 回归护栏 + 缺口的历史事实:**不封顶**的 AX5 排版需求(实测 108.24pt = + /// footnote 52.51 + caption2 47.73 + 内衬 8)远超出厂的固定 52pt —— 谁再把 + /// `barHeight` 写回常量,这条就红。 + @Test("未封顶的 AX5 排版需求远超出厂 52pt 固定条高(这就是原缺口)") + func unclampedLargestTypeOverflowsTheOldFixedBar() { + let shipped = DS.Layout.minHitTarget + DS.Space.sm8 + + let unclamped = KeyBarProbe.typographicRequirement(at: KeyBarProbe.largestCategory) + + #expect(unclamped > shipped * 2) + } + + /// 零回归的准确口径:**键帽还没超过 44pt 命中目标的档位**(XS…XL,含出厂 + /// 默认 L)条高逐点还是 52pt。再往上(XXL 起)键帽本身就比 44pt 高了,条高 + /// 必须跟着长——那正是本次修复,不是回归。 + @Test("默认与更小字号档条高逐点不变(44 + 8 = 52,零回归)") + func keyBarKeepsItsShippedHeightAtTheSmallerSizes() { + let shipped = DS.Layout.minHitTarget + DS.Space.sm8 + + for category in [UIContentSizeCategory.extraSmall, .small, .medium, + .large, .extraLarge] { + #expect(KeyBarMetrics.barHeight(category) == shipped, "\(category.rawValue)") + } + #expect(KeyBarProbe.measure(at: .large).barHeight == shipped) + } + + @Test("键帽真的随字号长大(不是把字号焊死换来的假绿)") + func keyBarActuallyGrowsWithType() { + let standard = KeyBarProbe.measure(at: .large) + let largest = KeyBarProbe.measure(at: KeyBarProbe.largestCategory) + + #expect(largest.keycap > standard.keycap) + #expect(largest.barHeight > standard.barHeight) + } + + /// 键栏骑在软键盘上方:长高是必须的,长疯了就把它要驱动的终端吃掉了。 + /// 故字号在 DS 的密集内容上限(`numericClamp`)处封顶——两处策略必须同源。 + @Test("字号封顶 == DS 密集内容策略,封顶后条高不再增长且不超出货架高度的两倍") + func keyBarTypeCeilingMatchesTheDesignSystemPolicy() { + let shipped = DS.Layout.minHitTarget + DS.Space.sm8 + + #expect(DynamicTypeSize(KeyBarMetrics.typeCeiling) == DS.Typography.numericClamp) + + let ceiling = KeyBarMetrics.barHeight(KeyBarMetrics.typeCeiling) + #expect(KeyBarMetrics.barHeight(KeyBarProbe.largestCategory) == ceiling) + // 封顶之前照常长大(不是从第一档就焊死)。 + #expect(ceiling > KeyBarMetrics.barHeight(.large)) + // 有界:AX5 的键栏最多是出厂高度的两倍,终端不会被键栏吃掉。 + #expect(ceiling <= shipped * 2) + } + + @Test("frame 高度与 intrinsicContentSize 一致(自适应输入视图靠后者自量)") + func keyBarFrameMatchesItsIntrinsicHeight() { + for category in [UIContentSizeCategory.large, KeyBarProbe.largestCategory] { + let bar = KeyBarView(contentSizeCategory: category) + + #expect(bar.frame.height == bar.intrinsicContentSize.height) } } } @@ -148,30 +227,60 @@ enum LayoutProbe { } } -/// UIKit 度量:键帽在 AX5 下**需要**多高,对比键栏**给**多高。 +/// UIKit 度量:把**真** `KeyBarView` 建在某个字号档上,量它**实际画出来**的 +/// 键帽有多高、它**给出**的条高有多高。两个数都来自同一个真视图,所以 +/// 「不裁切」是量出来的,不是算出来的。 /// -/// 为什么不直接把 `KeyBarView` 构建在 AX5 trait 里量:`KeyBarView` 的字号来自 -/// `UIFont.preferredFont(forTextStyle:)`(无 `compatibleWith:`),它读的是 +/// 为什么不用 `UITraitCollection.performAsCurrent { KeyBarView() }`: +/// `UIFont.preferredFont(forTextStyle:)`(**无** `compatibleWith:`)读的是 /// **App 级** `preferredContentSizeCategory`,**不看** `UITraitCollection.current` -/// —— 所以 `performAsCurrent { KeyBarView() }` 量出来的是默认字号(实测 38pt), -/// 是个会"永远通过"的假绿。改为按 `compatibleWith:` 取真实 AX5 行高来算需求, -/// 与键帽的两行(glyph + caption)+ 上下内衬一致。 +/// —— 那样量出来的永远是默认字号(实测 38pt)的**假绿**。修复后键栏把字号档 +/// 显式注入(`KeyBarView(contentSizeCategory:)`)并全程走 `compatibleWith:`, +/// 于是这里量到的就是屏幕上真会画出来的东西。 @MainActor enum KeyBarProbe { /// AX5 内容尺寸类别。 - private static let largestCategory = UIContentSizeCategory + static let largestCategory = UIContentSizeCategory .accessibilityExtraExtraExtraLarge - /// - Returns: (键栏给的高度, AX5 下键帽两行所需高度) - static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) { - let traits = UITraitCollection(preferredContentSizeCategory: largestCategory) + /// 全部 12 档(7 标准 + 5 无障碍)——「不破版」是对**每一档**的断言。 + static let allCategories: [UIContentSizeCategory] = [ + .extraSmall, .small, .medium, .large, .extraLarge, .extraExtraLarge, + .extraExtraExtraLarge, .accessibilityMedium, .accessibilityLarge, + .accessibilityExtraLarge, .accessibilityExtraExtraLarge, + .accessibilityExtraExtraExtraLarge, + ] + + /// - Returns: + /// - `barHeight` 键栏给出的条高(`intrinsicContentSize`); + /// - `keycap` 该档下真键帽**实际**要的高度(从真 `KeyBarView` 的按钮量); + /// - `typographic` 同一档的**排版口径**需求 —— 两行行高 + 上下内衬,即原 + /// 「已知缺口」用例的算法(未封顶的 AX5 实测 108.24pt vs 固定 52pt)。 + /// 它是 `keycap` 的保守上界(实测在封顶档 68.86 vs 54.67),**两个都** + /// 必须被条高盖住 —— 宁可多几点余量,也不能少一点造成裁切。 + static func measure( + at category: UIContentSizeCategory + ) -> (barHeight: CGFloat, keycap: CGFloat, typographic: CGFloat) { + let bar = KeyBarView(contentSizeCategory: category) + let keycap = (bar.keyButtons + [bar.voiceButton].compactMap { $0 }) + .map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height } + .max() ?? 0 + return ( + barHeight: bar.intrinsicContentSize.height, + keycap: keycap, + // 只借用「封顶」这一个决定,高度公式本身不复用(否则就是自证)。 + typographic: typographicRequirement( + at: KeyBarMetrics.effectiveCategory(category) + ) + ) + } + + /// 两行文字 + 键帽上下内衬,逐字沿用缺口用例的算法。 + static func typographicRequirement(at category: UIContentSizeCategory) -> CGFloat { + let traits = UITraitCollection(preferredContentSizeCategory: category) let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits) let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits) // KeyBarMetrics.buttonInsets 的上下各 xs4。 - let verticalInsets = DS.Space.xs4 * 2 - return ( - barHeight: KeyBarView().intrinsicContentSize.height, - needed: glyph.lineHeight + caption.lineHeight + verticalInsets - ) + return glyph.lineHeight + caption.lineHeight + DS.Space.xs4 * 2 } } diff --git a/ios/App/WebTermTests/KeyBarTests.swift b/ios/App/WebTermTests/KeyBarTests.swift index 0df7374..4529ee0 100644 --- a/ios/App/WebTermTests/KeyBarTests.swift +++ b/ios/App/WebTermTests/KeyBarTests.swift @@ -75,6 +75,102 @@ struct KeyBarTests { #expect(recorder.keys == KeyBarLayout.buttons.map(\.key)) } + // MARK: - Dynamic Type (T-iOS-34 · the bar tracks the keycap it draws) + + /// Measured height of every keycap, in layout order (the mic key last when + /// present) — the only honest way to ask "did the keycaps really re-render". + private func keycapHeights(_ bar: KeyBarView) -> [CGFloat] { + (bar.keyButtons + [bar.voiceButton].compactMap { $0 }) + .map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height } + } + + @Test("changing the text size re-renders every keycap and re-measures the bar") + func contentSizeCategoryChangeRebuildsTheKeycaps() { + // Arrange: a bar born at the standard size, mic key included. + let bar = KeyBarView( + voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}), + contentSizeCategory: .large + ) + let standardHeight = bar.intrinsicContentSize.height + let standardKeycaps = keycapHeights(bar) + + // Act: the user drags the text-size slider to the maximum. + bar.apply(contentSizeCategory: .accessibilityExtraExtraExtraLarge) + + // Assert: EVERY keycap grew (mic included) and the bar grew to hold them. + let largestKeycaps = keycapHeights(bar) + #expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge) + #expect(bar.intrinsicContentSize.height > standardHeight) + #expect(bar.frame.height == bar.intrinsicContentSize.height) + #expect(standardKeycaps.count == KeyBarLayout.buttons.count + 1) + #expect(zip(standardKeycaps, largestKeycaps).allSatisfy { $1 > $0 }) + #expect(largestKeycaps.allSatisfy { $0 <= bar.intrinsicContentSize.height }) + } + + /// The runtime path, not just the direct call: the user changes the text size + /// in Settings while a terminal is open, UIKit pushes a new trait collection, + /// and the bar must re-measure itself — otherwise it keeps whatever height it + /// was born with until the terminal is reopened. + @Test("a trait-collection change re-lays-out the bar (no reopen needed)") + func traitChangePropagatesToTheBar() { + // Arrange: a bar inside a real window (trait propagation needs one). + let bar = KeyBarView(contentSizeCategory: .large) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.addSubview(bar) + window.layoutIfNeeded() + let before = bar.intrinsicContentSize.height + + // Act: what UIKit does when the system text size changes. + window.traitOverrides.preferredContentSizeCategory = + .accessibilityExtraExtraExtraLarge + window.layoutIfNeeded() + + // Assert + #expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge) + #expect(bar.intrinsicContentSize.height > before) + } + + @Test("re-applying the same text size is a no-op (trait callbacks fire for anything)") + func reapplyingTheSameCategoryChangesNothing() { + // Arrange + let bar = KeyBarView(contentSizeCategory: .large) + let before = keycapHeights(bar) + let beforeHeight = bar.intrinsicContentSize.height + + // Act + bar.apply(contentSizeCategory: .large) + + // Assert + #expect(bar.contentSizeCategory == .large) + #expect(keycapHeights(bar) == before) + #expect(bar.intrinsicContentSize.height == beforeHeight) + } + + /// The HIG floor is enforced by a constraint, not by the glyph: at the small + /// text sizes a keycap's own content is only ~37pt tall, so without this the + /// bar would shrink below a tappable target. Asserted structurally because + /// `systemLayoutSizeFitting` reports the CONTENT size (it is what the + /// clipping test needs) and would not show the constraint at all. + @Test("every keycap carries the 44pt HIG hit-target floor, at every text size") + func keycapsCarryTheHitTargetFloor() { + for category in [UIContentSizeCategory.extraSmall, .large, + .accessibilityExtraExtraExtraLarge] { + let bar = KeyBarView( + voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}), + contentSizeCategory: category + ) + + for button in bar.keyButtons + [bar.voiceButton].compactMap({ $0 }) { + #expect(button.constraints.contains { + $0.firstAttribute == .height + && $0.relation == .greaterThanOrEqual + && $0.constant == DS.Layout.minHitTarget + }) + } + #expect(bar.intrinsicContentSize.height >= DS.Layout.minHitTarget) + } + } + // MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping) @Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap") diff --git a/ios/App/WebTermTests/SessionThumbnailTokenTests.swift b/ios/App/WebTermTests/SessionThumbnailTokenTests.swift new file mode 100644 index 0000000..52b87dc --- /dev/null +++ b/ios/App/WebTermTests/SessionThumbnailTokenTests.swift @@ -0,0 +1,231 @@ +import APIClient +import Foundation +import HostRegistry +import TestSupport +import Testing +import UIKit +import WireProtocol +@testable import WebTerm + +/// F1 · 令牌门主机上的缩略图管线。 +/// +/// 缺口:`SessionThumbnailPipeline.live()` 自建了一个**没有令牌源**的 +/// `URLSessionHTTPTransport`,于是在开了 `WEBTERM_TOKEN` 的主机上 +/// `GET /live-sessions/:id/preview` 一律 401;而本管线按设计**永不抛错** +/// (失败即占位图),401 就被静默降级成「每一张缩略图都是占位符」, +/// 屏幕上没有任何原因可查。 +/// +/// 这里钉三件事: +/// 1. 令牌真的到了 preview 请求头上(走的是**生产的**盖章代码路径 +/// `URLSessionHTTPTransport.authenticated`,不是测试里重写一遍); +/// 2. 只读纪律零回归 —— preview 是 RO,**绝不**带 `Origin`(Origin-iff-G); +/// 3. 没有令牌 / 令牌不对(401)时依旧**优雅降级**成占位图,不崩不抛。 +@MainActor +@Suite("SessionThumbnail 令牌接线") +struct SessionThumbnailTokenTests { + /// 32 个合法字符(§1.1 字符集 `[A-Za-z0-9._~+/=-]`,长度 16–512)。 + private static let token = "0123456789abcdefghijklmnopqrstuv" + private static let base = "http://192.168.1.20:3000" + + // MARK: - Fixtures + + /// 生产的盖章逻辑(`URLSessionHTTPTransport.authenticated`)串在一个可脚本化 + /// 的记录器前面:真实 Cookie 代码路径,零网络。 + private struct StampingTransport: HTTPTransport { + let stamper: URLSessionHTTPTransport + let recorder: FakeHTTPTransport + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + try await recorder.send(await stamper.authenticated(request)) + } + } + + private func makeEndpoint(_ base: String = SessionThumbnailTokenTests.base) throws + -> HostEndpoint { + let url = try #require(URL(string: base)) + return try #require(HostEndpoint(baseURL: url)) + } + + private func makeStore(token: String?) async throws -> InMemoryHostStore { + let store = InMemoryHostStore() + _ = try await store.upsert( + HostRegistry.Host( + id: UUID(), name: "mac", endpoint: try makeEndpoint(), + accessToken: try token.map { try AccessToken(validating: $0) } + ) + ) + return store + } + + private func previewURL(_ sessionId: UUID) throws -> URL { + try #require( + URL(string: "\(Self.base)/live-sessions/\(sessionId.uuidString.lowercased())/preview") + ) + } + + private func previewBody(_ sessionId: UUID) throws -> Data { + try #require(""" + {"id":"\(sessionId.uuidString.lowercased())","cols":80,"rows":24,"data":"hello"} + """.data(using: .utf8)) + } + + /// 装配一条**默认生产形状**的管线,只把网络出口换成记录器。 + private func makePipeline( + store: any HostStore + ) -> (pipeline: SessionThumbnailPipeline, recorder: FakeHTTPTransport) { + let recorder = FakeHTTPTransport() + let transport = StampingTransport( + stamper: AppEnvironment.thumbnailTransport( + hostStore: store, identityProvider: { nil } + ), + recorder: recorder + ) + return (SessionThumbnailPipeline.live(http: transport), recorder) + } + + // MARK: - 令牌门主机 + + @Test("令牌门主机:preview 请求带上 Cookie: webterm_auth=,且缩略图真渲染出来") + func previewRequestCarriesTheAccessTokenCookie() async throws { + // Arrange + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token)) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + let requests = await recorder.recordedRequests + #expect(requests.count == 1) + #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) + == "\(AccessTokenCookie.name)=\(Self.token)") + #expect(!image.isPlaceholder, "带上令牌后 preview 应当成功并渲染") + } + + @Test("preview 是只读路由:带令牌也绝不带 Origin(Origin-iff-G 零回归)") + func previewStaysOriginFree() async throws { + // Arrange + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token)) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + _ = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + let requests = await recorder.recordedRequests + #expect(requests.first?.value(forHTTPHeaderField: "Origin") == nil) + } + + // MARK: - 无令牌 / 令牌不对 + + @Test("主机没有令牌 → 请求逐字不带 Cookie(LAN 零配置主机零回归)") + func tokenlessHostSendsNoCookie() async throws { + // Arrange + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil)) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + let requests = await recorder.recordedRequests + #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) + #expect(!image.isPlaceholder) + } + + @Test("令牌缺失导致 401 → 降级成占位图,不崩不抛(管线的错误 UI 就是占位图)") + func unauthorizedDegradesToPlaceholder() async throws { + // Arrange:主机记录里没有令牌,服务器要求令牌 → 401。 + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil)) + await recorder.queueSuccess( + url: try previewURL(sessionId), status: 401, + body: try #require(#"{"error":"unauthorized"}"#.data(using: .utf8)) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + #expect(image == .placeholder) + #expect(await recorder.recordedRequests.count == 1) + } + + @Test("主机根本不在存储里(存储读不到该 origin)→ 无 Cookie,仍不崩") + func unknownOriginDegradesGracefully() async throws { + // Arrange:存储里是另一台主机。 + let sessionId = UUID() + let store = InMemoryHostStore() + let otherEndpoint = try makeEndpoint("http://192.168.1.99:3000") + _ = try await store.upsert( + HostRegistry.Host( + id: UUID(), name: "other", endpoint: otherEndpoint, + accessToken: try AccessToken(validating: Self.token) + ) + ) + let (pipeline, recorder) = makePipeline(store: store) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert:绝不把别台主机的令牌发出去。 + let requests = await recorder.recordedRequests + #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) + #expect(!image.isPlaceholder) + } + + // MARK: - 组合根装配本身 + + @Test("thumbnailTransport 就是 App 的同一条盖章传输(按 origin 取本主机令牌)") + func thumbnailTransportStampsPerOrigin() async throws { + // Arrange + let transport = AppEnvironment.thumbnailTransport( + hostStore: try await makeStore(token: Self.token), identityProvider: { nil } + ) + let mine = URLRequest(url: try previewURL(UUID())) + let other = URLRequest( + url: try #require(URL(string: "http://192.168.1.99:3000/live-sessions")) + ) + + // Act + let stamped = await transport.authenticated(mine) + let untouched = await transport.authenticated(other) + + // Assert + #expect(stamped.value(forHTTPHeaderField: AccessTokenCookie.header) + == "\(AccessTokenCookie.name)=\(Self.token)") + #expect(untouched.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) + } +} diff --git a/ios/README.md b/ios/README.md index 4494543..2b94e7d 100644 --- a/ios/README.md +++ b/ios/README.md @@ -288,9 +288,13 @@ remotely, watch status, and reattach with full scrollback. foreground contrast. Dynamic Type up to **AX5** is measured, not assumed: gate banner, telemetry chips and meta rows are asserted not to overflow 320 pt, with a numeric clamp keeping tabular figures from blowing up the layout. - **Known gap (measured, not fixed):** the fixed 52 pt key-bar needs ~108 pt at - AX5, so key caps clip — recorded as a `withKnownIssue` in - `DynamicTypeLayoutTests` so the test starts complaining once it is fixed. + The key-bar height is derived from the content size category rather than + frozen at 52 pt, so nothing clips at any size. Its keycap **font** is clamped + at `.accessibilityLarge` — the same dense-content policy the design system + already applies to telemetry chips — because an unclamped AX5 bar wants + ~108 pt of the 17-key strip and would eat the terminal. So AX3–AX5 render + keycaps at AX2 size; a test pins the clamp to the design-system constant so + the two cannot drift, and another keeps the bar at exactly 52 pt for XS–XL. - **Project git panel** — the ambient git state the server has served since w6, now on the phone: a **sync band** (`↑ahead ↓behind`, upstream, detached-HEAD and fetch-staleness states — green "in sync" *only* when `↑0 ↓0` **and** the diff --git a/ios/project.yml b/ios/project.yml index f315326..da89829 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -54,8 +54,9 @@ packages: # Package.resolved, which lives inside the bundle — is gitignored, so every # agent/CI run re-resolves from scratch, and a floating `from:` would silently # drift to whatever is newest. An exact pin makes every agent/CI run resolve - # the SAME source (the earlier drift 1.13.0 → 1.15.0 is what broke the App - # target); raising it is a deliberate, verified edit. + # the SAME source (an unattended drift off `from: 1.13.0` is what once broke + # the App target — see the history below); raising it is a deliberate, + # verified edit, and 1.15.0 below IS that deliberate raise. # # History (T-iOS-33/31 root fix): v1.14.0 added `public var hasActiveSelection` # to iOSTerminalView, which collided with a same-named property the App @@ -169,8 +170,11 @@ targets: # Unit-test bundle for App-target logic (ViewModels & pure UI components, # W3 T-iOS-11…14). Added by the orchestrator as a coordination point # (project.yml is T-iOS-1's Owns; same precedent as the W1 manifest wiring). - # Coverage GATE still counts only the 4 packages (plan §9) — these tests - # exist for correctness, not for the gate. + # These tests exist for correctness, NOT for the coverage gate: the gate runs + # per SwiftPM package over that package's own sources + # (IntegrationTests/scripts/coverage-gate.sh), and an App-target test bundle is + # not one of them. It now covers FIVE packages — plan §9's four plus ClientTLS, + # which B4 added (see .github/workflows/ios.yml's package-tests matrix). WebTermTests: type: bundle.unit-test platform: iOS