Files
web-terminal/ios/App/WebTerm/Components/KeyBar.swift
Yaojia Wang 5cc755b0b6 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.
2026-07-30 16:46:20 +02:00

459 lines
21 KiB
Swift

import SessionCore
import UIKit
/// T-iOS-11 · Mobile key bar + hardware key mapping, mirroring
/// `public/keybar.ts` the Claude Code keys a phone keyboard can't produce,
/// most-used first. EVERY labelbytes lookup resolves through
/// `KeyByteMap` (SessionCore) no byte literal lives in this file.
///
/// The bar is installed as the terminal's `inputAccessoryView` and sends via
/// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap
/// never pops or fights the soft keyboard same reason the web bar bypasses
/// xterm and calls `ws.send`).
// MARK: - Visibility policy (T-iPad-3)
/// Pure predicate deciding whether the soft-keyboard KeyBar
/// (`inputAccessoryView`) should show THE single decision point (mirrors
/// `PrivacyShadePolicy` / `LayoutPolicy`, no scattered checks in views):
/// - a hardware keyboard makes the on-screen key bar redundant default hidden;
/// - no hardware keyboard shown (unchanged iPhone behavior zero regression);
/// - an explicit user toggle (`userOverride`) always wins over the auto default.
///
/// Hardware presence is injected (`GCKeyboard.coalesced != nil` at the call
/// site) so this stays a fast, device-agnostic unit (`KeyBarVisibilityTests`).
enum KeyBarVisibility {
/// - Parameters:
/// - hardwareKeyboardPresent: injected `GCKeyboard.coalesced != nil`.
/// - userOverride: nil = follow the auto default; true/false = the user
/// explicitly forced show/hide (wins over the hardware-driven default).
static func isVisible(hardwareKeyboardPresent: Bool, userOverride: Bool?) -> Bool {
if let userOverride { return userOverride }
return !hardwareKeyboardPresent
}
}
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
/// One key-bar button: glyph, short function caption (shown under the glyph,
/// self-documenting on touch) and the full title for accessibility.
struct KeyBarButtonSpec: Equatable, Sendable {
let key: KeyByteMap.Key
let label: String
let caption: String
let title: String
let isPrimary: Bool
init(key: KeyByteMap.Key, label: String, caption: String, title: String,
isPrimary: Bool = false) {
self.key = key
self.label = label
self.caption = caption
self.title = title
self.isPrimary = isPrimary
}
}
/// Push-to-talk outlets for the 🎤 key (T-iOS-31). Supplied as a PAIR a mic
/// that can start but not stop would leave the microphone hot.
struct KeyBarVoiceHandlers {
let onDown: @MainActor () -> Void
let onUp: @MainActor () -> Void
}
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
/// (most-used Claude Code keys first), plus the 🎤 push-to-talk key
/// (T-iOS-31) which mirrors `keybar.ts buildMicButton` appended at the END,
/// only when voice handlers are supplied, and deliberately OUTSIDE
/// `buttons`/`KeyByteMap`: it maps to no bytes at all.
enum KeyBarLayout {
/// 🎤 glyph + caption, transcribed from `keybar.ts buildMicButton`.
static let voiceLabel = "🎤"
static let voiceCaption = "语音"
/// Accessibility title. Unlike the web (Chrome ships audio to Google), iOS
/// prefers on-device recognition the copy says what actually happens.
static let voiceTitle = "按住说话 — 转成文字后要你确认才送进终端"
static let buttons: [KeyBarButtonSpec] = [
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
KeyBarButtonSpec(key: .escEsc, label: "Esc²", caption: "回溯",
title: "Esc Esc — rewind / clear draft"),
KeyBarButtonSpec(key: .shiftTab, label: "⇧Tab", caption: "模式",
title: "Shift+Tab — cycle plan / auto-accept mode"),
KeyBarButtonSpec(key: .arrowUp, label: "", caption: "上一个",
title: "Up — previous option / history"),
KeyBarButtonSpec(key: .arrowDown, label: "", caption: "下一个",
title: "Down — next option / history"),
KeyBarButtonSpec(key: .enter, label: "", caption: "确认",
title: "Enter — confirm"),
KeyBarButtonSpec(key: .ctrlC, label: "^C", caption: "取消",
title: "Ctrl+C — cancel / quit"),
KeyBarButtonSpec(key: .ctrlR, label: "^R", caption: "搜历史",
title: "Ctrl+R — reverse-search command history"),
KeyBarButtonSpec(key: .ctrlO, label: "^O", caption: "详情",
title: "Ctrl+O — expand transcript / tool detail"),
KeyBarButtonSpec(key: .ctrlL, label: "^L", caption: "重绘",
title: "Ctrl+L — redraw screen"),
KeyBarButtonSpec(key: .ctrlT, label: "^T", caption: "任务",
title: "Ctrl+T — toggle task list"),
KeyBarButtonSpec(key: .ctrlB, label: "^B", caption: "后台",
title: "Ctrl+B — background running task"),
KeyBarButtonSpec(key: .ctrlD, label: "^D", caption: "退出",
title: "Ctrl+D — exit session (EOF)"),
KeyBarButtonSpec(key: .tab, label: "Tab", caption: "补全",
title: "Tab — complete / toggle"),
KeyBarButtonSpec(key: .arrowLeft, label: "", caption: "左移",
title: "Left — move cursor left"),
KeyBarButtonSpec(key: .arrowRight, label: "", caption: "右移",
title: "Right — move cursor right"),
KeyBarButtonSpec(key: .slash, label: "/", caption: "命令",
title: "Slash — command launcher"),
]
}
/// 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.
///
/// 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)
/// Horizontally scrolling key bar, installed as the terminal's
/// `inputAccessoryView`. Taps call `onKey` the screen routes them to
/// `TerminalViewModel.send(key:)`, which resolves bytes via `KeyByteMap`.
final class KeyBarView: UIInputView {
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
var onKey: (@MainActor (KeyByteMap.Key) -> Void)?
/// Built buttons in layout order (test-visible). The 🎤 key is NOT in here
/// it sends no bytes, so it stays out of the KeyByteMap-backed list.
private(set) var keyButtons: [UIButton] = []
/// 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?
/// - 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(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")
required init?(coder: NSCoder) {
return nil
}
override var intrinsicContentSize: CGSize {
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() {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = KeyBarMetrics.buttonSpacing
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
for spec in KeyBarLayout.buttons {
let button = makeButton(for: spec)
keyButtons = keyButtons + [button]
stack.addArrangedSubview(button)
}
if let voice {
let mic = makeVoiceButton(voice)
voiceButton = mic
stack.addArrangedSubview(mic)
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.addSubview(stack)
addSubview(scroll)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
scroll.topAnchor.constraint(equalTo: topAnchor),
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
stack.leadingAnchor.constraint(
equalTo: scroll.contentLayoutGuide.leadingAnchor,
constant: KeyBarMetrics.contentInset
),
stack.trailingAnchor.constraint(
equalTo: scroll.contentLayoutGuide.trailingAnchor,
constant: -KeyBarMetrics.contentInset
),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
])
}
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
let button = makeKeycap(
label: spec.label, caption: spec.caption,
title: spec.title, isPrimary: spec.isPrimary
)
button.addAction(
UIAction { [weak self] _ in self?.onKey?(spec.key) },
for: .touchUpInside
)
return button
}
/// The 🎤 key: same keycap look, but **press/release** semantics instead of a
/// tap, and it never goes through `onKey` (it maps to no bytes at all).
/// `.touchUpOutside`/`.touchCancel` also release, so sliding a finger off the
/// key can never leave the microphone open.
private func makeVoiceButton(_ voice: KeyBarVoiceHandlers) -> UIButton {
let button = makeKeycap(
label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption,
title: KeyBarLayout.voiceTitle, isPrimary: false
)
button.addAction(UIAction { _ in voice.onDown() }, for: .touchDown)
for event in [UIControl.Event.touchUpInside, .touchUpOutside, .touchCancel] {
button.addAction(UIAction { _ in voice.onUp() }, for: event)
}
return button
}
/// Shared keycap chrome: primary (Esc) wears the accent tint, the rest a
/// 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.)
///
/// 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 = KeyBarMetrics.glyphFont(contentSizeCategory)
config.attributedTitle = attributedLabel
var attributedCaption = AttributedString(caption)
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()
}
button.accessibilityLabel = title
// HIG minimum touch target regardless of glyph width.
button.heightAnchor
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
.isActive = true
return button
}
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
/// Hardware-keyboard chords routed through the SAME `KeyByteMap` mapping
/// (plan §7 T-iOS-11). Scope decision (documented deviation): only Esc,
/// Shift+Tab and the Ctrl chords are registered
/// - arrows stay SwiftTerm-native: a fixed CSI override would break
/// application-cursor-keys mode (DECCKM: vim/htop expect `ESC O A`);
/// - Enter/Tab/"/" are ordinary typing SwiftTerm already delivers;
/// - Esc·Esc is simply two Esc presses.
/// UIKeyCommand interception replaces (never duplicates) the responder's own
/// press handling, so each chord is sent exactly once.
enum HardwareKeyCommands {
struct Chord: Equatable {
let key: KeyByteMap.Key
let input: String
let modifiers: UIKeyModifierFlags
}
static let chords: [Chord] = [
Chord(key: .esc, input: UIKeyCommand.inputEscape, modifiers: []),
Chord(key: .shiftTab, input: "\t", modifiers: .shift),
Chord(key: .ctrlC, input: "c", modifiers: .control),
Chord(key: .ctrlR, input: "r", modifiers: .control),
Chord(key: .ctrlO, input: "o", modifiers: .control),
Chord(key: .ctrlL, input: "l", modifiers: .control),
Chord(key: .ctrlT, input: "t", modifiers: .control),
Chord(key: .ctrlB, input: "b", modifiers: .control),
Chord(key: .ctrlD, input: "d", modifiers: .control),
]
/// One prioritized `UIKeyCommand` per chord, all firing `action`.
/// (`UIKeyCommand` is `@MainActor` in the SDK, hence the isolation.)
@MainActor
static func build(action: Selector) -> [UIKeyCommand] {
chords.map { chord in
let command = UIKeyCommand(
action: action, input: chord.input, modifierFlags: chord.modifiers
)
command.wantsPriorityOverSystemBehavior = true
return command
}
}
/// Reverse lookup for the command handler: which chord fired.
@MainActor
static func key(matching command: UIKeyCommand) -> KeyByteMap.Key? {
chords.first {
$0.input == command.input && $0.modifiers == command.modifierFlags
}?.key
}
}