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