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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user