feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs

App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -54,9 +54,26 @@ struct KeyBarButtonSpec: Equatable, Sendable {
}
}
/// 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). The 🎤 voice button is P2 (T-iOS-31).
/// (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),
@@ -117,10 +134,19 @@ private enum KeyBarMetrics {
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).
/// 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?
init() {
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) {
self.voice = voice
super.init(
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
inputViewStyle: .keyboard
@@ -150,6 +176,11 @@ final class KeyBarView: UIInputView {
keyButtons = keyButtons + [button]
stack.addArrangedSubview(button)
}
if let voice {
let mic = makeVoiceButton(voice)
voiceButton = mic
stack.addArrangedSubview(mic)
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
@@ -177,41 +208,67 @@ final class KeyBarView: UIInputView {
}
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
// Keycap look: 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.)
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .gray()
config.cornerStyle = .fixed
config.background.cornerRadius = DS.Radius.sm8
var label = AttributedString(spec.label)
label.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .semibold
let button = makeKeycap(
label: spec.label, caption: spec.caption,
title: spec.title, isPrimary: spec.isPrimary
)
config.attributedTitle = label
var caption = AttributedString(spec.caption)
caption.font = UIFont.preferredFont(forTextStyle: .caption2)
caption.foregroundColor = .secondaryLabel
config.attributedSubtitle = caption
config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets
let button = UIButton(configuration: config)
if spec.isPrimary {
button.tintColor = DS.Palette.accentUIColor()
}
button.accessibilityLabel = spec.title
// HIG minimum touch target regardless of glyph width.
button.heightAnchor
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
.isActive = true
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.)
private func makeKeycap(
label: String, caption: String, title: String, isPrimary: Bool
) -> UIButton {
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
)
config.attributedTitle = attributedLabel
var attributedCaption = AttributedString(caption)
attributedCaption.font = UIFont.preferredFont(forTextStyle: .caption2)
attributedCaption.foregroundColor = .secondaryLabel
config.attributedSubtitle = attributedCaption
config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets
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)