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:
176
.github/workflows/android.yml
vendored
Normal file
176
.github/workflows/android.yml
vendored
Normal 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
3
android/.gitignore
vendored
@@ -40,3 +40,6 @@ service-account*.json
|
||||
|
||||
# Kover / test reports
|
||||
**/kover/
|
||||
|
||||
# Kotlin 2.x build side-effect (created by any ./gradlew invocation)
|
||||
.kotlin/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -540,10 +540,10 @@ W5 验收(report-only, 并行)
|
||||
| T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift`、`Components/TelemetryChips.swift`、`ViewModels/SessionListViewModel.swift` + Tests |
|
||||
| T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift`、`ViewModels/GateViewModel.swift` + `GateViewModelTests` |
|
||||
| T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` |
|
||||
| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **10** `@Test` + `scripts/coverage-gate.sh` + `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)。**未核实**:GH Actions 平台侧的运行结果(本环境 `gh` 未登录) |
|
||||
| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **26** `@Test`(10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫)+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS)+ `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml`。`ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**:GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea,两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 |
|
||||
| T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 |
|
||||
| T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名(P0 收官条目)。**缺口**:F-iOS 的真机项(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED,手工清单在 LOG |
|
||||
| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核(Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。**缺口**:P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` 尚未在**产物层**复核;release ipa 层核对仍未做(免费 team 无分发通道) |
|
||||
| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核(Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**:release ipa 层核对仍未做(免费 team 无分发通道) |
|
||||
| T-iOS-20 server APNs | `[x]` | `src/push/apns.ts` + `test/push-apns.test.ts`(含本地 h2c 假 APNs 的双 e2e;具体测试数以 `npm test` 输出为准,LOG 记 65)。**DEFERRED**:真机端到端需付费账号 |
|
||||
| T-iOS-37 server lastOutputAt | `[x]` | `src/types.ts:315` 可选字段 + `src/session/manager.ts:197` 映射 + 测试 |
|
||||
| T-iOS-38 APIClient P1 契约 | `[x]` | `APIClient/{ApnsToken,Projects,Prefs}.swift` + `ApnsTokenTests`/`ProjectsTests`/`PrefsRoundTripTests` |
|
||||
@@ -560,9 +560,9 @@ W5 验收(report-only, 并行)
|
||||
| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 |
|
||||
| T-iOS-32 Worktree + `--resume` 历史 | `[~]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift`、`ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**缺口**:Accept 的"端到端一次"(对真服务器真建/真删 worktree)无自动化腿、本环境也未手工跑 |
|
||||
| T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) |
|
||||
| T-iOS-34 主题 + Dynamic Type | `[~]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。**缺口(已实测、未修)**:AX5 下键栏需 ~108.24pt 而 `KeyBarMetrics.barHeight` 固定 52pt → 键帽裁切,以 `withKnownIssue` 钉住(`KeyBar.swift` 不在该任务 Owns) |
|
||||
| T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言(AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XS–XL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3–AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 |
|
||||
| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift` 的 `.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) |
|
||||
| T-iOS-36 P2 验收 | `[~]` | 本波 Wave D 的验收 agent 正在执行;**结果与真实数字以 `PROGRESS_LOG.md` 的该条目为准**,本文不预写结论 |
|
||||
| T-iOS-36 P2 验收 | `[x]` | 两轮独立复验(Wave D + 收尾波)均已跑完,**真实数字以 `PROGRESS_LOG.md` 的该条目为准**:452 包测 / 550 App 测(iPhone 16 Pro 与 iPad Pro 11" M4 各一遍,**0 known issue**)/ 26 集成测 / 覆盖率门 5/5 / 真机构建 `BUILD SUCCEEDED` 且产物无 `aps-environment`。P2 五项逐条走查见本节上方各行 |
|
||||
|
||||
**说明**:上表的"测试数"是 `@Test` **声明**静态计数(`grep -rh '@Test'`),与 commit `850531f`/`a5fa843` 里实跑数字一致;
|
||||
参数化用例实跑会更多。App bundle 侧共 **532** 条 `@Test` 声明。
|
||||
@@ -588,10 +588,15 @@ W5 验收(report-only, 并行)
|
||||
另补 iPad UI-test 腿(同 commit)。
|
||||
- **签名解锁**:`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关
|
||||
(commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`,7 天临时 profile)。
|
||||
- **已知未闭合缺口(供后续任务领走)**:① AX5 键栏裁切(见 T-iOS-34 行);② **无端到端令牌腿**——
|
||||
`IntegrationTests` 与模拟器内 live-server smoke 都不带 `WEBTERM_TOKEN` 起服务器(grep 无 `WEBTERM_TOKEN`/`webterm_auth`),
|
||||
令牌路径目前只有单元级覆盖;③ T-iOS-19 的产物层复核未覆盖 P2 新增的两条 usage description;
|
||||
④ worktree 建/删的**端到端**一次(T-iOS-32 Accept)既无自动化腿也未手工跑。
|
||||
- **收尾波(2026-07-30)闭合了其中三条**:① AX5 键栏裁切 → 已修(见 T-iOS-34 行);
|
||||
② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举)
|
||||
跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/
|
||||
令牌不替代 Origin(合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态;
|
||||
另加 `TokenPolicyDriftTests.swift` **8 例**漂移守卫(运行期读 `src/config.ts` 与 `src/http/auth.ts` 的真字面量,
|
||||
与三份 Swift 谓词逐条比对,含变异测试证明守卫自身够响);③ 两条 usage description 已在产物层复核(见 T-iOS-19 行)。
|
||||
- **剩余未闭合**:release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);
|
||||
Android instrumented 腿(`TinkAccessTokenStoreTest` 是令牌静态加密的唯一证明,需真机或模拟器,CI 上暂设为
|
||||
`workflow_dispatch` 触发——理由写在 `android.yml` 文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿)。
|
||||
|
||||
### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天
|
||||
|
||||
@@ -920,7 +925,7 @@ W5 验收(report-only, 并行)
|
||||
- **T-iOS-33** · 终端内搜索 `[x]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮。
|
||||
- **T-iOS-34** · 主题 + Dynamic Type `[~]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量(与同波任务文件不相交,开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版。
|
||||
- **落地**: `DesignSystem/{AppTheme,TerminalPalette}.swift` + `Screens/SettingsScreen.swift`(齿轮入口在 `ProjectsToolbarItem` 内,stack 与 split 两根视图共享)+ Tokens/Typography 浅色档;`AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。默认仍是深色(零回归)。
|
||||
- **缺口(已实测、未修)**: AX5 下键栏两行需 **108.24pt**,`KeyBarMetrics.barHeight` 固定 **52pt**(44+8)→ 键帽裁切;以 `withKnownIssue` 钉在 `DynamicTypeLayoutTests`(修好后该用例会报 "known issue was not recorded",提醒删标记)。`Components/KeyBar.swift` 不在该任务 Owns,需另派。
|
||||
- **缺口已闭合(收尾波,2026-07-30)**: `KeyBarMetrics.barHeight` 从常量 52pt 改为 `max(minHitTarget, keycapHeight(category)) + sm8`,`intrinsicContentSize`/init frame/`apply(contentSizeCategory:)` 三处同源,并经 iOS 17 `registerForTraitChanges` 支持运行期改档。`withKnownIssue` 已删。**取舍**:键帽字体封顶 `.accessibility2`(不封顶时 AX5 需 108.24pt,会把终端吃掉),沿用 DS 对密集内容的既有策略;实测条高 XS–XL 52 / XXL 55 / XXXL 59 / AccM 68 / AccL–AX5 77。
|
||||
- **T-iOS-35** · web `?join=` 互通(分享 QR 双向)`[x]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量(`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话。
|
||||
- **落地**: `DeepLinkRouter` 增 `.joinShared` 分支 + `DeepLinkJoinTests` 19 测(含把原"scheme 不是 webterminal"的拒绝理由改写为"web 形状只许单一 `join` 键");主机身份只经 `HostStore`+`HostEndpoint.originHeader` 解析,未配对 origin 一律走配对页且 hint 不回显链接内容。
|
||||
- **DEFERRED**: 用真手机相机扫 web 分享 QR 的目视走查(模拟器无相机)。
|
||||
|
||||
@@ -24,6 +24,35 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 📱 [x] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`)
|
||||
|
||||
- **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。
|
||||
- **编排**: 契约先冻结在 [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §1(orchestrator 亲自核服务端源码),再派 3 个 Workflow 共 22 个 agent。**A 串行 → B 并行 5 → C 串行 4 → D 并行 3 → E 条件 → 收尾波 4**。C 波刻意串行:新增 `.swift` 必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行必互踩。
|
||||
- 中途整个 C+D 波(7 个 agent)被 provider 侧 `529 Overloaded`/`ECONNRESET` 打挂;`resumeFromRunId` 把 A/B 从缓存回放、只重跑失败的,零返工。
|
||||
- **冻结契约(§1.1,踩过的坑都在里面)**: cookie 名 `webterm_auth`;`POST /auth` 体 `{"token":"…"}`;**`Accept` 头不能含 `text/html`**——含了会被当表单走 302 而不是 204/401;**204 但无 `Set-Cookie` = 服务端根本没开鉴权**,绝不能据此认为"已认证";原生客户端**自己手写 `Cookie` 头**,不解析 `Set-Cookie`、不用系统 cookie jar(URLSession/OkHttp 的 jar 在 WS upgrade 上行为不一致且难测);**令牌不替代 Origin**,两者都要带。
|
||||
- **真机构建解锁(最高杠杆,一次关掉 7 项验收阻塞)**: `DEVELOPMENT_TEAM=C738Z66SRW` + **target 级** `CODE_SIGN_IDENTITY`(废弃串 `"iPhone Developer"` 是 XcodeGen 的 product-type preset 在 target 层注入的,project 级怎么写都盖不住)→ 免费个人 team 上 `** BUILD SUCCEEDED **`,`-allowProvisioningUpdates` 现场签发 7 天 profile。**`aps-environment` 做成 env 开关且默认不挂**(`WEBTERM_PUSH_ENTITLEMENTS`),并**反向验证过必要性**:打开后真机构建报 `Personal development teams … do not support the Push Notifications capability`。
|
||||
- **顺手挖出一个会卡死全波的坑**: SwiftTerm 用浮动版本 `from: 1.13.0`,而 `.xcodeproj` 是 gitignore 的 → **任何人一 `xcodegen generate` 就解析到 1.15.0**,它在 1.14.0 新增的 `public var hasActiveSelection` 与 `TerminalScreen.swift` 本地同名属性冲突,且**加 `override` 修不了**(上游是 `public` 不是 `open`)。A1 先钉死版本止血,C3 删掉本地属性改用上游的、再抬到 1.15.0 根治。
|
||||
- **交付**(全部实测,非引用):
|
||||
|
||||
| | 之前 | 之后 |
|
||||
|---|---|---|
|
||||
| 包测试 | 310 | **452** |
|
||||
| App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) |
|
||||
| 集成测试 | 10 | **26** |
|
||||
| Android 测试 | 687 | **691** |
|
||||
| ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) |
|
||||
| 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) |
|
||||
| 真机构建 | `BUILD FAILED` | **`BUILD SUCCEEDED`** |
|
||||
|
||||
- **令牌**: 两端全链路(APIClient `/auth` 探针 + Keychain/Keystore 存储 + WS upgrade 带 Cookie + 401 终态不进退避环)。
|
||||
- **git 面板 parity**: iOS 补齐 `/projects/{log,pr,git/*,worktree*}` + `/sessions` + 队列——这是与 Android 和 web 差了两个月的那一块。
|
||||
- **P2 全 5 项**: 语音 PTT(epoch 防误发)、终端内搜索、worktree 生命周期、主题 + Dynamic Type、web `?join=` 互通。
|
||||
- **CI**: 修好 iOS 三条腿(缺 `npm ci` 会**硬失败**不是 skip / 缺 iPad UI 腿 / iOS 17 缺 runtime 时**静默报绿**),新建 `android.yml`(此前 Android 零 CI)。
|
||||
- **复核抓到并已修的 2 个 HIGH**: ① iOS 的 WS 令牌是**与主机无关**地解析的,混合机群(一台开令牌一台没开)下令牌门主机永远开不了终端,且 App 内无解 → 改为每主机一个 transport;② Android 把主机自身的 git 凭据 401(`src/http/git-ops.ts:108`)误报成"你的访问令牌错了",因为通用 401 映射跑在路由映射前面 → git 写路由标 `ROUTE_DEFINED`,保住服务端原文案。
|
||||
- **orchestrator 的两个裁定**: ① 令牌策略在 iOS 有 3 份、Android 1 份,**不解冻 `WireProtocol` 去收敛**(它是冻结的跨语言契约,共享密钥的 helper 不该进去),改为在 `IntegrationTests` 加漂移守卫——它是全树唯一能同时依赖三个包的目标;② `PairingProbe.swift` 越界改动是我特批的(B1 已收工释放该包),复核把它记为 LOW,已知悉。
|
||||
- **诚实的未闭合项**: release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);APNs 端到端(需付费账号);**两个 workflow 从未在任何 runner 上跑过**——本仓库唯一 remote 是自建 Gitea,GH Actions 从来没执行过,本地已逐条复跑其命令;Android instrumented 腿(令牌静态加密的唯一证明)设为 `workflow_dispatch` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。
|
||||
- **一个诚实的取舍**: 键栏在 AX3–AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。
|
||||
|
||||
### 🌿 [x] w6 项目详情 Git 面板 — G1–G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`)
|
||||
|
||||
- **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import APIClient
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftUI
|
||||
@@ -226,19 +225,25 @@ final class SessionThumbnailPipeline {
|
||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||
}
|
||||
|
||||
/// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存
|
||||
/// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份,
|
||||
/// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。
|
||||
/// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复:
|
||||
/// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。
|
||||
static func live() -> SessionThumbnailPipeline {
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
let transport = URLSessionHTTPTransport(identityProvider: {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
})
|
||||
return SessionThumbnailPipeline(
|
||||
/// 生产装配:取数走**组合根装配**的那条带凭证的传输
|
||||
/// (`AppEnvironment.thumbnailTransport()`)—— ephemeral URLSession(preview
|
||||
/// 字节可能含屏上密钥,内存缓存 only,同 T-iOS-19 对 RO GET 的裁定)
|
||||
/// + C-iOS-2 的设备证书身份(否则隧道主机的缩略图会被 nginx 拒握手)
|
||||
/// + **C1 每主机访问令牌 Cookie**。两个凭证都按请求/按握手现取,
|
||||
/// 中途导入的证书、中途输入的令牌下一次取数即生效,无需重启。
|
||||
///
|
||||
/// F1(本次修复):这里原先自建一个**没有令牌源**的传输,于是在开了
|
||||
/// `WEBTERM_TOKEN` 的主机上 `GET /live-sessions/:id/preview` 一律 401;
|
||||
/// 而本管线按设计永不抛错(失败即占位图),401 就被静默降级成
|
||||
/// **每一张**缩略图都是占位符,屏幕上没有任何原因可查。
|
||||
///
|
||||
/// - Parameter http: 取数传输。默认即上述生产装配;测试注入替身。
|
||||
static func live(
|
||||
http: any HTTPTransport = AppEnvironment.thumbnailTransport()
|
||||
) -> SessionThumbnailPipeline {
|
||||
SessionThumbnailPipeline(
|
||||
loader: { request in
|
||||
try await APIClient(endpoint: request.endpoint, http: transport)
|
||||
try await APIClient(endpoint: request.endpoint, http: http)
|
||||
.preview(id: request.sessionId)
|
||||
},
|
||||
renderer: { data, cols, rows in
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -103,26 +103,105 @@ struct DynamicTypeLayoutTests {
|
||||
#expect(size.height.isFinite)
|
||||
}
|
||||
|
||||
// MARK: - F29 · KeyBar(UIKit,固定条高 vs 放大的键帽)—— 已知缺口
|
||||
// MARK: - F29 · KeyBar(UIKit inputAccessoryView):条高跟随键帽
|
||||
|
||||
/// F1 · 这里原是 `withKnownIssue` 记录的**已知缺口**:`KeyBarMetrics.barHeight`
|
||||
/// 固定 52pt(44 + 8),而 AX5 下一枚键帽要 ~108pt(footnote 行高 52.51 +
|
||||
/// caption2 行高 47.73 + 上下 4+4 内衬)——超过一半被裁,T-iOS-34 的验收
|
||||
/// 「最大字号不破版」实际是 FAIL。现已修好:条高由**键帽实际画出来的高度**
|
||||
/// 推出(下限 44pt 命中目标,字号在 DS 的密集内容上限处封顶),所以本组
|
||||
/// 断言全部是真绿,`withKnownIssue` 标记已删除。
|
||||
@Test("KeyBar 在 AX5 下条高容得下它真正画出的键帽(原缺口:裁掉一半以上)")
|
||||
func keyBarFitsItsKeycapsAtLargestType() {
|
||||
let measured = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
|
||||
|
||||
/// 量到的事实(iPhone 16 Pro 模拟器):AX5 下一个键帽需要
|
||||
/// **108.24pt**(footnote 行高 52.51 + caption2 行高 47.73 + 上下 4+4 内衬),
|
||||
/// 而 `KeyBarMetrics.barHeight` 是**固定 52pt**(44 + 8)——超过一半被裁。
|
||||
///
|
||||
/// 修法(一行):`barHeight` 改为随 Dynamic Type 缩放,例如
|
||||
/// `UIFontMetrics(forTextStyle: .footnote).scaledValue(for: DS.Layout.minHitTarget + DS.Space.sm8)`,
|
||||
/// 并让 `intrinsicContentSize` 用同一个值。
|
||||
/// **`Components/KeyBar.swift` 不在 C4 的 Owns 里**,故这里只用
|
||||
/// `withKnownIssue` 把缺口钉成机器可见的记录:修好之后本用例会报
|
||||
/// "known issue was not recorded",提醒把这个标记删掉。
|
||||
@Test("KeyBar 键帽在 AX5 下超出固定条高(已知缺口:KeyBar.swift 属他人 Owns)")
|
||||
func keyBarKeycapExceedsBarHeightAtLargestType() {
|
||||
withKnownIssue("KeyBarMetrics.barHeight 固定 52pt,AX5 键帽需约 108pt → 裁切") {
|
||||
let requirement = KeyBarProbe.largestTypeRequirement()
|
||||
#expect(
|
||||
requirement.needed <= requirement.barHeight,
|
||||
"AX5 键帽需 \(requirement.needed)pt,条高只有 \(requirement.barHeight)pt"
|
||||
measured.keycap <= measured.barHeight + LayoutProbe.epsilon,
|
||||
"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 命中目标的档位**(XS…XL,含出厂
|
||||
/// 默认 L)条高逐点还是 52pt。再往上(XXL 起)键帽本身就比 44pt 高了,条高
|
||||
/// 必须跟着长——那正是本次修复,不是回归。
|
||||
@Test("默认与更小字号档条高逐点不变(44 + 8 = 52,零回归)")
|
||||
func keyBarKeepsItsShippedHeightAtTheSmallerSizes() {
|
||||
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||
|
||||
for category in [UIContentSizeCategory.extraSmall, .small, .medium,
|
||||
.large, .extraLarge] {
|
||||
#expect(KeyBarMetrics.barHeight(category) == shipped, "\(category.rawValue)")
|
||||
}
|
||||
#expect(KeyBarProbe.measure(at: .large).barHeight == shipped)
|
||||
}
|
||||
|
||||
@Test("键帽真的随字号长大(不是把字号焊死换来的假绿)")
|
||||
func keyBarActuallyGrowsWithType() {
|
||||
let standard = KeyBarProbe.measure(at: .large)
|
||||
let largest = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
|
||||
|
||||
#expect(largest.keycap > standard.keycap)
|
||||
#expect(largest.barHeight > standard.barHeight)
|
||||
}
|
||||
|
||||
/// 键栏骑在软键盘上方:长高是必须的,长疯了就把它要驱动的终端吃掉了。
|
||||
/// 故字号在 DS 的密集内容上限(`numericClamp`)处封顶——两处策略必须同源。
|
||||
@Test("字号封顶 == DS 密集内容策略,封顶后条高不再增长且不超出货架高度的两倍")
|
||||
func keyBarTypeCeilingMatchesTheDesignSystemPolicy() {
|
||||
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||
|
||||
#expect(DynamicTypeSize(KeyBarMetrics.typeCeiling) == DS.Typography.numericClamp)
|
||||
|
||||
let ceiling = KeyBarMetrics.barHeight(KeyBarMetrics.typeCeiling)
|
||||
#expect(KeyBarMetrics.barHeight(KeyBarProbe.largestCategory) == ceiling)
|
||||
// 封顶之前照常长大(不是从第一档就焊死)。
|
||||
#expect(ceiling > KeyBarMetrics.barHeight(.large))
|
||||
// 有界:AX5 的键栏最多是出厂高度的两倍,终端不会被键栏吃掉。
|
||||
#expect(ceiling <= shipped * 2)
|
||||
}
|
||||
|
||||
@Test("frame 高度与 intrinsicContentSize 一致(自适应输入视图靠后者自量)")
|
||||
func keyBarFrameMatchesItsIntrinsicHeight() {
|
||||
for category in [UIContentSizeCategory.large, KeyBarProbe.largestCategory] {
|
||||
let bar = KeyBarView(contentSizeCategory: category)
|
||||
|
||||
#expect(bar.frame.height == bar.intrinsicContentSize.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,30 +227,60 @@ enum LayoutProbe {
|
||||
}
|
||||
}
|
||||
|
||||
/// UIKit 度量:键帽在 AX5 下**需要**多高,对比键栏**给**多高。
|
||||
/// UIKit 度量:把**真** `KeyBarView` 建在某个字号档上,量它**实际画出来**的
|
||||
/// 键帽有多高、它**给出**的条高有多高。两个数都来自同一个真视图,所以
|
||||
/// 「不裁切」是量出来的,不是算出来的。
|
||||
///
|
||||
/// 为什么不直接把 `KeyBarView` 构建在 AX5 trait 里量:`KeyBarView` 的字号来自
|
||||
/// `UIFont.preferredFont(forTextStyle:)`(无 `compatibleWith:`),它读的是
|
||||
/// 为什么不用 `UITraitCollection.performAsCurrent { KeyBarView() }`:
|
||||
/// `UIFont.preferredFont(forTextStyle:)`(**无** `compatibleWith:`)读的是
|
||||
/// **App 级** `preferredContentSizeCategory`,**不看** `UITraitCollection.current`
|
||||
/// —— 所以 `performAsCurrent { KeyBarView() }` 量出来的是默认字号(实测 38pt),
|
||||
/// 是个会"永远通过"的假绿。改为按 `compatibleWith:` 取真实 AX5 行高来算需求,
|
||||
/// 与键帽的两行(glyph + caption)+ 上下内衬一致。
|
||||
/// —— 那样量出来的永远是默认字号(实测 38pt)的**假绿**。修复后键栏把字号档
|
||||
/// 显式注入(`KeyBarView(contentSizeCategory:)`)并全程走 `compatibleWith:`,
|
||||
/// 于是这里量到的就是屏幕上真会画出来的东西。
|
||||
@MainActor
|
||||
enum KeyBarProbe {
|
||||
/// AX5 内容尺寸类别。
|
||||
private static let largestCategory = UIContentSizeCategory
|
||||
static let largestCategory = UIContentSizeCategory
|
||||
.accessibilityExtraExtraExtraLarge
|
||||
|
||||
/// - Returns: (键栏给的高度, AX5 下键帽两行所需高度)
|
||||
static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) {
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: largestCategory)
|
||||
/// 全部 12 档(7 标准 + 5 无障碍)——「不破版」是对**每一档**的断言。
|
||||
static let allCategories: [UIContentSizeCategory] = [
|
||||
.extraSmall, .small, .medium, .large, .extraLarge, .extraExtraLarge,
|
||||
.extraExtraExtraLarge, .accessibilityMedium, .accessibilityLarge,
|
||||
.accessibilityExtraLarge, .accessibilityExtraExtraLarge,
|
||||
.accessibilityExtraExtraExtraLarge,
|
||||
]
|
||||
|
||||
/// - Returns:
|
||||
/// - `barHeight` 键栏给出的条高(`intrinsicContentSize`);
|
||||
/// - `keycap` 该档下真键帽**实际**要的高度(从真 `KeyBarView` 的按钮量);
|
||||
/// - `typographic` 同一档的**排版口径**需求 —— 两行行高 + 上下内衬,即原
|
||||
/// 「已知缺口」用例的算法(未封顶的 AX5 实测 108.24pt vs 固定 52pt)。
|
||||
/// 它是 `keycap` 的保守上界(实测在封顶档 68.86 vs 54.67),**两个都**
|
||||
/// 必须被条高盖住 —— 宁可多几点余量,也不能少一点造成裁切。
|
||||
static func measure(
|
||||
at category: UIContentSizeCategory
|
||||
) -> (barHeight: CGFloat, keycap: CGFloat, typographic: CGFloat) {
|
||||
let bar = KeyBarView(contentSizeCategory: category)
|
||||
let keycap = (bar.keyButtons + [bar.voiceButton].compactMap { $0 })
|
||||
.map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height }
|
||||
.max() ?? 0
|
||||
return (
|
||||
barHeight: bar.intrinsicContentSize.height,
|
||||
keycap: keycap,
|
||||
// 只借用「封顶」这一个决定,高度公式本身不复用(否则就是自证)。
|
||||
typographic: typographicRequirement(
|
||||
at: KeyBarMetrics.effectiveCategory(category)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 两行文字 + 键帽上下内衬,逐字沿用缺口用例的算法。
|
||||
static func typographicRequirement(at category: UIContentSizeCategory) -> CGFloat {
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: category)
|
||||
let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits)
|
||||
let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits)
|
||||
// KeyBarMetrics.buttonInsets 的上下各 xs4。
|
||||
let verticalInsets = DS.Space.xs4 * 2
|
||||
return (
|
||||
barHeight: KeyBarView().intrinsicContentSize.height,
|
||||
needed: glyph.lineHeight + caption.lineHeight + verticalInsets
|
||||
)
|
||||
return glyph.lineHeight + caption.lineHeight + DS.Space.xs4 * 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
231
ios/App/WebTermTests/SessionThumbnailTokenTests.swift
Normal file
231
ios/App/WebTermTests/SessionThumbnailTokenTests.swift
Normal 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._~+/=-]`,长度 16–512)。
|
||||
private static let token = "0123456789abcdefghijklmnopqrstuv"
|
||||
private static let base = "http://192.168.1.20:3000"
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// 生产的盖章逻辑(`URLSessionHTTPTransport.authenticated`)串在一个可脚本化
|
||||
/// 的记录器前面:真实 Cookie 代码路径,零网络。
|
||||
private struct StampingTransport: HTTPTransport {
|
||||
let stamper: URLSessionHTTPTransport
|
||||
let recorder: FakeHTTPTransport
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
try await recorder.send(await stamper.authenticated(request))
|
||||
}
|
||||
}
|
||||
|
||||
private func makeEndpoint(_ base: String = SessionThumbnailTokenTests.base) throws
|
||||
-> HostEndpoint {
|
||||
let url = try #require(URL(string: base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private func makeStore(token: String?) async throws -> InMemoryHostStore {
|
||||
let store = InMemoryHostStore()
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(
|
||||
id: UUID(), name: "mac", endpoint: try makeEndpoint(),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
)
|
||||
return store
|
||||
}
|
||||
|
||||
private func previewURL(_ sessionId: UUID) throws -> URL {
|
||||
try #require(
|
||||
URL(string: "\(Self.base)/live-sessions/\(sessionId.uuidString.lowercased())/preview")
|
||||
)
|
||||
}
|
||||
|
||||
private func previewBody(_ sessionId: UUID) throws -> Data {
|
||||
try #require("""
|
||||
{"id":"\(sessionId.uuidString.lowercased())","cols":80,"rows":24,"data":"hello"}
|
||||
""".data(using: .utf8))
|
||||
}
|
||||
|
||||
/// 装配一条**默认生产形状**的管线,只把网络出口换成记录器。
|
||||
private func makePipeline(
|
||||
store: any HostStore
|
||||
) -> (pipeline: SessionThumbnailPipeline, recorder: FakeHTTPTransport) {
|
||||
let recorder = FakeHTTPTransport()
|
||||
let transport = StampingTransport(
|
||||
stamper: AppEnvironment.thumbnailTransport(
|
||||
hostStore: store, identityProvider: { nil }
|
||||
),
|
||||
recorder: recorder
|
||||
)
|
||||
return (SessionThumbnailPipeline.live(http: transport), recorder)
|
||||
}
|
||||
|
||||
// MARK: - 令牌门主机
|
||||
|
||||
@Test("令牌门主机:preview 请求带上 Cookie: webterm_auth=<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 是只读路由:带令牌也绝不带 Origin(Origin-iff-G 零回归)")
|
||||
func previewStaysOriginFree() async throws {
|
||||
// Arrange
|
||||
let sessionId = UUID()
|
||||
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token))
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), body: try previewBody(sessionId)
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert
|
||||
let requests = await recorder.recordedRequests
|
||||
#expect(requests.first?.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
|
||||
// MARK: - 无令牌 / 令牌不对
|
||||
|
||||
@Test("主机没有令牌 → 请求逐字不带 Cookie(LAN 零配置主机零回归)")
|
||||
func tokenlessHostSendsNoCookie() async throws {
|
||||
// Arrange
|
||||
let sessionId = UUID()
|
||||
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil))
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), body: try previewBody(sessionId)
|
||||
)
|
||||
|
||||
// Act
|
||||
let image = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert
|
||||
let requests = await recorder.recordedRequests
|
||||
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
|
||||
#expect(!image.isPlaceholder)
|
||||
}
|
||||
|
||||
@Test("令牌缺失导致 401 → 降级成占位图,不崩不抛(管线的错误 UI 就是占位图)")
|
||||
func unauthorizedDegradesToPlaceholder() async throws {
|
||||
// Arrange:主机记录里没有令牌,服务器要求令牌 → 401。
|
||||
let sessionId = UUID()
|
||||
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil))
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), status: 401,
|
||||
body: try #require(#"{"error":"unauthorized"}"#.data(using: .utf8))
|
||||
)
|
||||
|
||||
// Act
|
||||
let image = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(image == .placeholder)
|
||||
#expect(await recorder.recordedRequests.count == 1)
|
||||
}
|
||||
|
||||
@Test("主机根本不在存储里(存储读不到该 origin)→ 无 Cookie,仍不崩")
|
||||
func unknownOriginDegradesGracefully() async throws {
|
||||
// Arrange:存储里是另一台主机。
|
||||
let sessionId = UUID()
|
||||
let store = InMemoryHostStore()
|
||||
let otherEndpoint = try makeEndpoint("http://192.168.1.99:3000")
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(
|
||||
id: UUID(), name: "other", endpoint: otherEndpoint,
|
||||
accessToken: try AccessToken(validating: Self.token)
|
||||
)
|
||||
)
|
||||
let (pipeline, recorder) = makePipeline(store: store)
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), body: try previewBody(sessionId)
|
||||
)
|
||||
|
||||
// Act
|
||||
let image = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert:绝不把别台主机的令牌发出去。
|
||||
let requests = await recorder.recordedRequests
|
||||
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
|
||||
#expect(!image.isPlaceholder)
|
||||
}
|
||||
|
||||
// MARK: - 组合根装配本身
|
||||
|
||||
@Test("thumbnailTransport 就是 App 的同一条盖章传输(按 origin 取本主机令牌)")
|
||||
func thumbnailTransportStampsPerOrigin() async throws {
|
||||
// Arrange
|
||||
let transport = AppEnvironment.thumbnailTransport(
|
||||
hostStore: try await makeStore(token: Self.token), identityProvider: { nil }
|
||||
)
|
||||
let mine = URLRequest(url: try previewURL(UUID()))
|
||||
let other = URLRequest(
|
||||
url: try #require(URL(string: "http://192.168.1.99:3000/live-sessions"))
|
||||
)
|
||||
|
||||
// Act
|
||||
let stamped = await transport.authenticated(mine)
|
||||
let untouched = await transport.authenticated(other)
|
||||
|
||||
// Assert
|
||||
#expect(stamped.value(forHTTPHeaderField: AccessTokenCookie.header)
|
||||
== "\(AccessTokenCookie.name)=\(Self.token)")
|
||||
#expect(untouched.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
|
||||
}
|
||||
}
|
||||
@@ -288,9 +288,13 @@ remotely, watch status, and reattach with full scrollback.
|
||||
foreground contrast. Dynamic Type up to **AX5** is measured, not assumed:
|
||||
gate banner, telemetry chips and meta rows are asserted not to overflow 320 pt,
|
||||
with a numeric clamp keeping tabular figures from blowing up the layout.
|
||||
**Known gap (measured, not fixed):** the fixed 52 pt key-bar needs ~108 pt at
|
||||
AX5, so key caps clip — recorded as a `withKnownIssue` in
|
||||
`DynamicTypeLayoutTests` so the test starts complaining once it is fixed.
|
||||
The key-bar height is derived from the content size category rather than
|
||||
frozen at 52 pt, so nothing clips at any size. Its keycap **font** is clamped
|
||||
at `.accessibilityLarge` — the same dense-content policy the design system
|
||||
already applies to telemetry chips — because an unclamped AX5 bar wants
|
||||
~108 pt of the 17-key strip and would eat the terminal. So AX3–AX5 render
|
||||
keycaps at AX2 size; a test pins the clamp to the design-system constant so
|
||||
the two cannot drift, and another keeps the bar at exactly 52 pt for XS–XL.
|
||||
- **Project git panel** — the ambient git state the server has served since w6,
|
||||
now on the phone: a **sync band** (`↑ahead ↓behind`, upstream, detached-HEAD
|
||||
and fetch-staleness states — green "in sync" *only* when `↑0 ↓0` **and** the
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user