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.
93 lines
3.4 KiB
Swift
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() }
|
|
}
|
|
}
|