Files
web-terminal/ios/App/WebTerm/Components/VoicePTT.swift
Yaojia Wang 284cfd193a 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.
2026-07-30 15:58:01 +02:00

580 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}