diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 2b135c8c..b6f428bd 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; }; C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */; }; + A62300000000000000000022 /* BatchTranscriptionCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */; }; + A62300000000000000000032 /* PromiseDropSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000031 /* PromiseDropSupportTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -54,6 +56,8 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = ""; }; + A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchTranscriptionCoordinatorTests.swift; sourceTree = ""; }; + A62300000000000000000031 /* PromiseDropSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromiseDropSupportTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -132,6 +136,8 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A62300000000000000000001 /* AudioBufferConverterTests.swift */, C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */, + A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */, + A62300000000000000000031 /* PromiseDropSupportTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -291,6 +297,8 @@ 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */, + A62300000000000000000022 /* BatchTranscriptionCoordinatorTests.swift in Sources */, + A62300000000000000000032 /* PromiseDropSupportTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 2410fd45..c9501c94 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2012,6 +2012,10 @@ struct ContentView: View { // MARK: - Stop and Process Transcription private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async { + // Recording session is ending: release dictation intent so the batch arbiter + // (and any dictation restart) no longer sees dictation as active. + FileTranscriptionSession.shared.endDictationIntent() + DebugLogger.shared.debug("stopAndProcessTranscription called", source: "ContentView") DebugLogger.shared.info("Output route selected: \(route.rawValue)", source: "ContentView") self.appBench("stop_path_enter route=\(route.rawValue)") @@ -3036,6 +3040,19 @@ struct ContentView: View { /// Capture app context at start to avoid mismatches if the user switches apps mid-session private func startRecording() { + // File-transcription batches drive the same shared ASR model; starting + // dictation mid-batch would run concurrent inference through one model. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } + + // Signal dictation intent synchronously, before any `await` below, so the + // batch transcribe closure's arbitration check (which also reads this flag) + // cannot race a window where neither isBatchTranscribing nor asr.isRunning + // is true yet. + FileTranscriptionSession.shared.beginDictationIntent() + let model = SettingsStore.shared.selectedSpeechModel DebugLogger.shared.info( "ContentView: startRecording() for model=\(model.displayName), supportsStreaming=\(model.supportsStreaming)", @@ -3067,6 +3084,7 @@ struct ContentView: View { }) if !self.asr.isRunning { self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed") + FileTranscriptionSession.shared.endDictationIntent() } } @@ -3301,6 +3319,12 @@ struct ContentView: View { self.menuBarManager.setOverlayMode(.command) guard !self.asr.isRunning else { return } + // Command mode starts recording directly (it does not route through + // beginDictationRecording), so it needs its own batch guard. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } self.advanceOverlayLifecycle() @@ -3341,6 +3365,12 @@ struct ContentView: View { self.setActiveRecordingMode(.edit) guard !self.asr.isRunning else { return } + // Rewrite/edit mode starts recording directly (it does not route through + // beginDictationRecording), so it needs its own batch guard. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } self.advanceOverlayLifecycle() @@ -3659,6 +3689,13 @@ extension ContentView { } private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) { + // Common chokepoint for dictate/prompt/command/rewrite hotkeys: file-transcription + // batches drive the same shared ASR model, so none of these modes may start + // while a batch is running. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } DebugLogger.shared.debug("Begin dictation recording for slot \(slot.rawValue)", source: "ContentView") self.appBench("begin_recording slot=\(slot.rawValue) mode=\(mode.rawValue)") if self.isOnboardingVoicePlaygroundStepActive { @@ -3708,6 +3745,18 @@ extension ContentView { self.beginDictationRecording(for: .secondary, mode: mode) } + /// Feedback when a dictation-family hotkey (dictate/prompt/command/rewrite) is + /// rejected because a file-transcription batch is running. Reuses the existing + /// stop cue rather than building new UI, so the user gets an audible signal that + /// the hotkey press had no effect instead of silence. + private func notifyDictationBlockedByBatch() { + DebugLogger.shared.warning( + "Dictation blocked: batch file transcription in progress", + source: "ContentView" + ) + TranscriptionSoundPlayer.shared.playStopSound() + } + private func appBench(_ message: String) { DebugLogger.shared.benchmark("APP_BENCH", message: message, source: "AppBenchmark") } diff --git a/Sources/Fluid/Fluid-Bridging-Header.h b/Sources/Fluid/Fluid-Bridging-Header.h index 94f6c2e8..103728b3 100644 --- a/Sources/Fluid/Fluid-Bridging-Header.h +++ b/Sources/Fluid/Fluid-Bridging-Header.h @@ -2,5 +2,6 @@ #define FLUID_BRIDGING_HEADER_H #include "CoreAudioCaptureSupportBridge.h" +#include "ObjCExceptionCatcher.h" #endif diff --git a/Sources/Fluid/ObjCExceptionCatcher.h b/Sources/Fluid/ObjCExceptionCatcher.h new file mode 100644 index 00000000..9076cd67 --- /dev/null +++ b/Sources/Fluid/ObjCExceptionCatcher.h @@ -0,0 +1,18 @@ +#ifndef OBJC_EXCEPTION_CATCHER_H +#define OBJC_EXCEPTION_CATCHER_H + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Runs the block, catching any Objective-C exception it raises. +/// Returns the exception's description, or nil if the block completed cleanly. +/// Needed because NSPasteboard access from background threads can raise +/// NSInternalInconsistencyException on internal races, and a C++ library in the +/// process installs a terminate handler that turns any uncaught NSException +/// into abort(). +NSString *_Nullable FluidCatchObjCException(void (NS_NOESCAPE ^block)(void)); + +NS_ASSUME_NONNULL_END + +#endif diff --git a/Sources/Fluid/ObjCExceptionCatcher.m b/Sources/Fluid/ObjCExceptionCatcher.m new file mode 100644 index 00000000..6a099883 --- /dev/null +++ b/Sources/Fluid/ObjCExceptionCatcher.m @@ -0,0 +1,10 @@ +#import "ObjCExceptionCatcher.h" + +NSString *FluidCatchObjCException(void (NS_NOESCAPE ^block)(void)) { + @try { + block(); + return nil; + } @catch (NSException *exception) { + return exception.description ?: @"unknown Objective-C exception"; + } +} diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift new file mode 100644 index 00000000..2ead617d --- /dev/null +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -0,0 +1,233 @@ +import Combine +import Foundation + +/// Coordinates sequential transcription of a batch of audio files (issue #219). +/// +/// The coordinator owns per-item state for multi-file transcription: strict sequential +/// processing in enqueue order, per-item success/failure/no-speech outcomes, cooperative +/// cancellation of the in-flight file, staging-directory cleanup after each item finishes, +/// and batch start/end hooks for dictation arbitration. All stored closures are invoked on +/// the main actor. +@MainActor +final class BatchTranscriptionCoordinator: ObservableObject { + /// A transcription request for a single audio file. + struct Request { + let url: URL + let stagingDir: URL? + + init(url: URL, stagingDir: URL? = nil) { + self.url = url + self.stagingDir = stagingDir + } + } + + /// Per-item transcription outcome. + enum Status { + case pending + case transcribing + case completed(TranscriptionResult) + case noSpeech + case failed(String) + case cancelled + } + + /// Tracked state for a single enqueued file. + struct Item: Identifiable { + let id: UUID + let url: URL + let stagingDir: URL? + var status: Status + } + + @Published private(set) var items: [Item] = [] + @Published private(set) var isRunning: Bool = false + + private let transcribe: @MainActor (URL) async throws -> TranscriptionResult + private let onBatchStart: @MainActor () -> Void + private let onBatchEnd: @MainActor () -> Void + + private var batchTask: Task? + /// Published so the UI can show a "Cancelling…" state: pending items stop + /// immediately, but the in-flight item may run to completion (the native + /// provider transcription has no interruption point). + @Published private(set) var isCancelRequested: Bool = false + + private var isCancelled: Bool = false + private var idleWaiters: [CheckedContinuation] = [] + + init( + transcribe: @escaping @MainActor (URL) async throws -> TranscriptionResult, + onBatchStart: @escaping @MainActor () -> Void = {}, + onBatchEnd: @escaping @MainActor () -> Void = {} + ) { + self.transcribe = transcribe + self.onBatchStart = onBatchStart + self.onBatchEnd = onBatchEnd + } + + // MARK: - Enqueue + + /// Append requests and start a sequential processing batch if idle. Enqueuing while a + /// batch is running appends to the live batch. + func enqueue(_ requests: [Request]) { + for request in requests { + self.items.append( + Item( + id: UUID(), + url: request.url, + stagingDir: request.stagingDir, + status: .pending + ) + ) + } + + if self.batchTask == nil { + self.startBatch() + } + } + + // MARK: - Cancellation + + /// Cancel the in-flight transcription and every still-pending item. No-op when idle. + func cancel() { + guard self.batchTask != nil else { return } + + self.isCancelled = true + self.isCancelRequested = true + + for index in 0.. Int? { + self.items.firstIndex { if case .pending = $0.status { return true } else { return false } } + } + + private func processItem(at index: Int) async { + self.items[index].status = .transcribing + + let url = self.items[index].url + + do { + let result = try await self.transcribe(url) + + if self.isCancelled { + self.items[index].status = .cancelled + } else if result.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.items[index].status = .noSpeech + } else { + self.items[index].status = .completed(result) + } + } catch is CancellationError { + self.items[index].status = .cancelled + } catch { + if self.isCancelled { + self.items[index].status = .cancelled + } else { + self.items[index].status = .failed(error.localizedDescription) + } + } + + // Staging dirs are owned by the coordinator and removed only after the item has + // finished transcribing (success, failure, or cancellation) — never before/during. + self.removeStagingDir(for: index) + } + + private func removeStagingDir(for index: Int) { + guard let stagingDir = self.items[index].stagingDir else { return } + try? FileManager.default.removeItem(at: stagingDir) + + // Per-item staging dirs live inside a drop-session root (see + // PromiseDropSupport.StagingSession). Once the last item dir is gone, + // remove the now-empty session root so drops don't leak temp shells. + let parent = stagingDir.deletingLastPathComponent() + guard parent.lastPathComponent.hasPrefix(PromiseDropSupport.stagingRootPrefix), + let remaining = try? FileManager.default.contentsOfDirectory(atPath: parent.path), + remaining.isEmpty else { return } + try? FileManager.default.removeItem(at: parent) + } + + private func resumeIdleWaiters() { + let waiters = self.idleWaiters + self.idleWaiters.removeAll(keepingCapacity: false) + waiters.forEach { $0.resume() } + } +} diff --git a/Sources/Fluid/Services/FileTranscriptionSession.swift b/Sources/Fluid/Services/FileTranscriptionSession.swift new file mode 100644 index 00000000..bf376edb --- /dev/null +++ b/Sources/Fluid/Services/FileTranscriptionSession.swift @@ -0,0 +1,105 @@ +import Combine +import Foundation + +/// App-level owner of file-transcription state (issue #219). +/// +/// The transcription service and batch coordinator used to be `@StateObject`s +/// of `MeetingTranscriptionView`, so switching sidebar pages destroyed the view +/// and silently killed any in-flight transcription. Owning them here keeps +/// batches running while the user navigates; the view re-attaches on return. +@MainActor +final class FileTranscriptionSession { + static let shared = FileTranscriptionSession() + + /// Set once `shared` is first touched. Lets the dictation path check batch + /// state without lazily constructing the session (and the ASR service). + private static var sharedIfCreated: FileTranscriptionSession? + + /// True while a batch is actively transcribing. Dictation must not start + /// concurrently: both paths drive the same shared ASR model, and + /// concurrent inference corrupts output. + static var isBatchTranscribing: Bool { + self.sharedIfCreated?.batchHolder.coordinator?.isRunning == true + } + + /// True from the moment a dictation/prompt/command/rewrite recording is about to + /// start until the session ends. This is the "intent to dictate" half of the + /// single-model arbiter shared with the batch transcribe closure below: it is set + /// synchronously on the main actor before any `await`, closing the TOCTOU window + /// where the batch closure's `AppServices.shared.asr.isRunning` check could pass + /// right before dictation flips `isRunning` true. + /// + /// Uses `sharedIfCreated` (not `shared`) so callers that only need to *read* the + /// flag never force-create the session/ASR service. `beginDictationIntent()` is the + /// one path allowed to force-create, since it's only called from the app's own + /// recording start path where creating the session is expected anyway. + private(set) var dictationIntent: Bool = false + + let service: MeetingTranscriptionService + let batchHolder: BatchCoordinatorHolder + + private init() { + let service = MeetingTranscriptionService(asrService: AppServices.shared.asr) + self.service = service + self.batchHolder = BatchCoordinatorHolder(transcribe: { url in + // Single-model arbitration: at most one of dictation, single-file + // transcription, and batch transcription may drive the shared ASR model at + // once. Wait for dictation intent (covers the TOCTOU window before + // AppServices.shared.asr.isRunning flips true), live dictation, and any + // in-flight single-file transcription. Cancellation still interrupts the wait. + while AppServices.shared.asr.isRunning + || FileTranscriptionSession.shared.dictationIntent + || service.isTranscribing { + try Task.checkCancellation() + try await Task.sleep(nanoseconds: 500_000_000) + } + return try await service.transcribeFile(url) + }) + Self.sharedIfCreated = self + } + + /// Signal that a dictation-family recording (dictate/prompt/command/rewrite) is + /// about to start. Must be called synchronously before any `await` in the caller. + func beginDictationIntent() { + self.dictationIntent = true + } + + /// Signal that the dictation-family recording session has ended (stopped, + /// cancelled, or failed to start). + func endDictationIntent() { + self.dictationIntent = false + } +} + +/// Wraps the batch coordinator so a finished batch can be cleared and a fresh +/// one lazily created, while republshing the inner coordinator's changes. +@MainActor +final class BatchCoordinatorHolder: ObservableObject { + @Published var coordinator: BatchTranscriptionCoordinator? + + private let transcribe: @MainActor (URL) async throws -> TranscriptionResult + private var forwarder: AnyCancellable? + + init(transcribe: @escaping @MainActor (URL) async throws -> TranscriptionResult) { + self.transcribe = transcribe + } + + func enqueue(_ requests: [BatchTranscriptionCoordinator.Request]) { + if self.coordinator == nil { + let coordinator = BatchTranscriptionCoordinator(transcribe: self.transcribe) + self.forwarder = coordinator.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + self.coordinator = coordinator + } + self.coordinator?.enqueue(requests) + } + + func clear() { + // Never orphan a running batch: without this, dropping the coordinator + // would leave it transcribing invisibly with no way to reach it. + self.coordinator?.cancel() + self.forwarder?.cancel() + self.forwarder = nil + self.coordinator = nil + } +} diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index 67d1eede..6706a78f 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -216,6 +216,7 @@ final class MeetingTranscriptionService: ObservableObject { source: "MeetingTranscriptionService" ) + try Task.checkCancellation() let nativeResult = try await provider.transcribeFile(at: fileURL) let processingTime = Date().timeIntervalSince(startTime) let result = TranscriptionResult( @@ -284,6 +285,7 @@ final class MeetingTranscriptionService: ObservableObject { self.currentStatus = duration > 0 ? "Transcribing audio (\(Int(duration))s)..." : "Transcribing audio..." while currentFrame < audioFile.length { + try Task.checkCancellation() let remainingFrames = AVAudioFrameCount(audioFile.length - currentFrame) let framesToRead = min(sourceFramesPerChunk, remainingFrames) @@ -373,6 +375,11 @@ final class MeetingTranscriptionService: ObservableObject { FileTranscriptionHistoryStore.shared.addEntry(result) return result + } catch is CancellationError { + // Cooperative cancellation (batch cancel, app teardown, etc.) is not a + // transcription failure: don't surface it as an error or fire a false + // failure analytics event. + throw CancellationError() } catch let error as TranscriptionError { self.error = error.localizedDescription AnalyticsService.shared.capture( diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index bea63c77..ce8d0a5a 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -1,16 +1,22 @@ +import Combine import SwiftUI import UniformTypeIdentifiers struct MeetingTranscriptionView: View { let asrService: ASRService - @StateObject private var transcriptionService: MeetingTranscriptionService + // Owned by FileTranscriptionSession (app-level) so in-flight transcriptions + // survive this view being destroyed by sidebar navigation. + @ObservedObject private var transcriptionService: MeetingTranscriptionService + @ObservedObject private var batchHolder: BatchCoordinatorHolder @ObservedObject private var fileHistoryStore = FileTranscriptionHistoryStore.shared @State private var selectedFileURL: URL? @Environment(\.theme) private var theme init(asrService: ASRService) { self.asrService = asrService - _transcriptionService = StateObject(wrappedValue: MeetingTranscriptionService(asrService: asrService)) + let session = FileTranscriptionSession.shared + self.transcriptionService = session.service + self.batchHolder = session.batchHolder } @State private var showingFilePicker = false @@ -20,6 +26,7 @@ struct MeetingTranscriptionView: View { @State private var showingCopyConfirmation = false @State private var isDropTargeted = false @State private var dropErrorMessage: String? + @State private var dropErrorGeneration = 0 enum ExportFormat: String, CaseIterable { case text = "Text (.txt)" @@ -58,18 +65,18 @@ struct MeetingTranscriptionView: View { // File Selection Card self.fileSelectionCard - // Progress Card (only show when transcribing) - if self.transcriptionService.isTranscribing { + // Progress Card (only show when transcribing outside a batch) + if self.transcriptionService.isTranscribing && !self.isBatchActive { self.progressCard } - // Results Card (only show when we have results) - if let result = transcriptionService.result { + // Results Card (only show when we have results outside a batch) + if let result = transcriptionService.result, !self.isBatchActive { self.resultsCard(result: result) } - // Error Card (only show when we have an error) - if let error = transcriptionService.error { + // Error Card (only show when we have a single-file error) + if let error = transcriptionService.error, !self.isBatchActive { self.errorCard(error: error) } @@ -78,6 +85,11 @@ struct MeetingTranscriptionView: View { self.dropErrorCard(message: message) } + // Batch transcription progress/results + if self.isBatchActive { + self.batchSection + } + // Recent transcriptions (persisted history) if !self.fileHistoryStore.entries.isEmpty { Divider() @@ -89,6 +101,16 @@ struct MeetingTranscriptionView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + // Overlay, not background: AppKit's drag-target search walks views + // front-to-back, so the drop target must sit above the SwiftUI content. + // Its hitTest returns nil, so clicks pass through to the UI beneath. + .overlay( + PromiseAwareDropView( + onTargetedChange: { self.isDropTargeted = $0 }, + onFiles: { self.handleIncomingFiles($0) }, + onError: { self.showDropError($0) } + ) + ) .background(self.theme.palette.windowBackground) .overlay(alignment: .topTrailing) { if self.showingCopyConfirmation { @@ -178,7 +200,7 @@ struct MeetingTranscriptionView: View { .padding(.vertical, 12) } .buttonStyle(.borderedProminent) - .disabled(self.transcriptionService.isTranscribing) + .disabled(self.transcriptionService.isTranscribing || self.isBatchActive) } else { // File picker button – whole area is tappable; supports drag-and-drop @@ -214,22 +236,16 @@ struct MeetingTranscriptionView: View { .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [8])) .foregroundColor(Color.fluidGreen.opacity(self.isDropTargeted ? 0.7 : 0.3)) ) - .onDrop(of: [.fileURL], isTargeted: self.$isDropTargeted) { providers in - self.handleDrop(providers: providers) - } } } .fileImporter( isPresented: self.$showingFilePicker, allowedContentTypes: MeetingTranscriptionService.allowedContentTypes, - allowsMultipleSelection: false + allowsMultipleSelection: true ) { result in switch result { case let .success(urls): - if let url = urls.first { - self.selectedFileURL = url - self.transcriptionService.reset() - } + self.handleIncomingFiles(urls.map { (url: $0, stagingDir: nil) }) case let .failure(error): DebugLogger.shared.error("File picker error: \(error)", source: "MeetingTranscriptionView") } @@ -535,34 +551,208 @@ struct MeetingTranscriptionView: View { ) } - // MARK: - Helper Functions + // MARK: - Batch Transcription Section - private static let supportedFileExtensions = MeetingTranscriptionService.supportedFileExtensions - - private static let dropErrorCopy = MeetingTranscriptionService.dropErrorCopy - - private func handleDrop(providers: [NSItemProvider]) -> Bool { - guard let provider = providers.first else { return false } - provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in - let url: URL? = (item as? URL) ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } - guard let url = url else { return } - let ext = url.pathExtension.lowercased() - guard Self.supportedFileExtensions.contains(ext) else { - DispatchQueue.main.async { - self.dropErrorMessage = Self.dropErrorCopy - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.dropErrorMessage = nil + @ViewBuilder + private var batchSection: some View { + if let coordinator = self.batchHolder.coordinator, !coordinator.items.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(self.batchHeader(coordinator: coordinator)) + .font(.headline) + + Spacer() + + if coordinator.isRunning { + // Pending items stop immediately; the in-flight file may + // run to completion (native provider transcription has + // no interruption point), hence the "Cancelling" state. + if coordinator.isCancelRequested { + Text("Cancelling…") + .font(.caption) + .foregroundColor(.secondary) + } else { + Button("Cancel") { coordinator.cancel() } + .buttonStyle(.borderless) + .foregroundColor(.red) + } + } else { + Button("Done") { self.dismissBatch() } + .buttonStyle(.borderless) + } + } + + Divider() + + VStack(spacing: 8) { + ForEach(coordinator.items) { item in + self.batchRow(item: item) } } - return } - DispatchQueue.main.async { - self.selectedFileURL = url - self.transcriptionService.reset() + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(self.theme.palette.cardBackground) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.45), lineWidth: 1) + ) + ) + } + } + + private func batchHeader(coordinator: BatchTranscriptionCoordinator) -> String { + if coordinator.isRunning { + return "Transcribing \(self.currentBatchPosition(in: coordinator)) of \(coordinator.items.count)" + } + return "Batch complete — \(coordinator.completedCount) transcribed, \(coordinator.failedCount) failed" + } + + /// One-based position of the item currently being transcribed (or the next pending + /// item while between files), for the "Transcribing x of y" header. + private func currentBatchPosition(in coordinator: BatchTranscriptionCoordinator) -> Int { + let items = coordinator.items + if let index = items.firstIndex(where: { if case .transcribing = $0.status { return true }; return false }) { + return index + 1 + } + if let index = items.firstIndex(where: { if case .pending = $0.status { return true }; return false }) { + return index + 1 + } + return items.count + } + + private func batchRow(item: BatchTranscriptionCoordinator.Item) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(item.url.lastPathComponent) + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + + self.batchDetailText(for: item.status) + } + + Spacer() + + self.batchStatusIcon(for: item.status) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.contentBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.3), lineWidth: 1) + ) + ) + } + + @ViewBuilder + private func batchDetailText(for status: BatchTranscriptionCoordinator.Status) -> some View { + switch status { + case .failed(let message): + Text(message) + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + .truncationMode(.tail) + case .noSpeech: + Text("No speech detected") + .font(.caption) + .foregroundColor(.secondary) + case .cancelled: + Text("Cancelled") + .font(.caption) + .foregroundColor(.secondary) + default: + EmptyView() + } + } + + @ViewBuilder + private func batchStatusIcon(for status: BatchTranscriptionCoordinator.Status) -> some View { + switch status { + case .pending: + Image(systemName: "clock") + .foregroundColor(.secondary) + .opacity(0.5) + case .transcribing: + ProgressView() + .controlSize(.small) + .fixedSize() + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.fluidGreen) + case .noSpeech: + Image(systemName: "speaker.slash.fill") + .foregroundColor(.secondary) + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + case .cancelled: + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + + // MARK: - Helper Functions + + /// True while a batch coordinator exists with any enqueued items (running or + /// finished-but-not-dismissed). Used to suppress the single-file cards while a + /// batch is in progress, since `transcribeFile` mutates the shared service state + /// for each batch item. + private var isBatchActive: Bool { + guard let coordinator = self.batchHolder.coordinator else { return false } + return !coordinator.items.isEmpty + } + + /// Routes incoming files from a drop or the file picker. + /// + /// A single concrete file (no staging directory) follows the existing single-file + /// flow: select it and wait for the user to tap Transcribe. Everything else — two + /// or more files, or any staged (promised) file — is enqueued on the batch + /// coordinator so staged directories are reliably cleaned up after transcription. + private func handleIncomingFiles(_ files: [(url: URL, stagingDir: URL?)]) { + guard !files.isEmpty else { return } + self.dropErrorMessage = nil + + // Single concrete file goes through the fast path only when there's no batch + // (running or finished-but-undismissed — isBatchActive covers both) and no + // single-file transcription already in flight; otherwise it's routed into the + // coordinator, which either appends to the live batch or shows up as a fresh + // row so the self-draining batch picks it up. + if files.count == 1, let file = files.first, file.stagingDir == nil, + !self.isBatchActive, !self.transcriptionService.isTranscribing { + self.selectedFileURL = file.url + self.transcriptionService.reset() + return + } + + self.batchHolder.enqueue(files.map { .init(url: $0.url, stagingDir: $0.stagingDir) }) + } + + /// Clears the batch list and resets shared service state so a subsequent batch + /// starts fresh and no stale single-file result lingers. + /// Shows a drop error and auto-dismisses it after a few seconds. Promise + /// resolution can report errors up to two minutes after the drop; without + /// auto-dismissal a stale red card would appear over an already-successful + /// batch and stick around until manually closed. + private func showDropError(_ message: String) { + self.dropErrorMessage = message + self.dropErrorGeneration += 1 + let generation = self.dropErrorGeneration + Task { @MainActor in + try? await Task.sleep(nanoseconds: 6_000_000_000) + if self.dropErrorGeneration == generation { self.dropErrorMessage = nil } } - return true + } + + private func dismissBatch() { + self.batchHolder.clear() + self.transcriptionService.reset() } private func transcribeFile() async { @@ -603,6 +793,8 @@ struct MeetingTranscriptionView: View { } } +// MARK: - Batch Coordinator Ownership + // MARK: - Document for Export struct TranscriptionDocument: FileDocument { diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift new file mode 100644 index 00000000..2a520882 --- /dev/null +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -0,0 +1,672 @@ +import AppKit +import SwiftUI + +/// A drop target that accepts both concrete file URLs and file-promise drags +/// (e.g. from Voice Memos), and resolves promised files into per-item staging +/// directories before reporting them via callbacks. +/// +/// The pure strategy/staging/filtering logic lives in `PromiseDropSupport`; +/// this view only owns AppKit drag plumbing and the async resolution poll. +struct PromiseAwareDropView: NSViewRepresentable { + let onTargetedChange: (Bool) -> Void + let onFiles: ([(url: URL, stagingDir: URL?)]) -> Void + let onError: (String) -> Void + + func makeNSView(context: Context) -> DropTargetView { + let view = DropTargetView() + view.onTargetedChange = self.onTargetedChange + view.onFiles = self.onFiles + view.onError = self.onError + return view + } + + func updateNSView(_ nsView: DropTargetView, context: Context) { + nsView.onTargetedChange = self.onTargetedChange + nsView.onFiles = self.onFiles + nsView.onError = self.onError + } + + // MARK: - Drop target + + final class DropTargetView: NSView { + var onTargetedChange: (Bool) -> Void = { _ in } + var onFiles: ([(url: URL, stagingDir: URL?)]) -> Void = { _ in } + var onError: (String) -> Void = { _ in } + + /// Shared background queue for `NSFilePromiseReceiver` resolution. Static + /// so in-flight promise resolution survives the view being destroyed + /// (e.g. by sidebar navigation) mid-drop. + private static let promiseQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.configureDropTypes() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.configureDropTypes() + } + + private func configureDropTypes() { + let promiseTypes = NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType(rawValue: $0) } + self.registerForDraggedTypes([.fileURL] + promiseTypes) + } + + /// The view sits in an `.overlay` so AppKit's front-to-back drag-target + /// search finds it above the SwiftUI content; returning nil here lets + /// every normal mouse event fall through to the UI beneath. Drag + /// dispatch does not consult `hitTest`, so drops still arrive. + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + DebugLogger.shared.debug( + "Drop target attached [inWindow=\(self.window != nil), frame=\(self.frame)]", + source: "PromiseAwareDropView" + ) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + let types = (sender.draggingPasteboard.types ?? []).map(\.rawValue) + let strategy = PromiseDropSupport.strategy(forPasteboardTypes: types) + DebugLogger.shared.debug( + "Drag entered [strategy=\(String(describing: strategy)), frame=\(self.frame)]", + source: "PromiseAwareDropView" + ) + guard strategy != nil else { + return [] + } + self.onTargetedChange(true) + return .copy + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + self.onTargetedChange(false) + } + + override func draggingEnded(_ sender: NSDraggingInfo) { + self.onTargetedChange(false) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let pasteboard = sender.draggingPasteboard + let types = (pasteboard.types ?? []).map(\.rawValue) + DebugLogger.shared.debug( + "Perform drag [types=\(types.count)]", + source: "PromiseAwareDropView" + ) + guard let strategy = PromiseDropSupport.strategy(forPasteboardTypes: types) else { + self.onError(MeetingTranscriptionService.dropErrorCopy) + return false + } + + switch strategy { + case .concreteFileURLs: + return self.handleConcreteURLs(pasteboard: pasteboard) + case .filePromise: + return self.handleFilePromises(sender: sender, pasteboard: pasteboard) + } + } + + // MARK: - Concrete File URLs + + private func handleConcreteURLs(pasteboard: NSPasteboard) -> Bool { + let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] + guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [URL], + !urls.isEmpty else { + self.onError(MeetingTranscriptionService.dropErrorCopy) + return false + } + + let supported = PromiseDropSupport.filterSupported(urls) + if supported.isEmpty { + self.onError(MeetingTranscriptionService.dropErrorCopy) + return false + } + + self.onFiles(supported.map { (url: $0, stagingDir: nil) }) + return true + } + + // MARK: - File Promises + + private func handleFilePromises(sender: NSDraggingInfo, pasteboard: NSPasteboard) -> Bool { + let session: PromiseDropSupport.StagingSession + do { + session = try PromiseDropSupport.StagingSession() + } catch { + self.onError("Could not prepare a staging directory for the drop: \(error.localizedDescription)") + return false + } + let legacyDir = try? session.makeItemDirectory() + let dataDir = try? session.makeItemDirectory() + // Captured by value so resolution keeps working (and can still hand + // files to the app-level batch session) if this view is destroyed + // by navigation while the drop is resolving. + let onFiles = self.onFiles + let onError = self.onError + + // Every pasteboard read can block indefinitely (verified: the same + // read is instant on one drop and deadlocks on the next), and a + // block inside performDragOperation freezes the system-wide drag + // session. So performDragOperation returns immediately and ALL + // pasteboard interaction happens on throwaway background threads; + // the main thread only ever polls the staging directories. + // + // NOTE: using `sender`/`pasteboard` after performDragOperation has + // returned, from another thread, is outside AppKit's documented + // lifetime contract. It is the only arrangement that neither + // deadlocks nor loses the drop (verified live against Voice Memos); + // if a provider stops answering, the reads fail harmlessly on their + // background threads and the poll timeout reports the failure. + let state = ResolutionState() + state.beginInFlight() // outer worker token, released when it finishes + + // The poller starts before any pasteboard read, so even a worker + // thread that hangs on its first read cannot leave the drop without + // a timeout/error path. Deliberately not tied to the view lifetime. + Task { @MainActor in + await DropTargetView.resolvePromises( + session: session, + state: state, + legacyDir: legacyDir, + dataDir: dataDir, + onFiles: onFiles, + onError: onError + ) + } + + Self.detachWorker { + defer { state.endInFlight() } + + // ORDERING MATTERS: NSPasteboard is not thread-safe, and + // receivePromisedFiles touches the pasteboard from its own + // queue. All of OUR pasteboard reads happen first, single- + // threaded, and only then do the receivers start — concurrent + // access crashed with an NSInternalInconsistencyException in + // NSPasteboard's type cache (verified live on a 3-memo drag). + // Every read is additionally wrapped in an ObjC exception + // catcher: a C++ terminate handler in the process turns any + // uncaught NSException into abort(). + + // 1. Snapshot the receiver objects and per-item metadata/bytes. + var receivers: [NSFilePromiseReceiver] = [] + var suggestedName: String? + var promisedTypeID: String? + var itemPayloads: [(name: String?, data: Data)] = [] + let readError = FluidCatchObjCException { + receivers = (pasteboard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver]) ?? [] + suggestedName = pasteboard.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") + ) + promisedTypeID = pasteboard.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-content-type") + ) + for item in pasteboard.pasteboardItems ?? [] { + guard let itemTypeID = item.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-content-type") + ) ?? promisedTypeID else { continue } + guard let data = item.data(forType: NSPasteboard.PasteboardType(itemTypeID)), + !data.isEmpty else { continue } + let name = item.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") + ) + itemPayloads.append((name: name, data: data)) + } + } + if let readError { + DebugLogger.shared.warning( + "Pasteboard read raised: \(readError)", + source: "PromiseAwareDropView" + ) + } + DebugLogger.shared.debug( + "Promise drop [receivers=\(receivers.count), items=\(itemPayloads.count), type=\(promisedTypeID ?? "?"), name=\(suggestedName ?? "?")]", + source: "PromiseAwareDropView" + ) + state.setPayloadCount(itemPayloads.count) + + // 2. Raw-data fallback: write each item's bytes into the shared + // data dir (verified as the reliable path for Voice Memos). + // Identically-named items get disambiguated file names. + if let dataDir, !itemPayloads.isEmpty { + var usedNames = Set() + for (index, payload) in itemPayloads.enumerated() { + let itemName = payload.name ?? suggestedName ?? "Dropped Audio \(index + 1)" + var fileName = itemName + var counter = 2 + while usedNames.contains(fileName) { + let base = (itemName as NSString).deletingPathExtension + let ext = (itemName as NSString).pathExtension + fileName = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)" + counter += 1 + } + usedNames.insert(fileName) + let target = dataDir.appendingPathComponent(fileName) + do { + try payload.data.write(to: target) + DebugLogger.shared.debug( + "Raw-data fallback wrote \(payload.data.count) bytes to \(target.lastPathComponent)", + source: "PromiseAwareDropView" + ) + } catch { + DebugLogger.shared.debug( + "Raw-data fallback write failed: \(error.localizedDescription)", + source: "PromiseAwareDropView" + ) + } + } + } + + // 3. Modern receivers, started only after our reads are done. + // Still required even when the raw-data path delivered: leaving + // a promise unresolved wedges the source app's drag machinery + // (Voice Memos refuses further drags until restarted). + for receiver in receivers { + guard let dir = try? session.makeItemDirectory() else { continue } + state.registerReceiverDir(dir) + // A per-receiver token: the soft timeout must not abandon a + // promise that is still downloading (e.g. iCloud taking 45s) + // just because the outer worker thread already finished + // (finding F8). Released on both the success and error paths. + state.beginInFlight() + receiver.receivePromisedFiles(atDestination: dir, options: [:], operationQueue: Self.promiseQueue) { url, error in + defer { state.endInFlight() } + if let error { + DebugLogger.shared.debug( + "Modern promise receiver failed [\(url.lastPathComponent)]: \(error.localizedDescription)", + source: "PromiseAwareDropView" + ) + state.markFailed(dir) + } else { + state.markCompleted(dir) + } + } + } + + // 4. Legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:), + // strictly last resort. A pending legacy request freezes the + // source app's promise machinery until it answers (verified + // live: Voice Memos unresponsive for 35-80s, blocking further + // drags), so it only fires if neither the modern receiver nor + // the raw-data path has produced anything within a short + // window. Invoked via perform(_:) with a string selector so no + // deprecation warning is emitted. + if let legacyDir { + let waitDeadline = Date().addingTimeInterval(3) + var otherPathDelivered = false + while Date() < waitDeadline { + let dataLanded = dataDir.map { + !((try? FileManager.default.contentsOfDirectory(atPath: $0.path)) ?? []).isEmpty + } ?? false + if dataLanded || !state.completedSnapshot().isEmpty { + otherPathDelivered = true + break + } + Thread.sleep(forTimeInterval: 0.2) + } + if !otherPathDelivered { + state.beginInFlight() + Self.detachWorker { + defer { state.endInFlight() } + var names: [String] = [] + let legacyError = FluidCatchObjCException { + names = (sender.perform(Selector(("namesOfPromisedFilesDroppedAtDestination:")), with: legacyDir)? + .takeUnretainedValue() as? [String]) ?? [] + } + if let legacyError { + DebugLogger.shared.warning( + "Legacy promise call raised: \(legacyError)", + source: "PromiseAwareDropView" + ) + } + DebugLogger.shared.debug( + "Legacy promise names: \(names)", + source: "PromiseAwareDropView" + ) + } + } + } + } + return true + } + + /// Thread-safe resolution state shared between the background delivery + /// threads and the main-actor poller: which receiver dirs have completed, + /// and whether the legacy/raw-data reads are still in flight (under heavy + /// inference load a provider can take 60s+ to answer — verified live — + /// and the poller must not give up and sweep the staging dirs while an + /// answer is still coming). + private final class ResolutionState: @unchecked Sendable { + private let lock = NSLock() + private var receiverDirs: [URL] = [] + private var completed: Set = [] + private var failed: Set = [] + private var inFlightCount = 0 + private var payloadCount = 0 + + func registerReceiverDir(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.receiverDirs.append(dir) + } + + func receiverDirsSnapshot() -> [URL] { + self.lock.lock() + defer { self.lock.unlock() } + return self.receiverDirs + } + + func markCompleted(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.completed.insert(dir) + } + + func completedSnapshot() -> Set { + self.lock.lock() + defer { self.lock.unlock() } + return self.completed + } + + /// Marks a receiver as resolved-but-failed. A failed receiver's + /// promise has already answered (with an error), so — unlike a + /// still-pending one — its staging dir is safe to sweep immediately + /// and it counts toward "every receiver resolved" for delivery + /// gating (finding F9). + func markFailed(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.failed.insert(dir) + } + + func failedSnapshot() -> Set { + self.lock.lock() + defer { self.lock.unlock() } + return self.failed + } + + /// Records the raw-data item count once, so a receiver-less + /// multi-item drop can still detect a partial delivery (finding F11). + func setPayloadCount(_ count: Int) { + self.lock.lock() + defer { self.lock.unlock() } + self.payloadCount = count + } + + func payloadCountValue() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.payloadCount + } + + func beginInFlight() { + self.lock.lock() + defer { self.lock.unlock() } + self.inFlightCount += 1 + } + + func endInFlight() { + self.lock.lock() + defer { self.lock.unlock() } + self.inFlightCount -= 1 + } + + var hasInFlightWork: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.inFlightCount > 0 + } + } + + /// Detached worker at user-initiated QoS: default-QoS threads get starved + /// while ML inference saturates the cores, which is exactly when a second + /// drop arrives mid-batch. + private static func detachWorker(_ body: @escaping () -> Void) { + let thread = Thread(block: body) + thread.qualityOfService = .userInitiated + thread.start() + } + + // MARK: - Promise Resolution Poll + + private static func resolvePromises( + session: PromiseDropSupport.StagingSession, + state: ResolutionState, + legacyDir: URL?, + dataDir: URL?, + onFiles: @escaping ([(url: URL, stagingDir: URL?)]) -> Void, + onError: @escaping (String) -> Void + ) async { + let pollInterval: UInt64 = 200_000_000 + let softTimeout: TimeInterval = 30 + // Under heavy inference load a provider can take 60s+ to answer + // (verified live: 80s for a second drop mid-batch). While any + // delivery thread is still in flight the poll keeps waiting, up to + // a hard cap so a truly wedged provider still ends in an error. + let hardTimeout: TimeInterval = 120 + let modernGrace: TimeInterval = 1.5 + let start = Date() + + func makeContext() -> DeliveryContext { + let receiverDirs = state.receiverDirsSnapshot() + let completed = state.completedSnapshot() + let failed = state.failedSnapshot() + return DeliveryContext( + allDirs: receiverDirs + [legacyDir, dataDir].compactMap(\.self), + // A failed receiver has already answered (with an error), so + // unlike a still-pending one its dir is safe to sweep now. + pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) && !failed.contains($0) }, + // The raw-data item count also bounds expectations: a + // receiver-less multi-item drop that partially lands must + // still surface a partial-failure error (finding F11). + totalExpected: max(receiverDirs.count, state.payloadCountValue(), 1), + session: session, + onFiles: onFiles, + onError: onError + ) + } + + var fallbackPrevSizes: [URL: Int64] = [:] + + while true { + let elapsed = Date().timeIntervalSince(start) + if elapsed >= hardTimeout { break } + if elapsed >= softTimeout, !state.hasInFlightWork { break } + + // A receiver dir only counts once its completion callback fired — + // a stalled (e.g. iCloud) download must never be delivered as a + // truncated file just because its size held still between polls. + let receiverDirs = state.receiverDirsSnapshot() + let readyModernDirs = state.completedSnapshot() + let failedModernDirs = state.failedSnapshot() + let modernFiles = self.listFiles(in: receiverDirs.filter(readyModernDirs.contains)) + let legacyFiles = self.listFiles(in: [legacyDir].compactMap(\.self)) + let dataFiles = self.listFiles(in: [dataDir].compactMap(\.self)) + let fallbackSizes = self.sizeMap(for: legacyFiles + dataFiles) + + // "Resolved" = completed OR failed: a failed receiver's promise + // has already answered, so it can never flip to completed later + // (finding F9) — waiting on it would spin the full soft timeout. + let resolvedCount = readyModernDirs.count + failedModernDirs.count + let allReceiversResolved = !receiverDirs.isEmpty && resolvedCount >= receiverDirs.count + + // Modern succeeded outright: every registered receiver resolved, + // at least one completed, and it produced files. (Receivers + // register in one tight loop with no pasteboard call in + // between, so a partially-registered snapshot is a micro-race + // at worst; completion still requires each dir's callback.) + let modernComplete = allReceiversResolved && !readyModernDirs.isEmpty && !modernFiles.isEmpty + if modernComplete { + self.deliver(modern: modernFiles, legacy: legacyFiles, data: dataFiles, context: makeContext()) + return + } + + // Modern produced nothing — either no receivers were ever + // registered, or every registered receiver resolved and none + // completed — past the grace period, but a fallback landed and + // its sizes are stable — use the fallback copies. + let modernExhaustedWithNoWins = receiverDirs.isEmpty + || (allReceiversResolved && readyModernDirs.isEmpty) + let modernProducedNothing = modernExhaustedWithNoWins && elapsed > modernGrace + // The worker must be done (no in-flight token) so a multi-item + // raw-data sweep that is still writing files 3..5 isn't + // delivered after only the first items stabilized. + let fallbackStable = !fallbackSizes.isEmpty && fallbackSizes == fallbackPrevSizes + && !state.hasInFlightWork + if modernProducedNothing && fallbackStable { + self.deliver(modern: [], legacy: legacyFiles, data: dataFiles, context: makeContext()) + return + } + + fallbackPrevSizes = fallbackSizes + try? await Task.sleep(nanoseconds: pollInterval) + } + + // Timeout: deliver whatever completed, preferring modern. Incomplete + // receiver dirs are excluded (possible truncation) and surface via + // the partial-failure error below. + let receiverDirs = state.receiverDirsSnapshot() + let readyModernDirs = state.completedSnapshot() + self.deliver( + modern: self.listFiles(in: receiverDirs.filter(readyModernDirs.contains)), + legacy: self.listFiles(in: [legacyDir].compactMap(\.self)), + data: self.listFiles(in: [dataDir].compactMap(\.self)), + context: makeContext() + ) + } + + /// Everything `deliver` needs besides the per-path file lists. + private struct DeliveryContext { + let allDirs: [URL] + /// Receiver dirs whose promise hasn't completed yet. They must NOT + /// be swept at delivery: the source app may still be writing into + /// them, and deleting the destination leaves its promise session + /// unresolved — Voice Memos then refuses to start any further drag + /// until restarted (verified live). They're cleaned up after the + /// promise completes (or a 120s deadline) by a deferred task. + let pendingReceiverDirs: [URL] + let totalExpected: Int + let session: PromiseDropSupport.StagingSession + let onFiles: ([(url: URL, stagingDir: URL?)]) -> Void + let onError: (String) -> Void + } + + /// Delivers the resolved files. Selection/dedup rules live in + /// `PromiseDropSupport.selectDelivery`; unsupported formats are filtered + /// like the concrete-URL path. + private static func deliver( + modern: [URL], + legacy: [URL], + data: [URL], + context: DeliveryContext + ) { + DebugLogger.shared.debug( + "Promise delivery [modern=\(modern.count), legacy=\(legacy.count), data=\(data.count), expected=\(context.totalExpected)]", + source: "PromiseAwareDropView" + ) + let selected = PromiseDropSupport.selectDelivery(modern: modern, legacy: legacy, data: data) + let supported = PromiseDropSupport.filterSupported(selected) + let result = supported.map { (url: $0, stagingDir: $0.deletingLastPathComponent()) } + + if result.isEmpty { + // Same pending-respecting sweep as the non-empty branch below: + // an empty result must not delete staging dirs whose receiver + // promise hasn't completed yet (finding F1) — removeAll() used + // to nuke the whole session root, pending dirs included, which + // wedges the source app's drag machinery. + for dir in PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: context.allDirs, + deliveredFiles: [], + pendingDirs: context.pendingReceiverDirs + ) { + try? FileManager.default.removeItem(at: dir) + } + if context.pendingReceiverDirs.isEmpty { + context.session.removeAll() + } else { + self.cleanUpLater(pendingDirs: context.pendingReceiverDirs) + } + let reason = selected.isEmpty + ? "No files could be read from the drop." + : "The dropped files are not a supported format." + context.onError("\(reason) \(MeetingTranscriptionService.dropErrorCopy)") + return + } + + // Item dirs that hold no delivered file — losing duplicates (e.g. the + // raw-data copy when the modern receiver won) and empty shells from + // failed paths — are dead weight. Deleting them can race the still- + // running legacy/data writer threads; that is harmless (the losers' + // files are throwaway copies) and keeps the temp dir clean. The + // batch coordinator removes the delivered dirs (and then the empty + // session root) after each item's transcription. + for dir in PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: context.allDirs, + deliveredFiles: supported, + pendingDirs: context.pendingReceiverDirs + ) { + try? FileManager.default.removeItem(at: dir) + } + + context.onFiles(result) + if result.count < context.totalExpected { + context.onError("\(context.totalExpected - result.count) file(s) could not be read from the drop.") + } + + self.cleanUpLater(pendingDirs: context.pendingReceiverDirs) + } + + /// Removes still-pending receiver dirs once their promise has had time + /// to finish writing (their content is a duplicate of what was already + /// delivered), then prunes the session root if it ended up empty. + private static func cleanUpLater(pendingDirs: [URL]) { + guard !pendingDirs.isEmpty else { return } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 120_000_000_000) + for dir in pendingDirs { + try? FileManager.default.removeItem(at: dir) + } + for parent in Set(pendingDirs.map { $0.deletingLastPathComponent() }) + where parent.lastPathComponent.hasPrefix(PromiseDropSupport.stagingRootPrefix) { + if ((try? FileManager.default.contentsOfDirectory(atPath: parent.path)) ?? []).isEmpty { + try? FileManager.default.removeItem(at: parent) + } + } + } + } + + // MARK: - Filesystem Helpers + + private static func listFiles(in dirs: [URL]) -> [URL] { + var files: [URL] = [] + for dir in dirs { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { continue } + for entry in entries where !entry.hasDirectoryPath { + files.append(entry) + } + } + return files + } + + private static func sizeMap(for urls: [URL]) -> [URL: Int64] { + var map: [URL: Int64] = [:] + for url in urls { + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + map[url] = Int64(size) + } + return map + } + } +} diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift new file mode 100644 index 00000000..a7078b33 --- /dev/null +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -0,0 +1,153 @@ +import AVFoundation +import Foundation +import UniformTypeIdentifiers + +/// Testable, nonisolated core logic for the promise-aware drop path (issue #219). +/// +/// The AppKit drop view (`PromiseAwareDropView`) needs a live drag session, so it +/// delegates the pure pieces here: deciding whether a pasteboard carries concrete +/// file URLs or file promises, staging promised files into collision-free per-item +/// directories, and filtering drops to formats AVFoundation can actually decode. +/// Everything is `nonisolated` so it can be unit-tested from a nonisolated test +/// context despite the app target's `MainActor` default actor isolation. +nonisolated enum PromiseDropSupport { + /// How a given pasteboard should be interpreted. + enum Strategy: Equatable { + /// Concrete file URLs are present (`public.file-url`). + case concreteFileURLs + /// Only file-promise metadata is present (e.g. a Voice Memos drag). + case filePromise + } + + /// The pasteboard type that carries a concrete file URL. + private static let concreteFileURLType = "public.file-url" + + /// Pasteboard type identifiers that signal a file-promise drop. Voice Memos + /// emits a mix of these (verified live 2026-07-25); any one is sufficient. + private static let filePromiseTypes: Set = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-content-type", + "Apple files promise pasteboard type", + ] + + /// Decides the drop strategy from raw pasteboard type strings. + /// + /// Concrete file URLs win over promises when both are present. Promises are + /// only accepted when the pasteboard also advertises an audio/movie content + /// type (Voice Memos exposes e.g. `com.apple.m4a-audio` directly in the + /// type list) — so an image promise from Photos is rejected up front rather + /// than staged and failed later. Returns `nil` for pasteboards that carry + /// neither file URLs nor decodable promises. + static func strategy(forPasteboardTypes types: [String]) -> Strategy? { + if types.contains(concreteFileURLType) { + return .concreteFileURLs + } + guard types.contains(where: { filePromiseTypes.contains($0) }) else { + return nil + } + let promisesAudioVisualContent = types.contains { type in + guard let utType = UTType(type) else { return false } + return utType.conforms(to: .audio) || utType.conforms(to: .movie) + } + return promisesAudioVisualContent ? .filePromise : nil + } + + /// Staging dirs that hold no delivered file and should be swept. + /// + /// Compares standardized *paths*, not URLs: a directory built via + /// `appendingPathComponent` and the same directory derived from a file via + /// `deletingLastPathComponent()` differ in trailing-slash form and compare + /// unequal as URLs — which once made the sweep delete a delivered file. + static func sweepableDirs(allDirs: [URL], deliveredFiles: [URL]) -> [URL] { + let deliveredDirPaths = Set(deliveredFiles.map { + $0.deletingLastPathComponent().standardizedFileURL.path + }) + return allDirs.filter { !deliveredDirPaths.contains($0.standardizedFileURL.path) } + } + + /// Staging dirs safe to delete right now: swept per `sweepableDirs`, minus + /// any dir whose receiver promise hasn't completed yet. + /// + /// Pending receiver dirs must never be removed while pending — the source + /// app (e.g. Voice Memos) may still be writing into them, and deleting the + /// destination out from under an in-flight `NSFilePromiseReceiver` wedges + /// its drag machinery until restart. Callers are expected to defer removal + /// of the excluded dirs until the pending promises resolve. + static func dirsSafeToRemoveNow(allDirs: [URL], deliveredFiles: [URL], pendingDirs: [URL]) -> [URL] { + let pendingPaths = Set(pendingDirs.map { $0.standardizedFileURL.path }) + return sweepableDirs(allDirs: allDirs, deliveredFiles: deliveredFiles) + .filter { !pendingPaths.contains($0.standardizedFileURL.path) } + } + + /// Merges the three promise-delivery paths into the final file list. + /// + /// Every modern-receiver file is delivered: each receiver stages into its own + /// directory, so two distinct memos that share a display name (e.g. two + /// "New Recording.m4a") are distinct files and must never be collapsed. + /// Legacy and raw-data files are alternates of the same dragged items, so + /// they only contribute names the delivered set doesn't already contain. + static func selectDelivery(modern: [URL], legacy: [URL], data: [URL]) -> [URL] { + var delivered = modern + var names = Set(modern.map(\.lastPathComponent)) + for url in legacy + data where !names.contains(url.lastPathComponent) { + delivered.append(url) + names.insert(url.lastPathComponent) + } + return delivered + } + + /// Audio/movie file extensions the OS can decode, queried nonisolated from + /// AVFoundation. Mirrors `MeetingTranscriptionService.supportedFileExtensions` + /// but is safe to call from any isolation context. + private static let supportedExtensions: Set = { + let avTypes = AVURLAsset.audiovisualTypes() + let extensions = avTypes.compactMap { fileType -> String? in + guard let utType = UTType(fileType.rawValue) else { return nil } + guard utType.conforms(to: .audio) || utType.conforms(to: .movie) else { return nil } + return utType.preferredFilenameExtension?.lowercased() + } + return Set(extensions) + }() + + /// Keeps only URLs whose (lowercased) extension is a decodable audio/movie + /// format. Order-preserving. + static func filterSupported(_ urls: [URL]) -> [URL] { + return urls.filter { url in + let ext = url.pathExtension.lowercased() + return !ext.isEmpty && supportedExtensions.contains(ext) + } + } + + /// A scratch directory tree under the user temp dir that gives each promised + /// file its own subdirectory, so identically-named files (e.g. several Voice + /// Memos drops named "New Recording.m4a") never collide. + /// + /// All state is immutable, so the session is `Sendable` and safe to share + /// across the main actor and the background queue that resolves promises. + /// Prefix of every drop-session staging root; the batch coordinator uses it + /// to recognize (and prune) empty session roots after item cleanup. + static let stagingRootPrefix = "PromiseDrop-" + + final class StagingSession: Sendable { + private let root: URL + + /// Creates a unique root directory under the user temp dir. + init() throws { + self.root = FileManager.default.temporaryDirectory + .appendingPathComponent("\(PromiseDropSupport.stagingRootPrefix)\(UUID().uuidString)") + try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true) + } + + /// Creates a fresh, unique subdirectory for a single promised item. + func makeItemDirectory() throws -> URL { + let dir = self.root.appendingPathComponent("item-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Deletes the whole staging tree. + func removeAll() { + try? FileManager.default.removeItem(at: self.root) + } + } +} diff --git a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift new file mode 100644 index 00000000..8bf18da6 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift @@ -0,0 +1,519 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Contract tests for BatchTranscriptionCoordinator (issue #219 batch transcription). +/// The coordinator owns per-item state for multi-file transcription: sequential +/// processing, per-item success/failure, cancellation, staging-dir cleanup, and +/// batch start/end hooks for dictation arbitration. +@MainActor +final class BatchTranscriptionCoordinatorTests: XCTestCase { + private func makeResult(text: String, fileName: String = "test.m4a") -> TranscriptionResult { + TranscriptionResult( + text: text, + confidence: 0.9, + duration: 1.0, + processingTime: 0.1, + fileName: fileName + ) + } + + private func tempAudioURL(name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("batch-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(name) + } + + // MARK: - Sequential processing & ordering + + func testProcessesFilesSequentiallyInOrder() async { + var transcribedPaths: [String] = [] + var concurrent = 0 + var maxConcurrent = 0 + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + concurrent += 1 + maxConcurrent = max(maxConcurrent, concurrent) + try? await Task.sleep(nanoseconds: 20_000_000) + transcribedPaths.append(url.lastPathComponent) + concurrent -= 1 + return self.makeResult(text: "text for \(url.lastPathComponent)", fileName: url.lastPathComponent) + }) + + let urls = ["a.m4a", "b.m4a", "c.m4a"].map { self.tempAudioURL(name: $0) } + coordinator.enqueue(urls.map { BatchTranscriptionCoordinator.Request(url: $0) }) + await coordinator.waitUntilIdle() + + XCTAssertEqual(transcribedPaths, ["a.m4a", "b.m4a", "c.m4a"], "files must process in enqueue order") + XCTAssertEqual(maxConcurrent, 1, "batch must never transcribe two files concurrently") + XCTAssertFalse(coordinator.isRunning) + XCTAssertEqual(coordinator.items.count, 3) + for item in coordinator.items { + guard case let .completed(result) = item.status else { + return XCTFail("expected .completed, got \(item.status)") + } + XCTAssertEqual(result.text, "text for \(item.url.lastPathComponent)") + } + } + + func testIsRunningTrueWhileProcessing() async { + let gate = AsyncGate() + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + await gate.wait() + return self.makeResult(text: "done", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "a.m4a"))]) + // Let the processing task start. + await Task.yield() + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertTrue(coordinator.isRunning) + if case .transcribing = coordinator.items[0].status {} else { + XCTFail("expected first item to be .transcribing, got \(coordinator.items[0].status)") + } + + gate.open() + await coordinator.waitUntilIdle() + XCTAssertFalse(coordinator.isRunning) + } + + // MARK: - Per-item failure isolation + + func testFailedItemDoesNotStopBatch() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + if url.lastPathComponent == "bad.m4a" { + throw MeetingTranscriptionService.TranscriptionError.transcriptionFailed("decode blew up") + } + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([ + .init(url: self.tempAudioURL(name: "good1.m4a")), + .init(url: self.tempAudioURL(name: "bad.m4a")), + .init(url: self.tempAudioURL(name: "good2.m4a")), + ]) + await coordinator.waitUntilIdle() + + guard case .completed = coordinator.items[0].status else { + return XCTFail("item 0 should complete, got \(coordinator.items[0].status)") + } + guard case let .failed(message) = coordinator.items[1].status else { + return XCTFail("item 1 should fail, got \(coordinator.items[1].status)") + } + XCTAssertTrue(message.contains("decode blew up"), "failure must preserve the underlying error message") + guard case .completed = coordinator.items[2].status else { + return XCTFail("item 2 should still complete after a failure, got \(coordinator.items[2].status)") + } + } + + // MARK: - Empty transcription + + func testEmptyTranscriptionMarksNoSpeech() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: " ", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "silent.m4a"))]) + await coordinator.waitUntilIdle() + + guard case .noSpeech = coordinator.items[0].status else { + return XCTFail("whitespace-only transcription must surface as .noSpeech, got \(coordinator.items[0].status)") + } + } + + // MARK: - Cancellation + + func testCancelStopsCurrentItemAndPendingItems() async { + let started = AsyncGate() + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + started.open() + // Simulate a long transcription that honors Task cancellation. + try await Task.sleep(nanoseconds: 10_000_000_000) + return self.makeResult(text: "should never finish", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([ + .init(url: self.tempAudioURL(name: "current.m4a")), + .init(url: self.tempAudioURL(name: "pending1.m4a")), + .init(url: self.tempAudioURL(name: "pending2.m4a")), + ]) + await started.wait() + coordinator.cancel() + await coordinator.waitUntilIdle() + + XCTAssertFalse(coordinator.isRunning) + guard case .cancelled = coordinator.items[0].status else { + return XCTFail("in-flight item must be .cancelled, not \(coordinator.items[0].status)") + } + guard case .cancelled = coordinator.items[1].status else { + return XCTFail("pending items must be .cancelled, not \(coordinator.items[1].status)") + } + guard case .cancelled = coordinator.items[2].status else { + return XCTFail("pending items must be .cancelled, not \(coordinator.items[2].status)") + } + } + + func testEnqueueAfterCancelStartsFreshBatch() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + coordinator.cancel() // cancel with nothing running must be a no-op + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "after.m4a"))]) + await coordinator.waitUntilIdle() + + guard case .completed = coordinator.items.last!.status else { + return XCTFail("batch must run normally after a cancel, got \(coordinator.items.last!.status)") + } + } + + // MARK: - Staging directory cleanup + + func testStagingDirRemovedAfterItemFinishes() async throws { + let fm = FileManager.default + let stagingDir = fm.temporaryDirectory.appendingPathComponent("staging-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + + var existedDuringTranscription = false + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + existedDuringTranscription = fm.fileExists(atPath: url.path) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertTrue(existedDuringTranscription, "staged file must exist for the whole transcription") + XCTAssertFalse(fm.fileExists(atPath: stagingDir.path), "staging dir must be deleted after the item finishes") + } + + func testStagingDirRemovedEvenWhenItemFails() async throws { + let fm = FileManager.default + let stagingDir = fm.temporaryDirectory.appendingPathComponent("staging-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + + let coordinator = BatchTranscriptionCoordinator(transcribe: { _ in + throw MeetingTranscriptionService.TranscriptionError.transcriptionFailed("nope") + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertFalse(fm.fileExists(atPath: stagingDir.path), "staging dir must be deleted even on failure") + } + + func testEmptySessionRootPrunedAfterLastItemCleanup() async throws { + let fm = FileManager.default + let sessionRoot = fm.temporaryDirectory.appendingPathComponent("PromiseDrop-\(UUID().uuidString)", isDirectory: true) + let stagingDir = sessionRoot.appendingPathComponent("item-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertFalse( + fm.fileExists(atPath: sessionRoot.path), + "an empty PromiseDrop- session root must be pruned once its last item dir is cleaned" + ) + } + + func testNonPromiseParentDirectoryIsNotPruned() async throws { + let fm = FileManager.default + let parent = fm.temporaryDirectory.appendingPathComponent("user-folder-\(UUID().uuidString)", isDirectory: true) + let stagingDir = parent.appendingPathComponent("item-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + defer { try? fm.removeItem(at: parent) } + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertTrue( + fm.fileExists(atPath: parent.path), + "only PromiseDrop- session roots may be pruned; other parent dirs must be left alone" + ) + } + + func testNonStagedFileIsNeverDeleted() async throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("user-files-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let audioURL = dir.appendingPathComponent("user-owned.m4a") + try Data([0x00]).write(to: audioURL) + defer { try? fm.removeItem(at: dir) } + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL)]) // no stagingDir: user's own file + await coordinator.waitUntilIdle() + + XCTAssertTrue(fm.fileExists(atPath: audioURL.path), "files without a stagingDir belong to the user and must never be deleted") + } + + // MARK: - Enqueue while running + + func testEnqueueWhileRunningAppendsToSameBatch() async { + let firstStarted = AsyncGate() + let releaseFirst = AsyncGate() + var order: [String] = [] + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + if url.lastPathComponent == "first.m4a" { + firstStarted.open() + await releaseFirst.wait() + } + order.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "first.m4a"))]) + await firstStarted.wait() + coordinator.enqueue([.init(url: self.tempAudioURL(name: "second.m4a"))]) + releaseFirst.open() + await coordinator.waitUntilIdle() + + XCTAssertEqual(order, ["first.m4a", "second.m4a"]) + XCTAssertEqual(coordinator.items.count, 2) + XCTAssertFalse(coordinator.isRunning) + } + + // MARK: - Batch lifecycle hooks (dictation arbitration) + + func testBatchStartAndEndHooksWrapTheWholeRun() async { + var events: [String] = [] + let coordinator = BatchTranscriptionCoordinator( + transcribe: { url in + events.append("transcribe \(url.lastPathComponent)") + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }, + onBatchStart: { events.append("start") }, + onBatchEnd: { events.append("end") } + ) + + coordinator.enqueue([ + .init(url: self.tempAudioURL(name: "a.m4a")), + .init(url: self.tempAudioURL(name: "b.m4a")), + ]) + await coordinator.waitUntilIdle() + + XCTAssertEqual( + events, + ["start", "transcribe a.m4a", "transcribe b.m4a", "end"], + "start/end hooks must fire exactly once around the whole batch, not per item" + ) + } + + func testBatchEndHookFiresOnCancel() async { + var endCount = 0 + let started = AsyncGate() + let coordinator = BatchTranscriptionCoordinator( + transcribe: { url in + started.open() + try await Task.sleep(nanoseconds: 10_000_000_000) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }, + onBatchEnd: { endCount += 1 } + ) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "a.m4a"))]) + await started.wait() + coordinator.cancel() + await coordinator.waitUntilIdle() + + XCTAssertEqual(endCount, 1, "the end hook must fire even when the batch is cancelled, or dictation stays blocked forever") + } + + // MARK: - Enqueue after a finished batch (F5/F15a) + + func testEnqueueAfterFinishedBatchProcessesOnlyNewItems() async { + var transcribedPaths: [String] = [] + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + transcribedPaths.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "first.m4a"))]) + await coordinator.waitUntilIdle() + XCTAssertEqual(transcribedPaths, ["first.m4a"]) + guard case .completed = coordinator.items[0].status else { + return XCTFail("first item should complete, got \(coordinator.items[0].status)") + } + + // Batch finished but items weren't dismissed (dismissBatch/clear() wasn't + // called) — enqueue must not re-walk from index 0 and re-run "first.m4a". + coordinator.enqueue([.init(url: self.tempAudioURL(name: "second.m4a"))]) + await coordinator.waitUntilIdle() + + XCTAssertEqual( + transcribedPaths, + ["first.m4a", "second.m4a"], + "re-enqueueing after a finished-but-undismissed batch must not re-run completed items" + ) + XCTAssertEqual(coordinator.items.count, 2) + guard case .completed = coordinator.items[0].status else { + return XCTFail("completed item must keep its status, got \(coordinator.items[0].status)") + } + guard case .completed = coordinator.items[1].status else { + return XCTFail("new item should complete, got \(coordinator.items[1].status)") + } + } + + // MARK: - Enqueue during the cancel window (F6/F15b) + + func testEnqueueDuringCancelWindowIsNotStranded() async { + let started = AsyncGate() + let releaseInFlight = AsyncGate() + var transcribedPaths: [String] = [] + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + if url.lastPathComponent == "in-flight.m4a" { + started.open() + await releaseInFlight.wait() + } + transcribedPaths.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "in-flight.m4a"))]) + await started.wait() + + // Cancel while the in-flight item is still gated/running. + coordinator.cancel() + + // Enqueue a new item while the cancelled batch's in-flight item hasn't + // finished yet — it must not be stranded. + coordinator.enqueue([.init(url: self.tempAudioURL(name: "after-cancel.m4a"))]) + + // Now let the in-flight (cancelled) item finish. + releaseInFlight.open() + + await coordinator.waitUntilIdle() + + guard case .cancelled = coordinator.items[0].status else { + return XCTFail("in-flight item must be .cancelled, got \(coordinator.items[0].status)") + } + guard case .completed = coordinator.items[1].status else { + return XCTFail("item enqueued during the cancel window must eventually process, got \(coordinator.items[1].status)") + } + XCTAssertEqual(transcribedPaths, ["in-flight.m4a", "after-cancel.m4a"]) + XCTAssertFalse(coordinator.isRunning) + } + + // MARK: - BatchCoordinatorHolder.clear() while running (F15c) + + func testHolderClearWhileRunningCancelsAndFreshEnqueueWorksAfterward() async { + let started = AsyncGate() + var transcribedPaths: [String] = [] + + let holder = BatchCoordinatorHolder(transcribe: { url in + if url.lastPathComponent == "long-running.m4a" { + started.open() + try await Task.sleep(nanoseconds: 10_000_000_000) + } + transcribedPaths.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + holder.enqueue([.init(url: self.tempAudioURL(name: "long-running.m4a"))]) + await started.wait() + + guard let orphanedCoordinator = holder.coordinator else { + return XCTFail("coordinator must exist while a batch is running") + } + holder.clear() + + XCTAssertNil(holder.coordinator, "clear() must drop the coordinator reference") + await orphanedCoordinator.waitUntilIdle() + XCTAssertFalse(orphanedCoordinator.isRunning, "clear() must cancel the orphaned batch, not leave it running") + guard case .cancelled = orphanedCoordinator.items[0].status else { + return XCTFail("orphaned in-flight item must be .cancelled, got \(orphanedCoordinator.items[0].status)") + } + XCTAssertFalse(transcribedPaths.contains("long-running.m4a"), "cancelled item must never reach a completed outcome") + + // A subsequent enqueue must work on a fresh coordinator. + holder.enqueue([.init(url: self.tempAudioURL(name: "fresh.m4a"))]) + await holder.coordinator?.waitUntilIdle() + + guard case .completed = holder.coordinator?.items.first?.status else { + return XCTFail("fresh coordinator after clear() must process normally") + } + } + + // MARK: - Cancellation surfaced from the transcribe closure's wait loop (F15d) + + func testCancellationThrownFromTranscribeClosureMarksItemCancelled() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { _ in + // Simulates the dictation-arbitration wait loop observing cancellation + // (e.g. FileTranscriptionSession's `try Task.checkCancellation()` while + // waiting for dictation/single-file transcription to finish) before ever + // calling into the transcription service. + throw CancellationError() + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "gated.m4a"))]) + await coordinator.waitUntilIdle() + + guard case .cancelled = coordinator.items[0].status else { + return XCTFail("a CancellationError thrown while waiting must mark the item .cancelled, got \(coordinator.items[0].status)") + } + } + + // MARK: - Summary + + func testSummaryCountsReflectOutcomes() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + switch url.lastPathComponent { + case "bad.m4a": throw MeetingTranscriptionService.TranscriptionError.transcriptionFailed("x") + case "silent.m4a": return self.makeResult(text: "", fileName: url.lastPathComponent) + default: return self.makeResult(text: "ok", fileName: url.lastPathComponent) + } + }) + + coordinator.enqueue([ + .init(url: self.tempAudioURL(name: "good.m4a")), + .init(url: self.tempAudioURL(name: "bad.m4a")), + .init(url: self.tempAudioURL(name: "silent.m4a")), + ]) + await coordinator.waitUntilIdle() + + XCTAssertEqual(coordinator.completedCount, 1) + XCTAssertEqual(coordinator.failedCount, 1) + XCTAssertEqual(coordinator.noSpeechCount, 1) + } +} + +/// Minimal async gate for coordinating fake transcriptions in tests. +@MainActor +private final class AsyncGate { + private var continuations: [CheckedContinuation] = [] + private var isOpen = false + + func open() { + self.isOpen = true + let waiters = self.continuations + self.continuations = [] + for c in waiters { c.resume() } + } + + func wait() async { + if self.isOpen { return } + await withCheckedContinuation { self.continuations.append($0) } + } +} diff --git a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift new file mode 100644 index 00000000..be07128d --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -0,0 +1,213 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Contract tests for the testable core of the promise-aware drop path (issue #219). +/// The AppKit drop view itself needs a live drag session; these tests pin down the +/// pure logic it delegates to: strategy selection from pasteboard types, and +/// per-item staging directories that cannot collide. +final class PromiseDropSupportTests: XCTestCase { + // MARK: - Strategy selection + + func testConcreteFileURLsWinOverPromises() { + let types = ["public.file-url", "com.apple.NSFilePromiseItemMetaData", "com.apple.pasteboard.promised-file-content-type"] + XCTAssertEqual(PromiseDropSupport.strategy(forPasteboardTypes: types), .concreteFileURLs) + } + + func testPromiseTypesSelectFilePromiseStrategy() { + // Exactly what Voice Memos puts on the pasteboard (verified live 2026-07-25). + let voiceMemosTypes = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-name", + "com.apple.pasteboard.promised-suggested-file-name", + "com.apple.pasteboard.promised-file-content-type", + "Apple files promise pasteboard type", + "com.apple.pasteboard.NSFilePromiseID", + "com.apple.m4a-audio", + "com.apple.uikit.private.drag-item", + "NSPromiseContentsPboardType", + "com.apple.pasteboard.promised-file-url", + ] + XCTAssertEqual(PromiseDropSupport.strategy(forPasteboardTypes: voiceMemosTypes), .filePromise) + } + + func testUnrelatedTypesSelectNoStrategy() { + XCTAssertNil(PromiseDropSupport.strategy(forPasteboardTypes: ["public.utf8-plain-text", "public.rtf"])) + XCTAssertNil(PromiseDropSupport.strategy(forPasteboardTypes: [])) + } + + func testNonAudioVisualPromiseIsRejected() { + // A Photos-style image promise must not be accepted: staging a JPEG only + // to fail it later is worse than refusing the drag affordance up front. + let photosLikeTypes = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-content-type", + "public.jpeg", + ] + XCTAssertNil(PromiseDropSupport.strategy(forPasteboardTypes: photosLikeTypes)) + } + + func testMoviePromiseIsAccepted() { + let movieTypes = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-content-type", + "com.apple.quicktime-movie", + ] + XCTAssertEqual(PromiseDropSupport.strategy(forPasteboardTypes: movieTypes), .filePromise) + } + + // MARK: - Delivery selection + + func testTwoDistinctSameNamedModernFilesAreBothDelivered() { + // Voice Memos are commonly all titled "New Recording": two different + // memos in one drag stage into two receiver dirs with the same file + // name. Both are real files — neither may be collapsed as a duplicate. + let memoA = URL(fileURLWithPath: "/tmp/item-A/New Recording.m4a") + let memoB = URL(fileURLWithPath: "/tmp/item-B/New Recording.m4a") + let delivered = PromiseDropSupport.selectDelivery(modern: [memoA, memoB], legacy: [], data: []) + XCTAssertEqual(delivered, [memoA, memoB]) + } + + func testFallbackCopiesOfDeliveredNamesAreSuppressed() { + let modern = URL(fileURLWithPath: "/tmp/item-A/Lick Mill Blvd.m4a") + let legacyDupe = URL(fileURLWithPath: "/tmp/item-L/Lick Mill Blvd.m4a") + let dataDupe = URL(fileURLWithPath: "/tmp/item-D/Lick Mill Blvd.m4a") + let delivered = PromiseDropSupport.selectDelivery(modern: [modern], legacy: [legacyDupe], data: [dataDupe]) + XCTAssertEqual(delivered, [modern], "legacy/data copies of an already-delivered name are the same dragged item") + } + + func testFallbackOnlyDeliveryUsesLegacyThenData() { + let legacyFile = URL(fileURLWithPath: "/tmp/item-L/Memo.m4a") + let dataDupe = URL(fileURLWithPath: "/tmp/item-D/Memo.m4a") + let dataOnly = URL(fileURLWithPath: "/tmp/item-D/Other.m4a") + let delivered = PromiseDropSupport.selectDelivery(modern: [], legacy: [legacyFile], data: [dataDupe, dataOnly]) + XCTAssertEqual(delivered, [legacyFile, dataOnly]) + } + + // MARK: - Staging directories + + func testEachItemGetsAUniqueStagingDirectory() throws { + let session = try PromiseDropSupport.StagingSession() + defer { session.removeAll() } + + let dirA = try session.makeItemDirectory() + let dirB = try session.makeItemDirectory() + + XCTAssertNotEqual(dirA, dirB, "two promised files named identically must never share a staging dir") + XCTAssertTrue(FileManager.default.fileExists(atPath: dirA.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: dirB.path)) + + // Identically-named files in the two dirs must coexist. + try Data([0x01]).write(to: dirA.appendingPathComponent("New Recording.m4a")) + try Data([0x02]).write(to: dirB.appendingPathComponent("New Recording.m4a")) + XCTAssertEqual(try Data(contentsOf: dirA.appendingPathComponent("New Recording.m4a")), Data([0x01])) + XCTAssertEqual(try Data(contentsOf: dirB.appendingPathComponent("New Recording.m4a")), Data([0x02])) + } + + func testStagingDirectoriesLiveUnderTemporaryDirectory() throws { + let session = try PromiseDropSupport.StagingSession() + defer { session.removeAll() } + + let dir = try session.makeItemDirectory() + let tempPath = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().path + XCTAssertTrue( + dir.resolvingSymlinksInPath().path.hasPrefix(tempPath), + "staging must be under the user temp dir, not next to user files" + ) + } + + func testRemoveAllDeletesEverything() throws { + let session = try PromiseDropSupport.StagingSession() + let dirA = try session.makeItemDirectory() + let dirB = try session.makeItemDirectory() + try Data([0x01]).write(to: dirA.appendingPathComponent("a.m4a")) + + session.removeAll() + + XCTAssertFalse(FileManager.default.fileExists(atPath: dirA.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: dirB.path)) + } + + // MARK: - Sweep selection + + func testSweepNeverIncludesADeliveredFilesDirectoryDespiteTrailingSlashDifferences() { + // Regression: dirs built with appendingPathComponent (no trailing slash) + // vs. deletingLastPathComponent() of a delivered file (trailing slash) + // compare unequal as URLs — the sweep once deleted a delivered file. + let base = URL(fileURLWithPath: "/tmp/PromiseDrop-test") + let dataDir = base.appendingPathComponent("item-data") + let legacyDir = base.appendingPathComponent("item-legacy") + let deliveredFile = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-data/", isDirectory: true) + .appendingPathComponent("Memo.m4a") + + let sweep = PromiseDropSupport.sweepableDirs(allDirs: [dataDir, legacyDir], deliveredFiles: [deliveredFile]) + + XCTAssertEqual(sweep, [legacyDir], "the delivered file's dir must never be swept") + } + + func testSweepRemovesAllDirsWhenNothingDelivered() { + let dirA = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a") + let dirB = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-b") + XCTAssertEqual(PromiseDropSupport.sweepableDirs(allDirs: [dirA, dirB], deliveredFiles: []), [dirA, dirB]) + } + + // MARK: - dirsSafeToRemoveNow (findings F1 / F9) + + func testDirsSafeToRemoveNowExcludesPendingReceiverDirsEvenWhenNothingDelivered() { + // Regression for F1: an empty delivery result must not sweep a pending + // receiver's staging dir — deleting the destination out from under an + // in-flight NSFilePromiseReceiver wedges the source app's drag machinery. + let pendingDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-pending") + let deadDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-dead") + + let safe = PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: [pendingDir, deadDir], + deliveredFiles: [], + pendingDirs: [pendingDir] + ) + + XCTAssertEqual(safe, [deadDir], "a still-pending receiver dir must never be swept, even with an empty delivery") + } + + func testDirsSafeToRemoveNowExcludesPendingDirsAlongsideDeliveredFiles() { + let deliveredFile = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a/Memo.m4a") + let deliveredDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a") + let pendingDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-pending") + let loserDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-loser") + + let safe = PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: [deliveredDir, pendingDir, loserDir], + deliveredFiles: [deliveredFile], + pendingDirs: [pendingDir] + ) + + XCTAssertEqual(safe, [loserDir]) + } + + func testDirsSafeToRemoveNowAllowsEverythingWhenNoPendingDirs() { + let dirA = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a") + let dirB = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-b") + + let safe = PromiseDropSupport.dirsSafeToRemoveNow(allDirs: [dirA, dirB], deliveredFiles: [], pendingDirs: []) + + XCTAssertEqual(safe, [dirA, dirB]) + } + + // MARK: - Supported-file filtering for multi-file drops + + func testFilterKeepsDecodableAudioAndRejectsJunk() { + let urls = [ + URL(fileURLWithPath: "/tmp/a.m4a"), + URL(fileURLWithPath: "/tmp/b.txt"), + URL(fileURLWithPath: "/tmp/c.wav"), + URL(fileURLWithPath: "/tmp/d.pdf"), + ] + let kept = PromiseDropSupport.filterSupported(urls) + XCTAssertEqual(kept.map(\.lastPathComponent), ["a.m4a", "c.wav"]) + } + + func testFilterAcceptsUppercaseExtensions() { + let kept = PromiseDropSupport.filterSupported([URL(fileURLWithPath: "/tmp/MEMO.M4A")]) + XCTAssertEqual(kept.count, 1) + } +} diff --git a/assets/file_transcription_batch.png b/assets/file_transcription_batch.png new file mode 100644 index 00000000..16bb5720 Binary files /dev/null and b/assets/file_transcription_batch.png differ