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
|
/// 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;
|
/// holds one of these and flips it when the user clicks the close button;
|
||||||
/// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`.
|
/// `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 {
|
final class DropCancellationToken {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var _cancelled = false
|
private var _cancelled = false
|
||||||
|
private var handlers: [() -> Void] = []
|
||||||
|
|
||||||
var isCancelled: Bool {
|
var isCancelled: Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
|
|
@ -15,8 +20,28 @@ final class DropCancellationToken {
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
if _cancelled {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
_cancelled = true
|
_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
|
/// Wait up to `timeout` seconds for outstanding relocations. Bridged off
|
||||||
/// the caller's actor so it never blocks the main thread.
|
/// the caller's actor so it never blocks the main thread.
|
||||||
func drain(timeout: TimeInterval) async {
|
func drain(timeout: TimeInterval) async {
|
||||||
|
let group = self.group
|
||||||
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||||
DispatchQueue.global().async {
|
DispatchQueue.global().async {
|
||||||
_ = self.group.wait(timeout: .now() + timeout)
|
_ = group.wait(timeout: .now() + timeout)
|
||||||
cont.resume()
|
cont.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,16 +143,23 @@ final class DropHandler {
|
||||||
writtenNames = [displayName]
|
writtenNames = [displayName]
|
||||||
|
|
||||||
case .promise(let receiver):
|
case .promise(let receiver):
|
||||||
// Promises carry no byte progress; totalBytes 0 → indeterminate
|
// The promise API exposes no total size, so the bar stays
|
||||||
// bar while the source app writes straight into the share.
|
// indeterminate; the detail line shows the live byte count we
|
||||||
|
// poll off the (shared) subdir as the source app streams in.
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
DropProgressToast.shared.begin(
|
DropProgressToast.shared.begin(
|
||||||
parent: box.window, filename: displayName, totalBytes: 0,
|
parent: box.window, filename: displayName, totalBytes: 0,
|
||||||
index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID
|
index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken)
|
writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken) { copied in
|
||||||
.map { $0.lastPathComponent }
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.update(
|
||||||
|
copied: copied, total: 0,
|
||||||
|
index: idx + 1, count: total, sessionID: sessionID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.map { $0.lastPathComponent }
|
||||||
}
|
}
|
||||||
|
|
||||||
let waiting = relocationPossible
|
let waiting = relocationPossible
|
||||||
|
|
@ -258,18 +266,54 @@ final class DropHandler {
|
||||||
/// exactly once — no host-side staging-then-copy. Returns the URLs actually
|
/// exactly once — no host-side staging-then-copy. Returns the URLs actually
|
||||||
/// written. Throws `DropCopyCancelled` if the user cancelled, or
|
/// written. Throws `DropCopyCancelled` if the user cancelled, or
|
||||||
/// `DropPromiseFailed` if the provider produced nothing.
|
/// `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(
|
private func receivePromise(
|
||||||
_ receiver: NSFilePromiseReceiver,
|
_ receiver: NSFilePromiseReceiver,
|
||||||
into subdir: URL,
|
into subdir: URL,
|
||||||
token: DropCancellationToken
|
token: DropCancellationToken,
|
||||||
|
progress: @escaping (Int64) -> Void
|
||||||
) throws -> [URL] {
|
) throws -> [URL] {
|
||||||
let opQueue = OperationQueue()
|
let opQueue = OperationQueue()
|
||||||
let lock = NSLock()
|
let lock = NSLock()
|
||||||
var urls: [URL] = []
|
var urls: [URL] = []
|
||||||
var firstError: Error?
|
var firstError: Error?
|
||||||
|
var polling = true
|
||||||
let sem = DispatchSemaphore(value: 0)
|
let sem = DispatchSemaphore(value: 0)
|
||||||
let expected = max(1, receiver.fileNames.count)
|
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
|
receiver.receivePromisedFiles(atDestination: subdir, options: [:], operationQueue: opQueue) { url, error in
|
||||||
lock.lock()
|
lock.lock()
|
||||||
if let error = error {
|
if let error = error {
|
||||||
|
|
@ -287,11 +331,14 @@ final class DropHandler {
|
||||||
if token.isCancelled { throw DropCopyCancelled() }
|
if token.isCancelled { throw DropCopyCancelled() }
|
||||||
if sem.wait(timeout: .now() + 30) == .timedOut { break }
|
if sem.wait(timeout: .now() + 30) == .timedOut { break }
|
||||||
}
|
}
|
||||||
|
if token.isCancelled { throw DropCopyCancelled() }
|
||||||
|
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
let result = urls
|
||||||
if urls.isEmpty { throw firstError ?? DropPromiseFailed() }
|
let err = firstError
|
||||||
return urls
|
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