From abafe64494f443dbe0f15c4bbca1ae4f3c01ebe1 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:59:51 -0700 Subject: [PATCH 01/17] feat: accept Voice Memos drags and batch file transcription (#219) - AppKit promise-aware drop target (SwiftUI onDrop cannot receive file promises): concrete URLs + file promises, resolved off-main via modern receiver, legacy promise API, and raw pasteboard data fallbacks - BatchTranscriptionCoordinator: sequential per-item state machine with cancellation, staging cleanup, and batch/dictation arbitration - App-level FileTranscriptionSession so in-flight work survives navigation - Multi-select file picker; batch progress UI with per-item status --- Fluid.xcodeproj/project.pbxproj | 8 + Sources/Fluid/ContentView.swift | 9 + .../BatchTranscriptionCoordinator.swift | 218 +++++++ .../Services/FileTranscriptionSession.swift | 76 +++ .../MeetingTranscriptionService.swift | 2 + .../Fluid/UI/MeetingTranscriptionView.swift | 248 ++++++-- Sources/Fluid/UI/PromiseAwareDropView.swift | 544 ++++++++++++++++++ Sources/Fluid/UI/PromiseDropSupport.swift | 139 +++++ .../BatchTranscriptionCoordinatorTests.swift | 384 +++++++++++++ .../PromiseDropSupportTests.swift | 171 ++++++ 10 files changed, 1760 insertions(+), 39 deletions(-) create mode 100644 Sources/Fluid/Services/BatchTranscriptionCoordinator.swift create mode 100644 Sources/Fluid/Services/FileTranscriptionSession.swift create mode 100644 Sources/Fluid/UI/PromiseAwareDropView.swift create mode 100644 Sources/Fluid/UI/PromiseDropSupport.swift create mode 100644 Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift create mode 100644 Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift 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..48c991fa 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -3074,6 +3074,15 @@ struct ContentView: View { /// Capture app context at start to avoid mismatches if the user switches apps mid-session private func startRecording() { + // File-transcription batches drive the same shared ASR model; starting + // dictation mid-batch would run concurrent inference through one model. + guard !FileTranscriptionSession.isBatchTranscribing else { + DebugLogger.shared.warning( + "Dictation blocked: batch file transcription in progress", + source: "ContentView" + ) + return + } let model = SettingsStore.shared.selectedSpeechModel DebugLogger.shared.info( "ContentView: startRecording() for model=\(model.displayName), supportsStreaming=\(model.supportsStreaming)", diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift new file mode 100644 index 00000000..e3fb8337 --- /dev/null +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -0,0 +1,218 @@ +import Combine +import Foundation + +/// Coordinates sequential transcription of a batch of audio files (issue #219). +/// +/// The coordinator owns per-item state for multi-file transcription: strict sequential +/// processing in enqueue order, per-item success/failure/no-speech outcomes, cooperative +/// cancellation of the in-flight file, staging-directory cleanup after each item finishes, +/// and batch start/end hooks for dictation arbitration. All stored closures are invoked on +/// the main actor. +@MainActor +final class BatchTranscriptionCoordinator: ObservableObject { + /// A transcription request for a single audio file. + struct Request { + let url: URL + let stagingDir: URL? + + init(url: URL, stagingDir: URL? = nil) { + self.url = url + self.stagingDir = stagingDir + } + } + + /// Per-item transcription outcome. + enum Status { + case pending + case transcribing + case completed(TranscriptionResult) + case noSpeech + case failed(String) + case cancelled + } + + /// Tracked state for a single enqueued file. + struct Item: Identifiable { + let id: UUID + let url: URL + let stagingDir: URL? + var status: Status + } + + @Published private(set) var items: [Item] = [] + @Published private(set) var isRunning: Bool = false + + private let transcribe: @MainActor (URL) async throws -> TranscriptionResult + private let onBatchStart: @MainActor () -> Void + private let onBatchEnd: @MainActor () -> Void + + private var batchTask: Task? + /// Published so the UI can show a "Cancelling…" state: pending items stop + /// immediately, but the in-flight item may run to completion (the native + /// provider transcription has no interruption point). + @Published private(set) var isCancelRequested: Bool = false + + private var isCancelled: Bool = false + private var idleWaiters: [CheckedContinuation] = [] + + init( + transcribe: @escaping @MainActor (URL) async throws -> TranscriptionResult, + onBatchStart: @escaping @MainActor () -> Void = {}, + onBatchEnd: @escaping @MainActor () -> Void = {} + ) { + self.transcribe = transcribe + self.onBatchStart = onBatchStart + self.onBatchEnd = onBatchEnd + } + + // MARK: - Enqueue + + /// Append requests and start a sequential processing batch if idle. Enqueuing while a + /// batch is running appends to the live batch. + func enqueue(_ requests: [Request]) { + for request in requests { + self.items.append( + Item( + id: UUID(), + url: request.url, + stagingDir: request.stagingDir, + status: .pending + ) + ) + } + + if self.batchTask == nil { + self.startBatch() + } + } + + // MARK: - Cancellation + + /// Cancel the in-flight transcription and every still-pending item. No-op when idle. + func cancel() { + guard self.batchTask != nil else { return } + + self.isCancelled = true + self.isCancelRequested = true + + for index in 0.. TranscriptionResult + private var forwarder: AnyCancellable? + + init(transcribe: @escaping @MainActor (URL) async throws -> TranscriptionResult) { + self.transcribe = transcribe + } + + func enqueue(_ requests: [BatchTranscriptionCoordinator.Request]) { + if self.coordinator == nil { + let coordinator = BatchTranscriptionCoordinator(transcribe: self.transcribe) + self.forwarder = coordinator.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + self.coordinator = coordinator + } + self.coordinator?.enqueue(requests) + } + + func clear() { + // Never orphan a running batch: without this, dropping the coordinator + // would leave it transcribing invisibly with no way to reach it. + self.coordinator?.cancel() + self.forwarder?.cancel() + self.forwarder = nil + self.coordinator = nil + } +} diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index 67d1eede..5a69072a 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -216,6 +216,7 @@ final class MeetingTranscriptionService: ObservableObject { source: "MeetingTranscriptionService" ) + try Task.checkCancellation() let nativeResult = try await provider.transcribeFile(at: fileURL) let processingTime = Date().timeIntervalSince(startTime) let result = TranscriptionResult( @@ -284,6 +285,7 @@ final class MeetingTranscriptionService: ObservableObject { self.currentStatus = duration > 0 ? "Transcribing audio (\(Int(duration))s)..." : "Transcribing audio..." while currentFrame < audioFile.length { + try Task.checkCancellation() let remainingFrames = AVAudioFrameCount(audioFile.length - currentFrame) let framesToRead = min(sourceFramesPerChunk, remainingFrames) diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index c2061e93..68a21911 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -1,16 +1,22 @@ +import Combine import SwiftUI import UniformTypeIdentifiers struct MeetingTranscriptionView: View { let asrService: ASRService - @StateObject private var transcriptionService: MeetingTranscriptionService + // Owned by FileTranscriptionSession (app-level) so in-flight transcriptions + // survive this view being destroyed by sidebar navigation. + @ObservedObject private var transcriptionService: MeetingTranscriptionService + @ObservedObject private var batchHolder: BatchCoordinatorHolder @ObservedObject private var fileHistoryStore = FileTranscriptionHistoryStore.shared @State private var selectedFileURL: URL? @Environment(\.theme) private var theme init(asrService: ASRService) { self.asrService = asrService - _transcriptionService = StateObject(wrappedValue: MeetingTranscriptionService(asrService: asrService)) + let session = FileTranscriptionSession.shared + self.transcriptionService = session.service + self.batchHolder = session.batchHolder } @State private var showingFilePicker = false @@ -58,18 +64,18 @@ struct MeetingTranscriptionView: View { // File Selection Card self.fileSelectionCard - // Progress Card (only show when transcribing) - if self.transcriptionService.isTranscribing { + // Progress Card (only show when transcribing outside a batch) + if self.transcriptionService.isTranscribing && !self.isBatchActive { self.progressCard } - // Results Card (only show when we have results) - if let result = transcriptionService.result { + // Results Card (only show when we have results outside a batch) + if let result = transcriptionService.result, !self.isBatchActive { self.resultsCard(result: result) } - // Error Card (only show when we have an error) - if let error = transcriptionService.error { + // Error Card (only show when we have a single-file error) + if let error = transcriptionService.error, !self.isBatchActive { self.errorCard(error: error) } @@ -78,6 +84,11 @@ struct MeetingTranscriptionView: View { self.dropErrorCard(message: message) } + // Batch transcription progress/results + if self.isBatchActive { + self.batchSection + } + // Recent transcriptions (persisted history) if !self.fileHistoryStore.entries.isEmpty { Divider() @@ -89,6 +100,16 @@ struct MeetingTranscriptionView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + // Overlay, not background: AppKit's drag-target search walks views + // front-to-back, so the drop target must sit above the SwiftUI content. + // Its hitTest returns nil, so clicks pass through to the UI beneath. + .overlay( + PromiseAwareDropView( + onTargetedChange: { self.isDropTargeted = $0 }, + onFiles: { self.handleIncomingFiles($0) }, + onError: { self.dropErrorMessage = $0 } + ) + ) .background(self.theme.palette.windowBackground) .overlay(alignment: .topTrailing) { if self.showingCopyConfirmation { @@ -178,7 +199,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 +235,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 +550,187 @@ struct MeetingTranscriptionView: View { ) } - // MARK: - Helper Functions + // MARK: - Batch Transcription Section + + @ViewBuilder + private var batchSection: some View { + if let coordinator = self.batchHolder.coordinator, !coordinator.items.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(self.batchHeader(coordinator: coordinator)) + .font(.headline) + + Spacer() + + if coordinator.isRunning { + // Pending items stop immediately; the in-flight file may + // run to completion (native provider transcription has + // no interruption point), hence the "Cancelling" state. + if coordinator.isCancelRequested { + Text("Cancelling…") + .font(.caption) + .foregroundColor(.secondary) + } else { + Button("Cancel") { coordinator.cancel() } + .buttonStyle(.borderless) + .foregroundColor(.red) + } + } else { + Button("Done") { self.dismissBatch() } + .buttonStyle(.borderless) + } + } + + Divider() - 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 + VStack(spacing: 8) { + ForEach(coordinator.items) { item in + self.batchRow(item: item) } } - return } - DispatchQueue.main.async { - self.selectedFileURL = url - self.transcriptionService.reset() - self.dropErrorMessage = nil + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(self.theme.palette.cardBackground) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.45), lineWidth: 1) + ) + ) + } + } + + private func batchHeader(coordinator: BatchTranscriptionCoordinator) -> String { + if coordinator.isRunning { + return "Transcribing \(self.currentBatchPosition(in: coordinator)) of \(coordinator.items.count)" + } + return "Batch complete — \(coordinator.completedCount) transcribed, \(coordinator.failedCount) failed" + } + + /// One-based position of the item currently being transcribed (or the next pending + /// item while between files), for the "Transcribing x of y" header. + private func currentBatchPosition(in coordinator: BatchTranscriptionCoordinator) -> Int { + let items = coordinator.items + if let index = items.firstIndex(where: { if case .transcribing = $0.status { return true }; return false }) { + return index + 1 + } + if let index = items.firstIndex(where: { if case .pending = $0.status { return true }; return false }) { + return index + 1 + } + return items.count + } + + private func batchRow(item: BatchTranscriptionCoordinator.Item) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(item.url.lastPathComponent) + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + + self.batchDetailText(for: item.status) } + + Spacer() + + self.batchStatusIcon(for: item.status) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.contentBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.3), lineWidth: 1) + ) + ) + } + + @ViewBuilder + private func batchDetailText(for status: BatchTranscriptionCoordinator.Status) -> some View { + switch status { + case .failed(let message): + Text(message) + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + .truncationMode(.tail) + case .noSpeech: + Text("No speech detected") + .font(.caption) + .foregroundColor(.secondary) + case .cancelled: + Text("Cancelled") + .font(.caption) + .foregroundColor(.secondary) + default: + EmptyView() } - return true + } + + @ViewBuilder + private func batchStatusIcon(for status: BatchTranscriptionCoordinator.Status) -> some View { + switch status { + case .pending: + Image(systemName: "clock") + .foregroundColor(.secondary) + .opacity(0.5) + case .transcribing: + ProgressView() + .controlSize(.small) + .fixedSize() + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.fluidGreen) + case .noSpeech: + Image(systemName: "speaker.slash.fill") + .foregroundColor(.secondary) + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + case .cancelled: + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + + // MARK: - Helper Functions + + /// True while a batch coordinator exists with any enqueued items (running or + /// finished-but-not-dismissed). Used to suppress the single-file cards while a + /// batch is in progress, since `transcribeFile` mutates the shared service state + /// for each batch item. + private var isBatchActive: Bool { + guard let coordinator = self.batchHolder.coordinator else { return false } + return !coordinator.items.isEmpty + } + + /// Routes incoming files from a drop or the file picker. + /// + /// A single concrete file (no staging directory) follows the existing single-file + /// flow: select it and wait for the user to tap Transcribe. Everything else — two + /// or more files, or any staged (promised) file — is enqueued on the batch + /// coordinator so staged directories are reliably cleaned up after transcription. + private func handleIncomingFiles(_ files: [(url: URL, stagingDir: URL?)]) { + guard !files.isEmpty else { return } + self.dropErrorMessage = nil + + let batchIsRunning = self.batchHolder.coordinator?.isRunning == true + if files.count == 1, let file = files.first, file.stagingDir == nil, !batchIsRunning { + self.selectedFileURL = file.url + self.transcriptionService.reset() + return + } + + self.batchHolder.enqueue(files.map { .init(url: $0.url, stagingDir: $0.stagingDir) }) + } + + /// Clears the batch list and resets shared service state so a subsequent batch + /// starts fresh and no stale single-file result lingers. + private func dismissBatch() { + self.batchHolder.clear() + self.transcriptionService.reset() } private func transcribeFile() async { @@ -603,6 +771,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..16eec755 --- /dev/null +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -0,0 +1,544 @@ +import AppKit +import SwiftUI + +/// A drop target that accepts both concrete file URLs and file-promise drags +/// (e.g. from Voice Memos), and resolves promised files into per-item staging +/// directories before reporting them via callbacks. +/// +/// The pure strategy/staging/filtering logic lives in `PromiseDropSupport`; +/// this view only owns AppKit drag plumbing and the async resolution poll. +struct PromiseAwareDropView: NSViewRepresentable { + let onTargetedChange: (Bool) -> Void + let onFiles: ([(url: URL, stagingDir: URL?)]) -> Void + let onError: (String) -> Void + + func makeNSView(context: Context) -> DropTargetView { + let view = DropTargetView() + view.onTargetedChange = self.onTargetedChange + view.onFiles = self.onFiles + view.onError = self.onError + return view + } + + func updateNSView(_ nsView: DropTargetView, context: Context) { + nsView.onTargetedChange = self.onTargetedChange + nsView.onFiles = self.onFiles + nsView.onError = self.onError + } + + // MARK: - Drop target + + final class DropTargetView: NSView { + var onTargetedChange: (Bool) -> Void = { _ in } + var onFiles: ([(url: URL, stagingDir: URL?)]) -> Void = { _ in } + var onError: (String) -> Void = { _ in } + + /// Shared background queue for `NSFilePromiseReceiver` resolution. Static + /// so in-flight promise resolution survives the view being destroyed + /// (e.g. by sidebar navigation) mid-drop. + private static let promiseQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 4 + return queue + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.configureDropTypes() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.configureDropTypes() + } + + private func configureDropTypes() { + let promiseTypes = NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType(rawValue: $0) } + self.registerForDraggedTypes([.fileURL] + promiseTypes) + } + + /// The view sits in an `.overlay` so AppKit's front-to-back drag-target + /// search finds it above the SwiftUI content; returning nil here lets + /// every normal mouse event fall through to the UI beneath. Drag + /// dispatch does not consult `hitTest`, so drops still arrive. + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + DebugLogger.shared.debug( + "Drop target attached [inWindow=\(self.window != nil), frame=\(self.frame)]", + source: "PromiseAwareDropView" + ) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + let types = (sender.draggingPasteboard.types ?? []).map(\.rawValue) + let strategy = PromiseDropSupport.strategy(forPasteboardTypes: types) + DebugLogger.shared.debug( + "Drag entered [strategy=\(String(describing: strategy)), frame=\(self.frame)]", + source: "PromiseAwareDropView" + ) + guard strategy != nil else { + return [] + } + self.onTargetedChange(true) + return .copy + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + self.onTargetedChange(false) + } + + override func draggingEnded(_ sender: NSDraggingInfo) { + self.onTargetedChange(false) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let pasteboard = sender.draggingPasteboard + let types = (pasteboard.types ?? []).map(\.rawValue) + DebugLogger.shared.debug( + "Perform drag [types=\(types.count)]", + source: "PromiseAwareDropView" + ) + guard let strategy = PromiseDropSupport.strategy(forPasteboardTypes: types) else { + self.onError(MeetingTranscriptionService.dropErrorCopy) + return false + } + + switch strategy { + case .concreteFileURLs: + return self.handleConcreteURLs(pasteboard: pasteboard) + case .filePromise: + return self.handleFilePromises(sender: sender, pasteboard: pasteboard) + } + } + + // MARK: - Concrete File URLs + + private func handleConcreteURLs(pasteboard: NSPasteboard) -> Bool { + let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] + guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [URL], + !urls.isEmpty else { + self.onError(MeetingTranscriptionService.dropErrorCopy) + return false + } + + let supported = PromiseDropSupport.filterSupported(urls) + if supported.isEmpty { + self.onError(MeetingTranscriptionService.dropErrorCopy) + return false + } + + self.onFiles(supported.map { (url: $0, stagingDir: nil) }) + return true + } + + // MARK: - File Promises + + private func handleFilePromises(sender: NSDraggingInfo, pasteboard: NSPasteboard) -> Bool { + let session: PromiseDropSupport.StagingSession + do { + session = try PromiseDropSupport.StagingSession() + } catch { + self.onError("Could not prepare a staging directory for the drop: \(error.localizedDescription)") + return false + } + let legacyDir = try? session.makeItemDirectory() + let dataDir = try? session.makeItemDirectory() + // Captured by value so resolution keeps working (and can still hand + // files to the app-level batch session) if this view is destroyed + // by navigation while the drop is resolving. + let onFiles = self.onFiles + let onError = self.onError + + // Every pasteboard read can block indefinitely (verified: the same + // read is instant on one drop and deadlocks on the next), and a + // block inside performDragOperation freezes the system-wide drag + // session. So performDragOperation returns immediately and ALL + // pasteboard interaction happens on throwaway background threads; + // the main thread only ever polls the staging directories. + // + // NOTE: using `sender`/`pasteboard` after performDragOperation has + // returned, from another thread, is outside AppKit's documented + // lifetime contract. It is the only arrangement that neither + // deadlocks nor loses the drop (verified live against Voice Memos); + // if a provider stops answering, the reads fail harmlessly on their + // background threads and the poll timeout reports the failure. + let state = ResolutionState() + state.beginInFlight() // outer worker token, released when it finishes + + // The poller starts before any pasteboard read, so even a worker + // thread that hangs on its first read cannot leave the drop without + // a timeout/error path. Deliberately not tied to the view lifetime. + Task { @MainActor in + await DropTargetView.resolvePromises( + session: session, + state: state, + legacyDir: legacyDir, + dataDir: dataDir, + onFiles: onFiles, + onError: onError + ) + } + + Self.detachWorker { + defer { state.endInFlight() } + + // Modern path: each NSFilePromiseReceiver writes into its own + // item directory via the shared background operation queue. A + // directory counts as delivered only after its completion + // callback fires — polling alone could hand over a file whose + // (e.g. iCloud) download stalled mid-write. + if let receivers = pasteboard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver] { + for receiver in receivers { + guard let dir = try? session.makeItemDirectory() else { continue } + state.registerReceiverDir(dir) + 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" + ) + } else { + state.markCompleted(dir) + } + } + } + } + + let suggestedName = pasteboard.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") + ) + let promisedTypeID = pasteboard.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-content-type") + ) + DebugLogger.shared.debug( + "Promise drop [receivers=\(state.receiverDirsSnapshot().count), type=\(promisedTypeID ?? "?"), name=\(suggestedName ?? "?")]", + source: "PromiseAwareDropView" + ) + + // Raw-data fallback: some providers (verified for Voice Memos) + // also put the complete file bytes on the pasteboard under the + // promised content type. Memory-heavy for long recordings, so + // it is the last resort in the delivery priority order. + if let dataDir, let typeID = promisedTypeID { + let fileName = suggestedName ?? "Dropped Audio" + guard let data = pasteboard.data(forType: NSPasteboard.PasteboardType(typeID)), + !data.isEmpty else { return } + let target = dataDir.appendingPathComponent(fileName) + do { + try data.write(to: target) + DebugLogger.shared.debug( + "Raw-data fallback wrote \(data.count) bytes to \(target.lastPathComponent)", + source: "PromiseAwareDropView" + ) + } catch { + DebugLogger.shared.debug( + "Raw-data fallback write failed: \(error.localizedDescription)", + source: "PromiseAwareDropView" + ) + } + } + + // Legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:), + // strictly last resort. A pending legacy request freezes the + // source app's promise machinery until it answers (verified + // live: Voice Memos unresponsive for 35-80s, blocking further + // drags), so it only fires if neither the modern receiver nor + // the raw-data path has produced anything within a short + // window. Invoked via perform(_:) with a string selector so no + // deprecation warning is emitted. + if let legacyDir { + let waitDeadline = Date().addingTimeInterval(3) + var otherPathDelivered = false + while Date() < waitDeadline { + let dataLanded = dataDir.map { + !((try? FileManager.default.contentsOfDirectory(atPath: $0.path)) ?? []).isEmpty + } ?? false + if dataLanded || !state.completedSnapshot().isEmpty { + otherPathDelivered = true + break + } + Thread.sleep(forTimeInterval: 0.2) + } + if !otherPathDelivered { + state.beginInFlight() + Self.detachWorker { + defer { state.endInFlight() } + let selector = Selector(("namesOfPromisedFilesDroppedAtDestination:")) + let names = (sender.perform(selector, with: legacyDir)? + .takeUnretainedValue() as? [String]) ?? [] + DebugLogger.shared.debug( + "Legacy promise names: \(names)", + source: "PromiseAwareDropView" + ) + } + } + } + } + return true + } + + /// Thread-safe resolution state shared between the background delivery + /// threads and the main-actor poller: which receiver dirs have completed, + /// and whether the legacy/raw-data reads are still in flight (under heavy + /// inference load a provider can take 60s+ to answer — verified live — + /// and the poller must not give up and sweep the staging dirs while an + /// answer is still coming). + private final class ResolutionState: @unchecked Sendable { + private let lock = NSLock() + private var receiverDirs: [URL] = [] + private var completed: Set = [] + private var inFlightCount = 0 + + func registerReceiverDir(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.receiverDirs.append(dir) + } + + func receiverDirsSnapshot() -> [URL] { + self.lock.lock() + defer { self.lock.unlock() } + return self.receiverDirs + } + + func markCompleted(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.completed.insert(dir) + } + + func completedSnapshot() -> Set { + self.lock.lock() + defer { self.lock.unlock() } + return self.completed + } + + func beginInFlight() { + self.lock.lock() + defer { self.lock.unlock() } + self.inFlightCount += 1 + } + + func endInFlight() { + self.lock.lock() + defer { self.lock.unlock() } + self.inFlightCount -= 1 + } + + var hasInFlightWork: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.inFlightCount > 0 + } + } + + /// Detached worker at user-initiated QoS: default-QoS threads get starved + /// while ML inference saturates the cores, which is exactly when a second + /// drop arrives mid-batch. + private static func detachWorker(_ body: @escaping () -> Void) { + let thread = Thread(block: body) + thread.qualityOfService = .userInitiated + thread.start() + } + + // MARK: - Promise Resolution Poll + + private static func resolvePromises( + session: PromiseDropSupport.StagingSession, + state: ResolutionState, + legacyDir: URL?, + dataDir: URL?, + onFiles: @escaping ([(url: URL, stagingDir: URL?)]) -> Void, + onError: @escaping (String) -> Void + ) async { + let pollInterval: UInt64 = 200_000_000 + let softTimeout: TimeInterval = 30 + // Under heavy inference load a provider can take 60s+ to answer + // (verified live: 80s for a second drop mid-batch). While any + // delivery thread is still in flight the poll keeps waiting, up to + // a hard cap so a truly wedged provider still ends in an error. + let hardTimeout: TimeInterval = 120 + let modernGrace: TimeInterval = 1.5 + let start = Date() + + func makeContext() -> DeliveryContext { + let receiverDirs = state.receiverDirsSnapshot() + let completed = state.completedSnapshot() + return DeliveryContext( + allDirs: receiverDirs + [legacyDir, dataDir].compactMap(\.self), + pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) }, + totalExpected: max(receiverDirs.count, 1), + session: session, + onFiles: onFiles, + onError: onError + ) + } + + var fallbackPrevSizes: [URL: Int64] = [:] + + while true { + let elapsed = Date().timeIntervalSince(start) + if elapsed >= hardTimeout { break } + if elapsed >= softTimeout, !state.hasInFlightWork { break } + + // A receiver dir only counts once its completion callback fired — + // a stalled (e.g. iCloud) download must never be delivered as a + // truncated file just because its size held still between polls. + let receiverDirs = state.receiverDirsSnapshot() + let readyModernDirs = state.completedSnapshot() + let 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) + + // Modern succeeded outright: every registered receiver completed. + // (Receivers register in one tight loop with no pasteboard call + // in between, so a partially-registered snapshot is a micro-race + // at worst; completion still requires each dir's callback.) + let modernComplete = !receiverDirs.isEmpty + && readyModernDirs.count >= receiverDirs.count + && !modernFiles.isEmpty + if modernComplete { + self.deliver(modern: modernFiles, legacy: legacyFiles, data: dataFiles, context: makeContext()) + return + } + + // Modern produced nothing past the grace period but a fallback + // landed and its sizes are stable — use the fallback copies. + let modernProducedNothing = readyModernDirs.isEmpty && elapsed > modernGrace + let fallbackStable = !fallbackSizes.isEmpty && fallbackSizes == fallbackPrevSizes + if modernProducedNothing && fallbackStable { + self.deliver(modern: [], legacy: legacyFiles, data: dataFiles, context: makeContext()) + return + } + + fallbackPrevSizes = fallbackSizes + try? await Task.sleep(nanoseconds: pollInterval) + } + + // Timeout: deliver whatever completed, preferring modern. Incomplete + // receiver dirs are excluded (possible truncation) and surface via + // the partial-failure error below. + let receiverDirs = state.receiverDirsSnapshot() + let readyModernDirs = state.completedSnapshot() + self.deliver( + modern: self.listFiles(in: receiverDirs.filter(readyModernDirs.contains)), + legacy: self.listFiles(in: [legacyDir].compactMap(\.self)), + data: self.listFiles(in: [dataDir].compactMap(\.self)), + context: makeContext() + ) + } + + /// Everything `deliver` needs besides the per-path file lists. + private struct DeliveryContext { + let allDirs: [URL] + /// Receiver dirs whose promise hasn't completed yet. They must NOT + /// be swept at delivery: the source app may still be writing into + /// them, and deleting the destination leaves its promise session + /// unresolved — Voice Memos then refuses to start any further drag + /// until restarted (verified live). They're cleaned up after the + /// promise completes (or a 120s deadline) by a deferred task. + let pendingReceiverDirs: [URL] + let totalExpected: Int + let session: PromiseDropSupport.StagingSession + let onFiles: ([(url: URL, stagingDir: URL?)]) -> Void + let onError: (String) -> Void + } + + /// Delivers the resolved files. Selection/dedup rules live in + /// `PromiseDropSupport.selectDelivery`; unsupported formats are filtered + /// like the concrete-URL path. + private static func deliver( + modern: [URL], + legacy: [URL], + data: [URL], + context: DeliveryContext + ) { + DebugLogger.shared.debug( + "Promise delivery [modern=\(modern.count), legacy=\(legacy.count), data=\(data.count), expected=\(context.totalExpected)]", + source: "PromiseAwareDropView" + ) + let selected = PromiseDropSupport.selectDelivery(modern: modern, legacy: legacy, data: data) + let supported = PromiseDropSupport.filterSupported(selected) + let result = supported.map { (url: $0, stagingDir: $0.deletingLastPathComponent()) } + + if result.isEmpty { + context.session.removeAll() + let reason = selected.isEmpty + ? "No files could be read from the drop." + : "The dropped files are not a supported format." + context.onError("\(reason) \(MeetingTranscriptionService.dropErrorCopy)") + return + } + + // Item dirs that hold no delivered file — losing duplicates (e.g. the + // raw-data copy when the modern receiver won) and empty shells from + // failed paths — are dead weight. Deleting them can race the still- + // running legacy/data writer threads; that is harmless (the losers' + // files are throwaway copies) and keeps the temp dir clean. The + // batch coordinator removes the delivered dirs (and then the empty + // session root) after each item's transcription. + let pendingPaths = Set(context.pendingReceiverDirs.map(\.standardizedFileURL.path)) + for dir in PromiseDropSupport.sweepableDirs(allDirs: context.allDirs, deliveredFiles: supported) + where !pendingPaths.contains(dir.standardizedFileURL.path) { + try? FileManager.default.removeItem(at: dir) + } + + context.onFiles(result) + if result.count < context.totalExpected { + context.onError("\(context.totalExpected - result.count) file(s) could not be read from the drop.") + } + + self.cleanUpLater(pendingDirs: context.pendingReceiverDirs) + } + + /// Removes still-pending receiver dirs once their promise has had time + /// to finish writing (their content is a duplicate of what was already + /// delivered), then prunes the session root if it ended up empty. + private static func cleanUpLater(pendingDirs: [URL]) { + guard !pendingDirs.isEmpty else { return } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 120_000_000_000) + for dir in pendingDirs { + try? FileManager.default.removeItem(at: dir) + } + for parent in Set(pendingDirs.map { $0.deletingLastPathComponent() }) + where parent.lastPathComponent.hasPrefix(PromiseDropSupport.stagingRootPrefix) { + if ((try? FileManager.default.contentsOfDirectory(atPath: parent.path)) ?? []).isEmpty { + try? FileManager.default.removeItem(at: parent) + } + } + } + } + + // MARK: - Filesystem Helpers + + private static func listFiles(in dirs: [URL]) -> [URL] { + var files: [URL] = [] + for dir in dirs { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { continue } + for entry in entries where !entry.hasDirectoryPath { + files.append(entry) + } + } + return files + } + + private static func sizeMap(for urls: [URL]) -> [URL: Int64] { + var map: [URL: Int64] = [:] + for url in urls { + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + map[url] = Int64(size) + } + return map + } + } +} diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift new file mode 100644 index 00000000..9fdc80e7 --- /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 (issue #219). +/// +/// The AppKit drop view (`PromiseAwareDropView`) needs a live drag session, so it +/// delegates the pure pieces here: deciding whether a pasteboard carries concrete +/// file URLs or file promises, staging promised files into collision-free per-item +/// directories, and filtering drops to formats AVFoundation can actually decode. +/// Everything is `nonisolated` so it can be unit-tested from a nonisolated test +/// context despite the app target's `MainActor` default actor isolation. +nonisolated enum PromiseDropSupport { + /// How a given pasteboard should be interpreted. + enum Strategy: Equatable { + /// Concrete file URLs are present (`public.file-url`). + case concreteFileURLs + /// Only file-promise metadata is present (e.g. a Voice Memos drag). + case filePromise + } + + /// The pasteboard type that carries a concrete file URL. + private static let concreteFileURLType = "public.file-url" + + /// Pasteboard type identifiers that signal a file-promise drop. Voice Memos + /// emits a mix of these (verified live 2026-07-25); any one is sufficient. + private static let filePromiseTypes: Set = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-content-type", + "Apple files promise pasteboard type", + ] + + /// Decides the drop strategy from raw pasteboard type strings. + /// + /// Concrete file URLs win over promises when both are present. Promises are + /// only accepted when the pasteboard also advertises an audio/movie content + /// type (Voice Memos exposes e.g. `com.apple.m4a-audio` directly in the + /// type list) — so an image promise from Photos is rejected up front rather + /// than staged and failed later. Returns `nil` for pasteboards that carry + /// neither file URLs nor decodable promises. + static func strategy(forPasteboardTypes types: [String]) -> Strategy? { + if types.contains(concreteFileURLType) { + return .concreteFileURLs + } + guard types.contains(where: { filePromiseTypes.contains($0) }) else { + return nil + } + let promisesAudioVisualContent = types.contains { type in + guard let utType = UTType(type) else { return false } + return utType.conforms(to: .audio) || utType.conforms(to: .movie) + } + return promisesAudioVisualContent ? .filePromise : nil + } + + /// Staging dirs that hold no delivered file and should be swept. + /// + /// Compares standardized *paths*, not URLs: a directory built via + /// `appendingPathComponent` and the same directory derived from a file via + /// `deletingLastPathComponent()` differ in trailing-slash form and compare + /// unequal as URLs — which once made the sweep delete a delivered file. + static func sweepableDirs(allDirs: [URL], deliveredFiles: [URL]) -> [URL] { + let deliveredDirPaths = Set(deliveredFiles.map { + $0.deletingLastPathComponent().standardizedFileURL.path + }) + return allDirs.filter { !deliveredDirPaths.contains($0.standardizedFileURL.path) } + } + + /// Merges the three promise-delivery paths into the final file list. + /// + /// Every modern-receiver file is delivered: each receiver stages into its own + /// directory, so two distinct memos that share a display name (e.g. two + /// "New Recording.m4a") are distinct files and must never be collapsed. + /// Legacy and raw-data files are alternates of the same dragged items, so + /// they only contribute names the delivered set doesn't already contain. + static func selectDelivery(modern: [URL], legacy: [URL], data: [URL]) -> [URL] { + var delivered = modern + var names = Set(modern.map(\.lastPathComponent)) + for url in legacy + data where !names.contains(url.lastPathComponent) { + delivered.append(url) + names.insert(url.lastPathComponent) + } + return delivered + } + + /// Audio/movie file extensions the OS can decode, queried nonisolated from + /// AVFoundation. Mirrors `MeetingTranscriptionService.supportedFileExtensions` + /// but is safe to call from any isolation context. + private static let supportedExtensions: Set = { + let avTypes = AVURLAsset.audiovisualTypes() + let extensions = avTypes.compactMap { fileType -> String? in + guard let utType = UTType(fileType.rawValue) else { return nil } + guard utType.conforms(to: .audio) || utType.conforms(to: .movie) else { return nil } + return utType.preferredFilenameExtension?.lowercased() + } + return Set(extensions) + }() + + /// Keeps only URLs whose (lowercased) extension is a decodable audio/movie + /// format. Order-preserving. + static func filterSupported(_ urls: [URL]) -> [URL] { + return urls.filter { url in + let ext = url.pathExtension.lowercased() + return !ext.isEmpty && supportedExtensions.contains(ext) + } + } + + /// A scratch directory tree under the user temp dir that gives each promised + /// file its own subdirectory, so identically-named files (e.g. several Voice + /// Memos drops named "New Recording.m4a") never collide. + /// + /// All state is immutable, so the session is `Sendable` and safe to share + /// across the main actor and the background queue that resolves promises. + /// Prefix of every drop-session staging root; the batch coordinator uses it + /// to recognize (and prune) empty session roots after item cleanup. + static let stagingRootPrefix = "PromiseDrop-" + + final class StagingSession: Sendable { + private let root: URL + + /// Creates a unique root directory under the user temp dir. + init() throws { + self.root = FileManager.default.temporaryDirectory + .appendingPathComponent("\(PromiseDropSupport.stagingRootPrefix)\(UUID().uuidString)") + try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true) + } + + /// Creates a fresh, unique subdirectory for a single promised item. + func makeItemDirectory() throws -> URL { + let dir = self.root.appendingPathComponent("item-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Deletes the whole staging tree. + func removeAll() { + try? FileManager.default.removeItem(at: self.root) + } + } +} diff --git a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift new file mode 100644 index 00000000..c8ddf44f --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift @@ -0,0 +1,384 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Contract tests for BatchTranscriptionCoordinator (issue #219 batch transcription). +/// The coordinator owns per-item state for multi-file transcription: sequential +/// processing, per-item success/failure, cancellation, staging-dir cleanup, and +/// batch start/end hooks for dictation arbitration. +@MainActor +final class BatchTranscriptionCoordinatorTests: XCTestCase { + private func makeResult(text: String, fileName: String = "test.m4a") -> TranscriptionResult { + TranscriptionResult( + text: text, + confidence: 0.9, + duration: 1.0, + processingTime: 0.1, + fileName: fileName + ) + } + + private func tempAudioURL(name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("batch-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(name) + } + + // MARK: - Sequential processing & ordering + + func testProcessesFilesSequentiallyInOrder() async { + var transcribedPaths: [String] = [] + var concurrent = 0 + var maxConcurrent = 0 + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + concurrent += 1 + maxConcurrent = max(maxConcurrent, concurrent) + try? await Task.sleep(nanoseconds: 20_000_000) + transcribedPaths.append(url.lastPathComponent) + concurrent -= 1 + return self.makeResult(text: "text for \(url.lastPathComponent)", fileName: url.lastPathComponent) + }) + + let urls = ["a.m4a", "b.m4a", "c.m4a"].map { self.tempAudioURL(name: $0) } + coordinator.enqueue(urls.map { BatchTranscriptionCoordinator.Request(url: $0) }) + await coordinator.waitUntilIdle() + + XCTAssertEqual(transcribedPaths, ["a.m4a", "b.m4a", "c.m4a"], "files must process in enqueue order") + XCTAssertEqual(maxConcurrent, 1, "batch must never transcribe two files concurrently") + XCTAssertFalse(coordinator.isRunning) + XCTAssertEqual(coordinator.items.count, 3) + for item in coordinator.items { + guard case let .completed(result) = item.status else { + return XCTFail("expected .completed, got \(item.status)") + } + XCTAssertEqual(result.text, "text for \(item.url.lastPathComponent)") + } + } + + func testIsRunningTrueWhileProcessing() async { + let gate = AsyncGate() + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + await gate.wait() + return self.makeResult(text: "done", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "a.m4a"))]) + // Let the processing task start. + await Task.yield() + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertTrue(coordinator.isRunning) + if case .transcribing = coordinator.items[0].status {} else { + XCTFail("expected first item to be .transcribing, got \(coordinator.items[0].status)") + } + + gate.open() + await coordinator.waitUntilIdle() + XCTAssertFalse(coordinator.isRunning) + } + + // MARK: - Per-item failure isolation + + func testFailedItemDoesNotStopBatch() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + if url.lastPathComponent == "bad.m4a" { + throw MeetingTranscriptionService.TranscriptionError.transcriptionFailed("decode blew up") + } + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([ + .init(url: self.tempAudioURL(name: "good1.m4a")), + .init(url: self.tempAudioURL(name: "bad.m4a")), + .init(url: self.tempAudioURL(name: "good2.m4a")), + ]) + await coordinator.waitUntilIdle() + + guard case .completed = coordinator.items[0].status else { + return XCTFail("item 0 should complete, got \(coordinator.items[0].status)") + } + guard case let .failed(message) = coordinator.items[1].status else { + return XCTFail("item 1 should fail, got \(coordinator.items[1].status)") + } + XCTAssertTrue(message.contains("decode blew up"), "failure must preserve the underlying error message") + guard case .completed = coordinator.items[2].status else { + return XCTFail("item 2 should still complete after a failure, got \(coordinator.items[2].status)") + } + } + + // MARK: - Empty transcription + + func testEmptyTranscriptionMarksNoSpeech() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: " ", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "silent.m4a"))]) + await coordinator.waitUntilIdle() + + guard case .noSpeech = coordinator.items[0].status else { + return XCTFail("whitespace-only transcription must surface as .noSpeech, got \(coordinator.items[0].status)") + } + } + + // MARK: - Cancellation + + func testCancelStopsCurrentItemAndPendingItems() async { + let started = AsyncGate() + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + started.open() + // Simulate a long transcription that honors Task cancellation. + try await Task.sleep(nanoseconds: 10_000_000_000) + return self.makeResult(text: "should never finish", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([ + .init(url: self.tempAudioURL(name: "current.m4a")), + .init(url: self.tempAudioURL(name: "pending1.m4a")), + .init(url: self.tempAudioURL(name: "pending2.m4a")), + ]) + await started.wait() + coordinator.cancel() + await coordinator.waitUntilIdle() + + XCTAssertFalse(coordinator.isRunning) + guard case .cancelled = coordinator.items[0].status else { + return XCTFail("in-flight item must be .cancelled, not \(coordinator.items[0].status)") + } + guard case .cancelled = coordinator.items[1].status else { + return XCTFail("pending items must be .cancelled, not \(coordinator.items[1].status)") + } + guard case .cancelled = coordinator.items[2].status else { + return XCTFail("pending items must be .cancelled, not \(coordinator.items[2].status)") + } + } + + func testEnqueueAfterCancelStartsFreshBatch() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + coordinator.cancel() // cancel with nothing running must be a no-op + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "after.m4a"))]) + await coordinator.waitUntilIdle() + + guard case .completed = coordinator.items.last!.status else { + return XCTFail("batch must run normally after a cancel, got \(coordinator.items.last!.status)") + } + } + + // MARK: - Staging directory cleanup + + func testStagingDirRemovedAfterItemFinishes() async throws { + let fm = FileManager.default + let stagingDir = fm.temporaryDirectory.appendingPathComponent("staging-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + + var existedDuringTranscription = false + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + existedDuringTranscription = fm.fileExists(atPath: url.path) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertTrue(existedDuringTranscription, "staged file must exist for the whole transcription") + XCTAssertFalse(fm.fileExists(atPath: stagingDir.path), "staging dir must be deleted after the item finishes") + } + + func testStagingDirRemovedEvenWhenItemFails() async throws { + let fm = FileManager.default + let stagingDir = fm.temporaryDirectory.appendingPathComponent("staging-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + + let coordinator = BatchTranscriptionCoordinator(transcribe: { _ in + throw MeetingTranscriptionService.TranscriptionError.transcriptionFailed("nope") + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertFalse(fm.fileExists(atPath: stagingDir.path), "staging dir must be deleted even on failure") + } + + func testEmptySessionRootPrunedAfterLastItemCleanup() async throws { + let fm = FileManager.default + let sessionRoot = fm.temporaryDirectory.appendingPathComponent("PromiseDrop-\(UUID().uuidString)", isDirectory: true) + let stagingDir = sessionRoot.appendingPathComponent("item-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertFalse( + fm.fileExists(atPath: sessionRoot.path), + "an empty PromiseDrop- session root must be pruned once its last item dir is cleaned" + ) + } + + func testNonPromiseParentDirectoryIsNotPruned() async throws { + let fm = FileManager.default + let parent = fm.temporaryDirectory.appendingPathComponent("user-folder-\(UUID().uuidString)", isDirectory: true) + let stagingDir = parent.appendingPathComponent("item-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: stagingDir, withIntermediateDirectories: true) + let audioURL = stagingDir.appendingPathComponent("memo.m4a") + try Data([0x00]).write(to: audioURL) + defer { try? fm.removeItem(at: parent) } + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL, stagingDir: stagingDir)]) + await coordinator.waitUntilIdle() + + XCTAssertTrue( + fm.fileExists(atPath: parent.path), + "only PromiseDrop- session roots may be pruned; other parent dirs must be left alone" + ) + } + + func testNonStagedFileIsNeverDeleted() async throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("user-files-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let audioURL = dir.appendingPathComponent("user-owned.m4a") + try Data([0x00]).write(to: audioURL) + defer { try? fm.removeItem(at: dir) } + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: audioURL)]) // no stagingDir: user's own file + await coordinator.waitUntilIdle() + + XCTAssertTrue(fm.fileExists(atPath: audioURL.path), "files without a stagingDir belong to the user and must never be deleted") + } + + // MARK: - Enqueue while running + + func testEnqueueWhileRunningAppendsToSameBatch() async { + let firstStarted = AsyncGate() + let releaseFirst = AsyncGate() + var order: [String] = [] + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + if url.lastPathComponent == "first.m4a" { + firstStarted.open() + await releaseFirst.wait() + } + order.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "first.m4a"))]) + await firstStarted.wait() + coordinator.enqueue([.init(url: self.tempAudioURL(name: "second.m4a"))]) + releaseFirst.open() + await coordinator.waitUntilIdle() + + XCTAssertEqual(order, ["first.m4a", "second.m4a"]) + XCTAssertEqual(coordinator.items.count, 2) + XCTAssertFalse(coordinator.isRunning) + } + + // MARK: - Batch lifecycle hooks (dictation arbitration) + + func testBatchStartAndEndHooksWrapTheWholeRun() async { + var events: [String] = [] + let coordinator = BatchTranscriptionCoordinator( + transcribe: { url in + events.append("transcribe \(url.lastPathComponent)") + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }, + onBatchStart: { events.append("start") }, + onBatchEnd: { events.append("end") } + ) + + coordinator.enqueue([ + .init(url: self.tempAudioURL(name: "a.m4a")), + .init(url: self.tempAudioURL(name: "b.m4a")), + ]) + await coordinator.waitUntilIdle() + + XCTAssertEqual( + events, + ["start", "transcribe a.m4a", "transcribe b.m4a", "end"], + "start/end hooks must fire exactly once around the whole batch, not per item" + ) + } + + func testBatchEndHookFiresOnCancel() async { + var endCount = 0 + let started = AsyncGate() + let coordinator = BatchTranscriptionCoordinator( + transcribe: { url in + started.open() + try await Task.sleep(nanoseconds: 10_000_000_000) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }, + onBatchEnd: { endCount += 1 } + ) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "a.m4a"))]) + await started.wait() + coordinator.cancel() + await coordinator.waitUntilIdle() + + XCTAssertEqual(endCount, 1, "the end hook must fire even when the batch is cancelled, or dictation stays blocked forever") + } + + // MARK: - 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..529d558a --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -0,0 +1,171 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Contract tests for the testable core of the promise-aware drop path (issue #219). +/// The AppKit drop view itself needs a live drag session; these tests pin down the +/// pure logic it delegates to: strategy selection from pasteboard types, and +/// per-item staging directories that cannot collide. +final class PromiseDropSupportTests: XCTestCase { + // MARK: - Strategy selection + + func testConcreteFileURLsWinOverPromises() { + let types = ["public.file-url", "com.apple.NSFilePromiseItemMetaData", "com.apple.pasteboard.promised-file-content-type"] + XCTAssertEqual(PromiseDropSupport.strategy(forPasteboardTypes: types), .concreteFileURLs) + } + + func testPromiseTypesSelectFilePromiseStrategy() { + // Exactly what Voice Memos puts on the pasteboard (verified live 2026-07-25). + let voiceMemosTypes = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-name", + "com.apple.pasteboard.promised-suggested-file-name", + "com.apple.pasteboard.promised-file-content-type", + "Apple files promise pasteboard type", + "com.apple.pasteboard.NSFilePromiseID", + "com.apple.m4a-audio", + "com.apple.uikit.private.drag-item", + "NSPromiseContentsPboardType", + "com.apple.pasteboard.promised-file-url", + ] + XCTAssertEqual(PromiseDropSupport.strategy(forPasteboardTypes: voiceMemosTypes), .filePromise) + } + + func testUnrelatedTypesSelectNoStrategy() { + XCTAssertNil(PromiseDropSupport.strategy(forPasteboardTypes: ["public.utf8-plain-text", "public.rtf"])) + XCTAssertNil(PromiseDropSupport.strategy(forPasteboardTypes: [])) + } + + func testNonAudioVisualPromiseIsRejected() { + // A Photos-style image promise must not be accepted: staging a JPEG only + // to fail it later is worse than refusing the drag affordance up front. + let photosLikeTypes = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-content-type", + "public.jpeg", + ] + XCTAssertNil(PromiseDropSupport.strategy(forPasteboardTypes: photosLikeTypes)) + } + + func testMoviePromiseIsAccepted() { + let movieTypes = [ + "com.apple.NSFilePromiseItemMetaData", + "com.apple.pasteboard.promised-file-content-type", + "com.apple.quicktime-movie", + ] + XCTAssertEqual(PromiseDropSupport.strategy(forPasteboardTypes: movieTypes), .filePromise) + } + + // MARK: - Delivery selection + + func testTwoDistinctSameNamedModernFilesAreBothDelivered() { + // Voice Memos are commonly all titled "New Recording": two different + // memos in one drag stage into two receiver dirs with the same file + // name. Both are real files — neither may be collapsed as a duplicate. + let memoA = URL(fileURLWithPath: "/tmp/item-A/New Recording.m4a") + let memoB = URL(fileURLWithPath: "/tmp/item-B/New Recording.m4a") + let delivered = PromiseDropSupport.selectDelivery(modern: [memoA, memoB], legacy: [], data: []) + XCTAssertEqual(delivered, [memoA, memoB]) + } + + func testFallbackCopiesOfDeliveredNamesAreSuppressed() { + let modern = URL(fileURLWithPath: "/tmp/item-A/Lick Mill Blvd.m4a") + let legacyDupe = URL(fileURLWithPath: "/tmp/item-L/Lick Mill Blvd.m4a") + let dataDupe = URL(fileURLWithPath: "/tmp/item-D/Lick Mill Blvd.m4a") + let delivered = PromiseDropSupport.selectDelivery(modern: [modern], legacy: [legacyDupe], data: [dataDupe]) + XCTAssertEqual(delivered, [modern], "legacy/data copies of an already-delivered name are the same dragged item") + } + + func testFallbackOnlyDeliveryUsesLegacyThenData() { + let legacyFile = URL(fileURLWithPath: "/tmp/item-L/Memo.m4a") + let dataDupe = URL(fileURLWithPath: "/tmp/item-D/Memo.m4a") + let dataOnly = URL(fileURLWithPath: "/tmp/item-D/Other.m4a") + let delivered = PromiseDropSupport.selectDelivery(modern: [], legacy: [legacyFile], data: [dataDupe, dataOnly]) + XCTAssertEqual(delivered, [legacyFile, dataOnly]) + } + + // MARK: - Staging directories + + func testEachItemGetsAUniqueStagingDirectory() throws { + let session = try PromiseDropSupport.StagingSession() + defer { session.removeAll() } + + let dirA = try session.makeItemDirectory() + let dirB = try session.makeItemDirectory() + + XCTAssertNotEqual(dirA, dirB, "two promised files named identically must never share a staging dir") + XCTAssertTrue(FileManager.default.fileExists(atPath: dirA.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: dirB.path)) + + // Identically-named files in the two dirs must coexist. + try Data([0x01]).write(to: dirA.appendingPathComponent("New Recording.m4a")) + try Data([0x02]).write(to: dirB.appendingPathComponent("New Recording.m4a")) + XCTAssertEqual(try Data(contentsOf: dirA.appendingPathComponent("New Recording.m4a")), Data([0x01])) + XCTAssertEqual(try Data(contentsOf: dirB.appendingPathComponent("New Recording.m4a")), Data([0x02])) + } + + func testStagingDirectoriesLiveUnderTemporaryDirectory() throws { + let session = try PromiseDropSupport.StagingSession() + defer { session.removeAll() } + + let dir = try session.makeItemDirectory() + let tempPath = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().path + XCTAssertTrue( + dir.resolvingSymlinksInPath().path.hasPrefix(tempPath), + "staging must be under the user temp dir, not next to user files" + ) + } + + func testRemoveAllDeletesEverything() throws { + let session = try PromiseDropSupport.StagingSession() + let dirA = try session.makeItemDirectory() + let dirB = try session.makeItemDirectory() + try Data([0x01]).write(to: dirA.appendingPathComponent("a.m4a")) + + session.removeAll() + + XCTAssertFalse(FileManager.default.fileExists(atPath: dirA.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: dirB.path)) + } + + // MARK: - Sweep selection + + func testSweepNeverIncludesADeliveredFilesDirectoryDespiteTrailingSlashDifferences() { + // Regression: dirs built with appendingPathComponent (no trailing slash) + // vs. deletingLastPathComponent() of a delivered file (trailing slash) + // compare unequal as URLs — the sweep once deleted a delivered file. + let base = URL(fileURLWithPath: "/tmp/PromiseDrop-test") + let dataDir = base.appendingPathComponent("item-data") + let legacyDir = base.appendingPathComponent("item-legacy") + let deliveredFile = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-data/", isDirectory: true) + .appendingPathComponent("Memo.m4a") + + let sweep = PromiseDropSupport.sweepableDirs(allDirs: [dataDir, legacyDir], deliveredFiles: [deliveredFile]) + + XCTAssertEqual(sweep, [legacyDir], "the delivered file's dir must never be swept") + } + + func testSweepRemovesAllDirsWhenNothingDelivered() { + let dirA = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a") + let dirB = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-b") + XCTAssertEqual(PromiseDropSupport.sweepableDirs(allDirs: [dirA, dirB], deliveredFiles: []), [dirA, dirB]) + } + + // MARK: - 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) + } +} From 30a04beead7166165a67b59b45c4cd2186d6bb7f Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:31:42 -0700 Subject: [PATCH 02/17] fix: deliver every item of a multi-memo drag; add batch UI screenshot --- Sources/Fluid/UI/PromiseAwareDropView.swift | 63 ++++++++++++++------ assets/file_transcription_batch.png | Bin 0 -> 307601 bytes 2 files changed, 44 insertions(+), 19 deletions(-) create mode 100644 assets/file_transcription_batch.png diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index 16eec755..8b32914b 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -220,25 +220,46 @@ struct PromiseAwareDropView: NSViewRepresentable { ) // Raw-data fallback: some providers (verified for Voice Memos) - // also put the complete file bytes on the pasteboard under the - // promised content type. Memory-heavy for long recordings, so - // it is the last resort in the delivery priority order. - if let dataDir, let typeID = promisedTypeID { - let fileName = suggestedName ?? "Dropped Audio" - guard let data = pasteboard.data(forType: NSPasteboard.PasteboardType(typeID)), - !data.isEmpty else { return } - let target = dataDir.appendingPathComponent(fileName) - do { - try data.write(to: target) - DebugLogger.shared.debug( - "Raw-data fallback wrote \(data.count) bytes to \(target.lastPathComponent)", - source: "PromiseAwareDropView" - ) - } catch { - DebugLogger.shared.debug( - "Raw-data fallback write failed: \(error.localizedDescription)", - source: "PromiseAwareDropView" - ) + // also put each item's complete file bytes on the pasteboard + // under the promised content type. Read PER ITEM — the + // pasteboard-level data(forType:) only returns the first item, + // which would silently drop the rest of a multi-memo drag. + // Memory-heavy for long recordings, so it is the last resort in + // the delivery priority order. Identically-named items get + // disambiguated file names within the shared data dir. + if let dataDir { + var usedNames = Set() + for (index, item) in (pasteboard.pasteboardItems ?? []).enumerated() { + 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 itemName = item.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") + ) ?? suggestedName ?? "Dropped Audio \(index + 1)" + var fileName = itemName + var counter = 2 + while usedNames.contains(fileName) { + let base = (itemName as NSString).deletingPathExtension + let ext = (itemName as NSString).pathExtension + fileName = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)" + counter += 1 + } + usedNames.insert(fileName) + let target = dataDir.appendingPathComponent(fileName) + do { + try data.write(to: target) + DebugLogger.shared.debug( + "Raw-data fallback wrote \(data.count) bytes to \(target.lastPathComponent)", + source: "PromiseAwareDropView" + ) + } catch { + DebugLogger.shared.debug( + "Raw-data fallback write failed: \(error.localizedDescription)", + source: "PromiseAwareDropView" + ) + } } } @@ -410,7 +431,11 @@ struct PromiseAwareDropView: NSViewRepresentable { // Modern produced nothing past the grace period but a fallback // landed and its sizes are stable — use the fallback copies. let modernProducedNothing = readyModernDirs.isEmpty && elapsed > modernGrace + // The worker must be done (no in-flight token) so a multi-item + // raw-data sweep that is still writing files 3..5 isn't + // delivered after only the first items stabilized. let fallbackStable = !fallbackSizes.isEmpty && fallbackSizes == fallbackPrevSizes + && !state.hasInFlightWork if modernProducedNothing && fallbackStable { self.deliver(modern: [], legacy: legacyFiles, data: dataFiles, context: makeContext()) return diff --git a/assets/file_transcription_batch.png b/assets/file_transcription_batch.png new file mode 100644 index 0000000000000000000000000000000000000000..16bb5720968128fb7330735127d289d46a47f60b GIT binary patch literal 307601 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˧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

U zGf5dcJ2%<0D5&mV-_}HPHtV`q0=6r5Wo=B;sGA)iDEac~DIP?=3$H{Zk@RR%4d5Vp zg-`-j;Y?IImahH2LB_0+U+{KCB4QZIi_CXL7Rk&+XFn5o9KN$UeVt%Y4M}nL)fi6U zT1?9^5}6nLSe#SoZkgqN=!}(q$M5#@zs)b%uY9@675+42du!0 z#}wGjoRn-`kE-lpE!md2ZkmsWax3bNL`#kf2i(f!HFV1@g1A>a$Xs+Rb5M`kIsBns(YHniZy$8Q&{T?gw{6 z!br*!G3-hd-PU#iB(qWb6;JTDgr5N{hLYWtoXhcG{R~DUo~IeAy&7WU>>Y3^C;ST5 zX4-LHhKB9aMg?C?ZF@2G%B1N*-Fk;4Zx+PXRWH+tIk;vBDkNvdl)Bkw!^X^~j0Kd{ z)B*a~WX~qN!ZUL9RpavF-P}1x{KRPsN_l(bP*Xumw!uX8#;CAs^t@3_0z)ZQ8W_rg z?c-@8qq_-bzSzO;MYeJBqBg@R(C==~F+P@HwB`$r$MLN%dTuMdcO#DzHvhWxaNk%i z-*EBoBj~qDs~sniP4?Mw5fR#R*MF|#RrkJXu3#<%9>pT!w^>us&h)XIh|%$C&YQR^ zhSP=5dDDqY)tx8k66E)h>j6HqC7U*SF(09o#UsjfR4E)=|54;9JB`}xp4*X9)p-KT zG1&sCYG!66@!3U&vxsdmR4MbK)5rMcUYHxrG3<(Mdj!= zC&9;7tPj)1-D;Gsgez&|3wM~Z-_uA8I=^M&oh)d(gRM%mZM*`ZdqZ4eE1vyET7k#S zXi65gO87N+GJTf&o`=x}Pf=%58oNUDnNOtLmJN@qKh#@qogA}q05{`*OwVTRMf~m^ z7PrbC-n669;V@Myv{@BV0ev%0gTY%|%9XGf6qxc(;upJ2KSB6?76HWZ=g*|F)q9lu zM$eW>pJN+$@XxW0+z{cvNxv<8SIv0*o|~Eoq`%0Px9l~65KA_zRuT{hvt{-T4OOs7 zdSJc6^0QNVW1ck!u|tsTy)vmc_7N5IN>VkPKLb_E{%SPK;Ji{|<2@amR&VQ#@3*ED zJGkh~Y^jF7+|w^-cJ-h49>J)S_8;n9JudOqKukNO@~uC;e6n@%$yey8q&G26>F9s9 zE}0_-46c`?JD)&Pk1IzD|B_VRl{;bW@)?pxQush)Rca%Xl(TT&i-DP%r32K|6km$6 z_y5U(g59>G(?nK#PFa%b)Eu<7#l!?S3BEbwOAS^oyjTV2)f z*qLYOsJfPz(yBrkH%&t4at9uiI^5BGP5@j8oUvG z(za7^&!>uB9CyH_F2O%486mE}w1jCY15D(AmL84$seUYXZZTh#)7{^5n{2g*-0B86 ze?Q8qG3p;yM>+ID6JuZHOB(Ks?Jh>(f0ZY{r$RKVx^thcN|M@;!aW_e7kMWDbmQ0% z@<_(ns$g)_kw{{j`p*3Te@ZK;AvHZ}#zL%VN}w3YV_x-pWCvHn_c*Ze7lV68>JP)F z1-#GLcsopU%D{@ltuoQs<7cZ}Q-$Rj=n7lt+NsUQhyB6Hi{lpgX32G?0OBuM?UY@Z zv%kC1&GiUTe~+~Bz)YTiv!E681mfAvAzSlURO4z z24?2tb&J?~dMD8#)G3tsqUtEwi^JeAVi4BxAy7jeTEt@6dS2 zfjZM8?5*!+7Zi_dVV;hz2V6+Ya}(Z>w)7}fSHqQ~Ch@fZ(;L9vgo*NHdLku&n2F)Z zh+xTTPrDmuC3+^@L@;u<5a?%jh8diwfAIv6j1Q%i(Ri4s4^qW0-kXX9recGv z=)!pM62{T9_DD4Q!&3K@J$s7F(2D)g As4t0{a5n`a+jdLDl`iE@R9>a;c337Mj zEj_we;+YX;nb5g!oVGmopJB0n*o8v-*3aQAaWj+Izj)4{k{*@xZ31MhyQwfEWv%C3 zR>)AW)x=qV=~c(%D0NR|26~BSm*59(n`b@Y&E=#bA5HbQw^?=E|I%7+5tWK@?C{iO zUIHXj8lH>|^YPR*zVm3*lZhRbn50xp(__c7?C}#{bmuO_g*$G(%`mzikvL<0QkdDj zet6asn|~ZT&|JrO<>bbBb1y79-4aSVwRi!~a?h6={xrWDemvUr$FlUL@C*7KUrIEm z<>Aj%fr$=QOlZQdS@NB0or>>_3QEgT5mQS4>Y+L5q{lH`;d%#@%my&n@JKSj)3Ia`!!4n+TjIl@D2bV(f}MiL9$j$=Qm%C2a_uEyi)<|+ll8Cd2CMpytk@; zdA_H;t){(|IrdY?xGlB&DKYuI!}ds9pO$mk^Kq^Om`15Mm`_8WSQR3Fa*hF*p-<1w zk+p-MafBRjiqk`*YBu)a>K=5q8scLXD{rL0%i!t|CX2d&!NBh z`gq3s+aYMnQZ-#GB{hNEheKI@N7n*z04s688$+12=`Wk|fL2r)({5P< z`RfM&Z++!|l}=ji1BxFYokmGx1z-nnp|CO8SHKH^E#JmBCI5d}irZuNid)wj5gF@7pO&T9vUlnh+s#ae3XPXohg)`e<)PgS|C}?i zSk+(pdxGX}Hql*R1*mHJKS#!5;)Lb;t|pbaHFqImYtN7tmFu<>r9QMGcoZ}QcvHzAk13QJ?vr#2Eoom9lrt-|S-ooOtR63eD%)meFwk|n%Z7(q zhoq5oiS*3bt%~gg_M|*V>G3*`KW;%vZY+u(P#Wdk>7MgIz!fXit*NTM5F^AcXUQ11 zst(%T0j_akSctA9WZWdjh&Oj4$G1))9B>-n;q)b%S2z-%_o89BD{jQvUNvx-lHPb9 zMlApz7nirTxyMHJn7Nq*h9~Zw4dD(9z@souLe_xE1ztdOgLk)ifX^{?1i>|!cOH46 za9#M7pC@_1QXL4L!rfqo$la++^@n5nKJl)$+8eYEeZ7F=l*Ph)q%uHPoCYI!GXr$x zLx__(+co0Ydie?~Pv{P6s(>Ks`XHKqIZf2qu#N6aL8;9-o-XfVb4G|BIH8!!Sq&$xwa6^Owu2w*GjO*49OYCV_A) znRdcD5sIkd3K#b{;$TDI7`jKNtobpUU!UxvTae_@4yGPOSiF*8VPq_4D-b7d8|xQn zrPnRD&mpKdiIo#u-yRyJk{PyrG^Z)S7;*5-zWAV;f)UqSa{YOI*mD%_z-AMOi;s;rWe)RW7S5N783E*AwIKfFQ&g? zgsZM;eDYcqG3DULec^1A{hIpVdBfcubyQ|xf2t6NM2 zX=3-Bd@2OoR=4)<6!P1nKTF4O>Aw!uGkLKy)7D=Ii=%T6g`JY@9(H$6QXULwsckWF zT3`kAKf07sAiWNcQ!GNn%lEB6*Bx2HvwjV7j7x6@`6*Bx{|(aOKU>HjSPZ($Yg{5) z6Y(%LMu)34YfF{|p+o4O0W0kC-(+^kXwM@EVR13^hfl-0b&*A{n!M~wLGF3(UVDpA zRfVRUWj5bo+%+%<4v^8Yi{T0g$LF_O)9U*2Hi7ffGU%2!PzAo~Rk*QWJBQqp=iR*3 zl*M%Nk6?`i=bJ(DEsx{!BkyFnAB22ZU$VU?F%ogZ+Ew(*2^LLh@m>t0GBpF93y(q6 zs>-25o5KCkGGcR$Lc>_7GaS?5oFz~)|JqHP7G=F{pQo;fL4XBpIuWQDxf7yen9H=v zt-<=Xjv{}EqvjodGzO}uh?q;mJ!URdA936$7e$QP+D2e#WL8H?ECBK<_RmA3LOi}roQ-3MXP?d@0!euxzJgD@6GQ>PCyfnu zn^LQYGr%{$Xkv${Ud1?V+F7(!1H<;lwE%(kghVFK~P@wXC2x7=>byn>pq% zzYzdTr^spIZq|PfQ(pv;wL(^`BFHtxgM^8aN~N*xcyVN?td`4#4nWU>6HbE zD-1C><)#jYy8qO+W05fIB|Zh_%7%*}K&96*`-ral*H> z()7jw8Wqj`XWgyk#UDqaB$#W*i^J_c^?coNiUVi;00451ynLwkU6u(eO?7a6ytl`3x&A?2hl8!V3P_UjV& zJy=k|nOCyod9&ZnB9ffetC!Dce=xr@u;^v0z703tRBM6qhX zhvI6b1f>JMBE>(3$t`e(XHryN$h@}f<-mQ+Rhh?ohP2Ya{-OOYQ~^e<=o$HUrWJEU z9Zb{G!sHE;%OV+`$x3k}%)!6PLO0Havj-~dg5=!gZsq3Kx7n+9xLNF?el504dowxZ ziPMNN(_Nm3ahte4;-8rwwa*u>fTOX?^_w#^Gf1?0e!$cR;#k!XVmdx_sqZwbImt13 zp|bOCWs8t|Vm-IZQ6}5Ed1G-T26hre(WkgM-ar7axAoOGI;G$nUf*7P*Lc6KtPqmC z&;Tq-t*lS`<2a7EzNmETCuSYb?$~zXf0V-1iiUCGKno$tL-2psJ&B@cY9nIsCWDkE z*~)s&l6GIh30lBxIJo5&i6KN33IpV^7b}y+d2^q}ql$!?jKNiQQt?V{zNDQDnag&3XUXIltm1Djt)lwl?8Mn91PEG5O6ion!38~}D zM$)F8b`U&7Wl{7T(p+Ii%sa4HGOYqO5XVDg^-jx97=9#>I2W0mhQe~jLELGkovR4& zc>&>&@i*#3gIPt|JP%wWX;Jg2!iW;*3gb2Oe5Db^{UfCRZ+QTMt1IDc6v}T zQj9*$eojI34y&cFdoO}KB|V0#m!J}iL>RneQ_y-)$+4O#KuIpg#x^{kk1*Dpjjr2T z2T&)TK*WVDA<+B2k6Z*VOkl9iY!vx1~@Hux7x_zi*q1 zw0Y)*(=!OE+8ku%ri&PPPZzbm@rnNZkH{w7OvQgwA0orr5b8Q&%pYBSf?XbQ z!bM}W3w5Ah_rrBV(z!Opi{i5MJ^;4m$(XK$RcT+{n@VPhZG7>sy)2T@!(Z|Ou$P_N zum1j-9F)QXx`emm|$CuTS5+O5o*4!Ru%#0#DC^O6y+Qcpf`f13HZ5dHVQLcAe zg7p~#Wn}9)o&)+8{+E1AuB4jO)?{Vew0Wm&Hklr3coj9%hQSUv)fH4oyv?l)lTWA{GBy7g-x6jSChvgxgLkM-x*Lb9@|BH7I#c?=fRj&MWJSt z&%#}x`XzP-7Wx4QEX=TqrjKz#-QV<9i&Zr9n#nt9eHC0U(0&4tiv)%(99YQ%ksGC} zkKon#D?y75R}obqjlgY+_xU5_pgqT&bxQnk?YakTc>~D9oJ}bBtrtJV2d_hPLkpc- zOZpLr3yrec z0_6;YELNbhh>d$b+pv@X+J0Y^bM%FFpPL^kL3 zn-Eg*K~Bf+HswpE`jrIropsi)mSWS;{teGB3IPEG4$#&6lVBQe2pmin)d zQIh(3CqrzZIuNikVcRdUacqbLoLLc)W!SoO$u>r6U*A$%?iu?QVX;wdWq~av!c+am zJshK-1&bRGr#&gC?gAX-br}6wlWdF&;d`j~Sb_|fAy@GceaQt|V(aR} zNOh{wTEhBc%j8(L9lbl%k6S@n`8e^}p;qnS{>afu+cprG_5@_CmTvy_PD_QMVTtv8 z$PL!43g&twI`0J1>;0f(?ASnmxF|0scWM^?2Wxs@^nk- zNap=+3Nqma6UQC5?4i$)(|YQ`RH^Z}TOb1e+@@nR;jP%tKjZs2l8~hue#vs>&_uak z3u3giN{=afQ8@QabfCP0zXwRKbJw3G1l_N$Hq`LvHK?-bvCt@m3dCyA0PZ<4aaex5 zP3B#2csw-9NTT**yYiLsrdSDrVP`Aigvv@@Uzu0_)>% zf6PHNz|RtDi|XVXQK??@@094fb&bM$|CPpxY&7}IKKj*hdiP!1ga)}dgl2yWxEljK ze>b`W=A`H~;Z6xc$d2f!z(m2OpUeE_oLF2KHN1Xm*?vj&IriB$PfvGQ-AC#ttUuQb z{O5rD)@9}e;M;Toui$w9xx~<#vQszv%C4okZCkrnh)UVkhFH5r>UBSLOoHS|SK!sN zD;L~^imG!2;iIZy?V=oJM{)js1$uNj@FdgSrvU~fQW;F8hxWUSP zEe{+V6>+rI+uL)ncd?v2#!iaEoGfoMe8*nn@FuLZlVMvRu*VJSf2JjMYA*#Y5J6T| zkNvA@9`NcKzeJpKCo3r0WXn>2)<^Dcy$4xX$QkFeJe^$y2qjwuIyOTVw8_?rwC+WOYW0r9VW)f@Y?eyi&o1@3hRj}J}GlV^xEPi$v zN}QKCZXA_E!y+i+$8$GF&83={BzxUhij-4B`JS6y6ahk7G)Ju~pHmIJM}gM-O|)C( zZHzJD=x^7V0(dq)fW!DZse(%O>C?zE(IZN=N3b-{$O65oK3CGWVcNpbLL^=9%o2sM6k^9){ z-co|kQhde|Vo47TwQj-i1-0lRVVZ-j0g}?b7UC6CA!fo8h~n4Ca(Wf}v?j$25-0QS zwTp9R8=o#Bela3m3A*Ej6z*IMPm_hlT0d@%pzQ)ns?}Z{K`%LpQbrCP+=gV!|M2RF zCWk{X)%4SL_56CFKY5d6mXSmBa*P+H8>gwwx2Z7z5iR+lJ*u2=zDqh9=WTx83)9sM z1S1}mWT~G$LT=icFm)@yyBO|0z2AuVF|;Xt^gKlTuud{rw_^+@%&K=_bEjWrFZ%VZ zk4w);!7lRh?)jpyltkZP>X8R=Mb#P8Xi|u)>%UuASU4QG8G1eoRUeWdAlpk z$*AM^X$zinZHd7)JH#C`%r%_5g#|g>qCxP`O`1_<(ArfIKW76qR+^jb&Nch zpC?3zCO&YMvFFLRJpEueF~Ej$bOZus{;+xa^>bAs^WBtZSpVIboVIBDZ@{NC^306h z%j9xsV>xYBU+;Tbu7QR|j-Nk`?UMwBW!bL=hMy@Oa}S*LE*?6>U?Q{M_F?eiW@*WZTv62X*sa+9ko|bIU`Vlb(jPY1|~P?MzPn^~dAlK7~Q6YddTR(CKedE~Bx~($PUYA!!G+raA#{ z8{TQmVQBkX4HL77mcSK$C3|*@EljW1 z*5?xi$;~7bU4OTr+c|20#8Xx2?T%2uVSdLF`89OK9`GLK85r7*s*CUs(j#) z@wp)zC(5Y0?HW+XF_RE1$CH8=U$~Zs(&+ z-1J|1y&!AZ@`IW4btSObzY*GVrOB7rjnH=7-*$ytyh<_;8T#3p`cZ*y%O(Fdo-I+b zNhnM+jUUUtG6NuW!{hf8y^8RkF~mVk7P(2UlKxE}uM1}nG&J3KcZ|dzO`$R)o-1PnVz$Pi=Z%iGD-7Q(J!*d>82M!hUD;2k^ZmfPe|2@j>6gJE=z*h*bQTOO z#;>WmpSEkCA;n0VONEWj-yk)y`ASx)uj#qqTQu~Wva-UE1b9;82=Mg*Ocy~0LS;sD z?<;-8ab<_f_FOu!tC&{YQJw`-Sj^l2Q^k81Bc7CCj$*U2akNuuuqIqTCpvF>hDe0H zI+OR$K=XfzL;oXQ`3L<_WLvAX*4UU0i_cv>QexTR`pHt@pk&*|L9h@RO<07y1^bYc zLVF|Mao%*mR2`Awzw_GfHECOSPsSI9ytC0nzxq6Y4NG<5;5Iqs9=GDTPRpqndW5FE zj;0hEkd*pPDC71<&;DXOFBVDkmBq#FD=4k(r9v(OC{`2iY0oMmd^^0CSQ#?>p$53Qf00x8SOa|;qk|gmO*afJeJ2nya5IBO6|9Vq|4j3MlV6Ne42jODG`^S`YfL$!<~HqiRCi+TcESrxtVdu&0hEqrOhf$+1d1BE*<+{}y)CLo%^9 zq@8RT!pen)L+mi}N|TrUF^`9K>GjzfZ0f@>P$GmCM07Wt@VTz6RBr>Mf>5V_KFK)r z*qgU|sRkwf9b)ozH`zS&to61(@Dy0L(HHrsSy;!w1uDUu?D619YHskWycdtl<;Qx$ zFsA3dt@J;=!*Q1NN#nYs`7Q2mtVW1;IPz_weM6H^-uzX}A@%&B0rzo&2rTWI=N}FY zDVJjQfOjG_lB+CJ?*egUY3wlj&};P>%rN_Yv-+0eWeeVLj0<7=J1R~;atAJtp$#`2 z1Cv8eFpjVp@-l*6?i>|(J`k!!J3SFdt7MNuFL}kAMNaV@wkBCAR_+q8m^^+mYf9e_ zRy~8$?dxNypJlSeoaUSvNt8d4C)q&7>8FCi+);DTOheD*FPvpXg5~Bis_kK58 z2lw1^fdV8dfbQzI+su&jvuHzx#o7ZR$MVTgBEq!XQfYjwGV3d(infjndM@*R7a}n8 zJE|94JphjuB*4OvW<9AYOcKZhh5;^bMKH#s7mdR+Qz37B`0HJive8W7rU9y!4av3&!+at`I~{mpA^@Tk84BS;tf3 zSKhap>Uf?XOa{31tuY~3oCea^EL z?Cwji2h$W+B}+*hNKfN)j_s-aJ7VxsXn{TRQ+H*Gnt{6&!@DYlK@Kf)G9L3bdYX9JQ7SY>>$Jjx;SACH{I7dFlG4te!Ai>)_yZ{v}McY32DZDfINVEwPi*8;k zX%&2XEIW;W17%4P>ZUk@eb1uGj}%pN_dvtX+(UvDC;k21=0q>B(2#_CmR(W{DM>a@ z--dyNq{-|B=yq@wJapI6tGMnL&!zbQGoX(QmUhx}iN2c$`dW7&z_x<*$z0yEiEo;` zcaJ=C%R|g^)X~fV_sT93Ila_373%rK&PzUkb;!dgeJ?8|t*%Gg)Xx=CL?zAACE$+K zUDsx()3YcijMML=Rz`-~q{$uCtN`80mCaKzWZV(7m(k)S!zq{#WMxOhH?hK_&fn4F zQOJJ9Wge2hwVIQhpRNduwl@?e9A&UJ@gf?{U{u2SNEqngLuKn*cra z%sl*6*lbdA4u}sW1rM$BYMt#;>b1cMlm|72PZ>o_*wt%1VS7F71ubKR@+R*Ns#D7;g zgcK#oC(#&t^Qi6$!CqHc!>1;=uQ3em>7s4em;CNalw#Wlx-|T;@s#WEFy)NpTLz)R zoBp1M$|-wp)ZW4mZ`XBIwhwbINiIn}OZQlCn?>xiWtG30XU)w~8rvNTSGx`b$#4NM zJuU*wpiWN9UHY(CqQ#%I3dVqWXB~nl!GGqdW3s^{vVq7)W~pN&&T?^*aZbQ;K`dlT z0br&iu?RY>KIB6Di-|rdHm~E@G9%N4MMknk@>(Z{u%yOBs+Q;O zaVAC~Ig%JwlZCcAc^n9a|Jj6sbExK~*x0v@Z0No4Gkk|6F)y zmrGk^fZT@&vD`xlMyCb^pgH~Zrw0QH}uF55h5Gk$P>{X(MzPl8@)GA+)6f;T%@ZWTo>PL z^SLIx=d=0Y>)aL*L-EV42C9{O6eA;cbA4%g+`#1SBJC3pfNgzL^YYQu?Z+i{Od z^320S=)C(SNqN#_PVPXFI+n0I0EZh`1WEDR_khIVcj+-kOc=E^o&+s#Ev81y zf(1Av@L&*q$PzgyYnS)ASDu44a$MLpm7<^1MXm;CL%sq&qQrm1r}AV4z(J!-V7MhWoVd zB)K@<8S>${^g>zfR?5@;!tkb*D6%z4(^vPrPd?P7Pd|9JN`e7Xu4m{}>1$-TAE$$H zE`^U8j@FHnkIu>#w#CzS@^}e`DT&aEP(shdRqVwEaK- zWYB3UsZ72s*eTM2B$bz?oy=o}GJ`&r0WNnb_ECugx@Nm1NxSMC6H_<%WuKqrCGxK3 z@o{3CK%2f@@;*y&?Rp*#h$Mv+nqLN-gW8`Tf~A6hLF$Z#yCkJvRsTUWl6PCNZTNK~ zhjESoiU&zBi~W#d<7SPk?7URycUDXIag#sSekd@uSI9OHtYQt(bm1K2xYgFx& zjmkp_yyy?RTfkCKcmrGt>Q^z#U;imKqYI#Z&^$#9u;xCla>up#EXQ4Bu1l1k?DPY9 z!nnY#C0-G=efv>)pwQxO-HmqILd9O69h05CgDfG+qFh`Rg%O$7u0(};yzR{9hbZAc z0ZdBOY8KYTJagio@!h%AEUXKfyS%m`uUSLfpdj8)gwTd#8O!^oSe;?;=!&qF%Y7;R zxG=&V_HmEn{kR67#R~~DZ=#f!@f)f`{e#n_`Ns+8~e$6eEy0LHB;li>qbe@G;HwKW1 zHA?s7EYr9ru<8lt!i=>q+sy15DIu4*Uu}85&>DxyxZ@^jt~h>ON4>cZR;!XmRa)o^Nu75T_) z1?HHp9_Uv^i_d?{YK+2<(NCsjXR8W|AlKrG77rFR#q83)JnqnU!;g|A0?M@Cy{%Ky zz@V3}H-D6)=5y$)BRU@q{WKclKYI7sFGVAkm9>alwO^*ihcHO5nQHfgVm?V(P*Z;d zw-Y1%iE^GkdyKe@;~KzXK*Ee%9t}sLY zRmPHG->-&+`iXT-tPni-1W)iW7Q}@|{kPfRqMS7ztNIg09=g^WJ77E$#a2xuamjd{ zKBlf&NeZtVNn~t*3jvsxG4_6|PZN7#O^@`NZY6NH;PnO?VqrtO>1H|nfdqH12%D;7 zRui8^9rO+_pgPx_9SD5-Z2D%LEPu0O<9C~aDhq2kS+nAV{ARlP<#CH)xhW&~+~nh! zq6aP@jg4pB6Apq<)ZwC|%PRwCQ1FIxHBClsJ{%JB>sx{JUVD#+oIcO7{aL*^@| zG9xL<^;yCY!&*+uQFs6tZ*QHdJGlJVl3a6tPUS& zSr4iUz8;08K73i)3Ww(l7n0_qUUf)HMH)RRM!8pci3n{K#1_G$;76=u`gk)m$`Q|M zx>~K|+sC$$CN+4`jdpPZ+4Q_mnZ1w{!6=LB%BBY|}E~r!oR-=UYtpON?fny(0(V&+$afsT23`8C!1sc`*SpSyc zU|W`9iz;Zgw-=E}RMi3OPW+QrZ^vbxI*8OzFflFvYH(iTcQd9y+8!c2N^w>3)PW=; zDYz44Ux8#q*ifd~!huoZVE`a{&P!Mvauk^P2WI5lNd+r#Sc7MQ@eeVlIa7P35vX7y zptAPk(?`I(VCk-wud_%&EQp`?WckSi9&T0`UWB9BJtqptR8hraOH zuMo{DZr^w^^_MMdugJ0;Q#FH(wEA%|DCT5_cDIoE07l_wtaH~P0DOBYmF>w!cTDSm&o~_GzTm6g5(?S{N(b{F;t+icvq&Kp?h9y!mytGUD9)+8~?Y ztG9x5?breTihd5uzTtq77egD4aQuzeeQ@6ZZ)~F8vZs9UeZscc&=I-P_BTmO1-3A_ zf)}N+b8+d$3i!w~C9Ng8Y{UUsB5yRj9*AuK4$t(MVNr?CCErVvCr8rsDi(mV+61D#lfj}lhB z#o>grtAM|MI@Y?ng7E)sIkUHlNL@>VGJ7H`X_Dg%vyTXHl3DhuPh1oC=VkmSbKC&i zV<<;w=vD289D)VR`l3xEqMv7!fbdJ*5;(DRh&%uShW_rw|G~5VuuxOM4E+Zy<%_h0 z%B>lolBGE|xiJxAJ?HJ8jy8w13jyj(oj7>F{*E)$iTY+>TiWuSTK`-`CuAT|Xb~T+ z5B?oBTYTIKo2nl+>1(oNojb*fn5?Drin65SUiu?Ln9+ShUemjBW3%%M7h3GTB7w7wKdTBjAczQC0JlqIGg#zYS`x+v3JCgb^2fT!Oz%Old?ZSgr{ zNvS3lyT7D51rXgIvtYYNJ>S5r3O-M6SQ+bOkIh$mesxnev(be{e28wb9a~s)liWCV zdP|P6FulIK=m<_UOz{B93rXoLJ#9&KbdS5*@G#Mg#(|jo2n4TLmVoJXml+V0BCF z-LcdM?W-nZg{#4`szgk*iYi5`y_pIl4*UX?+N0DSt->hwyagEB_su9H+XRf~X0cRO z5fndY7XIZV5(GZ?rATLCO~_%7grRWGPv(ucdf_c!)=ynm<-uLajzgKQPJ4BGihf-4{;YKK{_7m$Ggd;cuQ=&S>}xvv2hGf~kF0O)G#I?Fq3+fWbZ$Ur(> z2UedtHc;-VK1Ytemp)dH_x0S!a{cJ++2>3g!B6M!@6SDt(9K<+$+;a4FROt6G@rs> z{T_@akg%E<0IR6crqhN)ZGLRc12h6UD-4#e2nV@1js2htk^@vrU2HyuxbYiEpG`HP z*V}y!+l<$l>E63}&*@zXu5vSlU|Nk;D&^x0aSY`di%*{?Jc*epN<}W}yJvMq&nvku zQJisS$%wFy7;o4(Y$#dX2IylYQX5oY_%kg{GDN?`!z|v<*jXUJ3M158L@Y0H z&j-!3pi(V37+-Q>*%+_lb)AyR^6Fq8^h#C^#t4^s8`-&*RFm0C*XYe30?l9Ah-}Kd zwZ|!y1PJj;NKUm{>#Rs2zK7%iT2Y4_YDBdN0A&29Tn|`fz$I~B(+)S70!>){CLgO-t)^z15by_cWNcG#BzK`miH{O|=|Mz$p|tuOG-ZWXMuMl1eKEy((CD z-Hw^afv4FM>vi!v%=QN5F{k6<@yhnFq&*Ojhx5vF5L8zz-D*R()IeAopb41MV9%bc zDhoRrt^p?+2m+x!7g_eeC>=OYh#3%qN(&NJU!qH&A^M6=Y>vqh?KJ(adGzGZQ*Mak z8nuB>JlE@*mk_}U4JC~MLgmU7X%&fY!Ob|JY#KXJCC&xgvhGxS0+Z#L!=Aqn3P?`e z&&g>9;E1p2&DIbBwv_bO(fOSqHY$a?ez$fF=4@v@Y$!E zVZ#G{S>B&{8n$u>7n{~wqnOMT0jLl;1qUk^OSZRD6?K(MPe7R|!uG|Hs4NFMw&dCb zI)_;3t_?yYn4zQoLAlBH$44#4Y%U3H)4=8 zs6ME^zML(ce71b0msbU1TPrdvT#-UpdhS7IkKH=d6Rep@3*7;4Du`;>1p6r5FD^nR z*>&z?N%jdBW!@|OUT{`vQBwh4`zEo2gEFcji>m6HVfWyC-SYJYITpHSoD= zk(HH`wvtyN8_?oz0T}B$)!l!3LZ3O$-$Xk}1hic3L~1;nS3j{Mee#>GTl)?bL7s#4 zY62M)Y_?BVXy9yD4~KP>x3w5lESnjJ2qUTD^UBY+qd&qNWZ#CR+dCm*TbG|nY#LP@T;O71ib6u8ZXm_F~cGm?P0WieF<1y*i&724rtAs z0ZFTc5t9K|-6@9>bnDVX48+JNK@Np{aXHZF2LKl(-6Ta_!_S+ZMJ_o0k4>-o$ELeI zqfu)VEro#>-5xCR;nQEQym=ZX6L<%dZ~XjEhwr5o3!aQVK4tzme`5^Oe<1h88R1G% zaBvS|VVXD)L&F3+hN@K*+?^n+=a`r#J44&`BfFP0YCE;~yNOTKFKp`!@6Ncim#j1A z#pfAUqUL)_9T3Nm`?eCh%pe{1!StZpY4fxB)9A^A?5F$H*k7H>GYF3A@^o8Y{9D;4 zzNdb3=*Qpgy6~U8XhDrvBGZ|(zTH>)14@OPp&iz%4zmR0*ZoMhTpzmhH+rL2A!cQH z4Nudo5K{)^=f=-Rr2QGbjB)gnq_9&@f@nW2e*wuVA`Z4|>DdW56reun>YNeRqqygN zo57z4h@ha#P8T1w&_op{x6gOlyt!Cu71F^Fe?hf`dhRjzs3y$dm9!PX*RxsXPUPuO zv2lFWWX)xc=ut@nU$I@it5#wz9K6CYqS`}rOEqun&x3`|ck;V+#B`J+yI5!l&!S6+9k{X@I>;9Cxw#XLFM!#E3YasTh4YCw8 zs&}INRnyoM0mnn04hIEZ|HWr(uB&#pOU@RTJ@!>cwSm*9gSe`C_qzVvpJpgDO4z^y;h*IKEi8+8L3Iq7?NhC1q*7C+SP5kt?P^40tU9CX4#q znncv9cTVqB6P_Wf8F1f|erWzzpU0-moRMX~2%H4>$QvuJ!c=%}me7A2h^de*|ColU ze4`ZV267i8#LVGymi-VL?L+IR+xG~!{f7MyFD`X2N|~x>lJpS+zKorwKXAx0xBDQ~ zQjT)g=M0{8Y7VmRBPp_2f|l0}S@gZ6%o;q~MnMtmJS6FtJ*iJ<)$q+IOqNDMGd-{F)ag(jE*6IkvSRaEt~>d zSV6WAKwrWY+x3emXXhQ_4;kbPR}uQCou|rMsF4IIE=G_~SE;+AY~!!k@Yc8D*sj@p z(aQr6cm&3VF{1!pGcv?;5AVQye^Sc}#V!{X4XcO1=@zNqX(aA2S2J+7^}1(H+&}nB zcX<01_MYWJu!VwBI|$yRoJ4SYkgMBs`2S`z1ZI_CYTM5q)<`gjtU<`>2t}7eV%?9Q zTypABO8UuJ`n&T)v8+s(PA4enzgn`7N-iCE5Q_X^fnH^_F(DYaWrLMGgP;$N%G?oy zZcwzF>OmNvsoHuoX)5f1Edr3q+}dijYS~k4P2oa3TTRVv7EO^{hPc0E;%ju{y&Qvj zc7GBwX4L9Zx0Q^Bm9|GLQ&q3*a%U!8W#VpNw~dN+L@iBHdd{w0fnjqln?}OxgSWl_ z-TC;T&vbL}x#1txYfuvBcQw+&%?1!*uyvvE->M{&QI?#T|9Aq?;TPNmPMPYdQuNF7 zsQrUgL+uGcGx%Dv^!=wx~6<=_+U&h3&a zQ$97b$_q`dKpQ{?H-^{NrTR}caxCmwF-Kf zy1=v>32UzbF6}~C{XO5U?A?WOE}gOa8iA_9plQs%V`j%S+j94+7CX0}rANE>zr=^2 zC$7lA(Mp28BMucnAMj-j5`Scm^Zx@kLnWPOY3dpjZQypk<^YTTHnK-Qy*EGjJY#i@ z%+N&b3NPE?6&e^M``ZPCsf_=Er2SO3!xT7Fb{3!ef*9*A z5}Svvpj@vqgK|HXe8r~8#@A4z=V>z1^@Ns1=;(Pd$AXE<9p9VVO|-iLaNk2vLP=X! z*m+Eo6?`aUI!9^q=RNzoZ8dUdO}W!>(tFaY2<`DhQeKIeMb7JV(;jXlC8s05mxDt3 zrxhY9b38ePCJb-0_jl@b-9>3-P&LK5ZrAv(1Ka(rEo*~5W&6*L-|Lw2-}3^%KsH9A zm~)Y7?fFSOa4~jqFZ~st6}ec0wGXWEnN8FV8vXNrlsr&?{QZ}|i$DP7VvA0{^F_HV~AVi(VU|(2Uk*4`<3f$EExwwvi ziu1Wg*Ld0c(`WyzuJKLj?k9ek{$yZcuL^2IMatqr5#|FT()d%@0jfPE-)Gsc$&sQC zPnzgbpBaR5M=0~-7rZ6{RT?Y)!Co=Uj4=6gUx3TVY`qTp<7gUm6>a0!Tv{X75vyXa zTThL#@y2)xeG2s!?RzNvP2&SU{0nuW>XdS#H*mgu^&FIm8i%M}C}~%=o%#EjXyGm2 zIZ03EO4afgzZ{YlK6+=>u(*5qo&W08^k1K+{vW-g3cuj_bs1t)+JYOq{44Np|7WqE KHvIXUzx+>;u+RSh literal 0 HcmV?d00001 From 41951bef51d9d872ce9ee1bd2c4568667861db4a Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:44:51 -0700 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20crash=20on=20multi-item=20promise?= =?UTF-8?q?=20drags=20=E2=80=94=20serialize=20pasteboard=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSPasteboard is not thread-safe: our worker's per-item reads raced the promise receivers' internal pasteboard access and crashed in the type cache (NSInternalInconsistencyException -> ggml terminate handler -> abort). All app-side reads now happen first on one thread, receivers start only afterwards on a serial queue, and every pasteboard call is wrapped in an ObjC exception catcher so a future race logs instead of killing the app. --- Sources/Fluid/Fluid-Bridging-Header.h | 1 + Sources/Fluid/ObjCExceptionCatcher.h | 18 +++ Sources/Fluid/ObjCExceptionCatcher.m | 10 ++ Sources/Fluid/UI/PromiseAwareDropView.swift | 132 ++++++++++++-------- 4 files changed, 109 insertions(+), 52 deletions(-) create mode 100644 Sources/Fluid/ObjCExceptionCatcher.h create mode 100644 Sources/Fluid/ObjCExceptionCatcher.m diff --git a/Sources/Fluid/Fluid-Bridging-Header.h b/Sources/Fluid/Fluid-Bridging-Header.h index 94f6c2e8..103728b3 100644 --- a/Sources/Fluid/Fluid-Bridging-Header.h +++ b/Sources/Fluid/Fluid-Bridging-Header.h @@ -2,5 +2,6 @@ #define FLUID_BRIDGING_HEADER_H #include "CoreAudioCaptureSupportBridge.h" +#include "ObjCExceptionCatcher.h" #endif diff --git a/Sources/Fluid/ObjCExceptionCatcher.h b/Sources/Fluid/ObjCExceptionCatcher.h new file mode 100644 index 00000000..9076cd67 --- /dev/null +++ b/Sources/Fluid/ObjCExceptionCatcher.h @@ -0,0 +1,18 @@ +#ifndef OBJC_EXCEPTION_CATCHER_H +#define OBJC_EXCEPTION_CATCHER_H + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Runs the block, catching any Objective-C exception it raises. +/// Returns the exception's description, or nil if the block completed cleanly. +/// Needed because NSPasteboard access from background threads can raise +/// NSInternalInconsistencyException on internal races, and a C++ library in the +/// process installs a terminate handler that turns any uncaught NSException +/// into abort(). +NSString *_Nullable FluidCatchObjCException(void (NS_NOESCAPE ^block)(void)); + +NS_ASSUME_NONNULL_END + +#endif diff --git a/Sources/Fluid/ObjCExceptionCatcher.m b/Sources/Fluid/ObjCExceptionCatcher.m new file mode 100644 index 00000000..6a099883 --- /dev/null +++ b/Sources/Fluid/ObjCExceptionCatcher.m @@ -0,0 +1,10 @@ +#import "ObjCExceptionCatcher.h" + +NSString *FluidCatchObjCException(void (NS_NOESCAPE ^block)(void)) { + @try { + block(); + return nil; + } @catch (NSException *exception) { + return exception.description ?: @"unknown Objective-C exception"; + } +} diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index 8b32914b..69ecaa18 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -38,7 +38,7 @@ struct PromiseAwareDropView: NSViewRepresentable { /// (e.g. by sidebar navigation) mid-drop. private static let promiseQueue: OperationQueue = { let queue = OperationQueue() - queue.maxConcurrentOperationCount = 4 + queue.maxConcurrentOperationCount = 1 return queue }() @@ -186,58 +186,59 @@ struct PromiseAwareDropView: NSViewRepresentable { Self.detachWorker { defer { state.endInFlight() } - // Modern path: each NSFilePromiseReceiver writes into its own - // item directory via the shared background operation queue. A - // directory counts as delivered only after its completion - // callback fires — polling alone could hand over a file whose - // (e.g. iCloud) download stalled mid-write. - if let receivers = pasteboard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver] { - for receiver in receivers { - guard let dir = try? session.makeItemDirectory() else { continue } - state.registerReceiverDir(dir) - 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" - ) - } else { - state.markCompleted(dir) - } - } + // ORDERING MATTERS: NSPasteboard is not thread-safe, and + // receivePromisedFiles touches the pasteboard from its own + // queue. All of OUR pasteboard reads happen first, single- + // threaded, and only then do the receivers start — concurrent + // access crashed with an NSInternalInconsistencyException in + // NSPasteboard's type cache (verified live on a 3-memo drag). + // Every read is additionally wrapped in an ObjC exception + // catcher: a C++ terminate handler in the process turns any + // uncaught NSException into abort(). + + // 1. Snapshot the receiver objects and per-item metadata/bytes. + var receivers: [NSFilePromiseReceiver] = [] + var suggestedName: String? + var promisedTypeID: String? + var itemPayloads: [(name: String?, data: Data)] = [] + let readError = FluidCatchObjCException { + receivers = (pasteboard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver]) ?? [] + suggestedName = pasteboard.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") + ) + promisedTypeID = pasteboard.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-content-type") + ) + for item in pasteboard.pasteboardItems ?? [] { + guard let itemTypeID = item.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-content-type") + ) ?? promisedTypeID else { continue } + guard let data = item.data(forType: NSPasteboard.PasteboardType(itemTypeID)), + !data.isEmpty else { continue } + let name = item.string( + forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") + ) + itemPayloads.append((name: name, data: data)) } } - - let suggestedName = pasteboard.string( - forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") - ) - let promisedTypeID = pasteboard.string( - forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-content-type") - ) + if let readError { + DebugLogger.shared.warning( + "Pasteboard read raised: \(readError)", + source: "PromiseAwareDropView" + ) + } DebugLogger.shared.debug( - "Promise drop [receivers=\(state.receiverDirsSnapshot().count), type=\(promisedTypeID ?? "?"), name=\(suggestedName ?? "?")]", + "Promise drop [receivers=\(receivers.count), items=\(itemPayloads.count), type=\(promisedTypeID ?? "?"), name=\(suggestedName ?? "?")]", source: "PromiseAwareDropView" ) - // Raw-data fallback: some providers (verified for Voice Memos) - // also put each item's complete file bytes on the pasteboard - // under the promised content type. Read PER ITEM — the - // pasteboard-level data(forType:) only returns the first item, - // which would silently drop the rest of a multi-memo drag. - // Memory-heavy for long recordings, so it is the last resort in - // the delivery priority order. Identically-named items get - // disambiguated file names within the shared data dir. - if let dataDir { + // 2. Raw-data fallback: write each item's bytes into the shared + // data dir (verified as the reliable path for Voice Memos). + // Identically-named items get disambiguated file names. + if let dataDir, !itemPayloads.isEmpty { var usedNames = Set() - for (index, item) in (pasteboard.pasteboardItems ?? []).enumerated() { - 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 itemName = item.string( - forType: NSPasteboard.PasteboardType("com.apple.pasteboard.promised-suggested-file-name") - ) ?? suggestedName ?? "Dropped Audio \(index + 1)" + for (index, payload) in itemPayloads.enumerated() { + let itemName = payload.name ?? suggestedName ?? "Dropped Audio \(index + 1)" var fileName = itemName var counter = 2 while usedNames.contains(fileName) { @@ -249,9 +250,9 @@ struct PromiseAwareDropView: NSViewRepresentable { usedNames.insert(fileName) let target = dataDir.appendingPathComponent(fileName) do { - try data.write(to: target) + try payload.data.write(to: target) DebugLogger.shared.debug( - "Raw-data fallback wrote \(data.count) bytes to \(target.lastPathComponent)", + "Raw-data fallback wrote \(payload.data.count) bytes to \(target.lastPathComponent)", source: "PromiseAwareDropView" ) } catch { @@ -263,7 +264,26 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - // Legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:), + // 3. Modern receivers, started only after our reads are done. + // Still required even when the raw-data path delivered: leaving + // a promise unresolved wedges the source app's drag machinery + // (Voice Memos refuses further drags until restarted). + for receiver in receivers { + guard let dir = try? session.makeItemDirectory() else { continue } + state.registerReceiverDir(dir) + 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" + ) + } else { + state.markCompleted(dir) + } + } + } + + // 4. Legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:), // strictly last resort. A pending legacy request freezes the // source app's promise machinery until it answers (verified // live: Voice Memos unresponsive for 35-80s, blocking further @@ -288,9 +308,17 @@ struct PromiseAwareDropView: NSViewRepresentable { state.beginInFlight() Self.detachWorker { defer { state.endInFlight() } - let selector = Selector(("namesOfPromisedFilesDroppedAtDestination:")) - let names = (sender.perform(selector, with: legacyDir)? - .takeUnretainedValue() as? [String]) ?? [] + 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" From e6137eaedf3f981af71cdd23e1b7e639ec8422fb Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:02:56 -0700 Subject: [PATCH 04/17] fix: address xhigh code-review findings - Single MainActor arbiter for the shared ASR model: dictation intent flag set synchronously before any await closes the TOCTOU gap; batch items wait on dictation intent, live capture, and in-flight single-file transcription - Batch guard moved to every hotkey entry point (dictate/prompt/command/ rewrite), with an audible cue when dictation is blocked mid-batch - Coordinator: pending-driven processing (completed items never re-run when enqueueing after an undismissed batch) and self-draining restart (items enqueued during the cancel window are picked up, not stranded) - CancellationError rethrown unwrapped from transcribeFile (no false failure analytics) - Drop resolver: empty-delivery path no longer deletes pending receiver dirs; receivers hold in-flight tokens so slow (iCloud) promises aren't abandoned at the 30s soft timeout; failed receivers count as resolved so partial failures deliver promptly; partial-failure accounting includes raw-data item counts - Single-file drops route into the batch when anything is in flight or a finished batch is still displayed; drop errors auto-dismiss - 7 new regression tests (182 total) --- Sources/Fluid/ContentView.swift | 48 ++++++- .../BatchTranscriptionCoordinator.swift | 23 ++- .../Services/FileTranscriptionSession.swift | 37 ++++- .../MeetingTranscriptionService.swift | 5 + .../Fluid/UI/MeetingTranscriptionView.swift | 28 +++- Sources/Fluid/UI/PromiseAwareDropView.swift | 105 ++++++++++++-- Sources/Fluid/UI/PromiseDropSupport.swift | 14 ++ .../BatchTranscriptionCoordinatorTests.swift | 135 ++++++++++++++++++ .../PromiseDropSupportTests.swift | 42 ++++++ 9 files changed, 407 insertions(+), 30 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 48c991fa..36c6ea3f 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2012,6 +2012,10 @@ struct ContentView: View { // MARK: - Stop and Process Transcription private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async { + // Recording session is ending: release dictation intent so the batch arbiter + // (and any dictation restart) no longer sees dictation as active. + FileTranscriptionSession.shared.endDictationIntent() + DebugLogger.shared.debug("stopAndProcessTranscription called", source: "ContentView") DebugLogger.shared.info("Output route selected: \(route.rawValue)", source: "ContentView") self.appBench("stop_path_enter route=\(route.rawValue)") @@ -3077,12 +3081,16 @@ struct ContentView: View { // File-transcription batches drive the same shared ASR model; starting // dictation mid-batch would run concurrent inference through one model. guard !FileTranscriptionSession.isBatchTranscribing else { - DebugLogger.shared.warning( - "Dictation blocked: batch file transcription in progress", - source: "ContentView" - ) + self.notifyDictationBlockedByBatch() return } + + // Signal dictation intent synchronously, before any `await` below, so the + // batch transcribe closure's arbitration check (which also reads this flag) + // cannot race a window where neither isBatchTranscribing nor asr.isRunning + // is true yet. + FileTranscriptionSession.shared.beginDictationIntent() + let model = SettingsStore.shared.selectedSpeechModel DebugLogger.shared.info( "ContentView: startRecording() for model=\(model.displayName), supportsStreaming=\(model.supportsStreaming)", @@ -3128,6 +3136,7 @@ struct ContentView: View { }) if startOutcome == .failed { self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed") + FileTranscriptionSession.shared.endDictationIntent() } } @@ -3362,6 +3371,12 @@ struct ContentView: View { self.menuBarManager.setOverlayMode(.command) guard !self.asr.isRunningOrStarting else { return } + // Command mode starts recording directly (it does not route through + // beginDictationRecording), so it needs its own batch guard. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } self.advanceOverlayLifecycle() @@ -3411,6 +3426,12 @@ struct ContentView: View { self.setActiveRecordingMode(.edit) guard !self.asr.isRunningOrStarting else { return } + // Rewrite/edit mode starts recording directly (it does not route through + // beginDictationRecording), so it needs its own batch guard. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } self.advanceOverlayLifecycle() @@ -3736,6 +3757,13 @@ extension ContentView { } private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) { + // Common chokepoint for dictate/prompt/command/rewrite hotkeys: file-transcription + // batches drive the same shared ASR model, so none of these modes may start + // while a batch is running. + guard !FileTranscriptionSession.isBatchTranscribing else { + self.notifyDictationBlockedByBatch() + return + } DebugLogger.shared.debug("Begin dictation recording for slot \(slot.rawValue)", source: "ContentView") self.appBench("begin_recording slot=\(slot.rawValue) mode=\(mode.rawValue)") if self.isOnboardingVoicePlaygroundStepActive { @@ -3789,6 +3817,18 @@ extension ContentView { self.beginDictationRecording(for: .secondary, mode: mode) } + /// Feedback when a dictation-family hotkey (dictate/prompt/command/rewrite) is + /// rejected because a file-transcription batch is running. Reuses the existing + /// stop cue rather than building new UI, so the user gets an audible signal that + /// the hotkey press had no effect instead of silence. + private func notifyDictationBlockedByBatch() { + DebugLogger.shared.warning( + "Dictation blocked: batch file transcription in progress", + source: "ContentView" + ) + TranscriptionSoundPlayer.shared.playStopSound() + } + private func appBench(_ message: String) { DebugLogger.shared.benchmark("APP_BENCH", message: message, source: "AppBenchmark") } diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift index e3fb8337..2ead617d 100644 --- a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -153,19 +153,34 @@ final class BatchTranscriptionCoordinator: ObservableObject { self.isRunning = false self.batchTask = nil - self.resumeIdleWaiters() + + // Self-draining: items enqueued while this batch was cancelling (or right as + // it finished) are still .pending and were never picked up above. Start a + // fresh run for them so they aren't stranded. cancel() already flips + // pre-existing .pending items to .cancelled, so only genuinely new items + // land here. + if self.items.contains(where: { if case .pending = $0.status { return true } else { return false } }) { + self.startBatch() + } else { + self.resumeIdleWaiters() + } } } private func processBatch() async { - var index = 0 - while index < self.items.count { + while let index = self.nextPendingIndex() { if self.isCancelled { return } await self.processItem(at: index) - index += 1 } } + /// Finds the first item still awaiting processing. Completed/failed/cancelled items + /// are never revisited, so re-enqueuing after a finished-but-undismissed batch only + /// processes the newly appended items. + private func nextPendingIndex() -> 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 diff --git a/Sources/Fluid/Services/FileTranscriptionSession.swift b/Sources/Fluid/Services/FileTranscriptionSession.swift index 638c1880..bf376edb 100644 --- a/Sources/Fluid/Services/FileTranscriptionSession.swift +++ b/Sources/Fluid/Services/FileTranscriptionSession.swift @@ -22,6 +22,19 @@ final class FileTranscriptionSession { self.sharedIfCreated?.batchHolder.coordinator?.isRunning == true } + /// True from the moment a dictation/prompt/command/rewrite recording is about to + /// start until the session ends. This is the "intent to dictate" half of the + /// single-model arbiter shared with the batch transcribe closure below: it is set + /// synchronously on the main actor before any `await`, closing the TOCTOU window + /// where the batch closure's `AppServices.shared.asr.isRunning` check could pass + /// right before dictation flips `isRunning` true. + /// + /// Uses `sharedIfCreated` (not `shared`) so callers that only need to *read* the + /// flag never force-create the session/ASR service. `beginDictationIntent()` is the + /// one path allowed to force-create, since it's only called from the app's own + /// recording start path where creating the session is expected anyway. + private(set) var dictationIntent: Bool = false + let service: MeetingTranscriptionService let batchHolder: BatchCoordinatorHolder @@ -29,10 +42,14 @@ final class FileTranscriptionSession { let service = MeetingTranscriptionService(asrService: AppServices.shared.asr) self.service = service self.batchHolder = BatchCoordinatorHolder(transcribe: { url in - // Batch-vs-dictation arbitration: if the user is dictating, wait for - // the live session to finish before running this item through the - // shared model. Cancellation still interrupts the wait. - while AppServices.shared.asr.isRunning { + // Single-model arbitration: at most one of dictation, single-file + // transcription, and batch transcription may drive the shared ASR model at + // once. Wait for dictation intent (covers the TOCTOU window before + // AppServices.shared.asr.isRunning flips true), live dictation, and any + // in-flight single-file transcription. Cancellation still interrupts the wait. + while AppServices.shared.asr.isRunning + || FileTranscriptionSession.shared.dictationIntent + || service.isTranscribing { try Task.checkCancellation() try await Task.sleep(nanoseconds: 500_000_000) } @@ -40,6 +57,18 @@ final class FileTranscriptionSession { }) Self.sharedIfCreated = self } + + /// Signal that a dictation-family recording (dictate/prompt/command/rewrite) is + /// about to start. Must be called synchronously before any `await` in the caller. + func beginDictationIntent() { + self.dictationIntent = true + } + + /// Signal that the dictation-family recording session has ended (stopped, + /// cancelled, or failed to start). + func endDictationIntent() { + self.dictationIntent = false + } } /// Wraps the batch coordinator so a finished batch can be cleared and a fresh diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index 5a69072a..6706a78f 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -375,6 +375,11 @@ final class MeetingTranscriptionService: ObservableObject { FileTranscriptionHistoryStore.shared.addEntry(result) return result + } catch is CancellationError { + // Cooperative cancellation (batch cancel, app teardown, etc.) is not a + // transcription failure: don't surface it as an error or fire a false + // failure analytics event. + throw CancellationError() } catch let error as TranscriptionError { self.error = error.localizedDescription AnalyticsService.shared.capture( diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index 68a21911..63f279e5 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -26,6 +26,7 @@ struct MeetingTranscriptionView: View { @State private var showingCopyConfirmation = false @State private var isDropTargeted = false @State private var dropErrorMessage: String? + @State private var dropErrorGeneration = 0 enum ExportFormat: String, CaseIterable { case text = "Text (.txt)" @@ -107,7 +108,7 @@ struct MeetingTranscriptionView: View { PromiseAwareDropView( onTargetedChange: { self.isDropTargeted = $0 }, onFiles: { self.handleIncomingFiles($0) }, - onError: { self.dropErrorMessage = $0 } + onError: { self.showDropError($0) } ) ) .background(self.theme.palette.windowBackground) @@ -716,8 +717,13 @@ struct MeetingTranscriptionView: View { guard !files.isEmpty else { return } self.dropErrorMessage = nil - let batchIsRunning = self.batchHolder.coordinator?.isRunning == true - if files.count == 1, let file = files.first, file.stagingDir == nil, !batchIsRunning { + // Single concrete file goes through the fast path only when there's no batch + // (running or finished-but-undismissed — isBatchActive covers both) and no + // single-file transcription already in flight; otherwise it's routed into the + // coordinator, which either appends to the live batch or shows up as a fresh + // row so the self-draining batch picks it up. + if files.count == 1, let file = files.first, file.stagingDir == nil, + !self.isBatchActive, !self.transcriptionService.isTranscribing { self.selectedFileURL = file.url self.transcriptionService.reset() return @@ -728,6 +734,22 @@ struct MeetingTranscriptionView: View { /// Clears the batch list and resets shared service state so a subsequent batch /// starts fresh and no stale single-file result lingers. + /// Shows a drop error and auto-dismisses it after a few seconds. Promise + /// resolution can report errors up to two minutes after the drop; without + /// auto-dismissal a stale red card would appear over an already-successful + /// batch and stick around until manually closed. + private func showDropError(_ message: String) { + self.dropErrorMessage = message + self.dropErrorGeneration += 1 + let generation = self.dropErrorGeneration + Task { @MainActor in + try? await Task.sleep(nanoseconds: 6_000_000_000) + if self.dropErrorGeneration == generation { + self.dropErrorMessage = nil + } + } + } + private func dismissBatch() { self.batchHolder.clear() self.transcriptionService.reset() diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index 69ecaa18..2a520882 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -231,6 +231,7 @@ struct PromiseAwareDropView: NSViewRepresentable { "Promise drop [receivers=\(receivers.count), items=\(itemPayloads.count), type=\(promisedTypeID ?? "?"), name=\(suggestedName ?? "?")]", source: "PromiseAwareDropView" ) + state.setPayloadCount(itemPayloads.count) // 2. Raw-data fallback: write each item's bytes into the shared // data dir (verified as the reliable path for Voice Memos). @@ -271,12 +272,19 @@ struct PromiseAwareDropView: NSViewRepresentable { for receiver in receivers { guard let dir = try? session.makeItemDirectory() else { continue } state.registerReceiverDir(dir) + // A per-receiver token: the soft timeout must not abandon a + // promise that is still downloading (e.g. iCloud taking 45s) + // just because the outer worker thread already finished + // (finding F8). Released on both the success and error paths. + state.beginInFlight() receiver.receivePromisedFiles(atDestination: dir, options: [:], operationQueue: Self.promiseQueue) { url, error in + defer { state.endInFlight() } if let error { DebugLogger.shared.debug( "Modern promise receiver failed [\(url.lastPathComponent)]: \(error.localizedDescription)", source: "PromiseAwareDropView" ) + state.markFailed(dir) } else { state.markCompleted(dir) } @@ -340,7 +348,9 @@ struct PromiseAwareDropView: NSViewRepresentable { private let lock = NSLock() private var receiverDirs: [URL] = [] private var completed: Set = [] + private var failed: Set = [] private var inFlightCount = 0 + private var payloadCount = 0 func registerReceiverDir(_ dir: URL) { self.lock.lock() @@ -366,6 +376,37 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.completed } + /// Marks a receiver as resolved-but-failed. A failed receiver's + /// promise has already answered (with an error), so — unlike a + /// still-pending one — its staging dir is safe to sweep immediately + /// and it counts toward "every receiver resolved" for delivery + /// gating (finding F9). + func markFailed(_ dir: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.failed.insert(dir) + } + + func failedSnapshot() -> Set { + self.lock.lock() + defer { self.lock.unlock() } + return self.failed + } + + /// Records the raw-data item count once, so a receiver-less + /// multi-item drop can still detect a partial delivery (finding F11). + func setPayloadCount(_ count: Int) { + self.lock.lock() + defer { self.lock.unlock() } + self.payloadCount = count + } + + func payloadCountValue() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.payloadCount + } + func beginInFlight() { self.lock.lock() defer { self.lock.unlock() } @@ -417,10 +458,16 @@ struct PromiseAwareDropView: NSViewRepresentable { func makeContext() -> DeliveryContext { let receiverDirs = state.receiverDirsSnapshot() let completed = state.completedSnapshot() + let failed = state.failedSnapshot() return DeliveryContext( allDirs: receiverDirs + [legacyDir, dataDir].compactMap(\.self), - pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) }, - totalExpected: max(receiverDirs.count, 1), + // A failed receiver has already answered (with an error), so + // unlike a still-pending one its dir is safe to sweep now. + pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) && !failed.contains($0) }, + // The raw-data item count also bounds expectations: a + // receiver-less multi-item drop that partially lands must + // still surface a partial-failure error (finding F11). + totalExpected: max(receiverDirs.count, state.payloadCountValue(), 1), session: session, onFiles: onFiles, onError: onError @@ -439,26 +486,36 @@ struct PromiseAwareDropView: NSViewRepresentable { // truncated file just because its size held still between polls. let receiverDirs = state.receiverDirsSnapshot() let readyModernDirs = state.completedSnapshot() + let failedModernDirs = state.failedSnapshot() let modernFiles = self.listFiles(in: receiverDirs.filter(readyModernDirs.contains)) let legacyFiles = self.listFiles(in: [legacyDir].compactMap(\.self)) let dataFiles = self.listFiles(in: [dataDir].compactMap(\.self)) let fallbackSizes = self.sizeMap(for: legacyFiles + dataFiles) - // Modern succeeded outright: every registered receiver completed. - // (Receivers register in one tight loop with no pasteboard call - // in between, so a partially-registered snapshot is a micro-race + // "Resolved" = completed OR failed: a failed receiver's promise + // has already answered, so it can never flip to completed later + // (finding F9) — waiting on it would spin the full soft timeout. + let resolvedCount = readyModernDirs.count + failedModernDirs.count + let allReceiversResolved = !receiverDirs.isEmpty && resolvedCount >= receiverDirs.count + + // Modern succeeded outright: every registered receiver resolved, + // at least one completed, and it produced files. (Receivers + // register in one tight loop with no pasteboard call in + // between, so a partially-registered snapshot is a micro-race // at worst; completion still requires each dir's callback.) - let modernComplete = !receiverDirs.isEmpty - && readyModernDirs.count >= receiverDirs.count - && !modernFiles.isEmpty + let modernComplete = allReceiversResolved && !readyModernDirs.isEmpty && !modernFiles.isEmpty if modernComplete { self.deliver(modern: modernFiles, legacy: legacyFiles, data: dataFiles, context: makeContext()) return } - // Modern produced nothing past the grace period but a fallback - // landed and its sizes are stable — use the fallback copies. - let modernProducedNothing = readyModernDirs.isEmpty && elapsed > modernGrace + // Modern produced nothing — either no receivers were ever + // registered, or every registered receiver resolved and none + // completed — past the grace period, but a fallback landed and + // its sizes are stable — use the fallback copies. + let modernExhaustedWithNoWins = receiverDirs.isEmpty + || (allReceiversResolved && readyModernDirs.isEmpty) + let modernProducedNothing = modernExhaustedWithNoWins && elapsed > modernGrace // The worker must be done (no in-flight token) so a multi-item // raw-data sweep that is still writing files 3..5 isn't // delivered after only the first items stabilized. @@ -520,7 +577,23 @@ struct PromiseAwareDropView: NSViewRepresentable { let result = supported.map { (url: $0, stagingDir: $0.deletingLastPathComponent()) } if result.isEmpty { - context.session.removeAll() + // Same pending-respecting sweep as the non-empty branch below: + // an empty result must not delete staging dirs whose receiver + // promise hasn't completed yet (finding F1) — removeAll() used + // to nuke the whole session root, pending dirs included, which + // wedges the source app's drag machinery. + for dir in PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: context.allDirs, + deliveredFiles: [], + pendingDirs: context.pendingReceiverDirs + ) { + try? FileManager.default.removeItem(at: dir) + } + if context.pendingReceiverDirs.isEmpty { + context.session.removeAll() + } else { + self.cleanUpLater(pendingDirs: context.pendingReceiverDirs) + } let reason = selected.isEmpty ? "No files could be read from the drop." : "The dropped files are not a supported format." @@ -535,9 +608,11 @@ struct PromiseAwareDropView: NSViewRepresentable { // files are throwaway copies) and keeps the temp dir clean. The // batch coordinator removes the delivered dirs (and then the empty // session root) after each item's transcription. - let pendingPaths = Set(context.pendingReceiverDirs.map(\.standardizedFileURL.path)) - for dir in PromiseDropSupport.sweepableDirs(allDirs: context.allDirs, deliveredFiles: supported) - where !pendingPaths.contains(dir.standardizedFileURL.path) { + for dir in PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: context.allDirs, + deliveredFiles: supported, + pendingDirs: context.pendingReceiverDirs + ) { try? FileManager.default.removeItem(at: dir) } diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift index 9fdc80e7..a7078b33 100644 --- a/Sources/Fluid/UI/PromiseDropSupport.swift +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -65,6 +65,20 @@ nonisolated enum PromiseDropSupport { return allDirs.filter { !deliveredDirPaths.contains($0.standardizedFileURL.path) } } + /// Staging dirs safe to delete right now: swept per `sweepableDirs`, minus + /// any dir whose receiver promise hasn't completed yet. + /// + /// Pending receiver dirs must never be removed while pending — the source + /// app (e.g. Voice Memos) may still be writing into them, and deleting the + /// destination out from under an in-flight `NSFilePromiseReceiver` wedges + /// its drag machinery until restart. Callers are expected to defer removal + /// of the excluded dirs until the pending promises resolve. + static func dirsSafeToRemoveNow(allDirs: [URL], deliveredFiles: [URL], pendingDirs: [URL]) -> [URL] { + let pendingPaths = Set(pendingDirs.map { $0.standardizedFileURL.path }) + return sweepableDirs(allDirs: allDirs, deliveredFiles: deliveredFiles) + .filter { !pendingPaths.contains($0.standardizedFileURL.path) } + } + /// Merges the three promise-delivery paths into the final file list. /// /// Every modern-receiver file is delivered: each receiver stages into its own diff --git a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift index c8ddf44f..8bf18da6 100644 --- a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift +++ b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift @@ -340,6 +340,141 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { XCTAssertEqual(endCount, 1, "the end hook must fire even when the batch is cancelled, or dictation stays blocked forever") } + // MARK: - Enqueue after a finished batch (F5/F15a) + + func testEnqueueAfterFinishedBatchProcessesOnlyNewItems() async { + var transcribedPaths: [String] = [] + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + transcribedPaths.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "first.m4a"))]) + await coordinator.waitUntilIdle() + XCTAssertEqual(transcribedPaths, ["first.m4a"]) + guard case .completed = coordinator.items[0].status else { + return XCTFail("first item should complete, got \(coordinator.items[0].status)") + } + + // Batch finished but items weren't dismissed (dismissBatch/clear() wasn't + // called) — enqueue must not re-walk from index 0 and re-run "first.m4a". + coordinator.enqueue([.init(url: self.tempAudioURL(name: "second.m4a"))]) + await coordinator.waitUntilIdle() + + XCTAssertEqual( + transcribedPaths, + ["first.m4a", "second.m4a"], + "re-enqueueing after a finished-but-undismissed batch must not re-run completed items" + ) + XCTAssertEqual(coordinator.items.count, 2) + guard case .completed = coordinator.items[0].status else { + return XCTFail("completed item must keep its status, got \(coordinator.items[0].status)") + } + guard case .completed = coordinator.items[1].status else { + return XCTFail("new item should complete, got \(coordinator.items[1].status)") + } + } + + // MARK: - Enqueue during the cancel window (F6/F15b) + + func testEnqueueDuringCancelWindowIsNotStranded() async { + let started = AsyncGate() + let releaseInFlight = AsyncGate() + var transcribedPaths: [String] = [] + + let coordinator = BatchTranscriptionCoordinator(transcribe: { url in + if url.lastPathComponent == "in-flight.m4a" { + started.open() + await releaseInFlight.wait() + } + transcribedPaths.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "in-flight.m4a"))]) + await started.wait() + + // Cancel while the in-flight item is still gated/running. + coordinator.cancel() + + // Enqueue a new item while the cancelled batch's in-flight item hasn't + // finished yet — it must not be stranded. + coordinator.enqueue([.init(url: self.tempAudioURL(name: "after-cancel.m4a"))]) + + // Now let the in-flight (cancelled) item finish. + releaseInFlight.open() + + await coordinator.waitUntilIdle() + + guard case .cancelled = coordinator.items[0].status else { + return XCTFail("in-flight item must be .cancelled, got \(coordinator.items[0].status)") + } + guard case .completed = coordinator.items[1].status else { + return XCTFail("item enqueued during the cancel window must eventually process, got \(coordinator.items[1].status)") + } + XCTAssertEqual(transcribedPaths, ["in-flight.m4a", "after-cancel.m4a"]) + XCTAssertFalse(coordinator.isRunning) + } + + // MARK: - BatchCoordinatorHolder.clear() while running (F15c) + + func testHolderClearWhileRunningCancelsAndFreshEnqueueWorksAfterward() async { + let started = AsyncGate() + var transcribedPaths: [String] = [] + + let holder = BatchCoordinatorHolder(transcribe: { url in + if url.lastPathComponent == "long-running.m4a" { + started.open() + try await Task.sleep(nanoseconds: 10_000_000_000) + } + transcribedPaths.append(url.lastPathComponent) + return self.makeResult(text: "ok", fileName: url.lastPathComponent) + }) + + holder.enqueue([.init(url: self.tempAudioURL(name: "long-running.m4a"))]) + await started.wait() + + guard let orphanedCoordinator = holder.coordinator else { + return XCTFail("coordinator must exist while a batch is running") + } + holder.clear() + + XCTAssertNil(holder.coordinator, "clear() must drop the coordinator reference") + await orphanedCoordinator.waitUntilIdle() + XCTAssertFalse(orphanedCoordinator.isRunning, "clear() must cancel the orphaned batch, not leave it running") + guard case .cancelled = orphanedCoordinator.items[0].status else { + return XCTFail("orphaned in-flight item must be .cancelled, got \(orphanedCoordinator.items[0].status)") + } + XCTAssertFalse(transcribedPaths.contains("long-running.m4a"), "cancelled item must never reach a completed outcome") + + // A subsequent enqueue must work on a fresh coordinator. + holder.enqueue([.init(url: self.tempAudioURL(name: "fresh.m4a"))]) + await holder.coordinator?.waitUntilIdle() + + guard case .completed = holder.coordinator?.items.first?.status else { + return XCTFail("fresh coordinator after clear() must process normally") + } + } + + // MARK: - Cancellation surfaced from the transcribe closure's wait loop (F15d) + + func testCancellationThrownFromTranscribeClosureMarksItemCancelled() async { + let coordinator = BatchTranscriptionCoordinator(transcribe: { _ in + // Simulates the dictation-arbitration wait loop observing cancellation + // (e.g. FileTranscriptionSession's `try Task.checkCancellation()` while + // waiting for dictation/single-file transcription to finish) before ever + // calling into the transcription service. + throw CancellationError() + }) + + coordinator.enqueue([.init(url: self.tempAudioURL(name: "gated.m4a"))]) + await coordinator.waitUntilIdle() + + guard case .cancelled = coordinator.items[0].status else { + return XCTFail("a CancellationError thrown while waiting must mark the item .cancelled, got \(coordinator.items[0].status)") + } + } + // MARK: - Summary func testSummaryCountsReflectOutcomes() async { diff --git a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift index 529d558a..be07128d 100644 --- a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -151,6 +151,48 @@ final class PromiseDropSupportTests: XCTestCase { XCTAssertEqual(PromiseDropSupport.sweepableDirs(allDirs: [dirA, dirB], deliveredFiles: []), [dirA, dirB]) } + // MARK: - dirsSafeToRemoveNow (findings F1 / F9) + + func testDirsSafeToRemoveNowExcludesPendingReceiverDirsEvenWhenNothingDelivered() { + // Regression for F1: an empty delivery result must not sweep a pending + // receiver's staging dir — deleting the destination out from under an + // in-flight NSFilePromiseReceiver wedges the source app's drag machinery. + let pendingDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-pending") + let deadDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-dead") + + let safe = PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: [pendingDir, deadDir], + deliveredFiles: [], + pendingDirs: [pendingDir] + ) + + XCTAssertEqual(safe, [deadDir], "a still-pending receiver dir must never be swept, even with an empty delivery") + } + + func testDirsSafeToRemoveNowExcludesPendingDirsAlongsideDeliveredFiles() { + let deliveredFile = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a/Memo.m4a") + let deliveredDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a") + let pendingDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-pending") + let loserDir = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-loser") + + let safe = PromiseDropSupport.dirsSafeToRemoveNow( + allDirs: [deliveredDir, pendingDir, loserDir], + deliveredFiles: [deliveredFile], + pendingDirs: [pendingDir] + ) + + XCTAssertEqual(safe, [loserDir]) + } + + func testDirsSafeToRemoveNowAllowsEverythingWhenNoPendingDirs() { + let dirA = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-a") + let dirB = URL(fileURLWithPath: "/tmp/PromiseDrop-test/item-b") + + let safe = PromiseDropSupport.dirsSafeToRemoveNow(allDirs: [dirA, dirB], deliveredFiles: [], pendingDirs: []) + + XCTAssertEqual(safe, [dirA, dirB]) + } + // MARK: - Supported-file filtering for multi-file drops func testFilterKeepsDecodableAudioAndRejectsJunk() { From 61f65b245e12d8ea2ffcade629806b51e5fa9160 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:36:05 -0700 Subject: [PATCH 05/17] fix: hold dictation intent across every start, stop, and cancel path Review findings on the single-model arbiter: a batch could start concurrent inference, or wait forever on a dictation that had already ended. - Claim intent at the command/rewrite callbacks and in beginDictationRecording, which start ASR directly and previously left both arbitration flags false until isRunning flipped. - Release it after stopAndProcessTranscription completes rather than at entry; ASRService.stop() clears isRunning before the final transcription pass, so an early release let a drop run during the visible "Transcribing" phase. - Release it in ASRService.stopWithoutTranscription, which every cancel path (Escape, overlay cancel, settings window teardown) funnels through, above the isRunning guard so a cancel while starting counts too. - Claim it after the already-running guard in startRecording, so an ignored start cannot latch a flag it never releases. --- Sources/Fluid/ContentView.swift | 28 ++++++++++--------- Sources/Fluid/Services/ASRService.swift | 3 ++ .../Services/FileTranscriptionSession.swift | 5 ++++ 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 36c6ea3f..7e000c6c 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2012,9 +2012,8 @@ struct ContentView: View { // MARK: - Stop and Process Transcription private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async { - // Recording session is ending: release dictation intent so the batch arbiter - // (and any dictation restart) no longer sees dictation as active. - FileTranscriptionSession.shared.endDictationIntent() + // Released at the end: stop() clears isRunning before the final transcription pass. + defer { FileTranscriptionSession.shared.endDictationIntent() } DebugLogger.shared.debug("stopAndProcessTranscription called", source: "ContentView") DebugLogger.shared.info("Output route selected: \(route.rawValue)", source: "ContentView") @@ -3085,12 +3084,6 @@ struct ContentView: View { return } - // Signal dictation intent synchronously, before any `await` below, so the - // batch transcribe closure's arbitration check (which also reads this flag) - // cannot race a window where neither isBatchTranscribing nor asr.isRunning - // is true yet. - FileTranscriptionSession.shared.beginDictationIntent() - let model = SettingsStore.shared.selectedSpeechModel DebugLogger.shared.info( "ContentView: startRecording() for model=\(model.displayName), supportsStreaming=\(model.supportsStreaming)", @@ -3101,6 +3094,9 @@ struct ContentView: View { return } + // Synchronous, before any await; after the guard so an ignored start can't latch it. + FileTranscriptionSession.shared.beginDictationIntent() + self.advanceOverlayLifecycle() self.setActiveRecordingMode(.dictate) let shouldShowDictationOverlay = !self.isRecordingForCommand @@ -3371,12 +3367,12 @@ struct ContentView: View { self.menuBarManager.setOverlayMode(.command) guard !self.asr.isRunningOrStarting else { return } - // Command mode starts recording directly (it does not route through - // beginDictationRecording), so it needs its own batch guard. + // Bypasses beginDictationRecording, so it needs its own guard and claim. guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return } + FileTranscriptionSession.shared.beginDictationIntent() self.advanceOverlayLifecycle() @@ -3394,6 +3390,7 @@ struct ContentView: View { self.menuBarManager.hideRecordingOverlayImmediately( reason: "command_asr_start_failed" ) + FileTranscriptionSession.shared.endDictationIntent() } } }, @@ -3426,12 +3423,13 @@ struct ContentView: View { self.setActiveRecordingMode(.edit) guard !self.asr.isRunningOrStarting else { return } - // Rewrite/edit mode starts recording directly (it does not route through - // beginDictationRecording), so it needs its own batch guard. + // Starts recording directly, bypassing beginDictationRecording, so it + // needs its own batch guard and intent claim. guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return } + FileTranscriptionSession.shared.beginDictationIntent() self.advanceOverlayLifecycle() @@ -3446,6 +3444,7 @@ struct ContentView: View { self.menuBarManager.hideRecordingOverlayImmediately( reason: "edit_asr_start_failed" ) + FileTranscriptionSession.shared.endDictationIntent() } } }, @@ -3781,6 +3780,8 @@ extension ContentView { self.appBench("asr_start_skipped reason=already_running_or_starting") return } + // Claimed before the async start: isRunning hasn't flipped yet. + FileTranscriptionSession.shared.beginDictationIntent() self.advanceOverlayLifecycle() if self.asr.micStatus == .authorized { self.appBench("overlay_mode_request mode=Dictation") @@ -3802,6 +3803,7 @@ extension ContentView { }) if startOutcome == .failed { self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed") + FileTranscriptionSession.shared.endDictationIntent() } DebugLogger.shared.benchmark( "APP_BENCH", 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/FileTranscriptionSession.swift b/Sources/Fluid/Services/FileTranscriptionSession.swift index bf376edb..1033d80d 100644 --- a/Sources/Fluid/Services/FileTranscriptionSession.swift +++ b/Sources/Fluid/Services/FileTranscriptionSession.swift @@ -69,6 +69,11 @@ final class FileTranscriptionSession { func endDictationIntent() { self.dictationIntent = false } + + /// Releases the flag without force-creating the session, for teardown paths. + static func endDictationIntentIfCreated() { + self.sharedIfCreated?.endDictationIntent() + } } /// Wraps the batch coordinator so a finished batch can be cleared and a fresh From 84572083d0902a985c505d2064192573b42bffaf Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:36:13 -0700 Subject: [PATCH 06/17] fix: sanitize promised file names and bound fallback deliveries - Reduce a provider-supplied promise name to one path component; separators or ".." let the raw-data fallback write outside the staging directory. - Bound selectDelivery by the pasteboard item count: the raw-data path renames same-named items ("New Recording 2.m4a"), which matched no modern name and was delivered as a phantom extra file for a two-memo drag. - Bind items.last instead of force-unwrapping it (swiftlint --strict). --- Sources/Fluid/UI/PromiseAwareDropView.swift | 14 +++- Sources/Fluid/UI/PromiseDropSupport.swift | 29 ++++++++- .../BatchTranscriptionCoordinatorTests.swift | 7 +- .../PromiseDropSupportTests.swift | 64 +++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index 2a520882..882f0ef0 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -239,7 +239,12 @@ struct PromiseAwareDropView: NSViewRepresentable { if let dataDir, !itemPayloads.isEmpty { var usedNames = Set() for (index, payload) in itemPayloads.enumerated() { - let itemName = payload.name ?? suggestedName ?? "Dropped Audio \(index + 1)" + let fallbackName = "Dropped Audio \(index + 1)" + // Provider-supplied, so untrusted: separators would escape staging. + let itemName = PromiseDropSupport.sanitizedFileName( + payload.name ?? suggestedName, + fallback: fallbackName + ) var fileName = itemName var counter = 2 while usedNames.contains(fileName) { @@ -572,7 +577,12 @@ struct PromiseAwareDropView: NSViewRepresentable { "Promise delivery [modern=\(modern.count), legacy=\(legacy.count), data=\(data.count), expected=\(context.totalExpected)]", source: "PromiseAwareDropView" ) - let selected = PromiseDropSupport.selectDelivery(modern: modern, legacy: legacy, data: data) + let selected = PromiseDropSupport.selectDelivery( + modern: modern, + legacy: legacy, + data: data, + expectedItemCount: context.totalExpected + ) let supported = PromiseDropSupport.filterSupported(selected) let result = supported.map { (url: $0, stagingDir: $0.deletingLastPathComponent()) } diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift index a7078b33..8a3a5342 100644 --- a/Sources/Fluid/UI/PromiseDropSupport.swift +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -79,17 +79,40 @@ nonisolated enum PromiseDropSupport { .filter { !pendingPaths.contains($0.standardizedFileURL.path) } } + /// Reduces a drag-source-supplied name to one safe path component; separators or + /// `..` would otherwise let `appendingPathComponent` write outside staging. + /// Unusable names (empty, `.`, `..`, all-separators) take the caller's fallback. + 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. + guard !trimmed.isEmpty, trimmed != ".", trimmed != "..", !trimmed.contains("/") else { + return fallback + } + return trimmed + } + /// Merges the three promise-delivery paths into the final file list. /// /// Every modern-receiver file is delivered: each receiver stages into its own /// directory, so two distinct memos that share a display name (e.g. two /// "New Recording.m4a") are distinct files and must never be collapsed. - /// Legacy and raw-data files are alternates of the same dragged items, so - /// they only contribute names the delivered set doesn't already contain. - static func selectDelivery(modern: [URL], legacy: [URL], data: [URL]) -> [URL] { + /// + /// Legacy and raw-data files are alternates of the same items, never extra ones, so + /// they only fill gaps. Names alone can't identify an alternate — the raw-data path + /// renames same-named items ("New Recording 2.m4a") — so `expectedItemCount` bounds + /// the result. Pass nil to keep name-only behavior. + 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) } diff --git a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift index 8bf18da6..8502f242 100644 --- a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift +++ b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift @@ -162,8 +162,11 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { coordinator.enqueue([.init(url: self.tempAudioURL(name: "after.m4a"))]) await coordinator.waitUntilIdle() - guard case .completed = coordinator.items.last!.status else { - return XCTFail("batch must run normally after a cancel, got \(coordinator.items.last!.status)") + 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)") } } diff --git a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift index be07128d..d05fa709 100644 --- a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -76,6 +76,70 @@ final class PromiseDropSupportTests: XCTestCase { XCTAssertEqual(delivered, [modern], "legacy/data copies of an already-delivered name are the same dragged item") } + func testRenamedFallbackCopyOfSameNamedMemosIsNotDeliveredAsExtraItem() { + // The renamed fallback copy matches no modern name, so name-only dedup used to + // 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 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") From e659501213114c339fb1e4012e7de2f0dc9e264c Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:35:13 -0700 Subject: [PATCH 07/17] fix: give every delivered promise file its own staging dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordinator deletes an item's staging dir as soon as that item finishes, so it assumes one item owns one dir. Delivery broke that assumption: the raw-data fallback writes every item into one shared dir, the legacy API writes every file into one destination dir, and a receiver carrying several file names fills one dir — and stagingDir was derived from the file's parent path, so N files claimed the same dir. The first item to finish destroyed files the queued items still needed; cancel() hit the same path while an item was transcribing. - Relocate each delivered file into a fresh item dir at delivery, reporting that dir as its owner. A failed move reports no owner rather than a shared dir. - Treat the legacy dir as pending while its writer is in flight: relocation empties it, so the sweep would otherwise delete a destination the OS is still writing into. - Release a receiver's in-flight token once, after its last file callback. receivePromisedFiles fires once per name, so the token was over-decremented, leaving hasInFlightWork false and disabling both the soft-timeout hold and the guard against delivering a half-written multi-item sweep. - Document staging-dir ownership on Request/Item. --- .../BatchTranscriptionCoordinator.swift | 2 + Sources/Fluid/UI/PromiseAwareDropView.swift | 51 ++++++++++--- Sources/Fluid/UI/PromiseDropSupport.swift | 21 ++++++ .../BatchTranscriptionCoordinatorTests.swift | 66 +++++++++++++++++ .../PromiseDropSupportTests.swift | 73 +++++++++++++++++++ 5 files changed, 204 insertions(+), 9 deletions(-) diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift index 2ead617d..34114433 100644 --- a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -13,6 +13,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { /// A transcription request for a single audio file. struct Request { let url: URL + /// Exclusively owned by this item; deleted once the item finishes — producers must never hand the same dir to two items. let stagingDir: URL? init(url: URL, stagingDir: URL? = nil) { @@ -35,6 +36,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { struct Item: Identifiable { let id: UUID let url: URL + /// Exclusively owned by this item; deleted once the item finishes — producers must never hand the same dir to two items. let stagingDir: URL? var status: Status } diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index 882f0ef0..ab2a3d13 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -280,19 +280,24 @@ struct PromiseAwareDropView: NSViewRepresentable { // A per-receiver token: the soft timeout must not abandon a // promise that is still downloading (e.g. iCloud taking 45s) // just because the outer worker thread already finished - // (finding F8). Released on both the success and error paths. + // (finding F8). Released once, after the receiver's LAST + // file callback — receivePromisedFiles fires once per name. state.beginInFlight() + let completion = ReceiverCompletion(fileCount: receiver.fileNames.count) receiver.receivePromisedFiles(atDestination: dir, options: [:], operationQueue: Self.promiseQueue) { url, error in - defer { state.endInFlight() } if let error { DebugLogger.shared.debug( "Modern promise receiver failed [\(url.lastPathComponent)]: \(error.localizedDescription)", source: "PromiseAwareDropView" ) + } + guard completion.recordFile(failed: error != nil) else { return } + if completion.anyFailed { state.markFailed(dir) } else { state.markCompleted(dir) } + state.endInFlight() } } @@ -343,6 +348,28 @@ struct PromiseAwareDropView: NSViewRepresentable { return true } + /// `receivePromisedFiles` fires once per name, so count down to release the + /// receiver's single 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) { + // Guard a zero/nil name count: it must still count down to zero. + self.remaining = max(fileCount, 1) + } + + /// Returns 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 } + self.remaining -= 1 + return self.remaining <= 0 + } + } + /// Thread-safe resolution state shared between the background delivery /// threads and the main-actor poller: which receiver dirs have completed, /// and whether the legacy/raw-data reads are still in flight (under heavy @@ -469,6 +496,9 @@ struct PromiseAwareDropView: NSViewRepresentable { // A failed receiver has already answered (with an error), so // unlike a still-pending one its dir is safe to sweep now. pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) && !failed.contains($0) }, + // Relocation empties the legacy dir, so the sweep would now see it as + // undelivered and delete a destination the OS may still be writing into. + pendingFallbackDirs: state.hasInFlightWork ? [legacyDir].compactMap(\.self) : [], // The raw-data item count also bounds expectations: a // receiver-less multi-item drop that partially lands must // still surface a partial-failure error (finding F11). @@ -558,6 +588,8 @@ struct PromiseAwareDropView: NSViewRepresentable { /// until restarted (verified live). They're cleaned up after the /// promise completes (or a 120s deadline) by a deferred task. let pendingReceiverDirs: [URL] + /// Fallback dirs whose writer thread hasn't finished. Same rule as above. + let pendingFallbackDirs: [URL] let totalExpected: Int let session: PromiseDropSupport.StagingSession let onFiles: ([(url: URL, stagingDir: URL?)]) -> Void @@ -584,7 +616,8 @@ struct PromiseAwareDropView: NSViewRepresentable { expectedItemCount: context.totalExpected ) let supported = PromiseDropSupport.filterSupported(selected) - let result = supported.map { (url: $0, stagingDir: $0.deletingLastPathComponent()) } + // Any path can land several files in one dir; the coordinator deletes per item. + let result = PromiseDropSupport.relocateForExclusiveOwnership(supported, session: context.session) if result.isEmpty { // Same pending-respecting sweep as the non-empty branch below: @@ -595,14 +628,14 @@ struct PromiseAwareDropView: NSViewRepresentable { for dir in PromiseDropSupport.dirsSafeToRemoveNow( allDirs: context.allDirs, deliveredFiles: [], - pendingDirs: context.pendingReceiverDirs + pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs ) { try? FileManager.default.removeItem(at: dir) } - if context.pendingReceiverDirs.isEmpty { + if context.pendingReceiverDirs.isEmpty, context.pendingFallbackDirs.isEmpty { context.session.removeAll() } else { - self.cleanUpLater(pendingDirs: context.pendingReceiverDirs) + self.cleanUpLater(pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs) } let reason = selected.isEmpty ? "No files could be read from the drop." @@ -620,8 +653,8 @@ struct PromiseAwareDropView: NSViewRepresentable { // session root) after each item's transcription. for dir in PromiseDropSupport.dirsSafeToRemoveNow( allDirs: context.allDirs, - deliveredFiles: supported, - pendingDirs: context.pendingReceiverDirs + deliveredFiles: result.map(\.url), + pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs ) { try? FileManager.default.removeItem(at: dir) } @@ -631,7 +664,7 @@ struct PromiseAwareDropView: NSViewRepresentable { context.onError("\(context.totalExpected - result.count) file(s) could not be read from the drop.") } - self.cleanUpLater(pendingDirs: context.pendingReceiverDirs) + self.cleanUpLater(pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs) } /// Removes still-pending receiver dirs once their promise has had time diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift index 8a3a5342..aac789c4 100644 --- a/Sources/Fluid/UI/PromiseDropSupport.swift +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -119,6 +119,27 @@ nonisolated enum PromiseDropSupport { return delivered } + /// Gives each delivered file its own dir: the coordinator deletes an item's dir the + /// moment that item finishes, so a shared one loses files still-queued items need. + /// A failed move reports `stagingDir: nil` — unowned beats shared. Order-preserving. + 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) + } + } + } + /// Audio/movie file extensions the OS can decode, queried nonisolated from /// AVFoundation. Mirrors `MeetingTranscriptionService.supportedFileExtensions` /// but is safe to call from any isolation context. diff --git a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift index 8502f242..b3f17775 100644 --- a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift +++ b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift @@ -270,6 +270,72 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { XCTAssertTrue(fm.fileExists(atPath: audioURL.path), "files without a stagingDir belong to the user and must never be deleted") } + // MARK: - Shared staging dir regression (issue #219 batch drop) + + 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 { diff --git a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift index d05fa709..98cca809 100644 --- a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -192,6 +192,79 @@ final class PromiseDropSupportTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: dirB.path)) } + // MARK: - Relocation for exclusive ownership (issue #219 batch drop) + + 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() { From a7e75d8face628b8007dc49af9a7ac4523710508 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:51:29 -0700 Subject: [PATCH 08/17] fix: drop a cancelled transcription instead of writing it to history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancellation was only checked before the transcription await, so a provider that returns normally after a cancel still reached the completion writes: the result landed in FileTranscriptionHistoryStore and fired success analytics while the batch card showed the item as cancelled. - Recheck after the native path's transcribeFile, which has no interruption point at all — the UI's "Cancelling…" state exists precisely because that call runs to completion. - Recheck after the chunked loop, which only checks between chunks, so a cancel during the final chunk (up to 20 minutes of audio) reached the writes too. Both throw into the coordinator's existing CancellationError path. --- Sources/Fluid/Services/MeetingTranscriptionService.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index 6706a78f..45d1fde0 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -218,6 +218,9 @@ final class MeetingTranscriptionService: ObservableObject { 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, @@ -348,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 From ebeec178254e0040114b55a6ebbba86a60771a44 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:51:29 -0700 Subject: [PATCH 09/17] style: compress Voice Memos drop and batch comments Branch-added comments were 19% of added lines against a repo norm nearer 8%. Collapses prose blocks to single lines and drops change narration, keeping the live-verified AppKit facts that are not inferable from the code: pasteboard main-thread deadlocks, read ordering vs the NSPasteboard type-cache crash, the legacy promise API's 35-80s block, the pending-dir sweep hazard, Cocoa 3072 on Catalyst sources, and the timeout rationale. 404 comment lines to 265. Also moves a dismissBatch() doc comment that had drifted onto showDropError. --- Sources/Fluid/ContentView.swift | 18 +- .../BatchTranscriptionCoordinator.swift | 41 ++-- .../Services/FileTranscriptionSession.swift | 52 ++--- .../Fluid/UI/MeetingTranscriptionView.swift | 47 ++-- Sources/Fluid/UI/PromiseAwareDropView.swift | 201 ++++++------------ Sources/Fluid/UI/PromiseDropSupport.swift | 84 +++----- .../BatchTranscriptionCoordinatorTests.swift | 37 ++-- .../PromiseDropSupportTests.swift | 34 ++- 8 files changed, 188 insertions(+), 326 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 7e000c6c..1d638d46 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -3077,8 +3077,8 @@ struct ContentView: View { /// Capture app context at start to avoid mismatches if the user switches apps mid-session private func startRecording() { - // File-transcription batches drive the same shared ASR model; starting - // dictation mid-batch would run concurrent inference through one model. + // Batches drive the same shared ASR model; starting dictation mid-batch would + // run concurrent inference through one model. guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return @@ -3423,8 +3423,7 @@ struct ContentView: View { self.setActiveRecordingMode(.edit) guard !self.asr.isRunningOrStarting else { return } - // Starts recording directly, bypassing beginDictationRecording, so it - // needs its own batch guard and intent claim. + // Bypasses beginDictationRecording, so needs its own batch guard and intent claim. guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return @@ -3756,9 +3755,8 @@ extension ContentView { } private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) { - // Common chokepoint for dictate/prompt/command/rewrite hotkeys: file-transcription - // batches drive the same shared ASR model, so none of these modes may start - // while a batch is running. + // Common chokepoint for dictate/prompt/command/rewrite hotkeys: batches drive + // the same shared ASR model, so none may start while one is running. guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return @@ -3819,10 +3817,8 @@ extension ContentView { self.beginDictationRecording(for: .secondary, mode: mode) } - /// Feedback when a dictation-family hotkey (dictate/prompt/command/rewrite) is - /// rejected because a file-transcription batch is running. Reuses the existing - /// stop cue rather than building new UI, so the user gets an audible signal that - /// the hotkey press had no effect instead of silence. + /// Rejection feedback when a dictation-family hotkey fires during a batch. Reuses the + /// stop cue rather than new UI, so the user hears the press had no effect. private func notifyDictationBlockedByBatch() { DebugLogger.shared.warning( "Dictation blocked: batch file transcription in progress", diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift index 34114433..1b9631e5 100644 --- a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -1,19 +1,16 @@ import Combine import Foundation -/// Coordinates sequential transcription of a batch of audio files (issue #219). -/// -/// The coordinator owns per-item state for multi-file transcription: strict sequential -/// processing in enqueue order, per-item success/failure/no-speech outcomes, cooperative -/// cancellation of the in-flight file, staging-directory cleanup after each item finishes, -/// and batch start/end hooks for dictation arbitration. All stored closures are invoked on -/// the main actor. +/// Sequentially transcribes a batch of audio files: strict enqueue-order processing, +/// per-item success/failure/no-speech outcomes, cooperative cancellation, staging-dir +/// cleanup per item, and batch start/end hooks for dictation arbitration. All stored +/// closures run on the main actor. @MainActor final class BatchTranscriptionCoordinator: ObservableObject { /// A transcription request for a single audio file. struct Request { let url: URL - /// Exclusively owned by this item; deleted once the item finishes — producers must never hand the same dir to two items. + /// Exclusively owned by this item; deleted when it finishes. Never share across items. let stagingDir: URL? init(url: URL, stagingDir: URL? = nil) { @@ -36,7 +33,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { struct Item: Identifiable { let id: UUID let url: URL - /// Exclusively owned by this item; deleted once the item finishes — producers must never hand the same dir to two items. + /// Exclusively owned by this item; deleted when it finishes. Never share across items. let stagingDir: URL? var status: Status } @@ -49,9 +46,8 @@ final class BatchTranscriptionCoordinator: ObservableObject { private let onBatchEnd: @MainActor () -> Void private var batchTask: Task? - /// Published so the UI can show a "Cancelling…" state: pending items stop - /// immediately, but the in-flight item may run to completion (the native - /// provider transcription has no interruption point). + /// Drives a "Cancelling…" UI state: the in-flight item may still run to completion + /// (no interruption point), even though pending items stop immediately. @Published private(set) var isCancelRequested: Bool = false private var isCancelled: Bool = false @@ -156,11 +152,9 @@ final class BatchTranscriptionCoordinator: ObservableObject { self.isRunning = false self.batchTask = nil - // Self-draining: items enqueued while this batch was cancelling (or right as - // it finished) are still .pending and were never picked up above. Start a - // fresh run for them so they aren't stranded. cancel() already flips - // pre-existing .pending items to .cancelled, so only genuinely new items - // land here. + // Self-draining: items enqueued during cancellation/finish are still .pending + // and were missed above, so start a fresh run. cancel() already flips + // pre-existing pending items to .cancelled, so only new ones land here. if self.items.contains(where: { if case .pending = $0.status { return true } else { return false } }) { self.startBatch() } else { @@ -176,9 +170,8 @@ final class BatchTranscriptionCoordinator: ObservableObject { } } - /// Finds the first item still awaiting processing. Completed/failed/cancelled items - /// are never revisited, so re-enqueuing after a finished-but-undismissed batch only - /// processes the newly appended items. + /// Completed/failed/cancelled items are never revisited, so re-enqueuing after a + /// finished-but-undismissed batch only processes newly appended items. private func nextPendingIndex() -> Int? { self.items.firstIndex { if case .pending = $0.status { return true } else { return false } } } @@ -208,8 +201,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { } } - // Staging dirs are owned by the coordinator and removed only after the item has - // finished transcribing (success, failure, or cancellation) — never before/during. + // Removed only after the item finishes (success, failure, or cancellation). self.removeStagingDir(for: index) } @@ -217,9 +209,8 @@ final class BatchTranscriptionCoordinator: ObservableObject { guard let stagingDir = self.items[index].stagingDir else { return } try? FileManager.default.removeItem(at: stagingDir) - // Per-item staging dirs live inside a drop-session root (see - // PromiseDropSupport.StagingSession). Once the last item dir is gone, - // remove the now-empty session root so drops don't leak temp shells. + // Prune the now-empty drop-session root (PromiseDropSupport.StagingSession) + // once its last item dir is gone, so drops don't leak temp shells. let parent = stagingDir.deletingLastPathComponent() guard parent.lastPathComponent.hasPrefix(PromiseDropSupport.stagingRootPrefix), let remaining = try? FileManager.default.contentsOfDirectory(atPath: parent.path), diff --git a/Sources/Fluid/Services/FileTranscriptionSession.swift b/Sources/Fluid/Services/FileTranscriptionSession.swift index 1033d80d..7d22114f 100644 --- a/Sources/Fluid/Services/FileTranscriptionSession.swift +++ b/Sources/Fluid/Services/FileTranscriptionSession.swift @@ -1,38 +1,31 @@ import Combine import Foundation -/// App-level owner of file-transcription state (issue #219). -/// -/// The transcription service and batch coordinator used to be `@StateObject`s -/// of `MeetingTranscriptionView`, so switching sidebar pages destroyed the view -/// and silently killed any in-flight transcription. Owning them here keeps -/// batches running while the user navigates; the view re-attaches on return. +/// App-level owner of file-transcription state, so switching sidebar pages (which +/// destroys `MeetingTranscriptionView`) doesn't silently kill an in-flight batch; +/// the view re-attaches on return. @MainActor final class FileTranscriptionSession { static let shared = FileTranscriptionSession() - /// Set once `shared` is first touched. Lets the dictation path check batch - /// state without lazily constructing the session (and the ASR service). + /// Set once `shared` is first touched, so the dictation path can check batch state + /// without force-creating the session (and the ASR service). private static var sharedIfCreated: FileTranscriptionSession? - /// True while a batch is actively transcribing. Dictation must not start - /// concurrently: both paths drive the same shared ASR model, and - /// concurrent inference corrupts output. + /// Dictation must not start while true: both paths drive the same shared ASR model, + /// and concurrent inference corrupts output. static var isBatchTranscribing: Bool { self.sharedIfCreated?.batchHolder.coordinator?.isRunning == true } - /// True from the moment a dictation/prompt/command/rewrite recording is about to - /// start until the session ends. This is the "intent to dictate" half of the - /// single-model arbiter shared with the batch transcribe closure below: it is set - /// synchronously on the main actor before any `await`, closing the TOCTOU window - /// where the batch closure's `AppServices.shared.asr.isRunning` check could pass - /// right before dictation flips `isRunning` true. + /// True from just before a dictation/prompt/command/rewrite recording starts until + /// the session ends. The "intent to dictate" half of the single-model arbiter shared + /// with the batch transcribe closure: set synchronously before any `await`, closing + /// the TOCTOU window where the batch's `asr.isRunning` check could pass right before + /// dictation flips `isRunning` true. /// - /// Uses `sharedIfCreated` (not `shared`) so callers that only need to *read* the - /// flag never force-create the session/ASR service. `beginDictationIntent()` is the - /// one path allowed to force-create, since it's only called from the app's own - /// recording start path where creating the session is expected anyway. + /// Readers use `sharedIfCreated` to avoid force-creating the session/ASR service; + /// `beginDictationIntent()` is the one path allowed to force-create. private(set) var dictationIntent: Bool = false let service: MeetingTranscriptionService @@ -42,11 +35,10 @@ final class FileTranscriptionSession { let service = MeetingTranscriptionService(asrService: AppServices.shared.asr) self.service = service self.batchHolder = BatchCoordinatorHolder(transcribe: { url in - // Single-model arbitration: at most one of dictation, single-file - // transcription, and batch transcription may drive the shared ASR model at - // once. Wait for dictation intent (covers the TOCTOU window before - // AppServices.shared.asr.isRunning flips true), live dictation, and any - // in-flight single-file transcription. Cancellation still interrupts the wait. + // Single-model arbitration: at most one of dictation, single-file, and batch + // transcription may drive the shared ASR model at once. Waits on dictation + // intent, live dictation, and any in-flight single-file transcription; + // cancellation still interrupts the wait. while AppServices.shared.asr.isRunning || FileTranscriptionSession.shared.dictationIntent || service.isTranscribing { @@ -76,8 +68,8 @@ final class FileTranscriptionSession { } } -/// Wraps the batch coordinator so a finished batch can be cleared and a fresh -/// one lazily created, while republshing the inner coordinator's changes. +/// Wraps the batch coordinator so a finished batch can be cleared and a fresh one lazily +/// created, republishing the inner coordinator's changes. @MainActor final class BatchCoordinatorHolder: ObservableObject { @Published var coordinator: BatchTranscriptionCoordinator? @@ -100,8 +92,8 @@ final class BatchCoordinatorHolder: ObservableObject { } func clear() { - // Never orphan a running batch: without this, dropping the coordinator - // would leave it transcribing invisibly with no way to reach it. + // Never orphan a running batch: dropping it without cancelling would leave it + // transcribing invisibly with no way to reach it. self.coordinator?.cancel() self.forwarder?.cancel() self.forwarder = nil diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index 63f279e5..fd8a906a 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -4,8 +4,7 @@ import UniformTypeIdentifiers struct MeetingTranscriptionView: View { let asrService: ASRService - // Owned by FileTranscriptionSession (app-level) so in-flight transcriptions - // survive this view being destroyed by sidebar navigation. + // Owned by FileTranscriptionSession so in-flight transcriptions survive sidebar navigation. @ObservedObject private var transcriptionService: MeetingTranscriptionService @ObservedObject private var batchHolder: BatchCoordinatorHolder @ObservedObject private var fileHistoryStore = FileTranscriptionHistoryStore.shared @@ -101,9 +100,8 @@ struct MeetingTranscriptionView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - // Overlay, not background: AppKit's drag-target search walks views - // front-to-back, so the drop target must sit above the SwiftUI content. - // Its hitTest returns nil, so clicks pass through to the UI beneath. + // Overlay (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 }, @@ -564,9 +562,8 @@ struct MeetingTranscriptionView: View { Spacer() if coordinator.isRunning { - // Pending items stop immediately; the in-flight file may - // run to completion (native provider transcription has - // no interruption point), hence the "Cancelling" state. + // In-flight file may still run to completion (no interruption point), + // hence "Cancelling" rather than an instant stop. if coordinator.isCancelRequested { Text("Cancelling…") .font(.caption) @@ -698,30 +695,22 @@ struct MeetingTranscriptionView: View { // MARK: - Helper Functions - /// True while a batch coordinator exists with any enqueued items (running or - /// finished-but-not-dismissed). Used to suppress the single-file cards while a - /// batch is in progress, since `transcribeFile` mutates the shared service state - /// for each batch item. + /// True while a batch coordinator has any enqueued items (running or finished-but- + /// not-dismissed); suppresses single-file cards since `transcribeFile` mutates the + /// shared service state per batch item. private var isBatchActive: Bool { guard let coordinator = self.batchHolder.coordinator else { return false } return !coordinator.items.isEmpty } - /// Routes incoming files from a drop or the file picker. - /// - /// A single concrete file (no staging directory) follows the existing single-file - /// flow: select it and wait for the user to tap Transcribe. Everything else — two - /// or more files, or any staged (promised) file — is enqueued on the batch - /// coordinator so staged directories are reliably cleaned up after transcription. + /// Routes incoming files from a drop or the file picker. A single concrete file (no + /// staging dir) follows the existing single-file flow; everything else — multiple + /// files, or any staged/promised file — is enqueued on the batch coordinator so + /// staged dirs are reliably cleaned up after transcription. private func handleIncomingFiles(_ files: [(url: URL, stagingDir: URL?)]) { guard !files.isEmpty else { return } self.dropErrorMessage = nil - // Single concrete file goes through the fast path only when there's no batch - // (running or finished-but-undismissed — isBatchActive covers both) and no - // single-file transcription already in flight; otherwise it's routed into the - // coordinator, which either appends to the live batch or shows up as a fresh - // row so the self-draining batch picks it up. if files.count == 1, let file = files.first, file.stagingDir == nil, !self.isBatchActive, !self.transcriptionService.isTranscribing { self.selectedFileURL = file.url @@ -732,12 +721,9 @@ struct MeetingTranscriptionView: View { self.batchHolder.enqueue(files.map { .init(url: $0.url, stagingDir: $0.stagingDir) }) } - /// Clears the batch list and resets shared service state so a subsequent batch - /// starts fresh and no stale single-file result lingers. - /// Shows a drop error and auto-dismisses it after a few seconds. Promise - /// resolution can report errors up to two minutes after the drop; without - /// auto-dismissal a stale red card would appear over an already-successful - /// batch and stick around until manually closed. + /// Auto-dismisses after a few seconds: promise resolution can report errors up to + /// two minutes after the drop, so without this a stale red card could sit over an + /// already-successful batch until manually closed. private func showDropError(_ message: String) { self.dropErrorMessage = message self.dropErrorGeneration += 1 @@ -750,6 +736,9 @@ struct MeetingTranscriptionView: View { } } + /// 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() diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index ab2a3d13..f293d287 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -1,12 +1,9 @@ import AppKit import SwiftUI -/// A drop target that accepts both concrete file URLs and file-promise drags -/// (e.g. from Voice Memos), and resolves promised files into per-item staging -/// directories before reporting them via callbacks. -/// -/// The pure strategy/staging/filtering logic lives in `PromiseDropSupport`; -/// this view only owns AppKit drag plumbing and the async resolution poll. +/// 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`; +/// this view owns only AppKit drag plumbing and the async resolution poll. struct PromiseAwareDropView: NSViewRepresentable { let onTargetedChange: (Bool) -> Void let onFiles: ([(url: URL, stagingDir: URL?)]) -> Void @@ -33,9 +30,7 @@ struct PromiseAwareDropView: NSViewRepresentable { var onFiles: ([(url: URL, stagingDir: URL?)]) -> Void = { _ in } var onError: (String) -> Void = { _ in } - /// Shared background queue for `NSFilePromiseReceiver` resolution. Static - /// so in-flight promise resolution survives the view being destroyed - /// (e.g. by sidebar navigation) mid-drop. + /// Static so in-flight promise resolution survives the view being destroyed mid-drop. private static let promiseQueue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 1 @@ -57,10 +52,8 @@ struct PromiseAwareDropView: NSViewRepresentable { self.registerForDraggedTypes([.fileURL] + promiseTypes) } - /// The view sits in an `.overlay` so AppKit's front-to-back drag-target - /// search finds it above the SwiftUI content; returning nil here lets - /// every normal mouse event fall through to the UI beneath. Drag - /// dispatch does not consult `hitTest`, so drops still arrive. + /// Sits in `.overlay` for drag-target search; returns nil so mouse events pass through + /// (drag dispatch doesn't consult hitTest, so drops still arrive). override func hitTest(_ point: NSPoint) -> NSView? { nil } @@ -147,31 +140,19 @@ struct PromiseAwareDropView: NSViewRepresentable { } let legacyDir = try? session.makeItemDirectory() let dataDir = try? session.makeItemDirectory() - // Captured by value so resolution keeps working (and can still hand - // files to the app-level batch session) if this view is destroyed - // by navigation while the drop is resolving. + // Captured by value: resolution must outlive this view if destroyed by navigation mid-drop. let onFiles = self.onFiles let onError = self.onError - // Every pasteboard read can block indefinitely (verified: the same - // read is instant on one drop and deadlocks on the next), and a - // block inside performDragOperation freezes the system-wide drag - // session. So performDragOperation returns immediately and ALL - // pasteboard interaction happens on throwaway background threads; - // the main thread only ever polls the staging directories. - // - // NOTE: using `sender`/`pasteboard` after performDragOperation has - // returned, from another thread, is outside AppKit's documented - // lifetime contract. It is the only arrangement that neither - // deadlocks nor loses the drop (verified live against Voice Memos); - // if a provider stops answering, the reads fail harmlessly on their - // background threads and the poll timeout reports the failure. + // A pasteboard read can block indefinitely, and blocking inside performDragOperation + // freezes the system-wide drag session — so it returns immediately and ALL pasteboard + // reads happen on background threads; using sender/pasteboard after return is outside + // AppKit's documented lifetime but is the only arrangement that doesn't deadlock or + // lose the drop against Voice Memos. A dead provider just fails on its own thread. let state = ResolutionState() state.beginInFlight() // outer worker token, released when it finishes - // The poller starts before any pasteboard read, so even a worker - // thread that hangs on its first read cannot leave the drop without - // a timeout/error path. Deliberately not tied to the view lifetime. + // Poller starts before any pasteboard read so a hung first read still hits a timeout. Task { @MainActor in await DropTargetView.resolvePromises( session: session, @@ -186,15 +167,11 @@ struct PromiseAwareDropView: NSViewRepresentable { Self.detachWorker { defer { state.endInFlight() } - // ORDERING MATTERS: NSPasteboard is not thread-safe, and - // receivePromisedFiles touches the pasteboard from its own - // queue. All of OUR pasteboard reads happen first, single- - // threaded, and only then do the receivers start — concurrent - // access crashed with an NSInternalInconsistencyException in - // NSPasteboard's type cache (verified live on a 3-memo drag). - // Every read is additionally wrapped in an ObjC exception - // catcher: a C++ terminate handler in the process turns any - // uncaught NSException into abort(). + // Ordering matters: NSPasteboard isn't thread-safe and receivePromisedFiles + // touches it from its own queue, so all our reads finish, single-threaded, + // before any receiver starts — concurrent access crashed NSPasteboard's type + // cache. Reads are wrapped in an ObjC exception catcher since an uncaught + // NSException aborts the process. // 1. Snapshot the receiver objects and per-item metadata/bytes. var receivers: [NSFilePromiseReceiver] = [] @@ -233,14 +210,13 @@ struct PromiseAwareDropView: NSViewRepresentable { ) state.setPayloadCount(itemPayloads.count) - // 2. Raw-data fallback: write each item's bytes into the shared - // data dir (verified as the reliable path for Voice Memos). - // Identically-named items get disambiguated file names. + // 2. Raw-data fallback: write each item's bytes into the shared data dir + // (the reliable path for Voice Memos). Disambiguate identically-named items. if let dataDir, !itemPayloads.isEmpty { var usedNames = Set() for (index, payload) in itemPayloads.enumerated() { let fallbackName = "Dropped Audio \(index + 1)" - // Provider-supplied, so untrusted: separators would escape staging. + // Provider-supplied name is untrusted: separators would escape staging. let itemName = PromiseDropSupport.sanitizedFileName( payload.name ?? suggestedName, fallback: fallbackName @@ -270,18 +246,15 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - // 3. Modern receivers, started only after our reads are done. - // Still required even when the raw-data path delivered: leaving - // a promise unresolved wedges the source app's drag machinery - // (Voice Memos refuses further drags until restarted). + // 3. Modern receivers, started only after our reads are done. Still required + // even when raw-data delivered: leaving a promise unresolved wedges the + // source app's drag machinery (Voice Memos refuses further drags until restart). for receiver in receivers { guard let dir = try? session.makeItemDirectory() else { continue } state.registerReceiverDir(dir) - // A per-receiver token: the soft timeout must not abandon a - // promise that is still downloading (e.g. iCloud taking 45s) - // just because the outer worker thread already finished - // (finding F8). Released once, after the receiver's LAST - // file callback — receivePromisedFiles fires once per name. + // Per-receiver token: soft timeout must not abandon a promise still + // downloading (e.g. iCloud) just because the outer worker finished. + // Released once, after the receiver's LAST file callback. state.beginInFlight() let completion = ReceiverCompletion(fileCount: receiver.fileNames.count) receiver.receivePromisedFiles(atDestination: dir, options: [:], operationQueue: Self.promiseQueue) { url, error in @@ -302,13 +275,10 @@ struct PromiseAwareDropView: NSViewRepresentable { } // 4. Legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:), - // strictly last resort. A pending legacy request freezes the - // source app's promise machinery until it answers (verified - // live: Voice Memos unresponsive for 35-80s, blocking further - // drags), so it only fires if neither the modern receiver nor - // the raw-data path has produced anything within a short - // window. Invoked via perform(_:) with a string selector so no - // deprecation warning is emitted. + // strictly last resort: an eager call blocks Voice Memos's drag machinery + // for 35-80s, so it only fires if modern/raw-data produced nothing within + // a short window. Invoked via perform(_:) with a string selector to avoid + // the deprecation warning. if let legacyDir { let waitDeadline = Date().addingTimeInterval(3) var otherPathDelivered = false @@ -370,12 +340,9 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - /// Thread-safe resolution state shared between the background delivery - /// threads and the main-actor poller: which receiver dirs have completed, - /// and whether the legacy/raw-data reads are still in flight (under heavy - /// inference load a provider can take 60s+ to answer — verified live — - /// and the poller must not give up and sweep the staging dirs while an - /// answer is still coming). + /// Shared between background delivery threads and the main-actor poller: which + /// receiver dirs completed, and whether legacy/raw-data reads are still in flight + /// (a provider can take 60s+ under load; the poller must not sweep staging dirs early). private final class ResolutionState: @unchecked Sendable { private let lock = NSLock() private var receiverDirs: [URL] = [] @@ -408,11 +375,8 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.completed } - /// Marks a receiver as resolved-but-failed. A failed receiver's - /// promise has already answered (with an error), so — unlike a - /// still-pending one — its staging dir is safe to sweep immediately - /// and it counts toward "every receiver resolved" for delivery - /// gating (finding F9). + /// A failed receiver's promise already answered, so unlike a pending one its dir + /// is safe to sweep immediately, and it counts toward "every receiver resolved". func markFailed(_ dir: URL) { self.lock.lock() defer { self.lock.unlock() } @@ -425,8 +389,7 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.failed } - /// Records the raw-data item count once, so a receiver-less - /// multi-item drop can still detect a partial delivery (finding F11). + /// Lets a receiver-less multi-item drop still detect a partial delivery. func setPayloadCount(_ count: Int) { self.lock.lock() defer { self.lock.unlock() } @@ -458,9 +421,8 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - /// Detached worker at user-initiated QoS: default-QoS threads get starved - /// while ML inference saturates the cores, which is exactly when a second - /// drop arrives mid-batch. + /// User-initiated QoS: default-QoS threads starve while ML inference saturates + /// the cores, exactly when a second drop can arrive mid-batch. private static func detachWorker(_ body: @escaping () -> Void) { let thread = Thread(block: body) thread.qualityOfService = .userInitiated @@ -479,10 +441,9 @@ struct PromiseAwareDropView: NSViewRepresentable { ) async { let pollInterval: UInt64 = 200_000_000 let softTimeout: TimeInterval = 30 - // Under heavy inference load a provider can take 60s+ to answer - // (verified live: 80s for a second drop mid-batch). While any - // delivery thread is still in flight the poll keeps waiting, up to - // a hard cap so a truly wedged provider still ends in an error. + // A provider can take 60s+ under heavy inference load (seen up to 80s); keep + // waiting while any delivery thread is in flight, up to a hard cap so a truly + // wedged provider still ends in an error. let hardTimeout: TimeInterval = 120 let modernGrace: TimeInterval = 1.5 let start = Date() @@ -493,15 +454,10 @@ struct PromiseAwareDropView: NSViewRepresentable { let failed = state.failedSnapshot() return DeliveryContext( allDirs: receiverDirs + [legacyDir, dataDir].compactMap(\.self), - // A failed receiver has already answered (with an error), so - // unlike a still-pending one its dir is safe to sweep now. pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) && !failed.contains($0) }, - // Relocation empties the legacy dir, so the sweep would now see it as - // undelivered and delete a destination the OS may still be writing into. + // Relocation empties the legacy dir, so a sweep would see it as undelivered + // and delete a destination the OS may still be writing into. pendingFallbackDirs: state.hasInFlightWork ? [legacyDir].compactMap(\.self) : [], - // The raw-data item count also bounds expectations: a - // receiver-less multi-item drop that partially lands must - // still surface a partial-failure error (finding F11). totalExpected: max(receiverDirs.count, state.payloadCountValue(), 1), session: session, onFiles: onFiles, @@ -516,9 +472,8 @@ struct PromiseAwareDropView: NSViewRepresentable { if elapsed >= hardTimeout { break } if elapsed >= softTimeout, !state.hasInFlightWork { break } - // A receiver dir only counts once its completion callback fired — - // a stalled (e.g. iCloud) download must never be delivered as a - // truncated file just because its size held still between polls. + // A receiver dir only counts once its completion callback fired — a stalled + // (e.g. iCloud) download must never look complete just from a stable size. let receiverDirs = state.receiverDirsSnapshot() let readyModernDirs = state.completedSnapshot() let failedModernDirs = state.failedSnapshot() @@ -527,33 +482,26 @@ struct PromiseAwareDropView: NSViewRepresentable { let dataFiles = self.listFiles(in: [dataDir].compactMap(\.self)) let fallbackSizes = self.sizeMap(for: legacyFiles + dataFiles) - // "Resolved" = completed OR failed: a failed receiver's promise - // has already answered, so it can never flip to completed later - // (finding F9) — waiting on it would spin the full soft timeout. + // "Resolved" = completed OR failed: a failed promise can never later flip to + // completed, so waiting on it would spin the full soft timeout. let resolvedCount = readyModernDirs.count + failedModernDirs.count let allReceiversResolved = !receiverDirs.isEmpty && resolvedCount >= receiverDirs.count - // Modern succeeded outright: every registered receiver resolved, - // at least one completed, and it produced files. (Receivers - // register in one tight loop with no pasteboard call in - // between, so a partially-registered snapshot is a micro-race - // at worst; completion still requires each dir's callback.) + // Modern succeeded outright: every receiver resolved, at least one completed, + // and it produced files. let modernComplete = allReceiversResolved && !readyModernDirs.isEmpty && !modernFiles.isEmpty if modernComplete { self.deliver(modern: modernFiles, legacy: legacyFiles, data: dataFiles, context: makeContext()) return } - // Modern produced nothing — either no receivers were ever - // registered, or every registered receiver resolved and none - // completed — past the grace period, but a fallback landed and - // its sizes are stable — use the fallback copies. + // Modern produced nothing (no receivers, or all resolved with none completed) + // past the grace period, and a fallback landed with stable sizes — use it. let modernExhaustedWithNoWins = receiverDirs.isEmpty || (allReceiversResolved && readyModernDirs.isEmpty) let modernProducedNothing = modernExhaustedWithNoWins && elapsed > modernGrace - // The worker must be done (no in-flight token) so a multi-item - // raw-data sweep that is still writing files 3..5 isn't - // delivered after only the first items stabilized. + // Worker must be fully done, so a multi-item raw-data sweep still writing + // later files isn't delivered early just because earlier ones stabilized. let fallbackStable = !fallbackSizes.isEmpty && fallbackSizes == fallbackPrevSizes && !state.hasInFlightWork if modernProducedNothing && fallbackStable { @@ -565,9 +513,8 @@ struct PromiseAwareDropView: NSViewRepresentable { try? await Task.sleep(nanoseconds: pollInterval) } - // Timeout: deliver whatever completed, preferring modern. Incomplete - // receiver dirs are excluded (possible truncation) and surface via - // the partial-failure error below. + // Timeout: deliver whatever completed, preferring modern; incomplete dirs are + // excluded (possible truncation) and surface via the partial-failure error. let receiverDirs = state.receiverDirsSnapshot() let readyModernDirs = state.completedSnapshot() self.deliver( @@ -581,12 +528,9 @@ struct PromiseAwareDropView: NSViewRepresentable { /// Everything `deliver` needs besides the per-path file lists. private struct DeliveryContext { let allDirs: [URL] - /// Receiver dirs whose promise hasn't completed yet. They must NOT - /// be swept at delivery: the source app may still be writing into - /// them, and deleting the destination leaves its promise session - /// unresolved — Voice Memos then refuses to start any further drag - /// until restarted (verified live). They're cleaned up after the - /// promise completes (or a 120s deadline) by a deferred task. + /// Receiver dirs whose promise hasn't completed yet. Must NOT be swept at delivery: + /// deleting an in-flight promise's destination wedges Voice Memos until restart. + /// Cleaned up later, after completion (or a 120s deadline). let pendingReceiverDirs: [URL] /// Fallback dirs whose writer thread hasn't finished. Same rule as above. let pendingFallbackDirs: [URL] @@ -596,9 +540,7 @@ struct PromiseAwareDropView: NSViewRepresentable { let onError: (String) -> Void } - /// Delivers the resolved files. Selection/dedup rules live in - /// `PromiseDropSupport.selectDelivery`; unsupported formats are filtered - /// like the concrete-URL path. + /// Delivers resolved files. Selection/dedup lives in `PromiseDropSupport.selectDelivery`. private static func deliver( modern: [URL], legacy: [URL], @@ -620,11 +562,8 @@ struct PromiseAwareDropView: NSViewRepresentable { let result = PromiseDropSupport.relocateForExclusiveOwnership(supported, session: context.session) if result.isEmpty { - // Same pending-respecting sweep as the non-empty branch below: - // an empty result must not delete staging dirs whose receiver - // promise hasn't completed yet (finding F1) — removeAll() used - // to nuke the whole session root, pending dirs included, which - // wedges the source app's drag machinery. + // Same pending-respecting sweep as below: must not delete staging dirs whose + // receiver promise hasn't completed — that wedges the source app's drag machinery. for dir in PromiseDropSupport.dirsSafeToRemoveNow( allDirs: context.allDirs, deliveredFiles: [], @@ -644,13 +583,10 @@ struct PromiseAwareDropView: NSViewRepresentable { return } - // Item dirs that hold no delivered file — losing duplicates (e.g. the - // raw-data copy when the modern receiver won) and empty shells from - // failed paths — are dead weight. Deleting them can race the still- - // running legacy/data writer threads; that is harmless (the losers' - // files are throwaway copies) and keeps the temp dir clean. The - // batch coordinator removes the delivered dirs (and then the empty - // session root) after each item's transcription. + // Dirs holding no delivered file (losing duplicates, empty shells from failed + // paths) are dead weight; deleting them may race still-running writer threads, + // which is harmless since the losers' files are throwaway copies. The batch + // coordinator removes delivered dirs (then the empty session root) per item. for dir in PromiseDropSupport.dirsSafeToRemoveNow( allDirs: context.allDirs, deliveredFiles: result.map(\.url), @@ -667,9 +603,8 @@ struct PromiseAwareDropView: NSViewRepresentable { self.cleanUpLater(pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs) } - /// Removes still-pending receiver dirs once their promise has had time - /// to finish writing (their content is a duplicate of what was already - /// delivered), then prunes the session root if it ended up empty. + /// Removes still-pending receiver dirs once their promise has had time to finish + /// writing (duplicates of what was already delivered), then prunes an empty session root. private static func cleanUpLater(pendingDirs: [URL]) { guard !pendingDirs.isEmpty else { return } Task { @MainActor in diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift index aac789c4..45b7f71d 100644 --- a/Sources/Fluid/UI/PromiseDropSupport.swift +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -2,14 +2,10 @@ import AVFoundation import Foundation import UniformTypeIdentifiers -/// Testable, nonisolated core logic for the promise-aware drop path (issue #219). -/// -/// The AppKit drop view (`PromiseAwareDropView`) needs a live drag session, so it -/// delegates the pure pieces here: deciding whether a pasteboard carries concrete -/// file URLs or file promises, staging promised files into collision-free per-item -/// directories, and filtering drops to formats AVFoundation can actually decode. -/// Everything is `nonisolated` so it can be unit-tested from a nonisolated test -/// context despite the app target's `MainActor` default actor isolation. +/// Testable, nonisolated core logic for the promise-aware drop path: pasteboard strategy +/// detection, collision-free per-item staging, and AVFoundation format filtering. +/// `PromiseAwareDropView` owns the live drag session and delegates the pure pieces here. +/// `nonisolated` so it's unit-testable despite the app target's `MainActor` default. nonisolated enum PromiseDropSupport { /// How a given pasteboard should be interpreted. enum Strategy: Equatable { @@ -22,22 +18,18 @@ nonisolated enum PromiseDropSupport { /// The pasteboard type that carries a concrete file URL. private static let concreteFileURLType = "public.file-url" - /// Pasteboard type identifiers that signal a file-promise drop. Voice Memos - /// emits a mix of these (verified live 2026-07-25); any one is sufficient. + /// Pasteboard types signaling a file-promise drop. 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", ] - /// Decides the drop strategy from raw pasteboard type strings. - /// - /// Concrete file URLs win over promises when both are present. Promises are - /// only accepted when the pasteboard also advertises an audio/movie content - /// type (Voice Memos exposes e.g. `com.apple.m4a-audio` directly in the - /// type list) — so an image promise from Photos is rejected up front rather - /// than staged and failed later. Returns `nil` for pasteboards that carry - /// neither file URLs nor decodable promises. + /// Concrete file URLs win when both are present. Promises are only accepted when the + /// pasteboard also advertises audio/movie content (Voice Memos exposes e.g. + /// `com.apple.m4a-audio` directly), so an image promise from Photos is rejected up + /// front instead of staged and failed later. `nil` if neither is present. static func strategy(forPasteboardTypes types: [String]) -> Strategy? { if types.contains(concreteFileURLType) { return .concreteFileURLs @@ -52,12 +44,9 @@ nonisolated enum PromiseDropSupport { return promisesAudioVisualContent ? .filePromise : nil } - /// Staging dirs that hold no delivered file and should be swept. - /// - /// Compares standardized *paths*, not URLs: a directory built via - /// `appendingPathComponent` and the same directory derived from a file via - /// `deletingLastPathComponent()` differ in trailing-slash form and compare - /// unequal as URLs — which once made the sweep delete a delivered file. + /// Staging dirs that hold no delivered file and should be swept. Compares standardized + /// *paths*, not URLs — trailing-slash differences between `appendingPathComponent` and + /// `deletingLastPathComponent()` once made two equal dirs compare unequal as URLs. static func sweepableDirs(allDirs: [URL], deliveredFiles: [URL]) -> [URL] { let deliveredDirPaths = Set(deliveredFiles.map { $0.deletingLastPathComponent().standardizedFileURL.path @@ -65,14 +54,10 @@ nonisolated enum PromiseDropSupport { return allDirs.filter { !deliveredDirPaths.contains($0.standardizedFileURL.path) } } - /// Staging dirs safe to delete right now: swept per `sweepableDirs`, minus - /// any dir whose receiver promise hasn't completed yet. - /// - /// Pending receiver dirs must never be removed while pending — the source - /// app (e.g. Voice Memos) may still be writing into them, and deleting the - /// destination out from under an in-flight `NSFilePromiseReceiver` wedges - /// its drag machinery until restart. Callers are expected to defer removal - /// of the excluded dirs until the pending promises resolve. + /// Dirs safe to delete now: `sweepableDirs` minus any dir whose receiver promise + /// hasn't completed. Deleting an in-flight `NSFilePromiseReceiver`'s destination + /// wedges the source app's drag machinery until restart; callers defer removal + /// of excluded dirs until the pending promise resolves. static func dirsSafeToRemoveNow(allDirs: [URL], deliveredFiles: [URL], pendingDirs: [URL]) -> [URL] { let pendingPaths = Set(pendingDirs.map { $0.standardizedFileURL.path }) return sweepableDirs(allDirs: allDirs, deliveredFiles: deliveredFiles) @@ -93,16 +78,11 @@ nonisolated enum PromiseDropSupport { return trimmed } - /// Merges the three promise-delivery paths into the final file list. - /// - /// Every modern-receiver file is delivered: each receiver stages into its own - /// directory, so two distinct memos that share a display name (e.g. two - /// "New Recording.m4a") are distinct files and must never be collapsed. - /// - /// Legacy and raw-data files are alternates of the same items, never extra ones, so - /// they only fill gaps. Names alone can't identify an alternate — the raw-data path - /// renames same-named items ("New Recording 2.m4a") — so `expectedItemCount` bounds - /// the result. Pass nil to keep name-only behavior. + /// Merges the three promise-delivery paths. Every modern-receiver file is delivered + /// (each stages into its own dir, so same-named memos are distinct files, never + /// collapsed). Legacy/raw-data files are alternates, not extras, so they only fill + /// gaps; since the raw-data path renames same-named items, names alone can't identify + /// an alternate, so `expectedItemCount` bounds the result (nil keeps name-only behavior). static func selectDelivery( modern: [URL], legacy: [URL], @@ -140,9 +120,8 @@ nonisolated enum PromiseDropSupport { } } - /// Audio/movie file extensions the OS can decode, queried nonisolated from - /// AVFoundation. Mirrors `MeetingTranscriptionService.supportedFileExtensions` - /// but is safe to call from any isolation context. + /// Audio/movie extensions the OS can decode. Mirrors + /// `MeetingTranscriptionService.supportedFileExtensions` but callable from any isolation. private static let supportedExtensions: Set = { let avTypes = AVURLAsset.audiovisualTypes() let extensions = avTypes.compactMap { fileType -> String? in @@ -153,8 +132,7 @@ nonisolated enum PromiseDropSupport { return Set(extensions) }() - /// Keeps only URLs whose (lowercased) extension is a decodable audio/movie - /// format. Order-preserving. + /// Keeps only URLs with a decodable audio/movie extension. Order-preserving. static func filterSupported(_ urls: [URL]) -> [URL] { return urls.filter { url in let ext = url.pathExtension.lowercased() @@ -162,14 +140,10 @@ nonisolated enum PromiseDropSupport { } } - /// A scratch directory tree under the user temp dir that gives each promised - /// file its own subdirectory, so identically-named files (e.g. several Voice - /// Memos drops named "New Recording.m4a") never collide. - /// - /// All state is immutable, so the session is `Sendable` and safe to share - /// across the main actor and the background queue that resolves promises. - /// Prefix of every drop-session staging root; the batch coordinator uses it - /// to recognize (and prune) empty session roots after item cleanup. + /// Scratch directory tree under the user temp dir; each promised file gets its own + /// subdirectory so identically-named files never collide. Immutable state makes the + /// session `Sendable`, safe to share between the main actor and the resolution queue. + /// Prefix of every staging root; the batch coordinator uses it to prune empty roots. static let stagingRootPrefix = "PromiseDrop-" final class StagingSession: Sendable { diff --git a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift index b3f17775..507026c5 100644 --- a/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift +++ b/Tests/FluidDictationIntegrationTests/BatchTranscriptionCoordinatorTests.swift @@ -2,10 +2,8 @@ import Foundation import XCTest -/// Contract tests for BatchTranscriptionCoordinator (issue #219 batch transcription). -/// The coordinator owns per-item state for multi-file transcription: sequential -/// processing, per-item success/failure, cancellation, staging-dir cleanup, and -/// batch start/end hooks for dictation arbitration. +/// 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 { @@ -270,7 +268,7 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { XCTAssertTrue(fm.fileExists(atPath: audioURL.path), "files without a stagingDir belong to the user and must never be deleted") } - // MARK: - Shared staging dir regression (issue #219 batch drop) + // MARK: - Shared staging dir regression func testEachItemFileStillExistsWhenItsOwnTranscriptionRuns() async throws { // A shared dir deleted after the first item destroyed files later items needed. @@ -409,7 +407,7 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { XCTAssertEqual(endCount, 1, "the end hook must fire even when the batch is cancelled, or dictation stays blocked forever") } - // MARK: - Enqueue after a finished batch (F5/F15a) + // MARK: - Enqueue after a finished batch func testEnqueueAfterFinishedBatchProcessesOnlyNewItems() async { var transcribedPaths: [String] = [] @@ -425,8 +423,7 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { return XCTFail("first item should complete, got \(coordinator.items[0].status)") } - // Batch finished but items weren't dismissed (dismissBatch/clear() wasn't - // called) — enqueue must not re-walk from index 0 and re-run "first.m4a". + // 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() @@ -444,7 +441,7 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { } } - // MARK: - Enqueue during the cancel window (F6/F15b) + // MARK: - Enqueue during the cancel window func testEnqueueDuringCancelWindowIsNotStranded() async { let started = AsyncGate() @@ -463,15 +460,12 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { coordinator.enqueue([.init(url: self.tempAudioURL(name: "in-flight.m4a"))]) await started.wait() - // Cancel while the in-flight item is still gated/running. - coordinator.cancel() + coordinator.cancel() // while the in-flight item is still gated/running - // Enqueue a new item while the cancelled batch's in-flight item hasn't - // finished yet — it must not be stranded. + // Enqueued while the cancelled item is still finishing — must not be stranded. coordinator.enqueue([.init(url: self.tempAudioURL(name: "after-cancel.m4a"))]) - // Now let the in-flight (cancelled) item finish. - releaseInFlight.open() + releaseInFlight.open() // let the in-flight (cancelled) item finish await coordinator.waitUntilIdle() @@ -485,7 +479,7 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { XCTAssertFalse(coordinator.isRunning) } - // MARK: - BatchCoordinatorHolder.clear() while running (F15c) + // MARK: - BatchCoordinatorHolder.clear() while running func testHolderClearWhileRunningCancelsAndFreshEnqueueWorksAfterward() async { let started = AsyncGate() @@ -516,8 +510,7 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { } XCTAssertFalse(transcribedPaths.contains("long-running.m4a"), "cancelled item must never reach a completed outcome") - // A subsequent enqueue must work on a fresh coordinator. - holder.enqueue([.init(url: self.tempAudioURL(name: "fresh.m4a"))]) + 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 { @@ -525,14 +518,12 @@ final class BatchTranscriptionCoordinatorTests: XCTestCase { } } - // MARK: - Cancellation surfaced from the transcribe closure's wait loop (F15d) + // MARK: - Cancellation surfaced from the transcribe closure's wait loop func testCancellationThrownFromTranscribeClosureMarksItemCancelled() async { let coordinator = BatchTranscriptionCoordinator(transcribe: { _ in - // Simulates the dictation-arbitration wait loop observing cancellation - // (e.g. FileTranscriptionSession's `try Task.checkCancellation()` while - // waiting for dictation/single-file transcription to finish) before ever - // calling into the transcription service. + // Simulates the arbitration wait loop's checkCancellation() firing before + // ever calling into the transcription service. throw CancellationError() }) diff --git a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift index 98cca809..9627cf37 100644 --- a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -2,10 +2,8 @@ import Foundation import XCTest -/// Contract tests for the testable core of the promise-aware drop path (issue #219). -/// The AppKit drop view itself needs a live drag session; these tests pin down the -/// pure logic it delegates to: strategy selection from pasteboard types, and -/// per-item staging directories that cannot collide. +/// 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 @@ -15,7 +13,7 @@ final class PromiseDropSupportTests: XCTestCase { } func testPromiseTypesSelectFilePromiseStrategy() { - // Exactly what Voice Memos puts on the pasteboard (verified live 2026-07-25). + // Exactly what Voice Memos puts on the pasteboard. let voiceMemosTypes = [ "com.apple.NSFilePromiseItemMetaData", "com.apple.pasteboard.promised-file-name", @@ -37,8 +35,7 @@ final class PromiseDropSupportTests: XCTestCase { } func testNonAudioVisualPromiseIsRejected() { - // A Photos-style image promise must not be accepted: staging a JPEG only - // to fail it later is worse than refusing the drag affordance up front. + // 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", @@ -59,9 +56,8 @@ final class PromiseDropSupportTests: XCTestCase { // MARK: - Delivery selection func testTwoDistinctSameNamedModernFilesAreBothDelivered() { - // Voice Memos are commonly all titled "New Recording": two different - // memos in one drag stage into two receiver dirs with the same file - // name. Both are real files — neither may be collapsed as a duplicate. + // 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: []) @@ -77,8 +73,8 @@ final class PromiseDropSupportTests: XCTestCase { } func testRenamedFallbackCopyOfSameNamedMemosIsNotDeliveredAsExtraItem() { - // The renamed fallback copy matches no modern name, so name-only dedup used to - // append it as a phantom third file. + // 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") @@ -192,7 +188,7 @@ final class PromiseDropSupportTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: dirB.path)) } - // MARK: - Relocation for exclusive ownership (issue #219 batch drop) + // MARK: - Relocation for exclusive ownership func testRelocationGivesEachFileFromASharedDirItsOwnStagingDirInOrder() throws { let session = try PromiseDropSupport.StagingSession() @@ -268,9 +264,8 @@ final class PromiseDropSupportTests: XCTestCase { // MARK: - Sweep selection func testSweepNeverIncludesADeliveredFilesDirectoryDespiteTrailingSlashDifferences() { - // Regression: dirs built with appendingPathComponent (no trailing slash) - // vs. deletingLastPathComponent() of a delivered file (trailing slash) - // compare unequal as URLs — the sweep once deleted a delivered file. + // 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") @@ -288,12 +283,11 @@ final class PromiseDropSupportTests: XCTestCase { XCTAssertEqual(PromiseDropSupport.sweepableDirs(allDirs: [dirA, dirB], deliveredFiles: []), [dirA, dirB]) } - // MARK: - dirsSafeToRemoveNow (findings F1 / F9) + // MARK: - dirsSafeToRemoveNow func testDirsSafeToRemoveNowExcludesPendingReceiverDirsEvenWhenNothingDelivered() { - // Regression for F1: an empty delivery result must not sweep a pending - // receiver's staging dir — deleting the destination out from under an - // in-flight NSFilePromiseReceiver wedges the source app's drag machinery. + // 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") From a3585316bc4571a52e9a5ce15ba45b40fe206015 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:13:58 -0700 Subject: [PATCH 10/17] fix: expect promised file names, not receivers, when bounding delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit totalExpected counted receiver dirs, but one NSFilePromiseReceiver can promise several files. Bounding selectDelivery by that count meant a single receiver advertising three names whose modern path failed delivered only the first fallback file and silently dropped the rest — the mirror of the phantom-extra -file bug the bound was added to fix. ResolutionState now accumulates each receiver's fileNames count; receiver dirs are still counted separately for the resolved/unresolved checks, which are genuinely per-dir. --- Sources/Fluid/UI/PromiseAwareDropView.swift | 153 +++++++----------- .../PromiseDropSupportTests.swift | 22 +++ 2 files changed, 80 insertions(+), 95 deletions(-) diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index f293d287..2591b3f1 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -1,9 +1,7 @@ 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`; -/// this view owns only AppKit drag plumbing and the async resolution poll. +// 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 @@ -30,7 +28,7 @@ struct PromiseAwareDropView: NSViewRepresentable { var onFiles: ([(url: URL, stagingDir: URL?)]) -> Void = { _ in } var onError: (String) -> Void = { _ in } - /// Static so in-flight promise resolution survives the view being destroyed mid-drop. + // static so in-flight resolution survives the view being destroyed mid-drop private static let promiseQueue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 1 @@ -52,8 +50,7 @@ struct PromiseAwareDropView: NSViewRepresentable { self.registerForDraggedTypes([.fileURL] + promiseTypes) } - /// Sits in `.overlay` for drag-target search; returns nil so mouse events pass through - /// (drag dispatch doesn't consult hitTest, so drops still arrive). + /// needed in `.overlay`; drops still arrive since drag dispatch doesn't consult hitTest override func hitTest(_ point: NSPoint) -> NSView? { nil } @@ -140,20 +137,16 @@ struct PromiseAwareDropView: NSViewRepresentable { } let legacyDir = try? session.makeItemDirectory() let dataDir = try? session.makeItemDirectory() - // Captured by value: resolution must outlive this view if destroyed by navigation mid-drop. - let onFiles = self.onFiles + let onFiles = self.onFiles // captured by value: resolution must outlive this view if destroyed mid-drop let onError = self.onError - // A pasteboard read can block indefinitely, and blocking inside performDragOperation - // freezes the system-wide drag session — so it returns immediately and ALL pasteboard - // reads happen on background threads; using sender/pasteboard after return is outside - // AppKit's documented lifetime but is the only arrangement that doesn't deadlock or - // lose the drop against Voice Memos. A dead provider just fails on its own thread. + // A main-thread pasteboard read can block indefinitely and freeze the system-wide drag session, so this + // returns immediately and ALL pasteboard reads happen on background threads (outside documented AppKit + // lifetime, but the only arrangement that doesn't deadlock or lose the drop against Voice Memos). let state = ResolutionState() state.beginInFlight() // outer worker token, released when it finishes - // Poller starts before any pasteboard read so a hung first read still hits a timeout. - Task { @MainActor in + 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, @@ -167,13 +160,10 @@ struct PromiseAwareDropView: NSViewRepresentable { Self.detachWorker { defer { state.endInFlight() } - // Ordering matters: NSPasteboard isn't thread-safe and receivePromisedFiles - // touches it from its own queue, so all our reads finish, single-threaded, - // before any receiver starts — concurrent access crashed NSPasteboard's type - // cache. Reads are wrapped in an ObjC exception catcher since an uncaught - // NSException aborts the process. - - // 1. Snapshot the receiver objects and per-item metadata/bytes. + // NSPasteboard isn't thread-safe and receivePromisedFiles touches it from its own queue, so all our + // reads must finish, single-threaded, before any receiver starts — concurrent access previously + // crashed NSPasteboard's type cache. Wrapped in an ObjC exception catcher since an uncaught + // NSException aborts the process. Step 1: snapshot the receiver objects and per-item metadata/bytes. var receivers: [NSFilePromiseReceiver] = [] var suggestedName: String? var promisedTypeID: String? @@ -210,14 +200,13 @@ struct PromiseAwareDropView: NSViewRepresentable { ) state.setPayloadCount(itemPayloads.count) - // 2. Raw-data fallback: write each item's bytes into the shared data dir - // (the reliable path for Voice Memos). Disambiguate identically-named items. + // Step 2: raw-data fallback (the reliable path for Voice Memos) — write each item's bytes into the + // shared data dir, disambiguating identically-named items. if let dataDir, !itemPayloads.isEmpty { var usedNames = Set() for (index, payload) in itemPayloads.enumerated() { let fallbackName = "Dropped Audio \(index + 1)" - // Provider-supplied name is untrusted: separators would escape staging. - let itemName = PromiseDropSupport.sanitizedFileName( + let itemName = PromiseDropSupport.sanitizedFileName( // provider-supplied name is untrusted: separators would escape staging payload.name ?? suggestedName, fallback: fallbackName ) @@ -246,16 +235,12 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - // 3. Modern receivers, started only after our reads are done. Still required - // even when raw-data delivered: leaving a promise unresolved wedges the - // source app's drag machinery (Voice Memos refuses further drags until restart). + // Step 3: modern receivers, started only after our reads are done — still required even when + // raw-data delivered, since an unresolved promise wedges Voice Memos's drag machinery until restart. for receiver in receivers { guard let dir = try? session.makeItemDirectory() else { continue } - state.registerReceiverDir(dir) - // Per-receiver token: soft timeout must not abandon a promise still - // downloading (e.g. iCloud) just because the outer worker finished. - // Released once, after the receiver's LAST file callback. - state.beginInFlight() + state.registerReceiverDir(dir, promisedFileCount: receiver.fileNames.count) + state.beginInFlight() // per-receiver token, released once after the receiver's LAST file callback let completion = ReceiverCompletion(fileCount: receiver.fileNames.count) receiver.receivePromisedFiles(atDestination: dir, options: [:], operationQueue: Self.promiseQueue) { url, error in if let error { @@ -274,11 +259,9 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - // 4. Legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:), - // strictly last resort: an eager call blocks Voice Memos's drag machinery - // for 35-80s, so it only fires if modern/raw-data produced nothing within - // a short window. Invoked via perform(_:) with a string selector to avoid - // the deprecation warning. + // Step 4: legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:, called via a string + // selector to dodge the deprecation warning) is strictly last resort — an eager call blocks Voice + // Memos's drag machinery for 35-80s — so it only fires if modern/raw-data produced nothing in time. if let legacyDir { let waitDeadline = Date().addingTimeInterval(3) var otherPathDelivered = false @@ -318,19 +301,17 @@ struct PromiseAwareDropView: NSViewRepresentable { return true } - /// `receivePromisedFiles` fires once per name, so count down to release the - /// receiver's single in-flight token exactly once. + // `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) { - // Guard a zero/nil name count: it must still count down to zero. - self.remaining = max(fileCount, 1) + self.remaining = max(fileCount, 1) // guard a zero/nil name count: it must still count down to zero } - /// Returns true exactly once, for the last file this receiver reports. + /// true exactly once, for the last file this receiver reports func recordFile(failed: Bool) -> Bool { self.lock.lock() defer { self.lock.unlock() } @@ -340,9 +321,8 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - /// Shared between background delivery threads and the main-actor poller: which - /// receiver dirs completed, and whether legacy/raw-data reads are still in flight - /// (a provider can take 60s+ under load; the poller must not sweep staging dirs early). + // Shared between background delivery threads and the main-actor poller: which receiver dirs completed, and + // whether legacy/raw-data reads are still in flight (a provider can take 60s+ under load). private final class ResolutionState: @unchecked Sendable { private let lock = NSLock() private var receiverDirs: [URL] = [] @@ -350,11 +330,21 @@ struct PromiseAwareDropView: NSViewRepresentable { private var failed: Set = [] private var inFlightCount = 0 private var payloadCount = 0 + private var promisedFileCount = 0 - func registerReceiverDir(_ dir: URL) { + /// One receiver can promise several files, so the name count — not the dir + /// count — is how many items the drop is expected to yield. + 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] { @@ -375,8 +365,7 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.completed } - /// A failed receiver's promise already answered, so unlike a pending one its dir - /// is safe to sweep immediately, and it counts toward "every receiver resolved". + /// already answered, so unlike pending its dir is safe to sweep immediately func markFailed(_ dir: URL) { self.lock.lock() defer { self.lock.unlock() } @@ -389,7 +378,7 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.failed } - /// Lets a receiver-less multi-item drop still detect a partial delivery. + /// lets a receiver-less multi-item drop still detect a partial delivery func setPayloadCount(_ count: Int) { self.lock.lock() defer { self.lock.unlock() } @@ -421,8 +410,7 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - /// User-initiated QoS: default-QoS threads starve while ML inference saturates - /// the cores, exactly when a second drop can arrive mid-batch. + /// 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 @@ -441,10 +429,7 @@ struct PromiseAwareDropView: NSViewRepresentable { ) async { let pollInterval: UInt64 = 200_000_000 let softTimeout: TimeInterval = 30 - // A provider can take 60s+ under heavy inference load (seen up to 80s); keep - // waiting while any delivery thread is in flight, up to a hard cap so a truly - // wedged provider still ends in an error. - let hardTimeout: TimeInterval = 120 + 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 let start = Date() @@ -455,10 +440,8 @@ struct PromiseAwareDropView: NSViewRepresentable { return DeliveryContext( allDirs: receiverDirs + [legacyDir, dataDir].compactMap(\.self), pendingReceiverDirs: receiverDirs.filter { !completed.contains($0) && !failed.contains($0) }, - // Relocation empties the legacy dir, so a sweep would see it as undelivered - // and delete a destination the OS may still be writing into. - pendingFallbackDirs: state.hasInFlightWork ? [legacyDir].compactMap(\.self) : [], - totalExpected: max(receiverDirs.count, state.payloadCountValue(), 1), + 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(), 1), session: session, onFiles: onFiles, onError: onError @@ -472,9 +455,7 @@ struct PromiseAwareDropView: NSViewRepresentable { if elapsed >= hardTimeout { break } if elapsed >= softTimeout, !state.hasInFlightWork { break } - // A receiver dir only counts once its completion callback fired — a stalled - // (e.g. iCloud) download must never look complete just from a stable size. - let receiverDirs = state.receiverDirsSnapshot() + 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)) @@ -482,28 +463,21 @@ struct PromiseAwareDropView: NSViewRepresentable { let dataFiles = self.listFiles(in: [dataDir].compactMap(\.self)) let fallbackSizes = self.sizeMap(for: legacyFiles + dataFiles) - // "Resolved" = completed OR failed: a failed promise can never later flip to - // completed, so waiting on it would spin the full soft timeout. + // "Resolved" = completed OR failed, since a failed promise can never later flip to completed. let resolvedCount = readyModernDirs.count + failedModernDirs.count let allReceiversResolved = !receiverDirs.isEmpty && resolvedCount >= receiverDirs.count - // Modern succeeded outright: every receiver resolved, at least one completed, - // and it produced files. - let modernComplete = allReceiversResolved && !readyModernDirs.isEmpty && !modernFiles.isEmpty + 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 } - // Modern produced nothing (no receivers, or all resolved with none completed) - // past the grace period, and a fallback landed with stable sizes — use it. let modernExhaustedWithNoWins = receiverDirs.isEmpty || (allReceiversResolved && readyModernDirs.isEmpty) let modernProducedNothing = modernExhaustedWithNoWins && elapsed > modernGrace - // Worker must be fully done, so a multi-item raw-data sweep still writing - // later files isn't delivered early just because earlier ones stabilized. let fallbackStable = !fallbackSizes.isEmpty && fallbackSizes == fallbackPrevSizes - && !state.hasInFlightWork + && !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 @@ -513,9 +487,7 @@ struct PromiseAwareDropView: NSViewRepresentable { try? await Task.sleep(nanoseconds: pollInterval) } - // Timeout: deliver whatever completed, preferring modern; incomplete dirs are - // excluded (possible truncation) and surface via the partial-failure error. - let receiverDirs = state.receiverDirsSnapshot() + 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)), @@ -525,14 +497,12 @@ struct PromiseAwareDropView: NSViewRepresentable { ) } - /// Everything `deliver` needs besides the per-path file lists. + // Everything `deliver` needs besides the per-path file lists. private struct DeliveryContext { let allDirs: [URL] - /// Receiver dirs whose promise hasn't completed yet. Must NOT be swept at delivery: - /// deleting an in-flight promise's destination wedges Voice Memos until restart. - /// Cleaned up later, after completion (or a 120s deadline). + // Receiver/fallback dirs not yet completed; must NOT be swept at delivery — deleting an in-flight + // promise's destination wedges Voice Memos until restart. Cleaned up later via `cleanUpLater`. let pendingReceiverDirs: [URL] - /// Fallback dirs whose writer thread hasn't finished. Same rule as above. let pendingFallbackDirs: [URL] let totalExpected: Int let session: PromiseDropSupport.StagingSession @@ -540,8 +510,7 @@ struct PromiseAwareDropView: NSViewRepresentable { let onError: (String) -> Void } - /// Delivers resolved files. Selection/dedup lives in `PromiseDropSupport.selectDelivery`. - private static func deliver( + private static func deliver( // selection/dedup lives in `PromiseDropSupport.selectDelivery` modern: [URL], legacy: [URL], data: [URL], @@ -558,13 +527,10 @@ struct PromiseAwareDropView: NSViewRepresentable { expectedItemCount: context.totalExpected ) let supported = PromiseDropSupport.filterSupported(selected) - // Any path can land several files in one dir; the coordinator deletes per item. - let result = PromiseDropSupport.relocateForExclusiveOwnership(supported, session: context.session) + 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 { - // Same pending-respecting sweep as below: must not delete staging dirs whose - // receiver promise hasn't completed — that wedges the source app's drag machinery. - for dir in PromiseDropSupport.dirsSafeToRemoveNow( + 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 @@ -583,10 +549,8 @@ struct PromiseAwareDropView: NSViewRepresentable { return } - // Dirs holding no delivered file (losing duplicates, empty shells from failed - // paths) are dead weight; deleting them may race still-running writer threads, - // which is harmless since the losers' files are throwaway copies. The batch - // coordinator removes delivered dirs (then the empty session root) per item. + // Dirs holding no delivered file are dead weight; deleting them may race still-running writer threads, + // harmless since the losers' files are throwaway copies. for dir in PromiseDropSupport.dirsSafeToRemoveNow( allDirs: context.allDirs, deliveredFiles: result.map(\.url), @@ -603,8 +567,7 @@ struct PromiseAwareDropView: NSViewRepresentable { self.cleanUpLater(pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs) } - /// Removes still-pending receiver dirs once their promise has had time to finish - /// writing (duplicates of what was already delivered), then prunes an empty session root. + /// removes dirs once their promise has had time to finish, then prunes the empty session root private static func cleanUpLater(pendingDirs: [URL]) { guard !pendingDirs.isEmpty else { return } Task { @MainActor in diff --git a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift index 9627cf37..fa3dca5a 100644 --- a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -88,6 +88,28 @@ final class PromiseDropSupportTests: XCTestCase { 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") From b455632cdade7b111e5018e71a1b90f21c314880 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:13:58 -0700 Subject: [PATCH 11/17] style: cut branch comment density below 10% per file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass over the drop/batch files: deletes comments that restate the code, compresses prose to single lines, and drops doc comments that only repeat a well-named symbol. Every live-verified fact is kept — pasteboard main-thread deadlocks, read ordering vs the NSPasteboard type-cache crash, the legacy promise API's 35-80s block, the pending-dir sweep hazard, staging-dir ownership, and the dictationIntent TOCTOU window. 265 comment lines to 78 across the six files. --- Sources/Fluid/ContentView.swift | 20 ++----- .../BatchTranscriptionCoordinator.swift | 40 ++++---------- .../Services/FileTranscriptionSession.swift | 40 +++----------- .../Fluid/UI/MeetingTranscriptionView.swift | 33 +++-------- Sources/Fluid/UI/PromiseDropSupport.swift | 55 ++++--------------- 5 files changed, 46 insertions(+), 142 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 1d638d46..c7ed5ae6 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2012,8 +2012,7 @@ struct ContentView: View { // MARK: - Stop and Process Transcription private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async { - // Released at the end: stop() clears isRunning before the final transcription pass. - defer { FileTranscriptionSession.shared.endDictationIntent() } + 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") @@ -3077,8 +3076,7 @@ struct ContentView: View { /// Capture app context at start to avoid mismatches if the user switches apps mid-session private func startRecording() { - // Batches drive the same shared ASR model; starting dictation mid-batch would - // run concurrent inference through one model. + // avoid concurrent inference on the shared ASR model guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return @@ -3094,8 +3092,7 @@ struct ContentView: View { return } - // Synchronous, before any await; after the guard so an ignored start can't latch it. - FileTranscriptionSession.shared.beginDictationIntent() + FileTranscriptionSession.shared.beginDictationIntent() // synchronous, before any await, so an ignored start can't latch it self.advanceOverlayLifecycle() self.setActiveRecordingMode(.dictate) @@ -3367,7 +3364,7 @@ struct ContentView: View { self.menuBarManager.setOverlayMode(.command) guard !self.asr.isRunningOrStarting else { return } - // Bypasses beginDictationRecording, so it needs its own guard and claim. + // bypasses beginDictationRecording; needs its own guard guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return @@ -3423,7 +3420,6 @@ struct ContentView: View { self.setActiveRecordingMode(.edit) guard !self.asr.isRunningOrStarting else { return } - // Bypasses beginDictationRecording, so needs its own batch guard and intent claim. guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return @@ -3755,8 +3751,6 @@ extension ContentView { } private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) { - // Common chokepoint for dictate/prompt/command/rewrite hotkeys: batches drive - // the same shared ASR model, so none may start while one is running. guard !FileTranscriptionSession.isBatchTranscribing else { self.notifyDictationBlockedByBatch() return @@ -3778,8 +3772,7 @@ extension ContentView { self.appBench("asr_start_skipped reason=already_running_or_starting") return } - // Claimed before the async start: isRunning hasn't flipped yet. - FileTranscriptionSession.shared.beginDictationIntent() + 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") @@ -3817,8 +3810,7 @@ extension ContentView { self.beginDictationRecording(for: .secondary, mode: mode) } - /// Rejection feedback when a dictation-family hotkey fires during a batch. Reuses the - /// stop cue rather than new UI, so the user hears the press had no effect. + /// reuses the stop cue as feedback when blocked by a batch private func notifyDictationBlockedByBatch() { DebugLogger.shared.warning( "Dictation blocked: batch file transcription in progress", diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift index 1b9631e5..77e36c8e 100644 --- a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -1,17 +1,13 @@ import Combine import Foundation -/// Sequentially transcribes a batch of audio files: strict enqueue-order processing, -/// per-item success/failure/no-speech outcomes, cooperative cancellation, staging-dir -/// cleanup per item, and batch start/end hooks for dictation arbitration. All stored -/// closures run on the main actor. +/// Sequentially transcribes a batch of audio files, with per-item outcomes, cooperative +/// cancellation, and staging-dir cleanup. All stored closures run on the main actor. @MainActor final class BatchTranscriptionCoordinator: ObservableObject { - /// A transcription request for a single audio file. struct Request { let url: URL - /// Exclusively owned by this item; deleted when it finishes. Never share across items. - let stagingDir: URL? + let stagingDir: URL? // exclusively owned by this item; deleted when it finishes, never shared init(url: URL, stagingDir: URL? = nil) { self.url = url @@ -19,7 +15,6 @@ final class BatchTranscriptionCoordinator: ObservableObject { } } - /// Per-item transcription outcome. enum Status { case pending case transcribing @@ -29,12 +24,10 @@ final class BatchTranscriptionCoordinator: ObservableObject { case cancelled } - /// Tracked state for a single enqueued file. struct Item: Identifiable { let id: UUID let url: URL - /// Exclusively owned by this item; deleted when it finishes. Never share across items. - let stagingDir: URL? + let stagingDir: URL? // exclusively owned by this item; deleted when it finishes, never shared var status: Status } @@ -46,9 +39,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { private let onBatchEnd: @MainActor () -> Void private var batchTask: Task? - /// Drives a "Cancelling…" UI state: the in-flight item may still run to completion - /// (no interruption point), even though pending items stop immediately. - @Published private(set) var isCancelRequested: Bool = false + @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] = [] @@ -65,8 +56,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { // MARK: - Enqueue - /// Append requests and start a sequential processing batch if idle. Enqueuing while a - /// batch is running appends to the live batch. + /// starts a sequential batch if idle, else appends to the live one func enqueue(_ requests: [Request]) { for request in requests { self.items.append( @@ -86,7 +76,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { // MARK: - Cancellation - /// Cancel the in-flight transcription and every still-pending item. No-op when idle. + /// cancels the in-flight transcription and every still-pending item; no-op when idle func cancel() { guard self.batchTask != nil else { return } @@ -105,8 +95,6 @@ final class BatchTranscriptionCoordinator: ObservableObject { // MARK: - Idle wait - /// Returns when no batch is processing. Used by tests and callers that need to await - /// the full run. func waitUntilIdle() async { if !self.isRunning { return } @@ -152,9 +140,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { self.isRunning = false self.batchTask = nil - // Self-draining: items enqueued during cancellation/finish are still .pending - // and were missed above, so start a fresh run. cancel() already flips - // pre-existing pending items to .cancelled, so only new ones land here. + // Self-draining: items enqueued during cancellation/finish are still .pending and were missed above. if self.items.contains(where: { if case .pending = $0.status { return true } else { return false } }) { self.startBatch() } else { @@ -170,8 +156,7 @@ final class BatchTranscriptionCoordinator: ObservableObject { } } - /// Completed/failed/cancelled items are never revisited, so re-enqueuing after a - /// finished-but-undismissed batch only processes newly appended items. + /// finished items are never revisited, so this only sees newly appended ones private func nextPendingIndex() -> Int? { self.items.firstIndex { if case .pending = $0.status { return true } else { return false } } } @@ -201,17 +186,14 @@ final class BatchTranscriptionCoordinator: ObservableObject { } } - // Removed only after the item finishes (success, failure, or cancellation). - self.removeStagingDir(for: index) + 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) - // Prune the now-empty drop-session root (PromiseDropSupport.StagingSession) - // once its last item dir is gone, so drops don't leak temp shells. - let parent = stagingDir.deletingLastPathComponent() + 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 } diff --git a/Sources/Fluid/Services/FileTranscriptionSession.swift b/Sources/Fluid/Services/FileTranscriptionSession.swift index 7d22114f..6580ce04 100644 --- a/Sources/Fluid/Services/FileTranscriptionSession.swift +++ b/Sources/Fluid/Services/FileTranscriptionSession.swift @@ -1,31 +1,19 @@ import Combine import Foundation -/// App-level owner of file-transcription state, so switching sidebar pages (which -/// destroys `MeetingTranscriptionView`) doesn't silently kill an in-flight batch; -/// the view re-attaches on return. +// 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() - /// Set once `shared` is first touched, so the dictation path can check batch state - /// without force-creating the session (and the ASR service). - private static var sharedIfCreated: FileTranscriptionSession? + private static var sharedIfCreated: FileTranscriptionSession? // lets callers check batch state without force-creating the session - /// Dictation must not start while true: both paths drive the same shared ASR model, - /// and concurrent inference corrupts output. static var isBatchTranscribing: Bool { - self.sharedIfCreated?.batchHolder.coordinator?.isRunning == true + self.sharedIfCreated?.batchHolder.coordinator?.isRunning == true // both paths drive the same shared ASR model; concurrent inference corrupts output } - /// True from just before a dictation/prompt/command/rewrite recording starts until - /// the session ends. The "intent to dictate" half of the single-model arbiter shared - /// with the batch transcribe closure: set synchronously before any `await`, closing - /// the TOCTOU window where the batch's `asr.isRunning` check could pass right before - /// dictation flips `isRunning` true. - /// - /// Readers use `sharedIfCreated` to avoid force-creating the session/ASR service; - /// `beginDictationIntent()` is the one path allowed to force-create. + /// True from just before a dictation-family recording starts until it ends. Set + /// synchronously before any `await`, closing the TOCTOU window where the batch's check could pass right before dictation flips this true. private(set) var dictationIntent: Bool = false let service: MeetingTranscriptionService @@ -35,10 +23,7 @@ final class FileTranscriptionSession { let service = MeetingTranscriptionService(asrService: AppServices.shared.asr) self.service = service self.batchHolder = BatchCoordinatorHolder(transcribe: { url in - // Single-model arbitration: at most one of dictation, single-file, and batch - // transcription may drive the shared ASR model at once. Waits on dictation - // intent, live dictation, and any in-flight single-file transcription; - // cancellation still interrupts the wait. + // At most one of dictation, single-file, and batch transcription may drive the shared ASR model at once. while AppServices.shared.asr.isRunning || FileTranscriptionSession.shared.dictationIntent || service.isTranscribing { @@ -50,26 +35,21 @@ final class FileTranscriptionSession { Self.sharedIfCreated = self } - /// Signal that a dictation-family recording (dictate/prompt/command/rewrite) is - /// about to start. Must be called synchronously before any `await` in the caller. func beginDictationIntent() { self.dictationIntent = true } - /// Signal that the dictation-family recording session has ended (stopped, - /// cancelled, or failed to start). func endDictationIntent() { self.dictationIntent = false } - /// Releases the flag without force-creating the session, for teardown paths. + /// releases the flag without force-creating the session, for teardown paths static func endDictationIntentIfCreated() { self.sharedIfCreated?.endDictationIntent() } } -/// Wraps the batch coordinator so a finished batch can be cleared and a fresh one lazily -/// created, republishing the inner coordinator's changes. +// Wraps the batch coordinator so a finished batch can be cleared and a fresh one lazily created, republishing its changes. @MainActor final class BatchCoordinatorHolder: ObservableObject { @Published var coordinator: BatchTranscriptionCoordinator? @@ -92,9 +72,7 @@ final class BatchCoordinatorHolder: ObservableObject { } func clear() { - // Never orphan a running batch: dropping it without cancelling would leave it - // transcribing invisibly with no way to reach it. - self.coordinator?.cancel() + 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/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index fd8a906a..b646e0be 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -4,8 +4,7 @@ import UniformTypeIdentifiers struct MeetingTranscriptionView: View { let asrService: ASRService - // Owned by FileTranscriptionSession so in-flight transcriptions survive sidebar navigation. - @ObservedObject 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? @@ -64,17 +63,14 @@ struct MeetingTranscriptionView: View { // File Selection Card self.fileSelectionCard - // Progress Card (only show when transcribing outside a batch) if self.transcriptionService.isTranscribing && !self.isBatchActive { self.progressCard } - // Results Card (only show when we have results outside a batch) if let result = transcriptionService.result, !self.isBatchActive { self.resultsCard(result: result) } - // Error Card (only show when we have a single-file error) if let error = transcriptionService.error, !self.isBatchActive { self.errorCard(error: error) } @@ -84,7 +80,6 @@ struct MeetingTranscriptionView: View { self.dropErrorCard(message: message) } - // Batch transcription progress/results if self.isBatchActive { self.batchSection } @@ -100,8 +95,7 @@ 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 (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 }, @@ -562,8 +556,7 @@ struct MeetingTranscriptionView: View { Spacer() if coordinator.isRunning { - // In-flight file may still run to completion (no interruption point), - // hence "Cancelling" rather than an instant stop. + // in-flight file may still run to completion, hence "Cancelling" not an instant stop if coordinator.isCancelRequested { Text("Cancelling…") .font(.caption) @@ -606,8 +599,7 @@ struct MeetingTranscriptionView: View { return "Batch complete — \(coordinator.completedCount) transcribed, \(coordinator.failedCount) failed" } - /// One-based position of the item currently being transcribed (or the next pending - /// item while between files), for the "Transcribing x of y" header. + /// 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 }) { @@ -695,18 +687,13 @@ struct MeetingTranscriptionView: View { // MARK: - Helper Functions - /// True while a batch coordinator has any enqueued items (running or finished-but- - /// not-dismissed); suppresses single-file cards since `transcribeFile` mutates the - /// shared service state per batch item. + /// 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 } - /// Routes incoming files from a drop or the file picker. A single concrete file (no - /// staging dir) follows the existing single-file flow; everything else — multiple - /// files, or any staged/promised file — is enqueued on the batch coordinator so - /// staged dirs are reliably cleaned up after transcription. + // 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 @@ -721,9 +708,7 @@ struct MeetingTranscriptionView: View { self.batchHolder.enqueue(files.map { .init(url: $0.url, stagingDir: $0.stagingDir) }) } - /// Auto-dismisses after a few seconds: promise resolution can report errors up to - /// two minutes after the drop, so without this a stale red card could sit over an - /// already-successful batch until manually closed. + /// 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 @@ -736,9 +721,7 @@ struct MeetingTranscriptionView: View { } } - /// Resets shared service state so a subsequent batch starts fresh with no stale - /// single-file result. - + /// 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() diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift index 45b7f71d..e2a1c22f 100644 --- a/Sources/Fluid/UI/PromiseDropSupport.swift +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -2,34 +2,23 @@ import AVFoundation import Foundation import UniformTypeIdentifiers -/// Testable, nonisolated core logic for the promise-aware drop path: pasteboard strategy -/// detection, collision-free per-item staging, and AVFoundation format filtering. -/// `PromiseAwareDropView` owns the live drag session and delegates the pure pieces here. -/// `nonisolated` so it's unit-testable despite the app target's `MainActor` default. +// Testable, nonisolated core logic for the promise-aware drop path (pasteboard strategy, staging, format filtering); `PromiseAwareDropView` owns the live drag session. nonisolated enum PromiseDropSupport { - /// How a given pasteboard should be interpreted. enum Strategy: Equatable { - /// Concrete file URLs are present (`public.file-url`). case concreteFileURLs - /// Only file-promise metadata is present (e.g. a Voice Memos drag). case filePromise } - /// The pasteboard type that carries a concrete file URL. private static let concreteFileURLType = "public.file-url" - /// Pasteboard types signaling a file-promise drop. Voice Memos emits a mix of these; - /// any one is sufficient. + // Pasteboard types signaling a file-promise drop; 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 file URLs win when both are present. Promises are only accepted when the - /// pasteboard also advertises audio/movie content (Voice Memos exposes e.g. - /// `com.apple.m4a-audio` directly), so an image promise from Photos is rejected up - /// front instead of staged and failed later. `nil` if neither is present. + // Concrete file URLs win when both are present; a promise is only accepted alongside advertised audio/movie content, so an image promise from Photos is rejected up front. static func strategy(forPasteboardTypes types: [String]) -> Strategy? { if types.contains(concreteFileURLType) { return .concreteFileURLs @@ -44,9 +33,7 @@ nonisolated enum PromiseDropSupport { return promisesAudioVisualContent ? .filePromise : nil } - /// Staging dirs that hold no delivered file and should be swept. Compares standardized - /// *paths*, not URLs — trailing-slash differences between `appendingPathComponent` and - /// `deletingLastPathComponent()` once made two equal dirs compare unequal as URLs. + // Staging dirs holding no delivered file. Compares standardized *paths*, not URLs — 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 @@ -54,35 +41,26 @@ nonisolated enum PromiseDropSupport { return allDirs.filter { !deliveredDirPaths.contains($0.standardizedFileURL.path) } } - /// Dirs safe to delete now: `sweepableDirs` minus any dir whose receiver promise - /// hasn't completed. Deleting an in-flight `NSFilePromiseReceiver`'s destination - /// wedges the source app's drag machinery until restart; callers defer removal - /// of excluded dirs until the pending promise resolves. + // `sweepableDirs` minus any dir whose receiver promise hasn't completed: 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) } } - /// Reduces a drag-source-supplied name to one safe path component; separators or - /// `..` would otherwise let `appendingPathComponent` write outside staging. - /// Unusable names (empty, `.`, `..`, all-separators) take the caller's fallback. + // Reduces a drag-source-supplied name to one safe path component; separators or `..` would otherwise 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. + // ("/" 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-receiver file is delivered - /// (each stages into its own dir, so same-named memos are distinct files, never - /// collapsed). Legacy/raw-data files are alternates, not extras, so they only fill - /// gaps; since the raw-data path renames same-named items, names alone can't identify - /// an alternate, so `expectedItemCount` bounds the result (nil keeps name-only behavior). + // 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], @@ -99,9 +77,7 @@ nonisolated enum PromiseDropSupport { return delivered } - /// Gives each delivered file its own dir: the coordinator deletes an item's dir the - /// moment that item finishes, so a shared one loses files still-queued items need. - /// A failed move reports `stagingDir: nil` — unowned beats shared. Order-preserving. + // Gives each delivered file its own dir, since the coordinator deletes an item's dir the moment it finishes. Order-preserving. static func relocateForExclusiveOwnership( _ files: [URL], session: StagingSession, @@ -120,8 +96,7 @@ nonisolated enum PromiseDropSupport { } } - /// Audio/movie extensions the OS can decode. Mirrors - /// `MeetingTranscriptionService.supportedFileExtensions` but callable from any isolation. + // audio/movie extensions the OS can decode, callable from any isolation private static let supportedExtensions: Set = { let avTypes = AVURLAsset.audiovisualTypes() let extensions = avTypes.compactMap { fileType -> String? in @@ -132,7 +107,6 @@ nonisolated enum PromiseDropSupport { return Set(extensions) }() - /// Keeps only URLs with a decodable audio/movie extension. Order-preserving. static func filterSupported(_ urls: [URL]) -> [URL] { return urls.filter { url in let ext = url.pathExtension.lowercased() @@ -140,30 +114,25 @@ nonisolated enum PromiseDropSupport { } } - /// Scratch directory tree under the user temp dir; each promised file gets its own - /// subdirectory so identically-named files never collide. Immutable state makes the - /// session `Sendable`, safe to share between the main actor and the resolution queue. - /// Prefix of every staging root; the batch coordinator uses it to prune empty roots. + // Prefix of every staging root; the batch coordinator uses it to prune empty roots once its item dir is gone. static let stagingRootPrefix = "PromiseDrop-" + // Scratch tree under the user temp dir; each promised file gets its own subdirectory so identically-named files never collide. final class StagingSession: Sendable { private let root: URL - /// Creates a unique root directory under the user temp dir. init() throws { self.root = FileManager.default.temporaryDirectory .appendingPathComponent("\(PromiseDropSupport.stagingRootPrefix)\(UUID().uuidString)") try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true) } - /// Creates a fresh, unique subdirectory for a single promised item. func makeItemDirectory() throws -> URL { let dir = self.root.appendingPathComponent("item-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } - /// Deletes the whole staging tree. func removeAll() { try? FileManager.default.removeItem(at: self.root) } From 809f0f556e5811e60cb83432bec7101b4a7150e8 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:26:45 -0700 Subject: [PATCH 12/17] fix: survive an undecodable promise and stop stalling a lone drop A Voice Memos promise can carry metadata the OS refuses to decode; NSFilePromiseReceiver raises rather than reporting an error, and an uncaught NSException aborts the process. Guard both decode points and degrade to the raw-data fallback. Delivery also waited on a receiver Voice Memos only cancels when the next drag starts, so a lone drop sat until the 120s hard timeout. An unresolved receiver that has written nothing past the grace no longer holds delivery open, and receivers get their own in-flight counter so an uncancelled one cannot gate the fallback writers. --- Sources/Fluid/UI/PromiseAwareDropView.swift | 140 ++++++++++++++---- .../PromiseDropSupportTests.swift | 29 ++++ 2 files changed, 140 insertions(+), 29 deletions(-) diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index 2591b3f1..c32deabd 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -1,7 +1,7 @@ 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`. +/// 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 @@ -28,7 +28,7 @@ struct PromiseAwareDropView: NSViewRepresentable { 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 + /// static so in-flight resolution survives the view being destroyed mid-drop private static let promiseQueue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 1 @@ -110,7 +110,8 @@ struct PromiseAwareDropView: NSViewRepresentable { 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 { + !urls.isEmpty + else { self.onError(MeetingTranscriptionService.dropErrorCopy) return false } @@ -239,23 +240,50 @@ struct PromiseAwareDropView: NSViewRepresentable { // raw-data delivered, since an unresolved promise wedges Voice Memos's drag machinery until restart. for receiver in receivers { guard let dir = try? session.makeItemDirectory() else { continue } - state.registerReceiverDir(dir, promisedFileCount: receiver.fileNames.count) - state.beginInFlight() // per-receiver token, released once after the receiver's LAST file callback - let completion = ReceiverCompletion(fileCount: receiver.fileNames.count) - 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" - ) + // Both `fileNames` and starting the receiver decode promise metadata and can raise (the OS + // refuses the NSCalendarDate in a Voice Memos promise). Uncaught, that aborts the process, + // so a bad promise must degrade to the fallbacks. Register the dir either way, otherwise + // the failed one is never swept. + 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 } + if completion.anyFailed { + state.markFailed(dir) + } else { + state.markCompleted(dir) + } + state.endReceiver() } - guard completion.recordFile(failed: error != nil) else { return } - if completion.anyFailed { - state.markFailed(dir) - } else { - state.markCompleted(dir) + } + 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() } - state.endInFlight() } } @@ -281,8 +309,10 @@ struct PromiseAwareDropView: NSViewRepresentable { defer { state.endInFlight() } var names: [String] = [] let legacyError = FluidCatchObjCException { - names = (sender.perform(Selector(("namesOfPromisedFilesDroppedAtDestination:")), with: legacyDir)? - .takeUnretainedValue() as? [String]) ?? [] + names = ( + sender.perform(Selector(("namesOfPromisedFilesDroppedAtDestination:")), with: legacyDir)? + .takeUnretainedValue() as? [String] + ) ?? [] } if let legacyError { DebugLogger.shared.warning( @@ -301,7 +331,7 @@ struct PromiseAwareDropView: NSViewRepresentable { return true } - // `receivePromisedFiles` fires once per name, so count down to release the receiver's in-flight token exactly once. + /// `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 @@ -316,19 +346,32 @@ struct PromiseAwareDropView: NSViewRepresentable { 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 } + + /// true only if the countdown hadn't already finished, so the token is released exactly once + 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 background delivery threads and the main-actor poller: which receiver dirs completed, and - // whether legacy/raw-data reads are still in flight (a provider can take 60s+ under load). + /// Shared between background delivery threads and the main-actor poller: which receiver dirs completed, and + /// whether legacy/raw-data reads are still in flight (a provider can take 60s+ under load). 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 @@ -372,12 +415,27 @@ struct PromiseAwareDropView: NSViewRepresentable { self.failed.insert(dir) } + /// Resolved for liveness, but the receiver raised mid-flight and may still be writing here, + /// so this dir must go through the deferred cleanup rather than the immediate sweep. + 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 + } + /// lets a receiver-less multi-item drop still detect a partial delivery func setPayloadCount(_ count: Int) { self.lock.lock() @@ -391,6 +449,8 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.payloadCount } + /// Tracks only the threads writing into the fallback dirs. Receivers are counted separately: + /// an uncancelled one never releases, and it must not hold the fallback delivery open. func beginInFlight() { self.lock.lock() defer { self.lock.unlock() } @@ -408,6 +468,18 @@ struct PromiseAwareDropView: NSViewRepresentable { 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 @@ -430,16 +502,17 @@ struct PromiseAwareDropView: NSViewRepresentable { let pollInterval: UInt64 = 200_000_000 let softTimeout: TimeInterval = 30 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 + 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) }, + 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(), 1), session: session, @@ -473,12 +546,20 @@ struct PromiseAwareDropView: NSViewRepresentable { return } + // Voice Memos only cancels a receiver when the NEXT drag starts, so a lone drop's receiver + // stays unresolved indefinitely. An unresolved receiver that has written nothing well past + // the grace is treated as non-producing; one mid-transfer keeps its files growing, so it + // still holds delivery open. + let unresolvedDirs = receiverDirs.filter { !readyModernDirs.contains($0) && !failedModernDirs.contains($0) } + let receiversStalled = elapsed > modernGrace + && unresolvedDirs.allSatisfy { self.listFiles(in: [$0]).isEmpty } + let modernExhaustedWithNoWins = receiverDirs.isEmpty - || (allReceiversResolved && readyModernDirs.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 { + if modernProducedNothing, fallbackStable { self.deliver(modern: [], legacy: legacyFiles, data: dataFiles, context: makeContext()) return } @@ -497,7 +578,7 @@ struct PromiseAwareDropView: NSViewRepresentable { ) } - // Everything `deliver` needs besides the per-path file lists. + /// Everything `deliver` needs besides the per-path file lists. private struct DeliveryContext { let allDirs: [URL] // Receiver/fallback dirs not yet completed; must NOT be swept at delivery — deleting an in-flight @@ -576,7 +657,8 @@ struct PromiseAwareDropView: NSViewRepresentable { try? FileManager.default.removeItem(at: dir) } for parent in Set(pendingDirs.map { $0.deletingLastPathComponent() }) - where parent.lastPathComponent.hasPrefix(PromiseDropSupport.stagingRootPrefix) { + where parent.lastPathComponent.hasPrefix(PromiseDropSupport.stagingRootPrefix) + { if ((try? FileManager.default.contentsOfDirectory(atPath: parent.path)) ?? []).isEmpty { try? FileManager.default.removeItem(at: parent) } diff --git a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift index fa3dca5a..0a0e812a 100644 --- a/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift +++ b/Tests/FluidDictationIntegrationTests/PromiseDropSupportTests.swift @@ -363,4 +363,33 @@ final class PromiseDropSupportTests: XCTestCase { 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 + } } From c67a68776ff4d5a90a9f13a50179d4bc85dd081c Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:33:08 -0700 Subject: [PATCH 13/17] fix: keep partial receiver output and count legacy promise names A receiver promising several files can fail on one and still have written the rest, but any failure marked the whole dir failed, and failed dirs are never delivered and are swept at once. Only a dir that produced nothing counts as failed now. The legacy path's returned names were logged and discarded, so a legacy-only multi-file drop left totalExpected at 1 and selectDelivery dropped every file after the first. --- Sources/Fluid/UI/PromiseAwareDropView.swift | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index c32deabd..f11aa16b 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -267,7 +267,10 @@ struct PromiseAwareDropView: NSViewRepresentable { ) } guard completion.recordFile(failed: error != nil) else { return } - if completion.anyFailed { + // A receiver promising several files can fail on one and still have written the rest; + // only a dir that produced nothing counts as failed, since failed dirs are never delivered. + let produced = !(((try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []).isEmpty) + if completion.anyFailed, !produced { state.markFailed(dir) } else { state.markCompleted(dir) @@ -324,6 +327,7 @@ struct PromiseAwareDropView: NSViewRepresentable { "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 } } } @@ -374,6 +378,7 @@ struct PromiseAwareDropView: NSViewRepresentable { 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 how many items the drop is expected to yield. @@ -436,6 +441,18 @@ struct PromiseAwareDropView: NSViewRepresentable { 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() @@ -514,7 +531,7 @@ struct PromiseAwareDropView: NSViewRepresentable { 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(), 1), + totalExpected: max(state.promisedFileCountValue(), state.payloadCountValue(), state.legacyNameCountValue(), 1), session: session, onFiles: onFiles, onError: onError From 5c07b8658ddd7b1365719a07766544cb9905a744 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:55:49 -0700 Subject: [PATCH 14/17] fix: check the batch guard before flipping recording mode A command or edit hotkey press during a file batch flipped activeRecordingMode and re-skinned the overlay before the batch guard returned, leaving the app in that mode with nothing recording and isCommandRecordingProvider reporting it to the hotkey manager. The rewrite path also captured the selection and started the rewrite service. The batch guard now runs first, so its blocked cue still plays while a recording is live. The isRunningOrStarting guard stays below the mutations: those mutations are how a cross-mode hotkey press switches an in-flight recording without restarting ASR. --- Sources/Fluid/ContentView.swift | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index c7ed5ae6..05422bfb 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -3355,20 +3355,18 @@ 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) - - // Set overlay mode to command - self.menuBarManager.setOverlayMode(.command) - - guard !self.asr.isRunningOrStarting else { return } - // bypasses beginDictationRecording; needs its own guard + // 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 } + + self.captureRecordingContext() + self.setActiveRecordingMode(.command) // tells stopAndProcessTranscription to process as command + self.menuBarManager.setOverlayMode(.command) + + guard !self.asr.isRunningOrStarting else { return } // mid-recording mode switch ends here: no restart FileTranscriptionSession.shared.beginDictationIntent() self.advanceOverlayLifecycle() @@ -3394,6 +3392,12 @@ struct ContentView: View { 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 @@ -3419,11 +3423,7 @@ struct ContentView: View { // Set flag so stopAndProcessTranscription knows to process as rewrite self.setActiveRecordingMode(.edit) - guard !self.asr.isRunningOrStarting else { return } - guard !FileTranscriptionSession.isBatchTranscribing else { - self.notifyDictationBlockedByBatch() - return - } + guard !self.asr.isRunningOrStarting else { return } // mid-recording mode switch ends here: no restart FileTranscriptionSession.shared.beginDictationIntent() self.advanceOverlayLifecycle() From 8057bd6bf482493fad3feeb5f8213f6f0069082c Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:55:49 -0700 Subject: [PATCH 15/17] style: compress this branch's comments Comment-only. Trims the branch's own comments toward the density target, keeping the why and dropping restatement. Pre-existing comments in shared files are untouched. --- Sources/Fluid/ObjCExceptionCatcher.h | 9 +-- .../BatchTranscriptionCoordinator.swift | 4 +- .../Services/FileTranscriptionSession.swift | 6 +- Sources/Fluid/UI/PromiseAwareDropView.swift | 60 +++++++------------ Sources/Fluid/UI/PromiseDropSupport.swift | 15 +++-- 5 files changed, 33 insertions(+), 61 deletions(-) diff --git a/Sources/Fluid/ObjCExceptionCatcher.h b/Sources/Fluid/ObjCExceptionCatcher.h index 9076cd67..bba23cc1 100644 --- a/Sources/Fluid/ObjCExceptionCatcher.h +++ b/Sources/Fluid/ObjCExceptionCatcher.h @@ -5,12 +5,9 @@ NS_ASSUME_NONNULL_BEGIN -/// Runs the block, catching any Objective-C exception it raises. -/// Returns the exception's description, or nil if the block completed cleanly. -/// Needed because NSPasteboard access from background threads can raise -/// NSInternalInconsistencyException on internal races, and a C++ library in the -/// process installs a terminate handler that turns any uncaught NSException -/// into abort(). +/// 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 diff --git a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift index 77e36c8e..07fe791f 100644 --- a/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift +++ b/Sources/Fluid/Services/BatchTranscriptionCoordinator.swift @@ -1,8 +1,7 @@ import Combine import Foundation -/// Sequentially transcribes a batch of audio files, with per-item outcomes, cooperative -/// cancellation, and staging-dir cleanup. All stored closures run on the main actor. +/// Sequentially transcribes a batch of audio files. All stored closures run on the main actor. @MainActor final class BatchTranscriptionCoordinator: ObservableObject { struct Request { @@ -156,7 +155,6 @@ final class BatchTranscriptionCoordinator: ObservableObject { } } - /// finished items are never revisited, so this only sees newly appended ones private func nextPendingIndex() -> Int? { self.items.firstIndex { if case .pending = $0.status { return true } else { return false } } } diff --git a/Sources/Fluid/Services/FileTranscriptionSession.swift b/Sources/Fluid/Services/FileTranscriptionSession.swift index 6580ce04..5a81f887 100644 --- a/Sources/Fluid/Services/FileTranscriptionSession.swift +++ b/Sources/Fluid/Services/FileTranscriptionSession.swift @@ -12,8 +12,7 @@ final class FileTranscriptionSession { self.sharedIfCreated?.batchHolder.coordinator?.isRunning == true // both paths drive the same shared ASR model; concurrent inference corrupts output } - /// True from just before a dictation-family recording starts until it ends. Set - /// synchronously before any `await`, closing the TOCTOU window where the batch's check could pass right before dictation flips this true. + /// 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 @@ -23,7 +22,7 @@ final class FileTranscriptionSession { let service = MeetingTranscriptionService(asrService: AppServices.shared.asr) self.service = service self.batchHolder = BatchCoordinatorHolder(transcribe: { url in - // At most one of dictation, single-file, and batch transcription may drive the shared ASR model at once. + // Only one of dictation, single-file, and batch may drive the shared ASR model. while AppServices.shared.asr.isRunning || FileTranscriptionSession.shared.dictationIntent || service.isTranscribing { @@ -49,7 +48,6 @@ final class FileTranscriptionSession { } } -// Wraps the batch coordinator so a finished batch can be cleared and a fresh one lazily created, republishing its changes. @MainActor final class BatchCoordinatorHolder: ObservableObject { @Published var coordinator: BatchTranscriptionCoordinator? diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index f11aa16b..8a2bcffb 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -141,9 +141,8 @@ struct PromiseAwareDropView: NSViewRepresentable { 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 block indefinitely and freeze the system-wide drag session, so this - // returns immediately and ALL pasteboard reads happen on background threads (outside documented AppKit - // lifetime, but the only arrangement that doesn't deadlock or lose the drop against Voice Memos). + // 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 @@ -161,10 +160,8 @@ struct PromiseAwareDropView: NSViewRepresentable { Self.detachWorker { defer { state.endInFlight() } - // NSPasteboard isn't thread-safe and receivePromisedFiles touches it from its own queue, so all our - // reads must finish, single-threaded, before any receiver starts — concurrent access previously - // crashed NSPasteboard's type cache. Wrapped in an ObjC exception catcher since an uncaught - // NSException aborts the process. Step 1: snapshot the receiver objects and per-item metadata/bytes. + // 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? @@ -201,8 +198,7 @@ struct PromiseAwareDropView: NSViewRepresentable { ) state.setPayloadCount(itemPayloads.count) - // Step 2: raw-data fallback (the reliable path for Voice Memos) — write each item's bytes into the - // shared data dir, disambiguating identically-named items. + // 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() { @@ -236,14 +232,12 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - // Step 3: modern receivers, started only after our reads are done — still required even when - // raw-data delivered, since an unresolved promise wedges Voice Memos's drag machinery until restart. + // 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 `fileNames` and starting the receiver decode promise metadata and can raise (the OS - // refuses the NSCalendarDate in a Voice Memos promise). Uncaught, that aborts the process, - // so a bad promise must degrade to the fallbacks. Register the dir either way, otherwise - // the failed one is never swept. + // 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 { @@ -267,8 +261,7 @@ struct PromiseAwareDropView: NSViewRepresentable { ) } guard completion.recordFile(failed: error != nil) else { return } - // A receiver promising several files can fail on one and still have written the rest; - // only a dir that produced nothing counts as failed, since failed dirs are never delivered. + // 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) @@ -290,9 +283,8 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - // Step 4: legacy fallback (deprecated namesOfPromisedFilesDroppedAtDestination:, called via a string - // selector to dodge the deprecation warning) is strictly last resort — an eager call blocks Voice - // Memos's drag machinery for 35-80s — so it only fires if modern/raw-data produced nothing in time. + // 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 @@ -355,7 +347,6 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.remaining <= 0 } - /// true only if the countdown hadn't already finished, so the token is released exactly once func abort() -> Bool { self.lock.lock() defer { self.lock.unlock() } @@ -366,8 +357,7 @@ struct PromiseAwareDropView: NSViewRepresentable { } } - /// Shared between background delivery threads and the main-actor poller: which receiver dirs completed, and - /// whether legacy/raw-data reads are still in flight (a provider can take 60s+ under load). + /// 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] = [] @@ -380,8 +370,7 @@ struct PromiseAwareDropView: NSViewRepresentable { private var promisedFileCount = 0 private var legacyNameCount = 0 - /// One receiver can promise several files, so the name count — not the dir - /// count — is how many items the drop is expected to yield. + /// 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() } @@ -420,8 +409,7 @@ struct PromiseAwareDropView: NSViewRepresentable { self.failed.insert(dir) } - /// Resolved for liveness, but the receiver raised mid-flight and may still be writing here, - /// so this dir must go through the deferred cleanup rather than the immediate sweep. + /// 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() } @@ -466,8 +454,7 @@ struct PromiseAwareDropView: NSViewRepresentable { return self.payloadCount } - /// Tracks only the threads writing into the fallback dirs. Receivers are counted separately: - /// an uncancelled one never releases, and it must not hold the fallback delivery open. + /// Fallback writers only — an uncancelled receiver never releases, so it must not gate delivery. func beginInFlight() { self.lock.lock() defer { self.lock.unlock() } @@ -553,7 +540,6 @@ struct PromiseAwareDropView: NSViewRepresentable { let dataFiles = self.listFiles(in: [dataDir].compactMap(\.self)) let fallbackSizes = self.sizeMap(for: legacyFiles + dataFiles) - // "Resolved" = completed OR failed, since a failed promise can never later flip to completed. let resolvedCount = readyModernDirs.count + failedModernDirs.count let allReceiversResolved = !receiverDirs.isEmpty && resolvedCount >= receiverDirs.count @@ -563,10 +549,8 @@ struct PromiseAwareDropView: NSViewRepresentable { return } - // Voice Memos only cancels a receiver when the NEXT drag starts, so a lone drop's receiver - // stays unresolved indefinitely. An unresolved receiver that has written nothing well past - // the grace is treated as non-producing; one mid-transfer keeps its files growing, so it - // still holds delivery open. + // 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 unresolvedDirs = receiverDirs.filter { !readyModernDirs.contains($0) && !failedModernDirs.contains($0) } let receiversStalled = elapsed > modernGrace && unresolvedDirs.allSatisfy { self.listFiles(in: [$0]).isEmpty } @@ -595,11 +579,9 @@ struct PromiseAwareDropView: NSViewRepresentable { ) } - /// Everything `deliver` needs besides the per-path file lists. private struct DeliveryContext { let allDirs: [URL] - // Receiver/fallback dirs not yet completed; must NOT be swept at delivery — deleting an in-flight - // promise's destination wedges Voice Memos until restart. Cleaned up later via `cleanUpLater`. + // Never swept at delivery: deleting an in-flight promise's destination wedges Voice Memos. let pendingReceiverDirs: [URL] let pendingFallbackDirs: [URL] let totalExpected: Int @@ -647,8 +629,7 @@ struct PromiseAwareDropView: NSViewRepresentable { return } - // Dirs holding no delivered file are dead weight; deleting them may race still-running writer threads, - // harmless since the losers' files are throwaway copies. + // 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), @@ -665,7 +646,6 @@ struct PromiseAwareDropView: NSViewRepresentable { self.cleanUpLater(pendingDirs: context.pendingReceiverDirs + context.pendingFallbackDirs) } - /// removes dirs once their promise has had time to finish, then prunes the empty session root private static func cleanUpLater(pendingDirs: [URL]) { guard !pendingDirs.isEmpty else { return } Task { @MainActor in diff --git a/Sources/Fluid/UI/PromiseDropSupport.swift b/Sources/Fluid/UI/PromiseDropSupport.swift index e2a1c22f..d84e3fde 100644 --- a/Sources/Fluid/UI/PromiseDropSupport.swift +++ b/Sources/Fluid/UI/PromiseDropSupport.swift @@ -11,14 +11,14 @@ nonisolated enum PromiseDropSupport { private static let concreteFileURLType = "public.file-url" - // Pasteboard types signaling a file-promise drop; Voice Memos emits a mix of these, any one is sufficient. + // 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 file URLs win when both are present; a promise is only accepted alongside advertised audio/movie content, so an image promise from Photos is rejected up front. + // 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 @@ -33,7 +33,7 @@ nonisolated enum PromiseDropSupport { return promisesAudioVisualContent ? .filePromise : nil } - // Staging dirs holding no delivered file. Compares standardized *paths*, not URLs — trailing-slash differences once made equal dirs compare unequal. + // 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 @@ -41,14 +41,14 @@ nonisolated enum PromiseDropSupport { return allDirs.filter { !deliveredDirPaths.contains($0.standardizedFileURL.path) } } - // `sweepableDirs` minus any dir whose receiver promise hasn't completed: deleting an in-flight promise's destination wedges the source app's drag machinery until restart. + // 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) } } - // Reduces a drag-source-supplied name to one safe path component; separators or `..` would otherwise let `appendingPathComponent` write outside staging. + // 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 @@ -77,7 +77,7 @@ nonisolated enum PromiseDropSupport { return delivered } - // Gives each delivered file its own dir, since the coordinator deletes an item's dir the moment it finishes. Order-preserving. + // The coordinator deletes an item's dir the moment it finishes, so each file needs its own. static func relocateForExclusiveOwnership( _ files: [URL], session: StagingSession, @@ -96,7 +96,6 @@ nonisolated enum PromiseDropSupport { } } - // audio/movie extensions the OS can decode, callable from any isolation private static let supportedExtensions: Set = { let avTypes = AVURLAsset.audiovisualTypes() let extensions = avTypes.compactMap { fileType -> String? in @@ -117,7 +116,7 @@ nonisolated enum PromiseDropSupport { // Prefix of every staging root; the batch coordinator uses it to prune empty roots once its item dir is gone. static let stagingRootPrefix = "PromiseDrop-" - // Scratch tree under the user temp dir; each promised file gets its own subdirectory so identically-named files never collide. + // Each promised file gets its own subdirectory so identically-named files never collide. final class StagingSession: Sendable { private let root: URL From 5cb1d04e7497186d9a5138e3f7be55cdf1599cb8 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:02:19 -0700 Subject: [PATCH 16/17] fix: keep polling a receiver that is still writing past the soft timeout Giving receivers their own in-flight counter left hasInFlightWork blind to them, so the 30s soft timeout could fire while a promise receiver was mid-transfer; the timeout path delivers only completed dirs, so that file was reported unreadable. Providers have been seen taking 80s. The loop now holds past the soft timeout while an unresolved receiver has written files, reusing the discriminator the stall fix already relies on, so an idle receiver still cannot gate delivery. --- Sources/Fluid/UI/PromiseAwareDropView.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index 8a2bcffb..ad97bb3f 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -530,7 +530,6 @@ struct PromiseAwareDropView: NSViewRepresentable { while true { let elapsed = Date().timeIntervalSince(start) if elapsed >= hardTimeout { break } - if elapsed >= softTimeout, !state.hasInFlightWork { 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() @@ -540,6 +539,12 @@ struct PromiseAwareDropView: NSViewRepresentable { 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 } + if elapsed >= softTimeout, !state.hasInFlightWork, !receiversProducing { break } + let resolvedCount = readyModernDirs.count + failedModernDirs.count let allReceiversResolved = !receiverDirs.isEmpty && resolvedCount >= receiverDirs.count @@ -551,9 +556,7 @@ struct PromiseAwareDropView: NSViewRepresentable { // 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 unresolvedDirs = receiverDirs.filter { !readyModernDirs.contains($0) && !failedModernDirs.contains($0) } - let receiversStalled = elapsed > modernGrace - && unresolvedDirs.allSatisfy { self.listFiles(in: [$0]).isEmpty } + let receiversStalled = elapsed > modernGrace && !receiversProducing let modernExhaustedWithNoWins = receiverDirs.isEmpty || ((allReceiversResolved || receiversStalled) && readyModernDirs.isEmpty) From c44affd667f8bc7acd8ba244fc4cfc1f29c1ec38 Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:46:33 -0700 Subject: [PATCH 17/17] fix: shorten the delivery wait once a receiver has completed A drop with several receivers where one completes and another stalls without writing satisfies neither early exit: modernComplete needs every receiver resolved, and the fallback path needs no modern wins at all. It waited the full 30s soft timeout to deliver files that were stable seconds earlier. The soft timeout is now 8s once any receiver has completed and 30s while none has, reusing the existing break rather than adding a second notion of settled: that break already holds delivery open for a receiver still writing, and cannot sweep a legacy dir early. --- Sources/Fluid/UI/PromiseAwareDropView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/UI/PromiseAwareDropView.swift b/Sources/Fluid/UI/PromiseAwareDropView.swift index ad97bb3f..fd9927b4 100644 --- a/Sources/Fluid/UI/PromiseAwareDropView.swift +++ b/Sources/Fluid/UI/PromiseAwareDropView.swift @@ -505,6 +505,7 @@ struct PromiseAwareDropView: NSViewRepresentable { ) 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() @@ -543,7 +544,8 @@ struct PromiseAwareDropView: NSViewRepresentable { // 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 } - if elapsed >= softTimeout, !state.hasInFlightWork, !receiversProducing { break } + 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