diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index d475db0e..6827befc 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; }; C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */; }; DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.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 */; }; @@ -56,6 +58,8 @@ A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = ""; }; DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.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; }; @@ -135,6 +139,8 @@ A62300000000000000000001 /* AudioBufferConverterTests.swift */, C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */, DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */, + A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */, + A62300000000000000000031 /* PromiseDropSupportTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -295,6 +301,8 @@ A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */, DA7100020000000000000002 /* DirectAudioReliabilityTests.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 db38b3a0..05422bfb 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2012,6 +2012,8 @@ struct ContentView: View { // MARK: - Stop and Process Transcription private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async { + defer { FileTranscriptionSession.shared.endDictationIntent() } // released after the final transcription pass + 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)") @@ -3074,6 +3076,12 @@ struct ContentView: View { /// Capture app context at start to avoid mismatches if the user switches apps mid-session private func startRecording() { + // avoid concurrent inference on the shared ASR model + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } + let model = SettingsStore.shared.selectedSpeechModel DebugLogger.shared.info( "ContentView: startRecording() for model=\(model.displayName), supportsStreaming=\(model.supportsStreaming)", @@ -3084,6 +3092,8 @@ struct ContentView: View { return } + FileTranscriptionSession.shared.beginDictationIntent() // synchronous, before any await, so an ignored start can't latch it + self.advanceOverlayLifecycle() self.setActiveRecordingMode(.dictate) let shouldShowDictationOverlay = !self.isRecordingForCommand @@ -3119,6 +3129,7 @@ struct ContentView: View { }) if startOutcome == .failed { self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed") + FileTranscriptionSession.shared.endDictationIntent() } } @@ -3344,15 +3355,19 @@ struct ContentView: View { }, commandModeCallback: { DebugLogger.shared.info("Command mode triggered", source: "ContentView") - self.captureRecordingContext() - - // Set flag so stopAndProcessTranscription knows to process as command - self.setActiveRecordingMode(.command) + // Bypasses beginDictationRecording, so it needs its own batch guard — and before the mode + // flip, which would otherwise leave the overlay in command mode with nothing recording. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } - // Set overlay mode to command + self.captureRecordingContext() + self.setActiveRecordingMode(.command) // tells stopAndProcessTranscription to process as command self.menuBarManager.setOverlayMode(.command) - guard !self.asr.isRunningOrStarting else { return } + guard !self.asr.isRunningOrStarting else { return } // mid-recording mode switch ends here: no restart + FileTranscriptionSession.shared.beginDictationIntent() self.advanceOverlayLifecycle() @@ -3370,12 +3385,19 @@ struct ContentView: View { self.menuBarManager.hideRecordingOverlayImmediately( reason: "command_asr_start_failed" ) + FileTranscriptionSession.shared.endDictationIntent() } } }, rewriteModeCallback: { guard !self.showPrivateAIEditModeUnavailableIfNeeded() else { return } + // Ahead of the capture and mode flip, which would otherwise strand the overlay in edit mode. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } + self.captureRecordingContext() // Try to capture text first while still in the other app @@ -3401,7 +3423,8 @@ struct ContentView: View { // Set flag so stopAndProcessTranscription knows to process as rewrite self.setActiveRecordingMode(.edit) - guard !self.asr.isRunningOrStarting else { return } + guard !self.asr.isRunningOrStarting else { return } // mid-recording mode switch ends here: no restart + FileTranscriptionSession.shared.beginDictationIntent() self.advanceOverlayLifecycle() @@ -3416,6 +3439,7 @@ struct ContentView: View { self.menuBarManager.hideRecordingOverlayImmediately( reason: "edit_asr_start_failed" ) + FileTranscriptionSession.shared.endDictationIntent() } } }, @@ -3727,6 +3751,10 @@ extension ContentView { } private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) { + 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 { @@ -3744,6 +3772,7 @@ extension ContentView { self.appBench("asr_start_skipped reason=already_running_or_starting") return } + FileTranscriptionSession.shared.beginDictationIntent() // claimed before the async start, since isRunning hasn't flipped yet self.advanceOverlayLifecycle() if self.asr.micStatus == .authorized { self.appBench("overlay_mode_request mode=Dictation") @@ -3765,6 +3794,7 @@ extension ContentView { }) if startOutcome == .failed { self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed") + FileTranscriptionSession.shared.endDictationIntent() } DebugLogger.shared.benchmark( "APP_BENCH", @@ -3780,6 +3810,15 @@ extension ContentView { self.beginDictationRecording(for: .secondary, mode: mode) } + /// reuses the stop cue as feedback when blocked by a batch + 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..bba23cc1 --- /dev/null +++ b/Sources/Fluid/ObjCExceptionCatcher.h @@ -0,0 +1,15 @@ +#ifndef OBJC_EXCEPTION_CATCHER_H +#define OBJC_EXCEPTION_CATCHER_H + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Runs the block, returning a raised exception's description or nil if it completed. +/// Pasteboard and file-promise calls raise, and a C++ terminate handler in-process +/// 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/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index c0becd00..94c122e8 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2161,6 +2161,9 @@ final class ASRService: ObservableObject { } func stopWithoutTranscription() async { + // Cancel paths skip stop-and-process; above the guard to cover the starting phase. + defer { FileTranscriptionSession.endDictationIntentIfCreated() } + if self.isStarting, self.isRunning == false { await self.cancelPendingAudioCaptureStart(reason: "stop_without_transcription") } diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift new file mode 100644 index 00000000..07fe791f --- /dev/null +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -0,0 +1,206 @@ +import Combine +import Foundation + +/// Sequentially transcribes a batch of audio files. All stored closures run on the main actor. +@MainActor +final class BatchTranscriptionCoordinator: ObservableObject { + struct Request { + let url: URL + let stagingDir: URL? // exclusively owned by this item; deleted when it finishes, never shared + + init(url: URL, stagingDir: URL? = nil) { + self.url = url + self.stagingDir = stagingDir + } + } + + enum Status { + case pending + case transcribing + case completed(TranscriptionResult) + case noSpeech + case failed(String) + case cancelled + } + + struct Item: Identifiable { + let id: UUID + let url: URL + let stagingDir: URL? // exclusively owned by this item; deleted when it finishes, never shared + 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 private(set) var isCancelRequested: Bool = false // "Cancelling…" state: the in-flight item may still run to completion + + 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 + + /// starts a sequential batch if idle, else appends to the live one + 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 + + /// cancels 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) + } + } + + self.removeStagingDir(for: index) // only after the item finishes (success, failure, or cancellation) + } + + private func removeStagingDir(for index: Int) { + guard let stagingDir = self.items[index].stagingDir else { return } + try? FileManager.default.removeItem(at: stagingDir) + + let parent = stagingDir.deletingLastPathComponent() // prune the now-empty drop-session root so drops don't leak temp shells + 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..5a81f887 --- /dev/null +++ b/Sources/Fluid/Services/FileTranscriptionSession.swift @@ -0,0 +1,78 @@ +import Combine +import Foundation + +// App-level owner of file-transcription state so switching sidebar pages doesn't kill an in-flight batch; the view re-attaches on return. +@MainActor +final class FileTranscriptionSession { + static let shared = FileTranscriptionSession() + + private static var sharedIfCreated: FileTranscriptionSession? // lets callers check batch state without force-creating the session + + static var isBatchTranscribing: Bool { + self.sharedIfCreated?.batchHolder.coordinator?.isRunning == true // both paths drive the same shared ASR model; concurrent inference corrupts output + } + + /// Set synchronously before any `await`, closing the window where the batch's check passes just as dictation starts. + 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 + // Only one of dictation, single-file, and batch may drive the shared ASR model. + 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 + } + + func beginDictationIntent() { + self.dictationIntent = true + } + + func endDictationIntent() { + self.dictationIntent = false + } + + /// releases the flag without force-creating the session, for teardown paths + static func endDictationIntentIfCreated() { + self.sharedIfCreated?.endDictationIntent() + } +} + +@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() { + self.coordinator?.cancel() // never orphan a running batch: it would transcribe invisibly with no way to reach it + 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..45d1fde0 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -216,7 +216,11 @@ final class MeetingTranscriptionService: ObservableObject { source: "MeetingTranscriptionService" ) + try Task.checkCancellation() let nativeResult = try await provider.transcribeFile(at: fileURL) + // The native path has no interruption point, so a provider can return a + // full result after a cancel; without this it lands in history anyway. + try Task.checkCancellation() let processingTime = Date().timeIntervalSince(startTime) let result = TranscriptionResult( text: nativeResult.text, @@ -284,6 +288,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) @@ -346,6 +351,10 @@ final class MeetingTranscriptionService: ObservableObject { let transcriptionResult = (text: finalText, confidence: avgConfidence) + // The loop only checks between chunks, so a cancel during the last chunk + // reaches here with a full result. + try Task.checkCancellation() + self.currentStatus = "Complete!" self.progress = 1.0 @@ -373,6 +382,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 c2061e93..b646e0be 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -1,16 +1,20 @@ +import Combine import SwiftUI import UniformTypeIdentifiers struct MeetingTranscriptionView: View { let asrService: ASRService - @StateObject private var transcriptionService: MeetingTranscriptionService + @ObservedObject private var transcriptionService: MeetingTranscriptionService // owned by FileTranscriptionSession so in-flight transcriptions survive sidebar navigation + @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 +24,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 +63,15 @@ struct MeetingTranscriptionView: View { // File Selection Card self.fileSelectionCard - // Progress Card (only show when transcribing) - if self.transcriptionService.isTranscribing { + if self.transcriptionService.isTranscribing && !self.isBatchActive { self.progressCard } - // Results Card (only show when we have results) - if let result = transcriptionService.result { + 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 { + if let error = transcriptionService.error, !self.isBatchActive { self.errorCard(error: error) } @@ -78,6 +80,10 @@ struct MeetingTranscriptionView: View { self.dropErrorCard(message: message) } + if self.isBatchActive { + self.batchSection + } + // Recent transcriptions (persisted history) if !self.fileHistoryStore.entries.isEmpty { Divider() @@ -89,6 +95,14 @@ struct MeetingTranscriptionView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + // Overlay (not background): AppKit's front-to-back drag search needs this above the SwiftUI content; hitTest returns nil so clicks pass through. + .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 +192,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 +228,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 +543,188 @@ 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 { + // in-flight file may still run to completion, hence "Cancelling" not an instant stop + 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" + } + + /// 1-based, 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 + + /// any enqueued items (running or finished-but-undismissed); suppresses single-file cards + private var isBatchActive: Bool { + guard let coordinator = self.batchHolder.coordinator else { return false } + return !coordinator.items.isEmpty + } + + // A single concrete (unstaged) file follows the existing single-file flow; everything else is enqueued on the batch coordinator so staged dirs get cleaned up. + private func handleIncomingFiles(_ files: [(url: URL, stagingDir: URL?)]) { + guard !files.isEmpty else { return } + self.dropErrorMessage = nil + + 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) }) + } + + /// auto-dismisses since promise resolution can report errors up to 2 minutes after the drop + 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 + } + + /// resets shared service state so a subsequent batch starts fresh with no stale single-file result + private func dismissBatch() { + self.batchHolder.clear() + self.transcriptionService.reset() } private func transcribeFile() async { @@ -603,6 +765,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..fd9927b4 --- /dev/null +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -0,0 +1,697 @@ +import AppKit +import SwiftUI + +/// Accepts concrete file URLs and file-promise drags (e.g. Voice Memos), resolving promises into per-item staging dirs. Strategy/staging/filtering logic lives in `PromiseDropSupport`. +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 } + + /// static so in-flight resolution survives the view being destroyed 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) + } + + /// needed in `.overlay`; drops still arrive since drag dispatch doesn't consult hitTest + 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() + let onFiles = self.onFiles // captured by value: resolution must outlive this view if destroyed mid-drop + let onError = self.onError + + // A main-thread pasteboard read can freeze the system-wide drag session, so this returns + // immediately and every read happens on a background thread. + let state = ResolutionState() + state.beginInFlight() // outer worker token, released when it finishes + + Task { @MainActor in // poller starts before any pasteboard read so a hung first read still hits a timeout + await DropTargetView.resolvePromises( + session: session, + state: state, + legacyDir: legacyDir, + dataDir: dataDir, + onFiles: onFiles, + onError: onError + ) + } + + Self.detachWorker { + defer { state.endInFlight() } + + // Step 1: snapshot receivers and per-item bytes. NSPasteboard isn't thread-safe and + // receivePromisedFiles touches it from its own queue, so every read must finish first. + 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) + + // Step 2: raw-data fallback (the reliable path for Voice Memos), disambiguating same-named items. + if let dataDir, !itemPayloads.isEmpty { + var usedNames = Set() + for (index, payload) in itemPayloads.enumerated() { + let fallbackName = "Dropped Audio \(index + 1)" + let itemName = PromiseDropSupport.sanitizedFileName( // provider-supplied name is untrusted: separators would escape staging + payload.name ?? suggestedName, + fallback: fallbackName + ) + 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" + ) + } + } + } + + // Step 3: modern receivers. Still required once raw-data delivered — an unresolved promise + // wedges Voice Memos's drag machinery until restart. + for receiver in receivers { + guard let dir = try? session.makeItemDirectory() else { continue } + // Both decode promise metadata and can raise (the OS refuses a Voice Memos NSCalendarDate); + // uncaught, that aborts the process, so a bad promise must degrade to the fallbacks. + var promisedFiles = 0 + let nameError = FluidCatchObjCException { promisedFiles = receiver.fileNames.count } + if let nameError { + DebugLogger.shared.warning( + "Modern promise receiver raised reading fileNames: \(nameError)", + source: "PromiseAwareDropView" + ) + try? FileManager.default.removeItem(at: dir) // nothing started writing here, and registering it would inflate the expected count + continue + } + state.registerReceiverDir(dir, promisedFileCount: promisedFiles) + + state.beginReceiver() // released once, after the receiver's LAST file callback + let completion = ReceiverCompletion(fileCount: promisedFiles) + let startError = FluidCatchObjCException { + receiver.receivePromisedFiles(atDestination: dir, options: [:], operationQueue: Self.promiseQueue) { url, error in + if let error { + DebugLogger.shared.debug( + "Modern promise receiver failed [\(url.lastPathComponent)]: \(error.localizedDescription)", + source: "PromiseAwareDropView" + ) + } + guard completion.recordFile(failed: error != nil) else { return } + // Failed dirs are never delivered, so only one that produced nothing counts as failed. + let produced = !(((try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []).isEmpty) + if completion.anyFailed, !produced { + state.markFailed(dir) + } else { + state.markCompleted(dir) + } + state.endReceiver() + } + } + if let startError { + DebugLogger.shared.warning( + "Modern promise receiver raised on start: \(startError)", + source: "PromiseAwareDropView" + ) + if completion.abort() { // callbacks may already have resolved the dir and released the token + state.markRaised(dir) + state.endReceiver() + } + } + } + + // Step 4: legacy fallback, last resort only — an eager call blocks Voice Memos for 35-80s. + // Called by string selector to dodge the deprecation warning. + 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" + ) + state.setLegacyNameCount(names.count) // on a legacy-only drop this is the sole record of how many files to expect + } + } + } + } + return true + } + + /// `receivePromisedFiles` fires once per name, so count down to release the receiver's in-flight token exactly once. + private final class ReceiverCompletion: @unchecked Sendable { + private let lock = NSLock() + private var remaining: Int + private(set) var anyFailed = false + + init(fileCount: Int) { + self.remaining = max(fileCount, 1) // guard a zero/nil name count: it must still count down to zero + } + + /// true exactly once, for the last file this receiver reports + func recordFile(failed: Bool) -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + if failed { self.anyFailed = true } + guard self.remaining > 0 else { return false } // a provider can over-fire, or fire after an abort + self.remaining -= 1 + return self.remaining <= 0 + } + + func abort() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + guard self.remaining > 0 else { return false } + self.remaining = 0 + self.anyFailed = true + return true + } + } + + /// Shared between the delivery threads and the main-actor poller: which dirs resolved, what is in flight. + private final class ResolutionState: @unchecked Sendable { + private let lock = NSLock() + private var receiverDirs: [URL] = [] + private var completed: Set = [] + private var failed: Set = [] + private var raised: Set = [] + private var inFlightCount = 0 + private var receiverInFlightCount = 0 + private var payloadCount = 0 + private var promisedFileCount = 0 + private var legacyNameCount = 0 + + /// One receiver can promise several files, so the name count — not the dir count — is what the drop yields. + func registerReceiverDir(_ dir: URL, promisedFileCount: Int) { + self.lock.lock() + defer { self.lock.unlock() } + self.receiverDirs.append(dir) + self.promisedFileCount += max(promisedFileCount, 1) + } + + func promisedFileCountValue() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.promisedFileCount + } + + 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 + } + + /// already answered, so unlike pending its dir is safe to sweep immediately + func markFailed(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.failed.insert(dir) + } + + /// Resolved, but the receiver raised mid-flight and may still be writing: defer the cleanup. + func markRaised(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.failed.insert(dir) + self.raised.insert(dir) + } + + func failedSnapshot() -> Set { + self.lock.lock() + defer { self.lock.unlock() } + return self.failed + } + + func raisedSnapshot() -> Set { + self.lock.lock() + defer { self.lock.unlock() } + return self.raised + } + + func setLegacyNameCount(_ count: Int) { + self.lock.lock() + defer { self.lock.unlock() } + self.legacyNameCount = count + } + + func legacyNameCountValue() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.legacyNameCount + } + + /// lets a receiver-less multi-item drop still detect a partial delivery + 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 + } + + /// Fallback writers only — an uncancelled receiver never releases, so it must not gate delivery. + 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 + } + + func beginReceiver() { + self.lock.lock() + defer { self.lock.unlock() } + self.receiverInFlightCount += 1 + } + + func endReceiver() { + self.lock.lock() + defer { self.lock.unlock() } + self.receiverInFlightCount -= 1 + } + } + + /// user-initiated QoS: default-QoS threads starve while ML inference saturates the cores + 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 + let settledSoftTimeout: TimeInterval = 8 // one completed ⇒ rest usually on disk; full 30s only pays when nothing arrived + let hardTimeout: TimeInterval = 120 // a provider can take 60s+ under heavy load (seen up to 80s) before a truly wedged one errors out + let modernGrace: TimeInterval = 1.5 // long enough for a working receiver to write its first byte + let start = Date() + + func makeContext() -> DeliveryContext { + let receiverDirs = state.receiverDirsSnapshot() + let completed = state.completedSnapshot() + let failed = state.failedSnapshot() + let raised = state.raisedSnapshot() + return DeliveryContext( + allDirs: receiverDirs + [legacyDir, dataDir].compactMap(\.self), + pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) && (!failed.contains($0) || raised.contains($0)) }, + pendingFallbackDirs: state.hasInFlightWork ? [legacyDir].compactMap(\.self) : [], // relocation empties the legacy dir, so a sweep would see it as undelivered and delete a destination still being written + totalExpected: max(state.promisedFileCountValue(), state.payloadCountValue(), state.legacyNameCountValue(), 1), + session: session, + onFiles: onFiles, + onError: onError + ) + } + + var fallbackPrevSizes: [URL: Int64] = [:] + + while true { + let elapsed = Date().timeIntervalSince(start) + if elapsed >= hardTimeout { break } + + let receiverDirs = state.receiverDirsSnapshot() // a receiver dir only counts once its completion callback fired, never just from a stable size + 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) + + // Receivers aren't counted as in-flight work (an uncancelled one never releases), so a slow + // provider — 80s has been seen — is kept alive by its files instead, not by a token. + let unresolvedDirs = receiverDirs.filter { !readyModernDirs.contains($0) && !failedModernDirs.contains($0) } + let receiversProducing = unresolvedDirs.contains { !self.listFiles(in: [$0]).isEmpty } + let effectiveSoftTimeout = readyModernDirs.isEmpty ? softTimeout : settledSoftTimeout + if elapsed >= effectiveSoftTimeout, !state.hasInFlightWork, !receiversProducing { break } + + let resolvedCount = readyModernDirs.count + failedModernDirs.count + let allReceiversResolved = !receiverDirs.isEmpty && resolvedCount >= receiverDirs.count + + let modernComplete = allReceiversResolved && !readyModernDirs.isEmpty && !modernFiles.isEmpty // every receiver resolved, at least one completed, files produced + if modernComplete { + self.deliver(modern: modernFiles, legacy: legacyFiles, data: dataFiles, context: makeContext()) + return + } + + // Voice Memos only cancels a receiver when the NEXT drag starts, so a lone drop's stays unresolved + // forever. One that has written nothing past the grace is non-producing; mid-transfer still holds. + let receiversStalled = elapsed > modernGrace && !receiversProducing + + let modernExhaustedWithNoWins = receiverDirs.isEmpty + || ((allReceiversResolved || receiversStalled) && readyModernDirs.isEmpty) + let modernProducedNothing = modernExhaustedWithNoWins && elapsed > modernGrace + let fallbackStable = !fallbackSizes.isEmpty && fallbackSizes == fallbackPrevSizes + && !state.hasInFlightWork // worker fully done, so a multi-item sweep still writing later files isn't delivered early + if modernProducedNothing, fallbackStable { + self.deliver(modern: [], legacy: legacyFiles, data: dataFiles, context: makeContext()) + return + } + + fallbackPrevSizes = fallbackSizes + try? await Task.sleep(nanoseconds: pollInterval) + } + + let receiverDirs = state.receiverDirsSnapshot() // timeout: deliver whatever completed; incomplete dirs are excluded and surface via the partial-failure error + 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() + ) + } + + private struct DeliveryContext { + let allDirs: [URL] + // Never swept at delivery: deleting an in-flight promise's destination wedges Voice Memos. + let pendingReceiverDirs: [URL] + let pendingFallbackDirs: [URL] + let totalExpected: Int + let session: PromiseDropSupport.StagingSession + let onFiles: ([(url: URL, stagingDir: URL?)]) -> Void + let onError: (String) -> Void + } + + private static func deliver( // selection/dedup lives in `PromiseDropSupport.selectDelivery` + 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, + expectedItemCount: context.totalExpected + ) + let supported = PromiseDropSupport.filterSupported(selected) + let result = PromiseDropSupport.relocateForExclusiveOwnership(supported, session: context.session) // any path can land several files in one dir; the coordinator deletes per item + + if result.isEmpty { + for dir in PromiseDropSupport.dirsSafeToRemoveNow( // must not delete dirs whose receiver promise hasn't completed — wedges the source app's drag machinery + allDirs: context.allDirs, + deliveredFiles: [], + pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs + ) { + try? FileManager.default.removeItem(at: dir) + } + if context.pendingReceiverDirs.isEmpty, context.pendingFallbackDirs.isEmpty { + context.session.removeAll() + } else { + self.cleanUpLater(pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs) + } + 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 + } + + // Dirs holding no delivered file are dead weight; racing a writer here is harmless, they're copies. + for dir in PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: context.allDirs, + deliveredFiles: result.map(\.url), + pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs + ) { + 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 + context.pendingFallbackDirs) + } + + 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..d84e3fde --- /dev/null +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -0,0 +1,139 @@ +import AVFoundation +import Foundation +import UniformTypeIdentifiers + +// Testable, nonisolated core logic for the promise-aware drop path (pasteboard strategy, staging, format filtering); `PromiseAwareDropView` owns the live drag session. +nonisolated enum PromiseDropSupport { + enum Strategy: Equatable { + case concreteFileURLs + case filePromise + } + + private static let concreteFileURLType = "public.file-url" + + // Voice Memos emits a mix of these; any one is sufficient. + private static let filePromiseTypes: Set = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-content-type", + "Apple files promise pasteboard type", + ] + + // Concrete URLs win; a promise needs advertised audio/movie content, so a Photos image promise is rejected. + 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 + } + + // Compares standardized *paths*: trailing-slash differences once made equal dirs compare unequal. + 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) } + } + + // Deleting an in-flight promise's destination wedges the source app's drag machinery until restart. + 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) } + } + + // Separators or `..` would let `appendingPathComponent` write outside staging. + static func sanitizedFileName(_ rawName: String?, fallback: String) -> String { + guard let rawName else { return fallback } + let lastComponent = (rawName as NSString).lastPathComponent + let trimmed = lastComponent.trimmingCharacters(in: .whitespacesAndNewlines) + // ("/" as NSString).lastPathComponent is "/", so it survives the reduction above + guard !trimmed.isEmpty, trimmed != ".", trimmed != "..", !trimmed.contains("/") else { + return fallback + } + return trimmed + } + + // Merges the three promise-delivery paths: every modern file is delivered; legacy/raw-data only fill gaps, bounded by `expectedItemCount`. + static func selectDelivery( + modern: [URL], + legacy: [URL], + data: [URL], + expectedItemCount: Int? = nil + ) -> [URL] { + var delivered = modern + var names = Set(modern.map(\.lastPathComponent)) + for url in legacy + data where !names.contains(url.lastPathComponent) { + if let expectedItemCount, delivered.count >= expectedItemCount { break } + delivered.append(url) + names.insert(url.lastPathComponent) + } + return delivered + } + + // The coordinator deletes an item's dir the moment it finishes, so each file needs its own. + static func relocateForExclusiveOwnership( + _ files: [URL], + session: StagingSession, + makeDirectory: (StagingSession) throws -> URL = { try $0.makeItemDirectory() }, + move: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } + ) -> [(url: URL, stagingDir: URL?)] { + files.map { file in + do { + let dir = try makeDirectory(session) + let destination = dir.appendingPathComponent(file.lastPathComponent) + try move(file, destination) + return (url: destination, stagingDir: dir) + } catch { + return (url: file, stagingDir: nil) + } + } + } + + 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) + }() + + static func filterSupported(_ urls: [URL]) -> [URL] { + return urls.filter { url in + let ext = url.pathExtension.lowercased() + return !ext.isEmpty && supportedExtensions.contains(ext) + } + } + + // Prefix of every staging root; the batch coordinator uses it to prune empty roots once its item dir is gone. + static let stagingRootPrefix = "PromiseDrop-" + + // Each promised file gets its own subdirectory so identically-named files never collide. + final class StagingSession: Sendable { + private let root: URL + + init() throws { + self.root = FileManager.default.temporaryDirectory + .appendingPathComponent("\(PromiseDropSupport.stagingRootPrefix)\(UUID().uuidString)") + try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true) + } + + func makeItemDirectory() throws -> URL { + let dir = self.root.appendingPathComponent("item-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + 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..507026c5 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift @@ -0,0 +1,579 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Contract tests for BatchTranscriptionCoordinator: sequential processing, per-item +/// success/failure, cancellation, staging-dir cleanup, and dictation-arbitration hooks. +@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 let last = coordinator.items.last else { + return XCTFail("enqueue after cancel must produce an item") + } + guard case .completed = last.status else { + return XCTFail("batch must run normally after a cancel, got \(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: - Shared staging dir regression + + func testEachItemFileStillExistsWhenItsOwnTranscriptionRuns() async throws { + // A shared dir deleted after the first item destroyed files later items needed. + // Dir existence at the end doesn't catch it — the file must be readable on its turn. + let fm = FileManager.default + let sessionRoot = fm.temporaryDirectory.appendingPathComponent("PromiseDrop-\(UUID().uuidString)", isDirectory: true) + var requests: [BatchTranscriptionCoordinator.Request] = [] + for index in 0..<3 { + let itemDir = sessionRoot.appendingPathComponent("item-\(index)-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: itemDir, withIntermediateDirectories: true) + let audioURL = itemDir.appendingPathComponent("memo\(index).m4a") + try Data([UInt8(index)]).write(to: audioURL) + requests.append(.init(url: audioURL, stagingDir: itemDir)) + } + + var checkedExistence: [Bool] = [] + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + checkedExistence.append(fm.fileExists(atPath: url.path)) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue(requests) + await coordinator.waitUntilIdle() + + XCTAssertEqual(checkedExistence, [true, true, true], "every item's file must still exist when its own turn to transcribe comes") + for item in coordinator.items { + guard case .completed = item.status else { + return XCTFail("expected .completed, got \(item.status)") + } + } + } + + func testCancelDuringTranscribeLeavesInFlightItemsFileReadable() 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 started = AsyncGate() + var wasReadableDuringInFlightRun = false + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + started.open() + wasReadableDuringInFlightRun = fm.fileExists(atPath: url.path) + try await Task.sleep(nanoseconds: 200_000_000) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([ + .init(url: audioURL, stagingDir: stagingDir), + .init(url: self.tempAudioURL(name: "pending.m4a")), + ]) + await started.wait() + coordinator.cancel() + await coordinator.waitUntilIdle() + + XCTAssertTrue(wasReadableDuringInFlightRun, "the in-flight item's file must still be readable while it's transcribing, even after cancel() runs") + guard case .cancelled = coordinator.items[0].status else { + return XCTFail("in-flight item must end .cancelled, got \(coordinator.items[0].status)") + } + guard case .cancelled = coordinator.items[1].status else { + return XCTFail("pending item must be .cancelled, got \(coordinator.items[1].status)") + } + } + + // 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 + + 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)") + } + + // Finished but undismissed: 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 + + 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() + + coordinator.cancel() // while the in-flight item is still gated/running + + // Enqueued while the cancelled item is still finishing — must not be stranded. + coordinator.enqueue([.init(url: self.tempAudioURL(name: "after-cancel.m4a"))]) + + releaseInFlight.open() // let the in-flight (cancelled) item finish + + 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 + + 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") + + holder.enqueue([.init(url: self.tempAudioURL(name: "fresh.m4a"))]) // must work on a fresh coordinator + 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 + + func testCancellationThrownFromTranscribeClosureMarksItemCancelled() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { _ in + // Simulates the arbitration wait loop's checkCancellation() firing 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..0a0e812a --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -0,0 +1,395 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Contract tests for the testable core of the promise-aware drop path: strategy +/// selection from pasteboard types, and per-item staging dirs 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. + 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() { + // Staging a JPEG only to fail it later is worse than refusing 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 commonly titles memos "New Recording"; same-named files from two + // receiver dirs are still two 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 testRenamedFallbackCopyOfSameNamedMemosIsNotDeliveredAsExtraItem() { + // Renamed fallback copy matches no modern name, so name-only dedup would append it + // as a phantom third file. + let memoA = URL(fileURLWithPath: "/tmp/item-A/New Recording.m4a") + let memoB = URL(fileURLWithPath: "/tmp/item-B/New Recording.m4a") + let dataDupe = URL(fileURLWithPath: "/tmp/data/New Recording.m4a") + let dataRenamed = URL(fileURLWithPath: "/tmp/data/New Recording 2.m4a") + let delivered = PromiseDropSupport.selectDelivery( + modern: [memoA, memoB], + legacy: [], + data: [dataDupe, dataRenamed], + expectedItemCount: 2 + ) + XCTAssertEqual(delivered, [memoA, memoB], "alternates must not exceed the dragged item count") + } + + func testOneReceiverPromisingSeveralFilesDeliversAllOfThemFromTheFallback() { + // The bound is the promised file-name count, not the receiver count: one receiver + // advertising three names whose modern path fails must still yield three files. + let files = (1...3).map { URL(fileURLWithPath: "/tmp/data/Memo \($0).m4a") } + let delivered = PromiseDropSupport.selectDelivery( + modern: [], + legacy: [], + data: files, + expectedItemCount: 3 + ) + XCTAssertEqual(delivered, files) + + // Counting receivers instead would cap this at 1 and silently drop two files. + let underCounted = PromiseDropSupport.selectDelivery( + modern: [], + legacy: [], + data: files, + expectedItemCount: 1 + ) + XCTAssertEqual(underCounted.count, 1, "documents the failure a receiver-count bound produces") + } + + func testFallbackStillFillsGapWhenAReceiverDoesNotDeliver() { + // One receiver didn't deliver, so the renamed copy is a real stand-in. + let memoA = URL(fileURLWithPath: "/tmp/item-A/New Recording.m4a") + let dataDupe = URL(fileURLWithPath: "/tmp/data/New Recording.m4a") + let dataRenamed = URL(fileURLWithPath: "/tmp/data/New Recording 2.m4a") + let delivered = PromiseDropSupport.selectDelivery( + modern: [memoA], + legacy: [], + data: [dataDupe, dataRenamed], + expectedItemCount: 2 + ) + XCTAssertEqual(delivered, [memoA, dataRenamed]) + } + + // MARK: - Promise-supplied file names + + func testTraversalNamesAreReducedToASinglePathComponent() { + XCTAssertEqual( + PromiseDropSupport.sanitizedFileName("../../Library/LaunchAgents/evil.plist", fallback: "fallback.m4a"), + "evil.plist" + ) + XCTAssertEqual( + PromiseDropSupport.sanitizedFileName("/etc/passwd", fallback: "fallback.m4a"), + "passwd" + ) + XCTAssertEqual( + PromiseDropSupport.sanitizedFileName("Sub/Dir/Memo.m4a", fallback: "fallback.m4a"), + "Memo.m4a" + ) + } + + func testUnusableNamesFallBackToTheGeneratedName() { + for raw in [nil, "", " ", ".", "..", "/", "../.."] { + XCTAssertEqual( + PromiseDropSupport.sanitizedFileName(raw, fallback: "Dropped Audio 1"), + "Dropped Audio 1", + "unusable name \(raw ?? "nil") must not become a file name" + ) + } + } + + func testOrdinaryNamesArePreserved() { + XCTAssertEqual( + PromiseDropSupport.sanitizedFileName("New Recording 2.m4a", fallback: "fallback.m4a"), + "New Recording 2.m4a" + ) + } + + 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: - Relocation for exclusive ownership + + func testRelocationGivesEachFileFromASharedDirItsOwnStagingDirInOrder() throws { + let session = try PromiseDropSupport.StagingSession() + defer { session.removeAll() } + let sharedDir = try session.makeItemDirectory() + + let names = ["a.m4a", "b.m4a", "c.m4a"] + let sourceFiles = try names.map { name -> URL in + let file = sharedDir.appendingPathComponent(name) + try Data([0x01]).write(to: file) + return file + } + + let result = PromiseDropSupport.relocateForExclusiveOwnership(sourceFiles, session: session) + + XCTAssertEqual(result.map { $0.url.lastPathComponent }, names, "order must be preserved") + let stagingDirs = result.compactMap(\.stagingDir) + XCTAssertEqual(stagingDirs.count, 3) + XCTAssertEqual(Set(stagingDirs.map(\.standardizedFileURL.path)).count, 3, "each file must get its own distinct dir") + for (url, dir) in result { + guard let dir else { return XCTFail("expected a staging dir") } + XCTAssertEqual(url.deletingLastPathComponent().standardizedFileURL.path, dir.standardizedFileURL.path) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + } + + func testRelocationFailureKeepsOriginalURLWithNilStagingDirAndOtherFilesStillRelocate() throws { + let session = try PromiseDropSupport.StagingSession() + defer { session.removeAll() } + let sharedDir = try session.makeItemDirectory() + + let failingFile = sharedDir.appendingPathComponent("bad.m4a") + let okFile = sharedDir.appendingPathComponent("ok.m4a") + try Data([0x01]).write(to: failingFile) + try Data([0x02]).write(to: okFile) + + let result = PromiseDropSupport.relocateForExclusiveOwnership( + [failingFile, okFile], + session: session, + move: { source, destination in + if source.lastPathComponent == "bad.m4a" { + throw NSError(domain: "test", code: 1) + } + try FileManager.default.moveItem(at: source, to: destination) + } + ) + + XCTAssertEqual(result[0].url, failingFile) + XCTAssertNil(result[0].stagingDir, "a failed move must report nil, not a dir shared with another item") + XCTAssertNotNil(result[1].stagingDir, "a failure for one file must not block others from relocating") + XCTAssertNotEqual(result[1].url, okFile, "the succeeding file must have actually moved") + } + + func testRelocationDirectoryCreationFailureDegradesTheSameWay() throws { + let session = try PromiseDropSupport.StagingSession() + defer { session.removeAll() } + let sharedDir = try session.makeItemDirectory() + let file = sharedDir.appendingPathComponent("memo.m4a") + try Data([0x01]).write(to: file) + + let result = PromiseDropSupport.relocateForExclusiveOwnership( + [file], + session: session, + makeDirectory: { _ in throw NSError(domain: "test", code: 2) } + ) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].url, file) + XCTAssertNil(result[0].stagingDir) + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path), "the original file must be left in place") + } + + // MARK: - Sweep selection + + func testSweepNeverIncludesADeliveredFilesDirectoryDespiteTrailingSlashDifferences() { + // appendingPathComponent (no trailing slash) vs. deletingLastPathComponent() of a + // delivered file (trailing slash) compare unequal as URLs — 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 + + func testDirsSafeToRemoveNowExcludesPendingReceiverDirsEvenWhenNothingDelivered() { + // An empty delivery result must not sweep a pending receiver's staging dir — + // deleting it out from under an in-flight NSFilePromiseReceiver wedges the 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) + } + + // MARK: - ObjC exception shim + // + // A file promise can carry metadata the OS refuses to decode, and NSFilePromiseReceiver raises + // an NSException rather than reporting an error. A terminate handler in-process turns any + // uncaught NSException into abort(), so the drop path depends on this shim catching it. + + func testCatcherReturnsNilWhenBlockCompletes() { + var ran = false + XCTAssertNil(FluidCatchObjCException { ran = true }) + XCTAssertTrue(ran) + } + + func testCatcherReturnsDescriptionInsteadOfAbortingOnRaise() { + let description = FluidCatchObjCException { + NSException(name: .invalidArgumentException, reason: "promise metadata decode failed", userInfo: nil).raise() + } + XCTAssertNotNil(description) + XCTAssertTrue(description?.contains("promise metadata decode failed") ?? false) + } + + func testCatcherIsolatesEachCallSoLaterWorkStillRuns() { + _ = FluidCatchObjCException { + NSException(name: .genericException, reason: "first receiver", userInfo: nil).raise() + } + var recovered = false + XCTAssertNil(FluidCatchObjCException { recovered = true }) + XCTAssertTrue(recovered) // a raise on one receiver must not stop the ones after it + } +} 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