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

176
.github/workflows/android.yml vendored Normal file
View File

@@ -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=<sdk> ./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

3
android/.gitignore vendored
View File

@@ -40,3 +40,6 @@ service-account*.json
# Kover / test reports
**/kover/
# Kotlin 2.x build side-effect (created by any ./gradlew invocation)
.kotlin/

View File

@@ -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
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)
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)

View File

@@ -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<Unit>()
val neverAnswers = CompletableDeferred<PairingProbeResult>()
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

View File

@@ -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 · XSXL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3AX5 的字号不再增长(不封顶时 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 对密集内容的既有策略实测条高 XSXL 52 / XXL 55 / XXXL 59 / AccM 68 / AccLAX5 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 的目视走查模拟器无相机)。

View File

@@ -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` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。
- **一个诚实的取舍**: 键栏在 AX3AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。
### 🌿 [x] w6 项目详情 Git 面板 — G1G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`)
- **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`

View File

@@ -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()

View File

@@ -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 URLSessionpreview
/// 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 URLSessionpreview
/// 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

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.

View File

@@ -103,26 +103,105 @@ struct DynamicTypeLayoutTests {
#expect(size.height.isFinite)
}
// MARK: - F29 · KeyBarUIKit vs
// MARK: - F29 · KeyBarUIKit inputAccessoryView
/// F1 · `withKnownIssue` ****`KeyBarMetrics.barHeight`
/// 52pt44 + 8 AX5 ~108ptfootnote 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)
/// 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 固定 52ptAX5 键帽需约 108pt → 裁切") {
let requirement = KeyBarProbe.largestTypeRequirement()
#expect(
requirement.needed <= requirement.barHeight,
"AX5 键帽需 \(requirement.needed)pt条高只有 \(requirement.barHeight)pt"
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)
#expect(
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 **XSXL
/// L 52ptXXL 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
}
}

View File

@@ -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")

View File

@@ -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._~+/=-]` 16512
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=<t>,且缩略图真渲染出来")
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 是只读路由:带令牌也绝不带 OriginOrigin-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("主机没有令牌 → 请求逐字不带 CookieLAN 零配置主机零回归)")
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)
}
}

View File

@@ -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 AX3AX5 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 XSXL.
- **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

View File

@@ -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