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)

View File

@@ -0,0 +1,152 @@
import AVFoundation
import Foundation
import Speech
/// T-iOS-31 · `VoiceDictating` Speech + AVAudioEngine
/// `VoicePTT.swift` 800 plan §4
/// ****`requiresOnDeviceRecognition`
/// Apple
/// web SEC-L2 `VoiceTranscript.sanitize`
///
/// PTT **** 12
@MainActor
final class SpeechDictation: VoiceDictating {
enum DictationError: LocalizedError {
case recognizerUnavailable
case microphoneUnavailable
var errorDescription: String? {
switch self {
case .recognizerUnavailable: "设备上的语音识别当前不可用。"
case .microphoneUnavailable: "拿不到麦克风输入。"
}
}
}
/// tap Apple
private static let tapBufferSize: AVAudioFrameCount = 1024
private lazy var audioEngine = AVAudioEngine()
private lazy var recognizer = SFSpeechRecognizer(locale: .current) ?? SFSpeechRecognizer()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
private var onPartial: (@MainActor (String) -> Void)?
private var isTapInstalled = false
private var latest = VoiceDictationResult(transcript: "", confidence: nil)
func requestAuthorization() async -> VoiceAuthorization {
let speech = await Self.requestSpeechAuthorization()
switch speech {
case .authorized: break
case .restricted: return .restricted
case .denied, .notDetermined: return .deniedSpeech
@unknown default: return .deniedSpeech
}
return await Self.requestMicrophoneAuthorization() ? .granted : .deniedMicrophone
}
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
guard let recognizer, recognizer.isAvailable else {
throw DictationError.recognizerUnavailable
}
self.onPartial = onPartial
latest = VoiceDictationResult(transcript: "", confidence: nil)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
self.request = request
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
// installTap ObjC
guard format.sampleRate > 0, format.channelCount > 0 else {
teardown()
throw DictationError.microphoneUnavailable
}
input.installTap(onBus: 0, bufferSize: Self.tapBufferSize, format: format) { buffer, _ in
request.append(buffer)
}
isTapInstalled = true
audioEngine.prepare()
try audioEngine.start()
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
// Sendable result actor
let transcript = result?.bestTranscription.formattedString
let confidence = result.flatMap { Self.averageConfidence(of: $0.bestTranscription) }
let isFinal = result?.isFinal ?? false
let didFail = error != nil
Task { @MainActor in
self?.ingest(
transcript: transcript, confidence: confidence,
isFinal: isFinal, didFail: didFail
)
}
}
}
func stop() async -> VoiceDictationResult {
teardown()
return latest
}
// MARK: Internals
private func ingest(
transcript: String?, confidence: Double?, isFinal: Bool, didFail: Bool
) {
if let transcript, !transcript.isEmpty {
latest = VoiceDictationResult(transcript: transcript, confidence: confidence)
onPartial?(transcript)
}
guard isFinal || didFail else { return }
teardown()
}
/// tap
private func teardown() {
request?.endAudio()
task?.cancel()
task = nil
request = nil
onPartial = nil
if isTapInstalled {
audioEngine.inputNode.removeTap(onBus: 0)
isTapInstalled = false
}
if audioEngine.isRunning {
audioEngine.stop()
}
try? AVAudioSession.sharedInstance()
.setActive(false, options: .notifyOthersOnDeactivation)
}
private static func averageConfidence(of transcription: SFTranscription) -> Double? {
let values = transcription.segments.map { Double($0.confidence) }.filter { $0 > 0 }
guard !values.isEmpty else { return nil }
return values.reduce(0, +) / Double(values.count)
}
private static func requestSpeechAuthorization() async
-> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
private static func requestMicrophoneAuthorization() async -> Bool {
await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
}
}

View File

@@ -0,0 +1,200 @@
import SwiftUI
/// T-iOS-33 · scrollback find bar
///
/// web `public/search.ts` + //×
/// xterm SearchAddon`terminal-session.ts:547-553`iOS
/// **** + ****
/// SwiftTerm 1.13.0 `findNext`/`findPrevious`/`clearSearch`
/// `SearchOptions` caseSensitive:false / regex:false xterm
///
/// **** SwiftTerm / PTY
/// 退read-only
// MARK: - Engine seam
/// `KeyCommandTerminalView`SwiftTerm
/// `AnyObject` `TerminalSearchModel` UIKit
@MainActor
protocol TerminalSearching: AnyObject {
/// true =
@discardableResult func searchNext(term: String) -> Bool
/// true
@discardableResult func searchPrevious(term: String) -> Bool
///
func searchClear()
}
/// webEnter = nextShift+Enter = prev
enum TerminalSearchDirection: String, Equatable, Sendable {
case next
case previous
}
/// `.idle` = /
enum TerminalSearchOutcome: Equatable, Sendable {
case idle
case found
case notFound
}
/// **** web `hooks.find(input.value, )`
/// trim
enum TerminalSearchQuery {
static func isSubmittable(_ raw: String) -> Bool {
!raw.isEmpty
}
}
// MARK: - Model
/// **** `query`
/// `.idle` didSet
@MainActor
@Observable
final class TerminalSearchModel {
/// named constants
enum Copy {
static let title = "搜索回滚缓冲"
static let placeholder = "搜索终端输出…"
static let noMatch = "无匹配"
static let previous = "上一处匹配"
static let next = "下一处匹配"
static let close = "关闭搜索"
static let open = "搜索"
}
/// `outcome` `.idle`
private struct Attempt: Equatable {
let term: String
let didHit: Bool
}
var query: String = ""
private(set) var isPresented = false
private var lastAttempt: Attempt?
/// SwiftTerm UIKit
@ObservationIgnored private weak var searcher: (any TerminalSearching)?
///
var outcome: TerminalSearchOutcome {
guard let lastAttempt, lastAttempt.term == query else { return .idle }
return lastAttempt.didHit ? .found : .notFound
}
///
var statusText: String? {
outcome == .notFound ? Copy.noMatch : nil
}
/// SwiftTerm `makeUIView`
func attach(_ searcher: any TerminalSearching) {
self.searcher = searcher
}
func present() {
isPresented = true
}
/// web `hide()``hooks.clear()`+
func dismiss() {
isPresented = false
query = ""
lastAttempt = nil
searcher?.searchClear()
}
/// /
func find(_ direction: TerminalSearchDirection) {
let term = query
guard TerminalSearchQuery.isSubmittable(term), let searcher else { return }
let didHit: Bool = switch direction {
case .next: searcher.searchNext(term: term)
case .previous: searcher.searchPrevious(term: term)
}
lastAttempt = Attempt(term: term, didHit: didHit)
}
}
// MARK: - View
/// web `#searchbox``position:fixed; top; right`
/// submit = / ×
struct TerminalSearchBar: View {
let model: TerminalSearchModel
/// web `open()` `input.focus()`
@FocusState private var isFieldFocused: Bool
private enum Metrics {
static let fieldMinWidth: CGFloat = 140
static let fieldMaxWidth: CGFloat = 220
}
var body: some View {
HStack(spacing: DS.Space.xs4) {
searchField
if let status = model.statusText {
Text(status)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.accessibilityIdentifier("terminal.search.status")
}
iconButton(
systemImage: "chevron.up",
title: TerminalSearchModel.Copy.previous,
identifier: "terminal.search.previousButton"
) { model.find(.previous) }
iconButton(
systemImage: "chevron.down",
title: TerminalSearchModel.Copy.next,
identifier: "terminal.search.nextButton"
) { model.find(.next) }
iconButton(
systemImage: "xmark",
title: TerminalSearchModel.Copy.close,
identifier: "terminal.search.closeButton"
) { model.dismiss() }
}
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs4)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.onAppear { isFieldFocused = true }
.accessibilityLabel(TerminalSearchModel.Copy.title)
}
private var searchField: some View {
TextField(
TerminalSearchModel.Copy.placeholder,
text: Binding(get: { model.query }, set: { model.query = $0 })
)
.textFieldStyle(.plain)
.font(DS.Typography.callout)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.submitLabel(.search)
.focused($isFieldFocused)
.frame(minWidth: Metrics.fieldMinWidth, maxWidth: Metrics.fieldMaxWidth)
.onSubmit { model.find(.next) }
.accessibilityIdentifier("terminal.search.field")
}
private func iconButton(
systemImage: String,
title: String,
identifier: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityIdentifier(identifier)
}
}

View File

@@ -0,0 +1,579 @@
import Foundation
import Observation
/// T-iOS-31 · PTT+ + 1.5s + epoch
///
/// = + + 800
/// `VoicePTTBanner.swift`SwiftUI `SpeechDictation.swift`
///
/// webplan §7 / 1.5s / epoch = port
/// `public/voice-commands.ts`
/// - `public/voice.ts`PTT `autoSend` false **
/// **
/// - `public/voice-commands.ts`**** + + 0.6
/// - `public/voice-confirm.ts``DEFAULT_WINDOW_MS = 1500`
///
///
/// 1. = ****`VoiceTranscript.sanitize`
/// C0/C1 `\r`/ESC PTY
/// 2. ****`.confirming` arm
/// 3. **1.5s **arm PTY
///
/// 4. **epoch ** epoch`confirm()`
/// /
///
/// `VoiceDictating` //epoch
/// `SpeechDictation` DEFERRED
// MARK: - Recognizer seam
/// TCC
enum VoiceAuthorization: Equatable, Sendable {
case granted
case deniedMicrophone
case deniedSpeech
case restricted
var denialReason: VoiceDenialReason? {
switch self {
case .granted: nil
case .deniedMicrophone: .microphone
case .deniedSpeech: .speech
case .restricted: .restricted
}
}
}
///
enum VoiceDenialReason: Equatable, Sendable {
case microphone
case speech
case restricted
var message: String {
switch self {
case .microphone:
"麦克风权限被拒绝。到 设置 → 隐私与安全性 → 麦克风 里打开 WebTerm 才能按住说话。"
case .speech:
"语音识别权限被拒绝。到 设置 → 隐私与安全性 → 语音识别 里打开 WebTerm。"
case .restricted:
"本设备的语音识别被限制(如「屏幕使用时间」策略),无法使用语音输入。"
}
}
}
/// `confidence`
struct VoiceDictationResult: Equatable, Sendable {
let transcript: String
let confidence: Double?
}
/// `SpeechDictation`Speech + AVAudioEngine
@MainActor
protocol VoiceDictating: AnyObject {
/// + TCC
func requestAuthorization() async -> VoiceAuthorization
/// `onPartial`
func start(onPartial: @escaping @MainActor (String) -> Void) async throws
///
func stop() async -> VoiceDictationResult
}
// MARK: - Transcript sanitisation (security boundary)
/// **** PTY
enum VoiceTranscript {
///
static let maxLength = 2000
/// / C0/C1
private static let whitespaceControls: Set<UInt32> = [0x09, 0x0A, 0x0B, 0x0C, 0x0D]
private static func isControl(_ scalar: Unicode.Scalar) -> Bool {
scalar.value < 0x20 || scalar.value == 0x7F || (0x80...0x9F).contains(scalar.value)
}
///
///
/// `\r`0x0D****
/// web `autoSend: false`
static func sanitize(_ raw: String) -> String {
let scalars = raw.unicodeScalars.compactMap { scalar -> Unicode.Scalar? in
if whitespaceControls.contains(scalar.value) { return " " }
return isControl(scalar) ? nil : scalar
}
let collapsed = String(String.UnicodeScalarView(scalars))
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
return String(collapsed.prefix(maxLength))
}
///
static func isInjectable(_ sanitized: String) -> Bool {
!sanitized.isEmpty
}
}
// MARK: - Command matcher (port of public/voice-commands.ts)
/// `.text` =
enum VoiceAction: String, Equatable, Sendable {
case approve
case reject
case text
}
/// gate v1 tool gate web
enum VoiceGateKind: String, Equatable, Sendable {
case tool
case plan
}
/// ASR
struct VoiceGateSnapshot: Equatable, Sendable {
let pendingApproval: Bool
let gate: VoiceGateKind?
}
/// web `VoiceMatchContext`
struct VoiceMatchContext: Equatable, Sendable {
let pendingApproval: Bool
let gate: VoiceGateKind?
let confidence: Double?
}
/// ****
/// `public/voice-commands.ts`/
/// shell
enum VoiceCommandMatcher {
/// `.approve` ASR `.reject`
static let minApproveConfidence = 0.6
static let approvePhrases: Set<String> = [
"确认", "批准", "同意", "通过", "允许", "可以", "", "好的", "好吧", "好啊",
"", "是的", "", "", "继续", "回车",
"yes", "yeah", "yep", "ok", "okay", "confirm", "approve", "accept",
"proceed", "go ahead",
]
static let rejectPhrases: Set<String> = [
"拒绝", "取消", "不行", "不要", "不用", "不同意", "不批准", "不可以", "不允许", "不通过",
"中断", "停止", "算了", "", "",
"no", "nope", "reject", "deny", "cancel", "abort", "decline", "stop",
]
static let negationPrefixes: [String] = [
"", "", "", "", "",
"no", "not", "dont", "cannot", "wont", "never",
]
/// web `PUNCTUATION_RE` ASCII + CJK
private static let punctuation = CharacterSet(
charactersIn: "'.,!?;:,。!?;:、「」『』“”‘’()()[]{}【】〈〉《》…—-_/\\|\"`~@#$%^&*+=<>"
)
private static func isASCIIWord(_ scalar: Unicode.Scalar) -> Bool {
("a"..."z").contains(scalar) || ("0"..."9").contains(scalar)
}
/// `trim don'tdont trim` web
static func normalize(_ raw: String) -> String {
let lowered = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let stripped = String(String.UnicodeScalarView(
lowered.unicodeScalars.filter { !punctuation.contains($0) }
))
return stripped.split(whereSeparator: \.isWhitespace).joined(separator: " ")
}
/// ASCII `now`
static func startsWithNegation(_ normalized: String) -> Bool {
negationPrefixes.contains { prefix in
guard normalized.hasPrefix(prefix) else { return false }
guard let head = prefix.unicodeScalars.first, isASCIIWord(head) else { return true }
guard let next = normalized.dropFirst(prefix.count).unicodeScalars.first else {
return true
}
return !isASCIIWord(next)
}
}
/// + gate + `.text`
static func match(transcript: String, context: VoiceMatchContext) -> VoiceAction {
let normalized = normalize(transcript)
guard context.pendingApproval, !normalized.isEmpty else { return .text }
guard context.gate == .tool else { return .text }
if startsWithNegation(normalized) {
return rejectPhrases.contains(normalized) ? .reject : .text
}
if rejectPhrases.contains(normalized) { return .reject }
guard approvePhrases.contains(normalized) else { return .text }
if let confidence = context.confidence, confidence < minApproveConfidence {
return .text
}
return .approve
}
}
// MARK: - Epoch (mis-send guard)
/// `sessionId` id`generation` **
/// **
struct VoiceEpoch: Equatable, Sendable {
let sessionId: UUID?
let generation: Int
}
/// epoch
enum VoiceEpochPolicy {
///
///
/// `.reconnecting` **** PTY
/// IDLE_TTL `attached`
///
static func invalidates(_ banner: TerminalViewModel.ConnectionBanner) -> Bool {
switch banner {
case .reconnecting: true
case .none, .connecting: false
}
}
}
/// epoch `TerminalScreen` `TerminalViewModel` sessionId / banner
/// PTT
@MainActor
@Observable
final class VoiceEpochSource {
private(set) var generation = 0
private(set) var sessionId: UUID?
var epoch: VoiceEpoch {
VoiceEpoch(sessionId: sessionId, generation: generation)
}
func noteSession(_ id: UUID?) {
sessionId = id
}
func noteBanner(_ banner: TerminalViewModel.ConnectionBanner) {
guard VoiceEpochPolicy.invalidates(banner) else { return }
generation += 1
}
}
// MARK: - Pending action / resolution
///
enum VoiceIntent: Equatable, Sendable {
///
case text(String)
/// held tool gate
case decision(VoiceDecision)
}
/// gate approve/reject gate wiring
enum VoiceDecision: String, Equatable, Sendable {
case approve
case reject
}
/// **** epoch
struct VoicePendingAction: Equatable, Sendable {
let intent: VoiceIntent
let epoch: VoiceEpoch
///
let preview: String
}
/// UI
enum VoiceResolution: Equatable, Sendable {
case committed(VoiceIntent)
case undone
case discarded
case staleEpoch
case empty
case readOnly
}
/// gate + ****nil gate
/// 退 web held gate dictation
struct VoiceGateBridge {
let snapshot: @MainActor () -> VoiceGateSnapshot
let decide: @MainActor (VoiceDecision) -> Void
}
// MARK: - ViewModel
/// PTT `Clock`
@MainActor
@Observable
final class VoicePTTViewModel {
/// web `voice-confirm.ts` `DEFAULT_WINDOW_MS = 1500`
static let undoWindow: Duration = .milliseconds(1500)
enum Phase: Equatable {
case idle
case requestingPermission
case listening(partial: String)
case confirming(VoicePendingAction)
case undoWindow(VoicePendingAction)
case denied(VoiceDenialReason)
case failed(message: String)
}
/// named constants
enum Copy {
static let hold = "按住说话"
static let requesting = "正在请求麦克风权限…"
static let listening = "正在听…松手结束"
static let listeningHint = "松手后要你确认才会发送"
static let confirmTitle = "确认发送到终端"
static let approveTitle = "确认批准这次工具调用"
static let rejectTitle = "确认拒绝这次工具调用"
static let send = "发送"
static let cancel = "取消"
static let undo = "撤销"
static let sending = "1.5 秒后发送 —— 可撤销"
static let deciding = "1.5 秒后提交决定 —— 可撤销"
static let recognizerFailed = "语音识别没能启动"
static let noticeUndone = "已撤销,什么都没发送。"
static let noticeDiscarded = "已丢弃这段口述。"
static let noticeStaleEpoch = "会话已切换或重连过,这段口述没有发送(避免打进别的会话)。"
static let noticeEmpty = "没听到内容,什么都没发送。"
static let noticeReadOnly = "这个会话已结束,不能再输入。"
static let dismissNotice = "关闭提示"
}
private(set) var phase: Phase = .idle
private(set) var lastResolution: VoiceResolution?
@ObservationIgnored private let dictation: any VoiceDictating
@ObservationIgnored private let clock: any Clock<Duration>
@ObservationIgnored private let epoch: @MainActor () -> VoiceEpoch
@ObservationIgnored private let isReadOnly: @MainActor () -> Bool
@ObservationIgnored private let inject: @MainActor (String) -> Void
@ObservationIgnored private let gate: VoiceGateBridge?
/// PTT
@ObservationIgnored private var isPressed = false
/// `dictation.start` start
@ObservationIgnored private var isStarting = false
/// epoch `VoicePendingAction`
@ObservationIgnored private var dictationEpoch = VoiceEpoch(sessionId: nil, generation: 0)
@ObservationIgnored private var windowTask: Task<Void, Never>?
init(
dictation: any VoiceDictating,
clock: any Clock<Duration>,
epoch: @escaping @MainActor () -> VoiceEpoch,
isReadOnly: @escaping @MainActor () -> Bool,
inject: @escaping @MainActor (String) -> Void,
gate: VoiceGateBridge? = nil
) {
self.dictation = dictation
self.clock = clock
self.epoch = epoch
self.isReadOnly = isReadOnly
self.inject = inject
self.gate = gate
}
// MARK: Derived UI state
var isUndoWindowOpen: Bool {
if case .undoWindow = phase { return true }
return false
}
var isListening: Bool {
if case .listening = phase { return true }
return false
}
/// nil = 西
var pendingAction: VoicePendingAction? {
switch phase {
case .confirming(let pending), .undoWindow(let pending): pending
default: nil
}
}
/// `.committed`
var noticeText: String? {
switch lastResolution {
case .none, .committed: nil
case .undone: Copy.noticeUndone
case .discarded: Copy.noticeDiscarded
case .staleEpoch: Copy.noticeStaleEpoch
case .empty: Copy.noticeEmpty
case .readOnly: Copy.noticeReadOnly
}
}
func clearNotice() {
lastResolution = nil
}
// MARK: PTT
///
func pressDown() async {
guard pendingAction == nil else { return }
isPressed = true
lastResolution = nil
guard !isReadOnly() else {
isPressed = false
phase = .idle
lastResolution = .readOnly
return
}
dictationEpoch = epoch()
phase = .requestingPermission
let authorization = await dictation.requestAuthorization()
guard authorization == .granted else {
isPressed = false
phase = .denied(authorization.denialReason ?? .restricted)
return
}
//
guard isPressed else {
phase = .idle
return
}
await beginListening()
}
/// `beginListening`
func pressUp() async {
isPressed = false
guard !isStarting, isListening else { return }
await finishListening()
}
private func beginListening() async {
phase = .listening(partial: "")
isStarting = true
do {
try await dictation.start { [weak self] partial in
guard let self, self.isListening else { return }
self.phase = .listening(partial: partial)
}
} catch {
isStarting = false
isPressed = false
phase = .failed(message: Self.failureMessage(error))
return
}
isStarting = false
//
guard isPressed else {
await finishListening()
return
}
}
private func finishListening() async {
let result = await dictation.stop()
let text = VoiceTranscript.sanitize(result.transcript)
guard VoiceTranscript.isInjectable(text) else {
phase = .idle
lastResolution = .empty
return
}
let snapshot = gate?.snapshot() ?? VoiceGateSnapshot(pendingApproval: false, gate: nil)
let context = VoiceMatchContext(
pendingApproval: snapshot.pendingApproval,
gate: snapshot.gate,
confidence: result.confidence
)
// **** web
let intent: VoiceIntent = switch VoiceCommandMatcher.match(
transcript: result.transcript, context: context
) {
case .text: .text(text)
case .approve: .decision(.approve)
case .reject: .decision(.reject)
}
phase = .confirming(
VoicePendingAction(intent: intent, epoch: dictationEpoch, preview: text)
)
}
private static func failureMessage(_ error: any Error) -> String {
guard let described = (error as? any LocalizedError)?.errorDescription,
!described.isEmpty
else { return Copy.recognizerFailed }
return "\(Copy.recognizerFailed)\(described)"
}
// MARK: Confirm / undo window
/// epoch arm
func confirm() {
guard case .confirming(let pending) = phase else { return }
guard epoch() == pending.epoch else {
discard(.staleEpoch)
return
}
phase = .undoWindow(pending)
windowTask = Task { [weak self] in
guard let self else { return }
do {
try await clock.sleep(for: Self.undoWindow, tolerance: nil)
} catch {
return // undo
}
commit()
}
}
///
func cancel() {
guard case .confirming = phase else { return }
phase = .idle
lastResolution = .discarded
}
/// App
func undo() {
guard case .undoWindow = phase else { return }
windowTask?.cancel()
phase = .idle
lastResolution = .undone
}
/// epoch confirm
private func commit() {
guard case .undoWindow(let pending) = phase else { return }
guard epoch() == pending.epoch else {
discard(.staleEpoch)
return
}
switch pending.intent {
case .text(let text):
inject(text)
case .decision(let decision):
guard let gate else {
discard(.discarded) // gate
return
}
gate.decide(decision)
}
phase = .idle
lastResolution = .committed(pending.intent)
}
private func discard(_ resolution: VoiceResolution) {
windowTask?.cancel()
phase = .idle
lastResolution = resolution
}
// MARK: Deterministic test barrier
///
func waitUntilWindowSettled() async {
await windowTask?.value
}
}

View File

@@ -0,0 +1,143 @@
import SwiftUI
/// T-iOS-31 · PTT SwiftUI `VoicePTT.swift`
/// `VoicePTTViewModel` `VoicePTT.swift`
/// 800 plan §4
/// PTT / / / / /
/// nil 西
struct VoicePTTBanner: View {
let model: VoicePTTViewModel
/// `.idle`
private var hasCard: Bool {
if case .idle = model.phase { return model.noticeText != nil }
return true
}
var body: some View {
if hasCard {
card
.padding(DS.Space.md12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.accessibilityIdentifier("terminal.voice.card")
}
}
@ViewBuilder private var card: some View {
switch model.phase {
case .requestingPermission:
label(VoicePTTViewModel.Copy.requesting, systemImage: "mic.badge.plus")
case .listening(let partial):
listening(partial: partial)
case .confirming(let pending):
confirming(pending)
case .undoWindow(let pending):
undoWindow(pending)
case .denied(let reason):
notice(reason.message, systemImage: "mic.slash")
case .failed(let message):
notice(message, systemImage: "exclamationmark.triangle")
case .idle:
if let text = model.noticeText {
notice(text, systemImage: "info.circle")
}
}
}
private func label(_ text: String, systemImage: String) -> some View {
Label(text, systemImage: systemImage)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
}
private func listening(partial: String) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
label(VoicePTTViewModel.Copy.listening, systemImage: "waveform")
if !partial.isEmpty {
Text(partial)
.font(DS.Typography.mono(.callout))
.foregroundStyle(DS.Palette.textSecondary)
.accessibilityIdentifier("terminal.voice.partial")
}
Text(VoicePTTViewModel.Copy.listeningHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textTertiary)
}
}
private func confirming(_ pending: VoicePendingAction) -> some View {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
Text(title(for: pending.intent))
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
Text(pending.preview)
.font(DS.Typography.mono(.callout))
.foregroundStyle(DS.Palette.textPrimary)
.accessibilityIdentifier("terminal.voice.preview")
HStack(spacing: DS.Space.sm8) {
Button(VoicePTTViewModel.Copy.cancel) { model.cancel() }
.buttonStyle(.bordered)
.accessibilityIdentifier("terminal.voice.cancelButton")
Button(VoicePTTViewModel.Copy.send) { model.confirm() }
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("terminal.voice.confirmButton")
}
.frame(minHeight: DS.Layout.minHitTarget)
}
}
private func undoWindow(_ pending: VoicePendingAction) -> some View {
HStack(spacing: DS.Space.sm8) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(pending.intent.isDecision
? VoicePTTViewModel.Copy.deciding
: VoicePTTViewModel.Copy.sending)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
Text(pending.preview)
.font(DS.Typography.mono(.callout))
.foregroundStyle(DS.Palette.textPrimary)
}
Spacer(minLength: DS.Space.sm8)
Button(VoicePTTViewModel.Copy.undo) { model.undo() }
.buttonStyle(.borderedProminent)
.frame(minHeight: DS.Layout.minHitTarget)
.accessibilityIdentifier("terminal.voice.undoButton")
}
}
private func notice(_ text: String, systemImage: String) -> some View {
HStack(spacing: DS.Space.sm8) {
label(text, systemImage: systemImage)
Spacer(minLength: DS.Space.sm8)
Button {
model.clearNotice()
} label: {
Image(systemName: "xmark")
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
}
.buttonStyle(.plain)
.accessibilityLabel(VoicePTTViewModel.Copy.dismissNotice)
}
}
private func title(for intent: VoiceIntent) -> String {
switch intent {
case .text: VoicePTTViewModel.Copy.confirmTitle
case .decision(.approve): VoicePTTViewModel.Copy.approveTitle
case .decision(.reject): VoicePTTViewModel.Copy.rejectTitle
}
}
}
extension VoiceIntent {
var isDecision: Bool {
if case .decision = self { return true }
return false
}
}