Files
Termind/Frameworks/TermindApplication/Speech/UserInputSpeechSession.swift
T
Kaede Akino 82d871c4fe refactor: separate feature policies and UI workflows
Move connection policy out of ProAccess, keep terminal uploads with their
session, and share question interaction and highlight editing operations.
Consolidate CPU usage and settings snapshot handling, and remove obsolete
terminal selection, theme helpers, and hard-coded latest-schema assertions.

Fix draining connection-pool recovery and stale pane upload bindings. Keep
screenshot matrices opt-in and retain behavioral migration coverage.

Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both
SSH forwarding regressions passed against the local OpenSSH fixture.
Both app builds, Swift formatting, AppStrings, and Chinese localization
checks passed. Parallel test runs still exhibit intermittent timeouts.
2026-10-02 16:40:27 +08:00

93 lines
3.4 KiB
Swift

import Foundation
import Observation
/// A question surface owns one session, including while its adaptive layout
/// measures multiple copies of the answer field.
@Observable
@MainActor
public final class UserInputSpeechSession {
public let ownerID = UUID()
private var coordinator: SpeechTranscriptionCoordinator?
private var startTask: Task<Void, Never>?
private var token: UUID?
public init() {}
public var isActive: Bool {
startTask != nil || coordinator?.isOwned(by: ownerID) == true
}
public var isRecording: Bool {
coordinator?.isOwned(by: ownerID) == true && coordinator?.phase == .recording
}
public var error: SpeechTranscriptionFailure? {
coordinator?.errorOwnerID == ownerID ? coordinator?.error : nil
}
public func start(
coordinator: SpeechTranscriptionCoordinator,
conversation: ConversationModel,
onApplied: @escaping @MainActor () -> Void
) {
guard !isActive, coordinator.canStart(for: ownerID),
let requestID = conversation.pendingUserInput?.id,
let questionID = conversation.currentUserInputQuestion?.id,
conversation.acceptsUserInputTranscription(requestID: requestID, questionID: questionID)
else { return }
self.coordinator = coordinator
let token = UUID()
self.token = token
observeTarget(conversation, requestID: requestID, questionID: questionID, token: token)
startTask = Task { [weak self, weak conversation] in
guard let self else { return }
defer { if self.token == token { self.startTask = nil } }
guard let conversation, !Task.isCancelled, self.token == token,
conversation.acceptsUserInputTranscription(requestID: requestID, questionID: questionID)
else { return }
await coordinator.start(ownerID: ownerID) { [weak self, weak conversation] transcript, _ in
guard let self, self.token == token,
conversation?.applyUserInputTranscription(
transcript, requestID: requestID, questionID: questionID) == true
else { return }
onApplied()
}
}
}
public func stop() {
coordinator?.stop(ownerID: ownerID)
}
public func cancel() {
token = nil
startTask?.cancel()
startTask = nil
coordinator?.cancel(ownerID: ownerID)
dismissError()
}
public func dismissError() {
coordinator?.dismissError(ownerID: ownerID)
}
/// Request retirement also happens outside a view (timeouts, cancellation, and queue changes).
/// Keep microphone ownership tied to that request even when no SwiftUI update has run yet.
private func observeTarget(_ conversation: ConversationModel, requestID: UUID, questionID: String, token: UUID) {
let accepts = withObservationTracking {
conversation.acceptsUserInputTranscription(requestID: requestID, questionID: questionID)
} onChange: { [weak self, weak conversation] in
Task { @MainActor in
guard let self, self.token == token else { return }
guard let conversation else {
self.cancel()
return
}
self.observeTarget(conversation, requestID: requestID, questionID: questionID, token: token)
}
}
if !accepts { cancel() }
}
}