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 / test reports
**/kover/ **/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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.pairing.AuthProbeResult import wang.yaojia.webterm.api.pairing.AuthProbeResult
import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult 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 * 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. * 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 * 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. * per call, so no state snapshot, log or crash report can carry it.
* *
@@ -161,8 +171,12 @@ public class PairingViewModel(
return return
} }
val scope = scope ?: return val scope = scope ?: return
probeJob?.cancel() val superseded = probeJob
superseded?.cancel()
probeJob = scope.launch { 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. // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard.
if (tier.isCertGated && !hasDeviceCert()) { if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier) _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) _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store). // 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 {
when (val result = prober.probe(candidate.endpoint)) { accessToken.isBlank() -> TokenAttempt.NONE
is PairingProbeResult.Success -> onProbeSuccess(candidate) else -> establishToken(candidate, accessToken) ?: return@launch
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
} }
// 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. * 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); * Returns the [TokenAttempt] to undo should pairing not complete (RULE 6) — [TokenAttempt.NONE] when
* false after publishing the matching [TokenError] / failure state. * 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 { val outcome = try {
authProber.validate(candidate.endpoint, token) authProber.validate(candidate.endpoint, token)
} catch (cancel: CancellationException) { } catch (cancel: CancellationException) {
@@ -191,13 +221,13 @@ public class PairingViewModel(
} catch (error: Throwable) { } catch (error: Throwable) {
// A network-level failure is not a token problem — classify it like any other probe error. // A network-level failure is not a token problem — classify it like any other probe error.
onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint)) onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint))
return false return null
} }
return when (outcome) { return when (outcome) {
AuthProbeResult.Accepted -> storeToken(candidate, token) AuthProbeResult.Accepted -> storeToken(candidate, token)
// 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated") // 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. // 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.InvalidToken -> failToken(candidate, TokenError.INVALID)
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.TOO_MANY_ATTEMPTS) AuthProbeResult.RateLimited -> failToken(candidate, TokenError.TOO_MANY_ATTEMPTS)
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED) AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
@@ -205,26 +235,49 @@ public class PairingViewModel(
} }
} }
private suspend fun storeToken(candidate: Candidate, token: String): Boolean { /** Persist [token], remembering what the store held before so RULE 6 can put it back. */
val stored = runCatching { tokenStore.put(candidate.endpoint, token) } private suspend fun storeToken(candidate: Candidate, token: String): TokenAttempt? {
return when { return try {
stored.getOrNull() == true -> true val previous = tokenStore.tokenFor(candidate.endpoint)
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT). // 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. // 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). */ /** 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( _uiState.value = PairingUiState.Confirming(
endpoint = candidate.endpoint, endpoint = candidate.endpoint,
name = candidate.name, name = candidate.name,
tier = candidate.tier, tier = candidate.tier,
tokenError = error, tokenError = error,
) )
return false return null
} }
private fun onProbeFailure(candidate: Candidate, error: PairingError) { 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) val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
if (host == null) { if (host == null) {
// Unreachable in practice (the endpoint already validated), but never persist a null host. // Unreachable in practice (the endpoint already validated), but never persist a null host.
@@ -253,10 +307,11 @@ public class PairingViewModel(
error = PairingError.HttpOkButNotWebTerminal, error = PairingError.HttpOkButNotWebTerminal,
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier), message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
) )
return return false
} }
hostStore.upsert(host) hostStore.upsert(host)
_uiState.value = PairingUiState.Paired(host) _uiState.value = PairingUiState.Paired(host)
return true
} }
private fun defaultName(endpoint: HostEndpoint): String = 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. */ /** 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) 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) { private fun PairingUiState.candidate(): Candidate? = when (this) {
is PairingUiState.Confirming -> Candidate(endpoint, name, tier) is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
is PairingUiState.Probing -> Candidate(endpoint, name, tier) is PairingUiState.Probing -> Candidate(endpoint, name, tier)

View File

@@ -1,7 +1,9 @@
package wang.yaojia.webterm.viewmodels package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf 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 * - 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; * 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]); * - 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). * - the token NEVER appears in the UI state (no accidental leak into a state dump / crash report).
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -33,6 +38,12 @@ class PairingTokenViewModelTest {
private companion object { private companion object {
const val LAN = "http://10.0.0.5:3000" const val LAN = "http://10.0.0.5:3000"
const val TOKEN = "0123456789abcdefTOKEN" 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. */ /** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */
@@ -238,6 +249,102 @@ class PairingTokenViewModelTest {
assertEquals(TOKEN, tokens.tokenFor(endpoint())) 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 ───────────────────────────────────────────────────────────────────────────────── // ── secrecy ─────────────────────────────────────────────────────────────────────────────────
@Test @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-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-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-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-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-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-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-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` | | 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-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-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-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-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` 里实跑数字一致; **说明**:上表的"测试数"是 `@Test` **声明**静态计数(`grep -rh '@Test'`),与 commit `850531f`/`a5fa843` 里实跑数字一致;
参数化用例实跑会更多。App bundle 侧共 **532**`@Test` 声明。 参数化用例实跑会更多。App bundle 侧共 **532**`@Test` 声明。
@@ -588,10 +588,15 @@ W5 验收(report-only, 并行)
另补 iPad UI-test 腿(同 commit 另补 iPad UI-test 腿(同 commit
- **签名解锁**`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关 - **签名解锁**`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关
commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`7 天临时 profile commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`7 天临时 profile
- **已知未闭合缺口(供后续任务领走)**:① AX5 键栏裁切(见 T-iOS-34 行);**无端到端令牌腿**—— - **收尾波2026-07-30闭合了其中三条**:① AX5 键栏裁切 → 已修(见 T-iOS-34 行);
`IntegrationTests` 与模拟器内 live-server smoke 都不带 `WEBTERM_TOKEN` 起服务器grep 无 `WEBTERM_TOKEN`/`webterm_auth` ② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举)
令牌路径目前只有单元级覆盖;③ T-iOS-19 的产物层复核未覆盖 P2 新增的两条 usage description 跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/
④ worktree 建/删的**端到端**一次T-iOS-32 Accept既无自动化腿也未手工跑。 令牌不替代 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 人天 ### 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-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**: 亮暗主题 + 最大字号不破版 - **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)。默认仍是深色零回归)。 - **落地**: `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 直达同会话 - **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 不回显链接内容 - **落地**: `DeepLinkRouter` `.joinShared` 分支 + `DeepLinkJoinTests` 19 含把原"scheme 不是 webterminal"的拒绝理由改写为"web 形状只许单一 `join` "主机身份只经 `HostStore`+`HostEndpoint.originHeader` 解析未配对 origin 一律走配对页且 hint 不回显链接内容
- **DEFERRED**: 用真手机相机扫 web 分享 QR 的目视走查模拟器无相机)。 - **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`) ### 🌿 [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` - **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 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). /// 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 /// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar
/// stays on-grid with the rest of the app. /// stays on-grid with the rest of the app.
private enum KeyBarMetrics { ///
/// A hit-target-tall row plus a little breathing room (44 + 8). /// T-iOS-34 acceptance (" + ****") · The bar height used
static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8 /// 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 buttonSpacing: CGFloat = DS.Space.xs4
static let contentInset: CGFloat = DS.Space.sm8 static let contentInset: CGFloat = DS.Space.sm8
static let buttonInsets = NSDirectionalEdgeInsets( static let buttonInsets = NSDirectionalEdgeInsets(
top: DS.Space.xs4, leading: DS.Space.md12, top: DS.Space.xs4, leading: DS.Space.md12,
bottom: DS.Space.xs4, trailing: 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) // MARK: - KeyBarView (inputAccessoryView)
@@ -140,19 +214,47 @@ final class KeyBarView: UIInputView {
/// The 🎤 push-to-talk key, nil unless voice handlers were supplied. /// The 🎤 push-to-talk key, nil unless voice handlers were supplied.
private(set) var voiceButton: UIButton? 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? private let voice: KeyBarVoiceHandlers?
/// - Parameter voice: press/release outlets for the 🎤 key (T-iOS-31). /// - Parameters:
/// nil no mic key at all (mirrors the web bar, which only renders it /// - voice: press/release outlets for the 🎤 key (T-iOS-31). nil no mic
/// when speech is supported AND a trigger is supplied). /// key at all (mirrors the web bar, which only renders it when speech is
init(voice: KeyBarVoiceHandlers? = nil) { /// 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.voice = voice
self.contentSizeCategory = contentSizeCategory
super.init( 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 inputViewStyle: .keyboard
) )
allowsSelfSizing = true allowsSelfSizing = true
buildBar() 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") @available(*, unavailable, message: "KeyBarView is code-built only")
@@ -161,7 +263,29 @@ final class KeyBarView: UIInputView {
} }
override var intrinsicContentSize: CGSize { 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() { private func buildBar() {
@@ -239,25 +363,35 @@ final class KeyBarView: UIInputView {
/// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is /// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is
/// the UIKit twin of DS.Palette.textSecondary the DS UIColor surface only /// the UIKit twin of DS.Palette.textSecondary the DS UIColor surface only
/// vends the accent, so native system labels stand in at this boundary.) /// vends the accent, so native system labels stand in at this boundary.)
private func makeKeycap( ///
label: String, caption: String, title: String, isPrimary: Bool /// Both fonts come from `KeyBarMetrics`, resolved `compatibleWith:` the
) -> UIButton { /// 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() var config: UIButton.Configuration = isPrimary ? .tinted() : .gray()
config.cornerStyle = .fixed config.cornerStyle = .fixed
config.background.cornerRadius = DS.Radius.sm8 config.background.cornerRadius = DS.Radius.sm8
var attributedLabel = AttributedString(label) var attributedLabel = AttributedString(label)
attributedLabel.font = UIFont.monospacedSystemFont( attributedLabel.font = KeyBarMetrics.glyphFont(contentSizeCategory)
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .semibold
)
config.attributedTitle = attributedLabel config.attributedTitle = attributedLabel
var attributedCaption = AttributedString(caption) var attributedCaption = AttributedString(caption)
attributedCaption.font = UIFont.preferredFont(forTextStyle: .caption2) attributedCaption.font = KeyBarMetrics.captionFont(contentSizeCategory)
attributedCaption.foregroundColor = .secondaryLabel attributedCaption.foregroundColor = .secondaryLabel
config.attributedSubtitle = attributedCaption config.attributedSubtitle = attributedCaption
config.titleAlignment = .center config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets 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) let button = UIButton(configuration: config)
if isPrimary { if isPrimary {
button.tintColor = DS.Palette.accentUIColor() button.tintColor = DS.Palette.accentUIColor()

View File

@@ -1,5 +1,4 @@
import APIClient import APIClient
import ClientTLS
import Foundation import Foundation
import os import os
import SwiftUI import SwiftUI
@@ -226,19 +225,25 @@ final class SessionThumbnailPipeline {
cache = SessionThumbnailCache(maxEntries: maxCacheEntries) cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
} }
/// ephemeral URLSessionpreview /// ****
/// only T-iOS-19 RO GET C-iOS-2 /// `AppEnvironment.thumbnailTransport()` ephemeral URLSessionpreview
/// mTLS nginx /// only T-iOS-19 RO GET
/// provider keychain MEDIUM /// + C-iOS-2 nginx
/// =nil /// + **C1 访 Cookie**/
static func live() -> SessionThumbnailPipeline { ///
let identityStore = KeychainClientIdentityStore() ///
let transport = URLSessionHTTPTransport(identityProvider: { /// F1****
identityStore.loadedIdentityOrNil() /// `WEBTERM_TOKEN` `GET /live-sessions/:id/preview` 401
}) /// 线401
return SessionThumbnailPipeline( /// ****
///
/// - Parameter http:
static func live(
http: any HTTPTransport = AppEnvironment.thumbnailTransport()
) -> SessionThumbnailPipeline {
SessionThumbnailPipeline(
loader: { request in loader: { request in
try await APIClient(endpoint: request.endpoint, http: transport) try await APIClient(endpoint: request.endpoint, http: http)
.preview(id: request.sessionId) .preview(id: request.sessionId)
}, },
renderer: { data, cols, rows in renderer: { data, cols, rows in

View File

@@ -22,6 +22,11 @@ import WireProtocol
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it); /// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
/// UserDefaults carries only the non-secret per-host lastSessionId. A token /// UserDefaults carries only the non-secret per-host lastSessionId. A token
/// never reaches a log line, a URL query, or an error description. /// 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 /// - No debug ATS overrides exist (project.yml declares NO
/// NSAllowsArbitraryLoads in any configuration only the five §5.2 CIDR /// NSAllowsArbitraryLoads in any configuration only the five §5.2 CIDR
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used; /// 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 // missing cert is the normal pre-install state ( nil); genuine faults
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only // are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
// fire for tunnel hosts, so a `nil` result is inert for local hosts. // fire for tunnel hosts, so a `nil` result is inert for local hosts.
let identityStore = KeychainClientIdentityStore() let identityProvider = keychainIdentityProvider()
let identityProvider: @Sendable () -> ClientIdentity? = {
identityStore.loadedIdentityOrNil()
}
let hostStore = KeychainHostStore() let hostStore = KeychainHostStore()
// C1 · ONE access-token source for the whole app, reading the same // C1 · ONE access-token source for the whole app, reading the same
// Keychain records the pairing flow writes. Both transports consult it // 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 /// 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 /// host (the engine never holds an HTTP client plan §3.2). The token rides
/// along at the transport, so this stays token-free. /// along at the transport, so this stays token-free.

View File

@@ -103,26 +103,105 @@ struct DynamicTypeLayoutTests {
#expect(size.height.isFinite) #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)
#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 固定 52ptAX5 键帽需约 108pt → 裁切") {
let requirement = KeyBarProbe.largestTypeRequirement()
#expect( #expect(
requirement.needed <= requirement.barHeight, measured.keycap <= measured.barHeight + LayoutProbe.epsilon,
"AX5 键帽 \(requirement.needed)pt,条高只有 \(requirement.barHeight)pt" "\(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` /// `UITraitCollection.performAsCurrent { KeyBarView() }`
/// `UIFont.preferredFont(forTextStyle:)` `compatibleWith:` /// `UIFont.preferredFont(forTextStyle:)`**** `compatibleWith:`
/// **App ** `preferredContentSizeCategory`**** `UITraitCollection.current` /// **App ** `preferredContentSizeCategory`**** `UITraitCollection.current`
/// `performAsCurrent { KeyBarView() }` 38pt /// 38pt**绿**
/// ""绿 `compatibleWith:` AX5 /// `KeyBarView(contentSizeCategory:)` `compatibleWith:`
/// glyph + caption+ /// 西
@MainActor @MainActor
enum KeyBarProbe { enum KeyBarProbe {
/// AX5 /// AX5
private static let largestCategory = UIContentSizeCategory static let largestCategory = UIContentSizeCategory
.accessibilityExtraExtraExtraLarge .accessibilityExtraExtraExtraLarge
/// - Returns: (, AX5 ) /// 12 7 + 5 ****
static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) { static let allCategories: [UIContentSizeCategory] = [
let traits = UITraitCollection(preferredContentSizeCategory: largestCategory) .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 glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits)
let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits) let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits)
// KeyBarMetrics.buttonInsets xs4 // KeyBarMetrics.buttonInsets xs4
let verticalInsets = DS.Space.xs4 * 2 return glyph.lineHeight + caption.lineHeight + DS.Space.xs4 * 2
return (
barHeight: KeyBarView().intrinsicContentSize.height,
needed: glyph.lineHeight + caption.lineHeight + verticalInsets
)
} }
} }

View File

@@ -75,6 +75,102 @@ struct KeyBarTests {
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key)) #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) // MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap") @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: 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, 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. 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 The key-bar height is derived from the content size category rather than
AX5, so key caps clip — recorded as a `withKnownIssue` in frozen at 52 pt, so nothing clips at any size. Its keycap **font** is clamped
`DynamicTypeLayoutTests` so the test starts complaining once it is fixed. 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, - **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 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 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 # Package.resolved, which lives inside the bundle — is gitignored, so every
# agent/CI run re-resolves from scratch, and a floating `from:` would silently # 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 # 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 # the SAME source (an unattended drift off `from: 1.13.0` is what once broke
# target); raising it is a deliberate, verified edit. # 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` # 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 # 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, # Unit-test bundle for App-target logic (ViewModels & pure UI components,
# W3 T-iOS-11…14). Added by the orchestrator as a coordination point # 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). # (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 # These tests exist for correctness, NOT for the coverage gate: the gate runs
# exist for correctness, not for the gate. # 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: WebTermTests:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS