mirror of https://github.com/cirruslabs/tart.git
refactor: receive file promises straight into the drop zone
Previously promised files (Photos/Mail/browser drags) were received into a host-private staging dir and then copied a second time into the shared drop zone. Now each promise is received directly into its per-item dropRoot/<uuid>/ subdir, so the source app writes it exactly once. Plain file/dir drags still stream through copyTree (progress + cancellation). Promises show an indeterminate bar (no byte progress is available from the promise API) and relocation uses the actual written names so provider de-duplication is honored. relocate() now reaps the subdir by emptiness rather than unconditionally, so a multi-file promise sharing one subdir isn't deleted out from under its pending siblings.
This commit is contained in:
parent
100fc1e8bc
commit
c596672c5a
|
|
@ -41,16 +41,24 @@ final class RelocationGate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns one VM window's drag-and-drop pipeline: copies dragged files (and
|
/// No promised file could be received (every provider errored or timed out).
|
||||||
/// file promises, and folders/.app bundles) into a per-file subdirectory of
|
struct DropPromiseFailed: LocalizedError {
|
||||||
|
var errorDescription: String? { "the source app didn't provide the file" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns one VM window's drag-and-drop pipeline: brings dragged files (and
|
||||||
|
/// folders/.app bundles, and file promises) into a per-item subdirectory of
|
||||||
/// the shared drop zone, drives the progress toast, then asks the guest agent
|
/// the shared drop zone, drives the progress toast, then asks the guest agent
|
||||||
/// to relocate each file under the cursor.
|
/// to relocate them under the cursor.
|
||||||
///
|
///
|
||||||
/// Design notes addressing prior edge cases:
|
/// Design notes addressing prior edge cases:
|
||||||
/// - Each file gets its own `dropRoot/<uuid>/` subdir, so same-named files in
|
/// - Each item gets its own `dropRoot/<uuid>/` subdir, so same-named files in
|
||||||
/// one gesture (or rapid re-drops) never collide on the share path.
|
/// one gesture (or rapid re-drops) never collide on the share path.
|
||||||
/// - `DropProgressCopier.copyTree` handles directories and removes partial
|
/// - Plain file/dir drags stream through `DropProgressCopier.copyTree`, which
|
||||||
/// output on any failure, so a half-written item is never visible to guest.
|
/// handles directories and removes partial output on any failure, so a
|
||||||
|
/// half-written item is never visible to the guest.
|
||||||
|
/// - File promises are received *directly into* their subdir, so they are
|
||||||
|
/// written once by the source app — no host-side staging-then-copy.
|
||||||
/// - Relocations run one-at-a-time on a serial queue and register with
|
/// - Relocations run one-at-a-time on a serial queue and register with
|
||||||
/// `RelocationGate`, bounding guest RPC/TCC pressure and making teardown
|
/// `RelocationGate`, bounding guest RPC/TCC pressure and making teardown
|
||||||
/// safe.
|
/// safe.
|
||||||
|
|
@ -58,6 +66,11 @@ final class RelocationGate {
|
||||||
/// - On non-macOS guests (or when the control socket is unavailable) the
|
/// - On non-macOS guests (or when the control socket is unavailable) the
|
||||||
/// relocation step is skipped and the toast says so honestly.
|
/// relocation step is skipped and the toast says so honestly.
|
||||||
final class DropHandler {
|
final class DropHandler {
|
||||||
|
private enum Item {
|
||||||
|
case file(URL)
|
||||||
|
case promise(NSFilePromiseReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
private let dropRoot: URL
|
private let dropRoot: URL
|
||||||
private let controlSocketURL: URL?
|
private let controlSocketURL: URL?
|
||||||
private let isMacGuest: Bool
|
private let isMacGuest: Bool
|
||||||
|
|
@ -82,33 +95,40 @@ final class DropHandler {
|
||||||
) {
|
) {
|
||||||
let cancelToken = DropCancellationToken()
|
let cancelToken = DropCancellationToken()
|
||||||
let box = WindowBox(parentWindow)
|
let box = WindowBox(parentWindow)
|
||||||
|
let items: [Item] = fileURLs.map(Item.file) + promiseReceivers.map(Item.promise)
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
|
||||||
copyQueue.async { [self] in
|
copyQueue.async { [self] in
|
||||||
var sources = fileURLs
|
|
||||||
sources.append(contentsOf: resolvePromisedFiles(promiseReceivers, token: cancelToken))
|
|
||||||
guard !sources.isEmpty else { return }
|
|
||||||
|
|
||||||
var failures: [String] = []
|
var failures: [String] = []
|
||||||
let total = sources.count
|
let total = items.count
|
||||||
|
|
||||||
for (idx, src) in sources.enumerated() {
|
for (idx, item) in items.enumerated() {
|
||||||
if cancelToken.isCancelled { break }
|
if cancelToken.isCancelled { break }
|
||||||
|
|
||||||
let sessionID = DropSession.next()
|
let sessionID = DropSession.next()
|
||||||
let name = src.lastPathComponent
|
|
||||||
let subdir = dropRoot.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let subdir = dropRoot.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
let dest = subdir.appendingPathComponent(name)
|
// Display name is known up front for both kinds; a promise provider
|
||||||
let totalBytes = DropProgressCopier.totalSize(of: src)
|
// may de-duplicate on write, so relocation uses the *actual* names.
|
||||||
|
let displayName: String
|
||||||
DispatchQueue.main.async {
|
switch item {
|
||||||
DropProgressToast.shared.begin(
|
case .file(let src): displayName = src.lastPathComponent
|
||||||
parent: box.window, filename: name, totalBytes: totalBytes,
|
case .promise(let r): displayName = r.fileNames.first ?? "Dropped file"
|
||||||
index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let writtenNames: [String]
|
||||||
|
switch item {
|
||||||
|
case .file(let src):
|
||||||
|
let dest = subdir.appendingPathComponent(displayName)
|
||||||
|
let totalBytes = DropProgressCopier.totalSize(of: src)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.begin(
|
||||||
|
parent: box.window, filename: displayName, totalBytes: totalBytes,
|
||||||
|
index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID
|
||||||
|
)
|
||||||
|
}
|
||||||
try DropProgressCopier.copyTree(
|
try DropProgressCopier.copyTree(
|
||||||
from: src, to: dest, totalBytes: totalBytes, token: cancelToken
|
from: src, to: dest, totalBytes: totalBytes, token: cancelToken
|
||||||
) { copied in
|
) { copied in
|
||||||
|
|
@ -119,6 +139,20 @@ 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.
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
let waiting = relocationPossible
|
let waiting = relocationPossible
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
@ -129,7 +163,9 @@ final class DropHandler {
|
||||||
sessionID: sessionID
|
sessionID: sessionID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
for name in writtenNames {
|
||||||
relocate(subdir: subdir, fileName: name, normalizedPoint: normalizedPoint, sessionID: sessionID)
|
relocate(subdir: subdir, fileName: name, normalizedPoint: normalizedPoint, sessionID: sessionID)
|
||||||
|
}
|
||||||
} catch is DropCopyCancelled {
|
} catch is DropCopyCancelled {
|
||||||
try? FileManager.default.removeItem(at: subdir)
|
try? FileManager.default.removeItem(at: subdir)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
@ -137,10 +173,10 @@ final class DropHandler {
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
} catch {
|
} catch {
|
||||||
// copyTree already removed the partial output; drop the now-empty
|
// copyTree already removed any partial output; drop the subdir too
|
||||||
// subdir too and remember the failure for one combined alert.
|
// and remember the failure for one combined alert.
|
||||||
try? FileManager.default.removeItem(at: subdir)
|
try? FileManager.default.removeItem(at: subdir)
|
||||||
failures.append("\(name): \(error.localizedDescription)")
|
failures.append("\(displayName): \(error.localizedDescription)")
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
DropProgressToast.shared.finish(success: false, sessionID: sessionID)
|
DropProgressToast.shared.finish(success: false, sessionID: sessionID)
|
||||||
}
|
}
|
||||||
|
|
@ -197,12 +233,17 @@ final class DropHandler {
|
||||||
}
|
}
|
||||||
sem.wait()
|
sem.wait()
|
||||||
|
|
||||||
// Successful relocation `mv`s the file out of the share (a cross-FS
|
// A successful relocation `mv`s the file out of the share (a cross-FS
|
||||||
// move that unlinks the host-side source), leaving an empty subdir to
|
// move that unlinks the host-side source). Reap the subdir only once
|
||||||
// reap. On failure the file stays put for the user to find.
|
// it's empty, so a multi-file promise sharing one subdir isn't deleted
|
||||||
|
// out from under its still-pending siblings. On failure the file stays
|
||||||
|
// put for the user to find.
|
||||||
if moved {
|
if moved {
|
||||||
|
let remaining = (try? FileManager.default.contentsOfDirectory(atPath: subdir.path)) ?? []
|
||||||
|
if remaining.isEmpty {
|
||||||
try? FileManager.default.removeItem(at: subdir)
|
try? FileManager.default.removeItem(at: subdir)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let resolved = folder
|
let resolved = folder
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
DropProgressToast.shared.setFinalDestination(resolved, sessionID: sessionID)
|
DropProgressToast.shared.setFinalDestination(resolved, sessionID: sessionID)
|
||||||
|
|
@ -212,48 +253,44 @@ final class DropHandler {
|
||||||
|
|
||||||
// MARK: - File promises (drags from Photos, Mail, browsers, …)
|
// MARK: - File promises (drags from Photos, Mail, browsers, …)
|
||||||
|
|
||||||
/// Materializes `NSFilePromiseReceiver`s into a host-private staging dir and
|
/// Receives `receiver`'s promised files *directly into* `subdir` (which is
|
||||||
/// returns the written file URLs so they flow through the same copy path as
|
/// already the shared drop-zone location), so the source app writes them
|
||||||
/// plain file drags. Best-effort: anything that errors or times out is
|
/// exactly once — no host-side staging-then-copy. Returns the URLs actually
|
||||||
/// skipped (the drop just yields fewer files, never a crash).
|
/// written. Throws `DropCopyCancelled` if the user cancelled, or
|
||||||
private func resolvePromisedFiles(
|
/// `DropPromiseFailed` if the provider produced nothing.
|
||||||
_ receivers: [NSFilePromiseReceiver],
|
private func receivePromise(
|
||||||
|
_ receiver: NSFilePromiseReceiver,
|
||||||
|
into subdir: URL,
|
||||||
token: DropCancellationToken
|
token: DropCancellationToken
|
||||||
) -> [URL] {
|
) throws -> [URL] {
|
||||||
guard !receivers.isEmpty else { return [] }
|
|
||||||
|
|
||||||
let staging = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("tart-drop-promise-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
guard (try? FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)) != nil else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
let opQueue = OperationQueue()
|
let opQueue = OperationQueue()
|
||||||
let lock = NSLock()
|
let lock = NSLock()
|
||||||
var urls: [URL] = []
|
var urls: [URL] = []
|
||||||
|
var firstError: Error?
|
||||||
let sem = DispatchSemaphore(value: 0)
|
let sem = DispatchSemaphore(value: 0)
|
||||||
let expected = receivers.reduce(0) { $0 + max(1, $1.fileNames.count) }
|
let expected = max(1, receiver.fileNames.count)
|
||||||
|
|
||||||
for receiver in receivers {
|
receiver.receivePromisedFiles(atDestination: subdir, options: [:], operationQueue: opQueue) { url, error in
|
||||||
receiver.receivePromisedFiles(atDestination: staging, options: [:], operationQueue: opQueue) { url, error in
|
|
||||||
if error == nil {
|
|
||||||
lock.lock()
|
lock.lock()
|
||||||
|
if let error = error {
|
||||||
|
if firstError == nil { firstError = error }
|
||||||
|
} else {
|
||||||
urls.append(url)
|
urls.append(url)
|
||||||
lock.unlock()
|
|
||||||
}
|
}
|
||||||
|
lock.unlock()
|
||||||
sem.signal()
|
sem.signal()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 30 s headroom for first-run providers (e.g. Photos exporting originals)
|
// 30 s headroom per file for first-run providers (e.g. Photos exporting
|
||||||
// while still failing fast if a provider never calls back.
|
// originals) while still failing fast if a provider never calls back.
|
||||||
for _ in 0..<expected {
|
for _ in 0..<expected {
|
||||||
if token.isCancelled { break }
|
if token.isCancelled { throw DropCopyCancelled() }
|
||||||
if sem.wait(timeout: .now() + 30) == .timedOut { break }
|
if sem.wait(timeout: .now() + 30) == .timedOut { break }
|
||||||
}
|
}
|
||||||
|
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
if urls.isEmpty { throw firstError ?? DropPromiseFailed() }
|
||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue