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.
325 lines
14 KiB
Swift
325 lines
14 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 label→bytes 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.
|
|
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
|
|
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
|
|
)
|
|
}
|
|
|
|
// 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?
|
|
|
|
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
|
|
)
|
|
allowsSelfSizing = true
|
|
buildBar()
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
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.)
|
|
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)
|
|
|
|
/// 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
|
|
}
|
|
}
|