diff --git a/Sources/tart/DropCancellationToken.swift b/Sources/tart/DropCancellationToken.swift index 9cd594b..a0382e6 100644 --- a/Sources/tart/DropCancellationToken.swift +++ b/Sources/tart/DropCancellationToken.swift @@ -3,9 +3,14 @@ import Foundation /// Thread-safe cancellation flag for an in-flight host→guest copy. The toast /// holds one of these and flips it when the user clicks the close button; /// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`. +/// +/// `onCancel` lets callers react to cancellation imperatively — used by the +/// file-promise path, whose `receivePromisedFiles` API has no cancellation +/// parameter, to tear down its operation queue and unblock its wait loop. final class DropCancellationToken { private let lock = NSLock() private var _cancelled = false + private var handlers: [() -> Void] = [] var isCancelled: Bool { lock.lock() @@ -15,8 +20,28 @@ final class DropCancellationToken { func cancel() { lock.lock() - defer { lock.unlock() } + if _cancelled { + lock.unlock() + return + } _cancelled = true + let toRun = handlers + handlers = [] + lock.unlock() + toRun.forEach { $0() } + } + + /// Invoke `handler` as soon as the token is cancelled — immediately if it + /// already is. Handlers run once, outside the lock. + func onCancel(_ handler: @escaping () -> Void) { + lock.lock() + if _cancelled { + lock.unlock() + handler() + return + } + handlers.append(handler) + lock.unlock() } } diff --git a/Sources/tart/DropHandler.swift b/Sources/tart/DropHandler.swift index 0946b80..f315d30 100644 --- a/Sources/tart/DropHandler.swift +++ b/Sources/tart/DropHandler.swift @@ -32,9 +32,10 @@ final class RelocationGate { /// Wait up to `timeout` seconds for outstanding relocations. Bridged off /// the caller's actor so it never blocks the main thread. func drain(timeout: TimeInterval) async { + let group = self.group await withCheckedContinuation { (cont: CheckedContinuation) in DispatchQueue.global().async { - _ = self.group.wait(timeout: .now() + timeout) + _ = group.wait(timeout: .now() + timeout) cont.resume() } } @@ -142,16 +143,23 @@ final class DropHandler { writtenNames = [displayName] case .promise(let receiver): - // Promises carry no byte progress; totalBytes 0 → indeterminate - // bar while the source app writes straight into the share. + // The promise API exposes no total size, so the bar stays + // indeterminate; the detail line shows the live byte count we + // poll off the (shared) subdir as the source app streams in. DispatchQueue.main.async { DropProgressToast.shared.begin( parent: box.window, filename: displayName, totalBytes: 0, index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID ) } - writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken) - .map { $0.lastPathComponent } + writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken) { copied in + DispatchQueue.main.async { + DropProgressToast.shared.update( + copied: copied, total: 0, + index: idx + 1, count: total, sessionID: sessionID + ) + } + }.map { $0.lastPathComponent } } let waiting = relocationPossible @@ -258,18 +266,54 @@ final class DropHandler { /// exactly once — no host-side staging-then-copy. Returns the URLs actually /// written. Throws `DropCopyCancelled` if the user cancelled, or /// `DropPromiseFailed` if the provider produced nothing. + /// + /// `progress` is fed the bytes streamed into `subdir` so far, polled while + /// the receive is in flight — the `NSFilePromiseReceiver` API itself + /// reports nothing until each file is fully written. Best-effort: a + /// provider that writes to a temp path and atomically renames into place + /// only becomes visible at the end. private func receivePromise( _ receiver: NSFilePromiseReceiver, into subdir: URL, - token: DropCancellationToken + token: DropCancellationToken, + progress: @escaping (Int64) -> Void ) throws -> [URL] { let opQueue = OperationQueue() let lock = NSLock() var urls: [URL] = [] var firstError: Error? + var polling = true let sem = DispatchSemaphore(value: 0) let expected = max(1, receiver.fileNames.count) + // B: receivePromisedFiles has no cancellation parameter. On ⊗, cancel the + // operation queue (cooperative providers honor it) and wake the wait loop + // immediately instead of sitting on the 30 s timeout. + token.onCancel { + opQueue.cancelAllOperations() + for _ in 0..