mirror of https://github.com/cirruslabs/tart.git
feat: live byte progress and best-effort cancel for file-promise drops
Covers two of the file-promise trade-offs (A + B from the review): A. Progress: poll bytes streamed into the (shared) per-item subdir while the opaque receivePromisedFiles runs, feeding the toast's live size readout instead of a dead spinner. Best-effort — providers that write a temp file and atomically rename only become visible at the end. B. Cancellation: DropCancellationToken gains an onCancel hook; the promise path cancels its operation queue and unblocks the wait loop on ⊗ instead of sitting on the 30 s timeout. Cooperative providers abort; either way we stop waiting and clean up at once. C (true determinate % via NSItemProvider.loadFileRepresentation) is intentionally NOT implemented: macOS AppKit drags don't vend NSItemProvider, and NSFilePromiseReceiver exposes no Progress/cancel surface, so a real percentage isn't reachable without an undocumented hack. The size poller is the honest ceiling. Also: capture the dispatch group locally in RelocationGate.drain to drop a Sendable-capture warning. Adds DropCancellationTokenTests.
This commit is contained in:
parent
c596672c5a
commit
b954c0d56c
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>) 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..<expected { sem.signal() }
|
||||
}
|
||||
|
||||
// A: poll bytes-on-disk so the toast shows a live, honest size during the
|
||||
// opaque receive. Stops via `polling` on any exit (the defer below).
|
||||
let pollQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-promise-poll")
|
||||
func schedulePoll() {
|
||||
pollQueue.asyncAfter(deadline: .now() + 0.15) {
|
||||
lock.lock()
|
||||
let go = polling
|
||||
lock.unlock()
|
||||
guard go else { return }
|
||||
progress(DropProgressCopier.totalSize(of: subdir))
|
||||
schedulePoll()
|
||||
}
|
||||
}
|
||||
schedulePoll()
|
||||
defer {
|
||||
lock.lock()
|
||||
polling = false
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
receiver.receivePromisedFiles(atDestination: subdir, options: [:], operationQueue: opQueue) { url, error in
|
||||
lock.lock()
|
||||
if let error = error {
|
||||
|
|
@ -287,11 +331,14 @@ final class DropHandler {
|
|||
if token.isCancelled { throw DropCopyCancelled() }
|
||||
if sem.wait(timeout: .now() + 30) == .timedOut { break }
|
||||
}
|
||||
if token.isCancelled { throw DropCopyCancelled() }
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if urls.isEmpty { throw firstError ?? DropPromiseFailed() }
|
||||
return urls
|
||||
let result = urls
|
||||
let err = firstError
|
||||
lock.unlock()
|
||||
if result.isEmpty { throw err ?? DropPromiseFailed() }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class DropCancellationTokenTests: XCTestCase {
|
||||
func testFlagFlips() {
|
||||
let t = DropCancellationToken()
|
||||
XCTAssertFalse(t.isCancelled)
|
||||
t.cancel()
|
||||
XCTAssertTrue(t.isCancelled)
|
||||
}
|
||||
|
||||
func testOnCancelRunsWhenCancelled() {
|
||||
let t = DropCancellationToken()
|
||||
var fired = 0
|
||||
t.onCancel { fired += 1 }
|
||||
XCTAssertEqual(fired, 0, "must not fire before cancel")
|
||||
t.cancel()
|
||||
XCTAssertEqual(fired, 1)
|
||||
}
|
||||
|
||||
func testOnCancelRunsImmediatelyIfAlreadyCancelled() {
|
||||
let t = DropCancellationToken()
|
||||
t.cancel()
|
||||
var fired = 0
|
||||
t.onCancel { fired += 1 }
|
||||
XCTAssertEqual(fired, 1, "late handler must run at once on an already-cancelled token")
|
||||
}
|
||||
|
||||
func testCancelIsIdempotentAndHandlersRunOnce() {
|
||||
let t = DropCancellationToken()
|
||||
var fired = 0
|
||||
t.onCancel { fired += 1 }
|
||||
t.cancel()
|
||||
t.cancel()
|
||||
XCTAssertEqual(fired, 1, "handlers must run exactly once across repeated cancels")
|
||||
}
|
||||
|
||||
func testMultipleHandlersAllRun() {
|
||||
let t = DropCancellationToken()
|
||||
var a = false
|
||||
var b = false
|
||||
t.onCancel { a = true }
|
||||
t.onCancel { b = true }
|
||||
t.cancel()
|
||||
XCTAssertTrue(a && b)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue