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)
|
||||
|
||||
152
ios/App/WebTerm/Components/SpeechDictation.swift
Normal file
152
ios/App/WebTerm/Components/SpeechDictation.swift
Normal 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 语义:松手即取**当前最佳假设**(不等最终结果),否则确认要多等 1–2 秒。
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
200
ios/App/WebTerm/Components/TerminalSearchBar.swift
Normal file
200
ios/App/WebTerm/Components/TerminalSearchBar.swift
Normal 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()
|
||||
}
|
||||
|
||||
/// 搜索方向(web:Enter = next,Shift+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)
|
||||
}
|
||||
}
|
||||
579
ios/App/WebTerm/Components/VoicePTT.swift
Normal file
579
ios/App/WebTerm/Components/VoicePTT.swift
Normal file
@@ -0,0 +1,579 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// T-iOS-31 · 语音 PTT(按住说话)+ 显式确认 + 1.5s 撤销 + epoch 防误发。
|
||||
///
|
||||
/// 本文件 = 缝 + 纯归约 + 状态机。同任务的另两块因 800 行硬上限拆出为兄弟文件:
|
||||
/// `VoicePTTBanner.swift`(SwiftUI 状态卡)与 `SpeechDictation.swift`(真机识别器)。
|
||||
///
|
||||
/// 三件套逐条移植 web(plan §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't→dont)→ 合并空白 → 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
|
||||
}
|
||||
}
|
||||
|
||||
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal file
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user