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

- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar
  froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped —
  recorded as a withKnownIssue rather than fixed. Height now derives from the
  content size category (and tracks live changes via registerForTraitChanges);
  the known-issue marker is gone, replaced by positive assertions including a
  12-category no-clip sweep and a guard that stays red if anyone writes the
  constant back. Honest tradeoff: the keycap font is clamped at .accessibility2,
  the same policy the design system already applies to dense content, because an
  unclamped AX5 bar would eat the terminal. A test pins the clamp so the two
  cannot drift.

- Thumbnails silently 401'd on a token-gated host: the pipeline built its own
  transport with no token source, and by design never throws, so every preview
  degraded to a placeholder with no signal. Assembled from AppEnvironment now.

- Android had zero CI while the token wave shipped 24 files of secret-handling
  code. The instrumented leg is workflow_dispatch-only and says why in the file:
  no one has ever seen it green on a runner, and a required leg nobody trusts
  just produces a false green.

- Android persisted a validated token before the host probe succeeded, stranding
  a secret for a host that never paired.

App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
This commit is contained in:
Yaojia Wang
2026-07-30 16:46:20 +02:00
parent 284cfd193a
commit 5cc755b0b6
14 changed files with 1120 additions and 102 deletions

View File

@@ -115,15 +115,89 @@ enum KeyBarLayout {
/// Layout constants all composed from the frozen `DS` scale (no literals).
/// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar
/// stays on-grid with the rest of the app.
private enum KeyBarMetrics {
/// A hit-target-tall row plus a little breathing room (44 + 8).
static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
///
/// T-iOS-34 acceptance (" + ****") · The bar height used
/// to be the CONSTANT 44 + 8 = 52pt while the keycap it draws is two lines of
/// Dynamic Type text at AX5 that keycap needs ~108pt, so more than half of it
/// was clipped. The height is now DERIVED from the keycap at the user's text
/// size, with two bounds that pull in opposite directions and are both required:
/// - a 44pt FLOOR, so the small sizes keep exactly today's 52pt bar and the HIG
/// hit target survives (zero regression);
/// - a CEILING on the text itself (`typeCeiling`), so the bar cannot grow
/// without limit. This bar is an `inputAccessoryView` riding on top of the
/// soft keyboard: an unclamped AX5 keycap turns a 52pt strip into a ~108pt one
/// and, with the keyboard already up, eats the terminal it exists to drive.
/// Clamping the FONT (not the frame) is what keeps that honest the height
/// below is computed from the SAME clamped fonts the keycaps render with, so
/// nothing is ever clipped at any size.
///
/// `internal`, not `private`: the acceptance is a MEASUREMENT
/// (`DynamicTypeLayoutTests` compares these against the real UIKit fonts and
/// against a real `KeyBarView`), and a private constant could only be
/// re-implemented in the test i.e. not tested at all.
enum KeyBarMetrics {
static let buttonSpacing: CGFloat = DS.Space.xs4
static let contentInset: CGFloat = DS.Space.sm8
static let buttonInsets = NSDirectionalEdgeInsets(
top: DS.Space.xs4, leading: DS.Space.md12,
bottom: DS.Space.xs4, trailing: DS.Space.md12
)
/// Dynamic Type ceiling for the keycap text the UIKit currency of the DS's
/// dense-content policy `DS.Typography.numericClamp` (`.accessibility2`,
/// whose `UIContentSizeCategory` twin is `.accessibilityLarge`). The two are
/// pinned equal by a test so they cannot drift apart.
///
/// Prose in this app scales all the way to AX5 and nothing caps it; a
/// 17-keycap control strip docked to the keyboard is the same dense case as
/// the telemetry chips past this point extra size only costs terminal rows.
static let typeCeiling: UIContentSizeCategory = .accessibilityLarge
/// The category the keycaps actually render at: the user's, capped at
/// `typeCeiling`.
static func effectiveCategory(
_ category: UIContentSizeCategory
) -> UIContentSizeCategory {
category > typeCeiling ? typeCeiling : category
}
/// Traits carrying the effective category the `compatibleWith:` argument
/// for every font below. `UIFont.preferredFont(forTextStyle:)` WITHOUT it
/// reads the app-wide category and would ignore the clamp entirely.
static func traits(_ category: UIContentSizeCategory) -> UITraitCollection {
UITraitCollection(preferredContentSizeCategory: effectiveCategory(category))
}
/// Glyph line ("Esc", "^C", "Tab"): monospaced footnote, so the glyphs stay
/// on a common width grid.
static func glyphFont(_ category: UIContentSizeCategory) -> UIFont {
UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(
forTextStyle: .footnote, compatibleWith: traits(category)
).pointSize,
weight: .semibold
)
}
/// Caption line ("", ""): caption2, secondary label colour.
static func captionFont(_ category: UIContentSizeCategory) -> UIFont {
UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits(category))
}
/// What ONE keycap needs at `category`: its two text lines plus the button's
/// own vertical content insets. Rounded up a fractional shortfall is still
/// a clipped descender.
static func keycapHeight(_ category: UIContentSizeCategory) -> CGFloat {
(glyphFont(category).lineHeight + captionFont(category).lineHeight
+ buttonInsets.top + buttonInsets.bottom).rounded(.up)
}
/// The bar's height at `category`: whatever the keycap needs, never below the
/// 44pt hit target, plus `sm8` of breathing room. At the standard sizes the
/// keycap is smaller than 44 44 + 8 = 52pt, identical to the old constant.
static func barHeight(_ category: UIContentSizeCategory) -> CGFloat {
max(DS.Layout.minHitTarget, keycapHeight(category)) + DS.Space.sm8
}
}
// MARK: - KeyBarView (inputAccessoryView)
@@ -140,19 +214,47 @@ final class KeyBarView: UIInputView {
/// The 🎤 push-to-talk key, nil unless voice handlers were supplied.
private(set) var voiceButton: UIButton?
/// The Dynamic Type category the bar is currently laid out for (T-iOS-34).
/// Test-visible: the acceptance is "the keycap the bar DRAWS fits the bar",
/// which is only measurable if the category the bar drew at is knowable.
private(set) var contentSizeCategory: UIContentSizeCategory
private let voice: KeyBarVoiceHandlers?
/// - Parameter voice: press/release outlets for the 🎤 key (T-iOS-31).
/// nil no mic key at all (mirrors the web bar, which only renders it
/// when speech is supported AND a trigger is supplied).
init(voice: KeyBarVoiceHandlers? = nil) {
/// - Parameters:
/// - voice: press/release outlets for the 🎤 key (T-iOS-31). nil no mic
/// key at all (mirrors the web bar, which only renders it when speech is
/// supported AND a trigger is supplied).
/// - contentSizeCategory: the text size to lay out for. Defaults to the
/// app's own the same value `UIFont.preferredFont(forTextStyle:)`
/// reads and is re-read from the traits whenever the user changes it.
/// Injectable so the AX5 acceptance can be measured without driving
/// Settings (a `UITraitCollection.performAsCurrent` wrapper would NOT
/// work: the un-`compatibleWith:` font API ignores it, which is exactly
/// the trap the old measurement fell into).
init(
voice: KeyBarVoiceHandlers? = nil,
contentSizeCategory: UIContentSizeCategory =
UIApplication.shared.preferredContentSizeCategory
) {
self.voice = voice
self.contentSizeCategory = contentSizeCategory
super.init(
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
frame: CGRect(
x: 0, y: 0, width: 0,
height: KeyBarMetrics.barHeight(contentSizeCategory)
),
inputViewStyle: .keyboard
)
allowsSelfSizing = true
buildBar()
// Live text-size changes: re-render the keycaps at the new size and let
// `allowsSelfSizing` re-measure the bar. Without this the bar would keep
// whatever height it was born with until the terminal is reopened.
registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) {
(bar: KeyBarView, _: UITraitCollection) in
bar.apply(contentSizeCategory: bar.traitCollection.preferredContentSizeCategory)
}
}
@available(*, unavailable, message: "KeyBarView is code-built only")
@@ -161,7 +263,29 @@ final class KeyBarView: UIInputView {
}
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight)
CGSize(
width: UIView.noIntrinsicMetric,
height: KeyBarMetrics.barHeight(contentSizeCategory)
)
}
/// Re-render every keycap at `category` and re-measure the bar. A no-op when
/// the category did not actually change (trait callbacks fire for unrelated
/// trait changes too).
func apply(contentSizeCategory category: UIContentSizeCategory) {
guard category != contentSizeCategory else { return }
contentSizeCategory = category
for (button, spec) in zip(keyButtons, KeyBarLayout.buttons) {
button.configuration = keycapConfiguration(
label: spec.label, caption: spec.caption, isPrimary: spec.isPrimary
)
}
voiceButton?.configuration = keycapConfiguration(
label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption,
isPrimary: false
)
frame.size.height = KeyBarMetrics.barHeight(category)
invalidateIntrinsicContentSize()
}
private func buildBar() {
@@ -239,25 +363,35 @@ final class KeyBarView: UIInputView {
/// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is
/// the UIKit twin of DS.Palette.textSecondary the DS UIColor surface only
/// vends the accent, so native system labels stand in at this boundary.)
private func makeKeycap(
label: String, caption: String, title: String, isPrimary: Bool
) -> UIButton {
///
/// Both fonts come from `KeyBarMetrics`, resolved `compatibleWith:` the
/// bar's current (clamped) category the SAME resolution `barHeight` is
/// computed from, which is what makes "the bar always fits its keycaps" true
/// by construction rather than by a hand-tuned constant.
private func keycapConfiguration(
label: String, caption: String, isPrimary: Bool
) -> UIButton.Configuration {
var config: UIButton.Configuration = isPrimary ? .tinted() : .gray()
config.cornerStyle = .fixed
config.background.cornerRadius = DS.Radius.sm8
var attributedLabel = AttributedString(label)
attributedLabel.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .semibold
)
attributedLabel.font = KeyBarMetrics.glyphFont(contentSizeCategory)
config.attributedTitle = attributedLabel
var attributedCaption = AttributedString(caption)
attributedCaption.font = UIFont.preferredFont(forTextStyle: .caption2)
attributedCaption.font = KeyBarMetrics.captionFont(contentSizeCategory)
attributedCaption.foregroundColor = .secondaryLabel
config.attributedSubtitle = attributedCaption
config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets
return config
}
private func makeKeycap(
label: String, caption: String, title: String, isPrimary: Bool
) -> UIButton {
let config = keycapConfiguration(
label: label, caption: caption, isPrimary: isPrimary
)
let button = UIButton(configuration: config)
if isPrimary {
button.tintColor = DS.Palette.accentUIColor()

View File

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

View File

@@ -22,6 +22,11 @@ import WireProtocol
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
/// UserDefaults carries only the non-secret per-host lastSessionId. A token
/// never reaches a log line, a URL query, or an error description.
/// - The one other `URLSession` in the app belongs to `thumbnailTransport()`
/// below assembled HERE, from these same providers, for the same reasons
/// (F1: the session-thumbnail pipeline is built by a SwiftUI `@State`
/// initializer that has no environment to read, and used to build a
/// credential-less transport of its own).
/// - No debug ATS overrides exist (project.yml declares NO
/// NSAllowsArbitraryLoads in any configuration only the five §5.2 CIDR
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
@@ -73,10 +78,7 @@ struct AppEnvironment: Sendable {
// missing cert is the normal pre-install state ( nil); genuine faults
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
let identityStore = KeychainClientIdentityStore()
let identityProvider: @Sendable () -> ClientIdentity? = {
identityStore.loadedIdentityOrNil()
}
let identityProvider = keychainIdentityProvider()
let hostStore = KeychainHostStore()
// C1 · ONE access-token source for the whole app, reading the same
// Keychain records the pairing flow writes. Both transports consult it
@@ -129,6 +131,52 @@ struct AppEnvironment: Sendable {
)
}
/// C-iOS-2 · The lazy, Keychain-backed device-identity provider: resolved
/// per TLS client-certificate challenge (never snapshotted), so a certificate
/// imported mid-run is presented on the NEXT handshake without a relaunch.
/// ONE definition, shared by `production()` and `thumbnailTransport()`.
static func keychainIdentityProvider() -> @Sendable () -> ClientIdentity? {
let identityStore = KeychainClientIdentityStore()
return { identityStore.loadedIdentityOrNil() }
}
/// F1 · The HTTP transport `SessionThumbnailPipeline.live()` fetches
/// `GET /live-sessions/:id/preview` over assembled HERE, at the
/// composition root, from the SAME two credential providers `production()`
/// installs on the app-wide transport: the lazy mTLS identity AND the
/// per-origin access token.
///
/// WHY IT EXISTS (the bug it fixes): the pipeline used to build its own
/// `URLSessionHTTPTransport` with an identity provider but **no token
/// source**, so on a `WEBTERM_TOKEN`-gated host every preview came back 401.
/// The pipeline never throws by design a failed preview IS a placeholder
/// so the whole session list silently degraded to placeholder thumbnails with
/// nothing on screen saying why. It is assembled here rather than in the
/// component because the pipeline is created by a SwiftUI `@State`
/// initializer that has no `AppEnvironment` to read from.
///
/// It is a SEPARATE `URLSession` from `production().http` for the same reason
/// (no environment at that call site), and that costs nothing: both are
/// `.ephemeral` (preview bytes are raw ring-buffer terminal output and must
/// never touch a disk cache T-iOS-19) and both resolve their credentials
/// per request, so neither can hold a stale token.
///
/// - Parameters:
/// - hostStore: where the per-host tokens live Keychain in production,
/// an in-memory double in tests.
/// - identityProvider: the mTLS identity source (see above).
static func thumbnailTransport(
hostStore: any HostStore = KeychainHostStore(),
identityProvider: @escaping @Sendable () -> ClientIdentity?
= AppEnvironment.keychainIdentityProvider()
) -> URLSessionHTTPTransport {
let tokens = AccessTokenSource(store: hostStore)
return URLSessionHTTPTransport(
identityProvider: identityProvider,
tokenForOrigin: { origin in await tokens.token(forOrigin: origin) }
)
}
/// Away-digest source for a session engine: wraps `APIClient.events` per
/// host (the engine never holds an HTTP client plan §3.2). The token rides
/// along at the transport, so this stays token-free.

View File

@@ -103,26 +103,105 @@ struct DynamicTypeLayoutTests {
#expect(size.height.isFinite)
}
// MARK: - F29 · KeyBarUIKit vs
// MARK: - F29 · KeyBarUIKit inputAccessoryView
/// F1 · `withKnownIssue` ****`KeyBarMetrics.barHeight`
/// 52pt44 + 8 AX5 ~108ptfootnote 52.51 +
/// caption2 47.73 + 4+4 T-iOS-34
/// FAIL****
/// 44pt DS
/// 绿`withKnownIssue`
@Test("KeyBar 在 AX5 下条高容得下它真正画出的键帽(原缺口:裁掉一半以上)")
func keyBarFitsItsKeycapsAtLargestType() {
let measured = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
#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(
requirement.needed <= requirement.barHeight,
"AX5 键帽 \(requirement.needed)pt,条高只有 \(requirement.barHeight)pt"
measured.keycap <= measured.barHeight + LayoutProbe.epsilon,
"\(category.rawValue)键帽 \(measured.keycap)pt > 条高 \(measured.barHeight)pt"
)
#expect(
measured.typographic <= measured.barHeight + LayoutProbe.epsilon,
"\(category.rawValue):排版需求 \(measured.typographic)pt > 条高 \(measured.barHeight)pt"
)
#expect(
measured.barHeight >= DS.Layout.minHitTarget,
"\(category.rawValue):条高 \(measured.barHeight)pt 低于命中目标"
)
}
}
/// + **** AX5 108.24pt =
/// footnote 52.51 + caption2 47.73 + 8 52pt
/// `barHeight`
@Test("未封顶的 AX5 排版需求远超出厂 52pt 固定条高(这就是原缺口)")
func unclampedLargestTypeOverflowsTheOldFixedBar() {
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
let unclamped = KeyBarProbe.typographicRequirement(at: KeyBarProbe.largestCategory)
#expect(unclamped > shipped * 2)
}
/// ** 44pt **XSXL
/// L 52ptXXL 44pt
///
@Test("默认与更小字号档条高逐点不变44 + 8 = 52零回归")
func keyBarKeepsItsShippedHeightAtTheSmallerSizes() {
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
for category in [UIContentSizeCategory.extraSmall, .small, .medium,
.large, .extraLarge] {
#expect(KeyBarMetrics.barHeight(category) == shipped, "\(category.rawValue)")
}
#expect(KeyBarProbe.measure(at: .large).barHeight == shipped)
}
@Test("键帽真的随字号长大(不是把字号焊死换来的假绿)")
func keyBarActuallyGrowsWithType() {
let standard = KeyBarProbe.measure(at: .large)
let largest = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
#expect(largest.keycap > standard.keycap)
#expect(largest.barHeight > standard.barHeight)
}
///
/// DS `numericClamp`
@Test("字号封顶 == DS 密集内容策略,封顶后条高不再增长且不超出货架高度的两倍")
func keyBarTypeCeilingMatchesTheDesignSystemPolicy() {
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
#expect(DynamicTypeSize(KeyBarMetrics.typeCeiling) == DS.Typography.numericClamp)
let ceiling = KeyBarMetrics.barHeight(KeyBarMetrics.typeCeiling)
#expect(KeyBarMetrics.barHeight(KeyBarProbe.largestCategory) == ceiling)
//
#expect(ceiling > KeyBarMetrics.barHeight(.large))
// AX5
#expect(ceiling <= shipped * 2)
}
@Test("frame 高度与 intrinsicContentSize 一致(自适应输入视图靠后者自量)")
func keyBarFrameMatchesItsIntrinsicHeight() {
for category in [UIContentSizeCategory.large, KeyBarProbe.largestCategory] {
let bar = KeyBarView(contentSizeCategory: category)
#expect(bar.frame.height == bar.intrinsicContentSize.height)
}
}
}
@@ -148,30 +227,60 @@ enum LayoutProbe {
}
}
/// UIKit AX5 ********
/// UIKit **** `KeyBarView` ****
/// ****
///
///
/// `KeyBarView` AX5 trait `KeyBarView`
/// `UIFont.preferredFont(forTextStyle:)` `compatibleWith:`
/// `UITraitCollection.performAsCurrent { KeyBarView() }`
/// `UIFont.preferredFont(forTextStyle:)`**** `compatibleWith:`
/// **App ** `preferredContentSizeCategory`**** `UITraitCollection.current`
/// `performAsCurrent { KeyBarView() }` 38pt
/// ""绿 `compatibleWith:` AX5
/// glyph + caption+
/// 38pt**绿**
/// `KeyBarView(contentSizeCategory:)` `compatibleWith:`
/// 西
@MainActor
enum KeyBarProbe {
/// AX5
private static let largestCategory = UIContentSizeCategory
static let largestCategory = UIContentSizeCategory
.accessibilityExtraExtraExtraLarge
/// - Returns: (, AX5 )
static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) {
let traits = UITraitCollection(preferredContentSizeCategory: largestCategory)
/// 12 7 + 5 ****
static let allCategories: [UIContentSizeCategory] = [
.extraSmall, .small, .medium, .large, .extraLarge, .extraExtraLarge,
.extraExtraExtraLarge, .accessibilityMedium, .accessibilityLarge,
.accessibilityExtraLarge, .accessibilityExtraExtraLarge,
.accessibilityExtraExtraExtraLarge,
]
/// - Returns:
/// - `barHeight` `intrinsicContentSize`
/// - `keycap` **** `KeyBarView`
/// - `typographic` **** +
/// AX5 108.24pt vs 52pt
/// `keycap` 68.86 vs 54.67****
///
static func measure(
at category: UIContentSizeCategory
) -> (barHeight: CGFloat, keycap: CGFloat, typographic: CGFloat) {
let bar = KeyBarView(contentSizeCategory: category)
let keycap = (bar.keyButtons + [bar.voiceButton].compactMap { $0 })
.map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height }
.max() ?? 0
return (
barHeight: bar.intrinsicContentSize.height,
keycap: keycap,
//
typographic: typographicRequirement(
at: KeyBarMetrics.effectiveCategory(category)
)
)
}
/// + 沿
static func typographicRequirement(at category: UIContentSizeCategory) -> CGFloat {
let traits = UITraitCollection(preferredContentSizeCategory: category)
let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits)
let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits)
// KeyBarMetrics.buttonInsets xs4
let verticalInsets = DS.Space.xs4 * 2
return (
barHeight: KeyBarView().intrinsicContentSize.height,
needed: glyph.lineHeight + caption.lineHeight + verticalInsets
)
return glyph.lineHeight + caption.lineHeight + DS.Space.xs4 * 2
}
}

