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