View File

@@ -75,6 +75,102 @@ struct KeyBarTests {
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
}
// MARK: - Dynamic Type (T-iOS-34 · the bar tracks the keycap it draws)
/// Measured height of every keycap, in layout order (the mic key last when
/// present) the only honest way to ask "did the keycaps really re-render".
private func keycapHeights(_ bar: KeyBarView) -> [CGFloat] {
(bar.keyButtons + [bar.voiceButton].compactMap { $0 })
.map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height }
}
@Test("changing the text size re-renders every keycap and re-measures the bar")
func contentSizeCategoryChangeRebuildsTheKeycaps() {
// Arrange: a bar born at the standard size, mic key included.
let bar = KeyBarView(
voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}),
contentSizeCategory: .large
)
let standardHeight = bar.intrinsicContentSize.height
let standardKeycaps = keycapHeights(bar)
// Act: the user drags the text-size slider to the maximum.
bar.apply(contentSizeCategory: .accessibilityExtraExtraExtraLarge)
// Assert: EVERY keycap grew (mic included) and the bar grew to hold them.
let largestKeycaps = keycapHeights(bar)
#expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge)
#expect(bar.intrinsicContentSize.height > standardHeight)
#expect(bar.frame.height == bar.intrinsicContentSize.height)
#expect(standardKeycaps.count == KeyBarLayout.buttons.count + 1)
#expect(zip(standardKeycaps, largestKeycaps).allSatisfy { $1 > $0 })
#expect(largestKeycaps.allSatisfy { $0 <= bar.intrinsicContentSize.height })
}
/// The runtime path, not just the direct call: the user changes the text size
/// in Settings while a terminal is open, UIKit pushes a new trait collection,
/// and the bar must re-measure itself otherwise it keeps whatever height it
/// was born with until the terminal is reopened.
@Test("a trait-collection change re-lays-out the bar (no reopen needed)")
func traitChangePropagatesToTheBar() {
// Arrange: a bar inside a real window (trait propagation needs one).
let bar = KeyBarView(contentSizeCategory: .large)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.addSubview(bar)
window.layoutIfNeeded()
let before = bar.intrinsicContentSize.height
// Act: what UIKit does when the system text size changes.
window.traitOverrides.preferredContentSizeCategory =
.accessibilityExtraExtraExtraLarge
window.layoutIfNeeded()
// Assert
#expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge)
#expect(bar.intrinsicContentSize.height > before)
}
@Test("re-applying the same text size is a no-op (trait callbacks fire for anything)")
func reapplyingTheSameCategoryChangesNothing() {
// Arrange
let bar = KeyBarView(contentSizeCategory: .large)
let before = keycapHeights(bar)
let beforeHeight = bar.intrinsicContentSize.height
// Act
bar.apply(contentSizeCategory: .large)
// Assert
#expect(bar.contentSizeCategory == .large)
#expect(keycapHeights(bar) == before)
#expect(bar.intrinsicContentSize.height == beforeHeight)
}
/// The HIG floor is enforced by a constraint, not by the glyph: at the small
/// text sizes a keycap's own content is only ~37pt tall, so without this the
/// bar would shrink below a tappable target. Asserted structurally because
/// `systemLayoutSizeFitting` reports the CONTENT size (it is what the
/// clipping test needs) and would not show the constraint at all.
@Test("every keycap carries the 44pt HIG hit-target floor, at every text size")
func keycapsCarryTheHitTargetFloor() {
for category in [UIContentSizeCategory.extraSmall, .large,
.accessibilityExtraExtraExtraLarge] {
let bar = KeyBarView(
voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}),
contentSizeCategory: category
)
for button in bar.keyButtons + [bar.voiceButton].compactMap({ $0 }) {
#expect(button.constraints.contains {
$0.firstAttribute == .height
&& $0.relation == .greaterThanOrEqual
&& $0.constant == DS.Layout.minHitTarget
})
}
#expect(bar.intrinsicContentSize.height >= DS.Layout.minHitTarget)
}
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")

View File

@@ -0,0 +1,231 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// F1 · 线
///
/// `SessionThumbnailPipeline.live()` ****
/// `URLSessionHTTPTransport` `WEBTERM_TOKEN`
/// `GET /live-sessions/:id/preview` 401线****
/// 401
///
///
///
/// 1. preview ****
/// `URLSessionHTTPTransport.authenticated`
/// 2. preview RO**** `Origin`Origin-iff-G
/// 3. / 401****
@MainActor
@Suite("SessionThumbnail 令牌接线")
struct SessionThumbnailTokenTests {
/// 32 §1.1 `[A-Za-z0-9._~+/=-]` 16512
private static let token = "0123456789abcdefghijklmnopqrstuv"
private static let base = "http://192.168.1.20:3000"
// MARK: - Fixtures
/// `URLSessionHTTPTransport.authenticated`
/// Cookie
private struct StampingTransport: HTTPTransport {
let stamper: URLSessionHTTPTransport
let recorder: FakeHTTPTransport
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
try await recorder.send(await stamper.authenticated(request))
}
}
private func makeEndpoint(_ base: String = SessionThumbnailTokenTests.base) throws
-> HostEndpoint {
let url = try #require(URL(string: base))
return try #require(HostEndpoint(baseURL: url))
}
private func makeStore(token: String?) async throws -> InMemoryHostStore {
let store = InMemoryHostStore()
_ = try await store.upsert(
HostRegistry.Host(
id: UUID(), name: "mac", endpoint: try makeEndpoint(),
accessToken: try token.map { try AccessToken(validating: $0) }
)
)
return store
}
private func previewURL(_ sessionId: UUID) throws -> URL {
try #require(
URL(string: "\(Self.base)/live-sessions/\(sessionId.uuidString.lowercased())/preview")
)
}
private func previewBody(_ sessionId: UUID) throws -> Data {
try #require("""
{"id":"\(sessionId.uuidString.lowercased())","cols":80,"rows":24,"data":"hello"}
""".data(using: .utf8))
}
/// ****线
private func makePipeline(
store: any HostStore
) -> (pipeline: SessionThumbnailPipeline, recorder: FakeHTTPTransport) {
let recorder = FakeHTTPTransport()
let transport = StampingTransport(
stamper: AppEnvironment.thumbnailTransport(
hostStore: store, identityProvider: { nil }
),
recorder: recorder
)
return (SessionThumbnailPipeline.live(http: transport), recorder)
}
// MARK: -
@Test("令牌门主机preview 请求带上 Cookie: webterm_auth=<t>,且缩略图真渲染出来")
func previewRequestCarriesTheAccessTokenCookie() async throws {
// Arrange
let sessionId = UUID()
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token))
await recorder.queueSuccess(
url: try previewURL(sessionId), body: try previewBody(sessionId)
)
// Act
let image = await pipeline.thumbnail(
for: SessionThumbnailRequest(
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
)
)
// Assert
let requests = await recorder.recordedRequests
#expect(requests.count == 1)
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header)
== "\(AccessTokenCookie.name)=\(Self.token)")
#expect(!image.isPlaceholder, "带上令牌后 preview 应当成功并渲染")
}
@Test("preview 是只读路由:带令牌也绝不带 OriginOrigin-iff-G 零回归)")
func previewStaysOriginFree() async throws {
// Arrange
let sessionId = UUID()
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token))
await recorder.queueSuccess(
url: try previewURL(sessionId), body: try previewBody(sessionId)
)
// Act
_ = await pipeline.thumbnail(
for: SessionThumbnailRequest(
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
)
)
// Assert
let requests = await recorder.recordedRequests
#expect(requests.first?.value(forHTTPHeaderField: "Origin") == nil)
}
// MARK: - /
@Test("主机没有令牌 → 请求逐字不带 CookieLAN 零配置主机零回归)")
func tokenlessHostSendsNoCookie() async throws {
// Arrange
let sessionId = UUID()
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil))
await recorder.queueSuccess(
url: try previewURL(sessionId), body: try previewBody(sessionId)
)
// Act
let image = await pipeline.thumbnail(
for: SessionThumbnailRequest(
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
)
)
// Assert
let requests = await recorder.recordedRequests
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
#expect(!image.isPlaceholder)
}
@Test("令牌缺失导致 401 → 降级成占位图,不崩不抛(管线的错误 UI 就是占位图)")
func unauthorizedDegradesToPlaceholder() async throws {
// Arrange 401
let sessionId = UUID()
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil))
await recorder.queueSuccess(
url: try previewURL(sessionId), status: 401,
body: try #require(#"{"error":"unauthorized"}"#.data(using: .utf8))
)
// Act
let image = await pipeline.thumbnail(
for: SessionThumbnailRequest(
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
)
)
// Assert
#expect(image == .placeholder)
#expect(await recorder.recordedRequests.count == 1)
}
@Test("主机根本不在存储里(存储读不到该 origin→ 无 Cookie仍不崩")
func unknownOriginDegradesGracefully() async throws {
// Arrange
let sessionId = UUID()
let store = InMemoryHostStore()
let otherEndpoint = try makeEndpoint("http://192.168.1.99:3000")
_ = try await store.upsert(
HostRegistry.Host(
id: UUID(), name: "other", endpoint: otherEndpoint,
accessToken: try AccessToken(validating: Self.token)
)
)
let (pipeline, recorder) = makePipeline(store: store)
await recorder.queueSuccess(
url: try previewURL(sessionId), body: try previewBody(sessionId)
)
// Act
let image = await pipeline.thumbnail(
for: SessionThumbnailRequest(
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
)
)
// Assert
let requests = await recorder.recordedRequests
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
#expect(!image.isPlaceholder)
}
// MARK: -
@Test("thumbnailTransport 就是 App 的同一条盖章传输(按 origin 取本主机令牌)")
func thumbnailTransportStampsPerOrigin() async throws {
// Arrange
let transport = AppEnvironment.thumbnailTransport(
hostStore: try await makeStore(token: Self.token), identityProvider: { nil }
)
let mine = URLRequest(url: try previewURL(UUID()))
let other = URLRequest(
url: try #require(URL(string: "http://192.168.1.99:3000/live-sessions"))
)
// Act
let stamped = await transport.authenticated(mine)
let untouched = await transport.authenticated(other)
// Assert
#expect(stamped.value(forHTTPHeaderField: AccessTokenCookie.header)
== "\(AccessTokenCookie.name)=\(Self.token)")
#expect(untouched.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
}
}

View File

@@ -288,9 +288,13 @@ remotely, watch status, and reattach with full scrollback.
foreground contrast. Dynamic Type up to **AX5** is measured, not assumed:
gate banner, telemetry chips and meta rows are asserted not to overflow 320 pt,
with a numeric clamp keeping tabular figures from blowing up the layout.
**Known gap (measured, not fixed):** the fixed 52 pt key-bar needs ~108 pt at
AX5, so key caps clip — recorded as a `withKnownIssue` in
`DynamicTypeLayoutTests` so the test starts complaining once it is fixed.
The key-bar height is derived from the content size category rather than
frozen at 52 pt, so nothing clips at any size. Its keycap **font** is clamped
at `.accessibilityLarge` the same dense-content policy the design system
already applies to telemetry chips — because an unclamped AX5 bar wants
~108 pt of the 17-key strip and would eat the terminal. So AX3AX5 render
keycaps at AX2 size; a test pins the clamp to the design-system constant so
the two cannot drift, and another keeps the bar at exactly 52 pt for XSXL.
- **Project git panel** — the ambient git state the server has served since w6,
now on the phone: a **sync band** (`↑ahead ↓behind`, upstream, detached-HEAD
and fetch-staleness states — green "in sync" *only* when `↑0 ↓0` **and** the

View File

@@ -54,8 +54,9 @@ packages:
# Package.resolved, which lives inside the bundle — is gitignored, so every
# agent/CI run re-resolves from scratch, and a floating `from:` would silently
# drift to whatever is newest. An exact pin makes every agent/CI run resolve
# the SAME source (the earlier drift 1.13.0 → 1.15.0 is what broke the App
# target); raising it is a deliberate, verified edit.
# the SAME source (an unattended drift off `from: 1.13.0` is what once broke
# the App target — see the history below); raising it is a deliberate,
# verified edit, and 1.15.0 below IS that deliberate raise.
#
# History (T-iOS-33/31 root fix): v1.14.0 added `public var hasActiveSelection`
# to iOSTerminalView, which collided with a same-named property the App
@@ -169,8 +170,11 @@ targets:
# Unit-test bundle for App-target logic (ViewModels & pure UI components,
# W3 T-iOS-11…14). Added by the orchestrator as a coordination point
# (project.yml is T-iOS-1's Owns; same precedent as the W1 manifest wiring).
# Coverage GATE still counts only the 4 packages (plan §9) — these tests
# exist for correctness, not for the gate.
# These tests exist for correctness, NOT for the coverage gate: the gate runs
# per SwiftPM package over that package's own sources
# (IntegrationTests/scripts/coverage-gate.sh), and an App-target test bundle is
# not one of them. It now covers FIVE packages — plan §9's four plus ClientTLS,
# which B4 added (see .github/workflows/ios.yml's package-tests matrix).
WebTermTests:
type: bundle.unit-test
platform: iOS