fix: harden drag-and-drop against the edge-case report

Introduces DropHandler to own the drop pipeline and addresses every item
from the review:

- Folders / .app bundles / packages: DropProgressCopier.copyTree walks
  directories instead of failing with a generic 'Failed to copy'.
- File promises (Photos, Mail, browser image drags) are now accepted and
  materialized instead of silently no-opping.
- Multi-file toast race: per-file DropSession id; stale relocation
  results for a superseded file are ignored by update/finish/
  setFinalDestination.
- Partial files: copyTree removes its partial output on any error, and
  the handler drops the now-empty subdir, so the guest never sees a
  truncated file.
- Teardown race: in-flight guest relocations register with
  RelocationGate; 'tart run' drains it (<=6s) before deleting the drop
  zone / exiting.
- Path collisions: each file copies into its own dropRoot/<uuid>/ subdir.
- Reserved name: a user --dir named 'Dropped Files' is now rejected.
- Linux / no agent: relocation is skipped and the toast says 'Copied to
  the shared folder' instead of a misleading Finder destination.
- Agent-down timing: success toast holds on a fallback timer that
  outlives the 5s RPC deadline so the final destination is always shown.
- Concurrency: relocations run one-at-a-time; copy failures are
  coalesced into a single alert instead of a modal storm.
- Zero-byte/unknown size shows just the copied amount, not '0 bytes of ?'.
- Removed dead DropFolderBox + stale doc comment; fixed the
  http://-vs-https:// typo in toRemoteOrLocalURL.

Tests: DropProgressCopierTests (replace/cancel/error-cleanup/empty/
directory/size), GuestDropParseTests (stdout parser), and a reserved-name
case in DirectoryShareTests. Product and test target both compile.
This commit is contained in:
Dal Rupnik 2026-05-19 09:35:30 +02:00
parent 3bae4c62cf
commit 100fc1e8bc
9 changed files with 681 additions and 214 deletions

View File

@ -570,6 +570,9 @@ struct Run: AsyncParsableCommand {
try vncImpl.stop()
}
// Let any in-flight guest relocation finish moving its file out of
// the share before we delete the drop zone underneath it.
await RelocationGate.shared.drain(timeout: 6)
cleanupDropZone()
OTel.shared.flush()
Foundation.exit(0)
@ -579,6 +582,7 @@ struct Run: AsyncParsableCommand {
fputs("\(error)\n", stderr)
await RelocationGate.shared.drain(timeout: 6)
cleanupDropZone()
OTel.shared.flush()
Foundation.exit(1)
@ -1063,7 +1067,7 @@ class TartVirtualMachineView: VZVirtualMachineView {
/// drag-destination table.
class VMContainerView: NSView {
let machineView: TartVirtualMachineView
private let copyQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-copy", qos: .userInitiated)
private var dropHandler: DropHandler?
init(machineView: TartVirtualMachineView) {
self.machineView = machineView
@ -1082,14 +1086,26 @@ class VMContainerView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard window != nil, machineView.dropZoneURL != nil else { return }
registerForDraggedTypes([.fileURL])
guard window != nil, let dropZoneURL = machineView.dropZoneURL else { return }
if dropHandler == nil {
dropHandler = DropHandler(
dropRoot: dropZoneURL,
controlSocketURL: MainApp.controlSocketURL,
isMacGuest: vm?.config.os == .darwin
)
}
// Accept both real file URLs and file promises (Photos, Mail attachments,
// browser image drags, ) so those drops don't silently no-op.
let promiseTypes = NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) }
registerForDraggedTypes([.fileURL] + promiseTypes)
}
// MARK: NSDraggingDestination
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
guard machineView.dropZoneURL != nil, !fileURLs(from: sender).isEmpty else { return [] }
guard machineView.dropZoneURL != nil,
!fileURLs(from: sender).isEmpty || !promiseReceivers(from: sender).isEmpty
else { return [] }
machineView.highlight.isActive = true
return .copy
}
@ -1107,14 +1123,15 @@ class VMContainerView: NSView {
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true }
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
guard let dropZoneURL = machineView.dropZoneURL else { return false }
guard let dropHandler = dropHandler else { return false }
let urls = fileURLs(from: sender)
guard !urls.isEmpty else { return false }
let promises = promiseReceivers(from: sender)
guard !urls.isEmpty || !promises.isEmpty else { return false }
// Compute the drop location in normalized view coordinates while we're
// still on the main thread (dragging info is only valid here). The
// forthcoming guest-side drop synthesis uses this to place files where
// the user actually pointed instead of in a generic share folder.
// guest-side drop synthesis uses this to place files where the user
// actually pointed instead of in a generic share folder.
let localPoint = self.convert(sender.draggingLocation, from: nil)
let normalizedPoint = DropGeometry.normalize(
point: localPoint,
@ -1122,104 +1139,12 @@ class VMContainerView: NSView {
isViewFlipped: self.isFlipped
)
// Snapshot the control socket URL and parent window on the main actor so
// the background Task / dispatch block can use them without crossing
// actor boundaries.
let controlSocketURL = MainApp.controlSocketURL
let parentWindow = self.window
// One cancellation token covers the whole drop gesture: clicking on
// the toast aborts the current file AND skips any remaining files in a
// multi-file drop.
let cancelToken = DropCancellationToken()
// Copy off the main thread: large files would otherwise freeze the VM
// window (which is the same view that's rendering the VM's framebuffer).
// The toast (HUD-style progress panel) anchors to `parentWindow` so the
// user sees a per-file progress bar and a cancel button instead of
// a frozen UI.
let totalFiles = urls.count
copyQueue.async {
for (idx, url) in urls.enumerated() {
// Honor cancellation before starting the next file in a multi-drop.
if cancelToken.isCancelled { break }
let dest = dropZoneURL.appendingPathComponent(url.lastPathComponent)
let totalBytes = ((try? FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? Int64) ?? 0
let fileIndex = idx + 1
DispatchQueue.main.async {
DropProgressToast.shared.begin(
parent: parentWindow,
filename: url.lastPathComponent,
totalBytes: totalBytes,
index: fileIndex,
count: totalFiles,
cancelToken: cancelToken
)
}
do {
try DropProgressCopier.copy(
from: url,
to: dest,
totalBytes: totalBytes,
token: cancelToken
) { copied in
DispatchQueue.main.async {
DropProgressToast.shared.update(
copied: copied,
total: totalBytes,
index: fileIndex,
count: totalFiles
)
}
}
// Show the final state immediately so the toast doesn't sit there
// waiting on the guest agent. The relocation runs concurrently and
// patches the destination text into the toast if it returns while
// the panel is still visible otherwise the user just sees "Done".
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: true)
}
let destPath = dest.path
Task {
let folder = await synthesizeGuestDrop(
hostFilePath: destPath,
atNormalized: normalizedPoint,
controlSocketURL: controlSocketURL
)
await MainActor.run {
DropProgressToast.shared.setFinalDestination(folder)
}
}
} catch is DropCopyCancelled {
// User clicked : remove the partial destination so the share
// folder doesn't accumulate half-copied junk, surface "Cancelled"
// on the toast, and skip the remaining files in this drop.
try? FileManager.default.removeItem(at: dest)
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: false, cancelled: true)
}
break
} catch {
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: false)
}
let name = url.lastPathComponent
let message = error.localizedDescription
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "Failed to copy \"\(name)\" to the VM"
alert.informativeText = message
alert.alertStyle = .warning
alert.runModal()
}
}
}
}
dropHandler.handle(
fileURLs: urls,
promiseReceivers: promises,
normalizedPoint: normalizedPoint,
parentWindow: self.window
)
return true
}
@ -1229,52 +1154,12 @@ class VMContainerView: NSView {
options: [.urlReadingFileURLsOnly: true]
) as? [URL] ?? []
}
}
/// Asks the in-guest tart-guest-agent to place `hostFilePath` somewhere visible
/// into the frontmost Finder window if there is one, otherwise revealed in
/// Finder. The host path is rewritten to the guest's mount of the drop share
/// (`/Volumes/My Shared Files/Dropped Files/<filename>`) before being passed
/// across the wire.
///
/// `normalizedPoint` is captured for a future RPC that actually targets the
/// cursor's position; for now the AppleScript path uses frontmost-app
/// heuristics and the coordinate is unused.
///
/// Returns true when the agent reports a visible outcome (file landed in
/// Finder or got revealed). On any failure agent unreachable, no guest
/// agent installed, AppleScript erroring returns false so the caller's
/// existing share-folder behavior remains the user-visible result.
/// Box that lets a sync copyQueue thread receive a value written by an async
/// Task. The semaphore round-trip provides happens-before ordering.
final class DropFolderBox: @unchecked Sendable {
var value: String = "Shared Files"
}
/// Asks the in-guest agent to relocate the dropped file, returning the basename
/// of the destination folder so the toast can show "Copied to Desktop" etc.
/// On agent failure the file remains in the share folder and we return
/// `"Shared Files"` so the user still sees a sensible destination.
private func synthesizeGuestDrop(
hostFilePath: String,
atNormalized normalized: CGPoint,
controlSocketURL: URL?
) async -> String {
guard let controlSocketURL = controlSocketURL else { return "Shared Files" }
let filename = (hostFilePath as NSString).lastPathComponent
let guestPath = "/Volumes/My Shared Files/Dropped Files/" + filename
do {
let outcome = try await GuestDropSynthesis.perform(
controlSocketURL: controlSocketURL,
guestFilePath: guestPath,
normalizedDropPoint: normalized
)
return outcome.destinationFolderName
} catch {
NSLog("[GuestDrop] relocate failed for \(guestPath): \(error)")
return "Shared Files"
private func promiseReceivers(from info: NSDraggingInfo) -> [NSFilePromiseReceiver] {
info.draggingPasteboard.readObjects(
forClasses: [NSFilePromiseReceiver.self],
options: nil
) as? [NSFilePromiseReceiver] ?? []
}
}
@ -1434,6 +1319,13 @@ struct DirectoryShare {
result.append(try DirectoryShare(parseFrom: rawDir))
}
if let dropZoneURL = dropZoneURL {
// "Dropped Files" is reserved for the drag-and-drop share. A user --dir
// with that exact name would silently clobber (or be clobbered by) it
// in the per-mount-tag dictionary, so reject it with a clear message
// instead mirroring the unnamed-share conflict error.
if result.contains(where: { $0.name == "Dropped Files" }) {
throw ValidationError("the directory share name \"Dropped Files\" is reserved for drag-and-drop. Rename your --dir share or pass --no-drag-and-drop.")
}
result.append(DirectoryShare(
name: "Dropped Files",
path: dropZoneURL,
@ -1570,7 +1462,7 @@ struct DirectoryShare {
extension String {
func toRemoteOrLocalURL() -> URL {
if (starts(with: "https://") || starts(with: "https://")) {
if (starts(with: "http://") || starts(with: "https://")) {
URL(string: self)!
} else {
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)

View File

@ -0,0 +1,266 @@
import AppKit
import Foundation
/// Monotonic per-file id. Stamped onto every `DropProgressToast` call so a
/// slow guest-relocation result for an earlier file can't clobber/hide the
/// toast while a later file in the same drop is still copying.
enum DropSession {
private static let lock = NSLock()
private static var counter = 0
static func next() -> Int {
lock.lock()
defer { lock.unlock() }
counter += 1
return counter
}
}
/// Tracks in-flight guest relocations process-wide so `tart run` can wait for
/// them before deleting the drop zone / calling `Foundation.exit`. Without
/// this, closing the VM window right after a drop races the guest `mv`
/// against drop-zone teardown and the file is lost.
final class RelocationGate {
static let shared = RelocationGate()
private let group = DispatchGroup()
private init() {}
func enter() { group.enter() }
func leave() { group.leave() }
/// 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 {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
DispatchQueue.global().async {
_ = self.group.wait(timeout: .now() + timeout)
cont.resume()
}
}
}
}
/// Owns one VM window's drag-and-drop pipeline: copies dragged files (and
/// file promises, and folders/.app bundles) into a per-file subdirectory of
/// the shared drop zone, drives the progress toast, then asks the guest agent
/// to relocate each file under the cursor.
///
/// Design notes addressing prior edge cases:
/// - Each file gets its own `dropRoot/<uuid>/` subdir, so same-named files in
/// one gesture (or rapid re-drops) never collide on the share path.
/// - `DropProgressCopier.copyTree` handles directories and removes partial
/// output on any failure, so a half-written item is never visible to guest.
/// - Relocations run one-at-a-time on a serial queue and register with
/// `RelocationGate`, bounding guest RPC/TCC pressure and making teardown
/// safe.
/// - Per-file `sessionID` keeps multi-file toasts from racing.
/// - On non-macOS guests (or when the control socket is unavailable) the
/// relocation step is skipped and the toast says so honestly.
final class DropHandler {
private let dropRoot: URL
private let controlSocketURL: URL?
private let isMacGuest: Bool
private let copyQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-copy", qos: .userInitiated)
private let relocationQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-relocate")
private var relocationPossible: Bool { isMacGuest && controlSocketURL != nil }
init(dropRoot: URL, controlSocketURL: URL?, isMacGuest: Bool) {
self.dropRoot = dropRoot
self.controlSocketURL = controlSocketURL
self.isMacGuest = isMacGuest
}
/// Entry point, called on the main thread from `performDragOperation`.
/// `parentWindow` is only ever touched on the main thread again.
func handle(
fileURLs: [URL],
promiseReceivers: [NSFilePromiseReceiver],
normalizedPoint: CGPoint,
parentWindow: NSWindow?
) {
let cancelToken = DropCancellationToken()
let box = WindowBox(parentWindow)
copyQueue.async { [self] in
var sources = fileURLs
sources.append(contentsOf: resolvePromisedFiles(promiseReceivers, token: cancelToken))
guard !sources.isEmpty else { return }
var failures: [String] = []
let total = sources.count
for (idx, src) in sources.enumerated() {
if cancelToken.isCancelled { break }
let sessionID = DropSession.next()
let name = src.lastPathComponent
let subdir = dropRoot.appendingPathComponent(UUID().uuidString, isDirectory: true)
let dest = subdir.appendingPathComponent(name)
let totalBytes = DropProgressCopier.totalSize(of: src)
DispatchQueue.main.async {
DropProgressToast.shared.begin(
parent: box.window, filename: name, totalBytes: totalBytes,
index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID
)
}
do {
try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true)
try DropProgressCopier.copyTree(
from: src, to: dest, totalBytes: totalBytes, token: cancelToken
) { copied in
DispatchQueue.main.async {
DropProgressToast.shared.update(
copied: copied, total: totalBytes,
index: idx + 1, count: total, sessionID: sessionID
)
}
}
let waiting = relocationPossible
DispatchQueue.main.async {
DropProgressToast.shared.finish(
success: true,
destinationFolder: waiting ? nil : "the shared folder",
awaitingRelocation: waiting,
sessionID: sessionID
)
}
relocate(subdir: subdir, fileName: name, normalizedPoint: normalizedPoint, sessionID: sessionID)
} catch is DropCopyCancelled {
try? FileManager.default.removeItem(at: subdir)
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: false, cancelled: true, sessionID: sessionID)
}
break
} catch {
// copyTree already removed the partial output; drop the now-empty
// subdir too and remember the failure for one combined alert.
try? FileManager.default.removeItem(at: subdir)
failures.append("\(name): \(error.localizedDescription)")
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: false, sessionID: sessionID)
}
}
}
if !failures.isEmpty {
let summary = failures
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = summary.count == 1
? "Failed to copy a file to the VM"
: "Failed to copy \(summary.count) files to the VM"
alert.informativeText = summary.joined(separator: "\n")
alert.alertStyle = .warning
alert.runModal()
}
}
}
}
// MARK: - Relocation (serialized, one at a time)
private func relocate(subdir: URL, fileName: String, normalizedPoint: CGPoint, sessionID: Int) {
guard relocationPossible, let socket = controlSocketURL else {
// Linux / no agent: the file stays in the shared folder. The toast
// already said "Copied to the shared folder"; nothing more to do.
return
}
RelocationGate.shared.enter()
relocationQueue.async {
defer { RelocationGate.shared.leave() }
let guestPath = "/Volumes/My Shared Files/Dropped Files/"
+ subdir.lastPathComponent + "/" + fileName
let sem = DispatchSemaphore(value: 0)
var folder = "Shared Files"
var moved = false
Task {
do {
let outcome = try await GuestDropSynthesis.perform(
controlSocketURL: socket,
guestFilePath: guestPath,
normalizedDropPoint: normalizedPoint
)
folder = outcome.destinationFolderName
moved = true
} catch {
NSLog("[GuestDrop] relocate failed for \(guestPath): \(error)")
}
sem.signal()
}
sem.wait()
// 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
// reap. On failure the file stays put for the user to find.
if moved {
try? FileManager.default.removeItem(at: subdir)
}
let resolved = folder
DispatchQueue.main.async {
DropProgressToast.shared.setFinalDestination(resolved, sessionID: sessionID)
}
}
}
// MARK: - File promises (drags from Photos, Mail, browsers, )
/// Materializes `NSFilePromiseReceiver`s into a host-private staging dir and
/// returns the written file URLs so they flow through the same copy path as
/// plain file drags. Best-effort: anything that errors or times out is
/// skipped (the drop just yields fewer files, never a crash).
private func resolvePromisedFiles(
_ receivers: [NSFilePromiseReceiver],
token: DropCancellationToken
) -> [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 lock = NSLock()
var urls: [URL] = []
let sem = DispatchSemaphore(value: 0)
let expected = receivers.reduce(0) { $0 + max(1, $1.fileNames.count) }
for receiver in receivers {
receiver.receivePromisedFiles(atDestination: staging, options: [:], operationQueue: opQueue) { url, error in
if error == nil {
lock.lock()
urls.append(url)
lock.unlock()
}
sem.signal()
}
}
// 30 s headroom for first-run providers (e.g. Photos exporting originals)
// while still failing fast if a provider never calls back.
for _ in 0..<expected {
if token.isCancelled { break }
if sem.wait(timeout: .now() + 30) == .timedOut { break }
}
lock.lock()
defer { lock.unlock() }
return urls
}
}
/// Carries an `NSWindow` (main-thread-only) through the background copy
/// closure. It is only ever read back on the main thread.
private final class WindowBox: @unchecked Sendable {
let window: NSWindow?
init(_ window: NSWindow?) { self.window = window }
}

View File

@ -1,31 +1,141 @@
import Foundation
/// Chunked file copy with throttled progress callbacks and cancellation.
/// Used by the drag-and-drop handler to feed `DropProgressToast` without
/// freezing the VM render view.
/// Chunked file/directory copy with throttled progress callbacks and
/// cancellation. Used by the drag-and-drop handler to feed `DropProgressToast`
/// without freezing the VM render view.
///
/// - Removes any existing file at `dst` first (drops semantically replace).
/// - `copyTree` handles both regular files and directories (folders, `.app`
/// bundles, packages) directories are walked depth-first and every
/// regular file inside is streamed through the same chunked path.
/// - Any pre-existing item at `dst` is removed first (drops semantically
/// replace).
/// - Polls `token.isCancelled` between chunks; throws `DropCopyCancelled`
/// immediately on cancel so the caller can clean up the partial file.
/// - Reports `progress(copied)` at most once every ~50 ms during the copy,
/// plus a final call at completion so the bar always reaches 100%.
/// - Throws on either side's I/O error; partial output at `dst` is left in
/// place so the caller can decide how to surface the error (delete +
/// alert, or leave it for the user).
/// immediately on cancel.
/// - `progress(copiedSoFar)` is reported at most once every ~50 ms (cumulative
/// across the whole tree), plus a guaranteed final call so the bar always
/// reaches 100%.
/// - On any throw (I/O error or cancellation) the partial output at `dst` is
/// removed so a truncated file never becomes visible to the guest.
enum DropProgressCopier {
static func copy(
private static let walkKeys: [URLResourceKey] = [.isDirectoryKey, .isRegularFileKey]
private static let walkKeySet: Swift.Set<URLResourceKey> = [.isDirectoryKey, .isRegularFileKey]
/// Total byte size of `url`: the file size for a regular file, or the
/// recursive sum of regular-file sizes for a directory. Best-effort
/// unreadable entries contribute 0 (the bar just runs a touch fast).
static func totalSize(of url: URL) -> Int64 {
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return 0 }
if !isDir.boolValue {
return ((try? fm.attributesOfItem(atPath: url.path)[.size]) as? Int64) ?? 0
}
var total: Int64 = 0
if let en = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) {
for case let child as URL in en {
let v = try? child.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
if v?.isRegularFile == true { total += Int64(v?.fileSize ?? 0) }
}
}
return total
}
/// Copy `src` (file or directory) to `dst`. On any error the partial `dst`
/// is cleaned up before rethrowing, so callers never leave a half-written
/// item in the drop zone.
static func copyTree(
from src: URL,
to dst: URL,
totalBytes: Int64,
token: DropCancellationToken,
progress: (Int64) -> Void
) throws {
_ = totalBytes // accepted for future use (ETA, average rate, etc.)
if FileManager.default.fileExists(atPath: dst.path) {
try FileManager.default.removeItem(at: dst)
let fm = FileManager.default
if fm.fileExists(atPath: dst.path) {
try fm.removeItem(at: dst)
}
guard FileManager.default.createFile(atPath: dst.path, contents: nil) else {
var copied: Int64 = 0
var lastReport = Date(timeIntervalSince1970: 0)
let reportInterval: TimeInterval = 0.05
func report(force: Bool) {
let now = Date()
if force || now.timeIntervalSince(lastReport) >= reportInterval {
progress(copied)
lastReport = now
}
}
do {
var isDir: ObjCBool = false
_ = fm.fileExists(atPath: src.path, isDirectory: &isDir)
if isDir.boolValue {
try fm.createDirectory(at: dst, withIntermediateDirectories: true)
// Deterministic depth-first walk so the destination tree mirrors the
// source and directories are created before their contents.
let children = (try? fm.contentsOfDirectory(
at: src, includingPropertiesForKeys: walkKeys, options: []
)) ?? []
for child in children.sorted(by: { $0.path < $1.path }) {
if token.isCancelled { throw DropCopyCancelled() }
let childDst = dst.appendingPathComponent(child.lastPathComponent)
try copyInto(child, childDst, &copied, token, report)
}
} else {
try copyFile(src, dst, &copied, token, report)
}
report(force: true)
} catch {
try? fm.removeItem(at: dst)
throw error
}
}
// MARK: - Internals
private static func copyInto(
_ src: URL,
_ dst: URL,
_ copied: inout Int64,
_ token: DropCancellationToken,
_ report: (Bool) -> Void
) throws {
let fm = FileManager.default
let values = try? src.resourceValues(forKeys: walkKeySet)
if values?.isDirectory == true {
try fm.createDirectory(at: dst, withIntermediateDirectories: true)
let children = (try? fm.contentsOfDirectory(
at: src, includingPropertiesForKeys: walkKeys, options: []
)) ?? []
for child in children.sorted(by: { $0.path < $1.path }) {
if token.isCancelled { throw DropCopyCancelled() }
try copyInto(child, dst.appendingPathComponent(child.lastPathComponent), &copied, token, report)
}
} else if values?.isRegularFile == true {
try copyFile(src, dst, &copied, token, report)
} else {
// Symlink / socket / device node: recreate symlinks, skip the rest
// rather than block on a fifo or copy a device.
if let dest = try? fm.destinationOfSymbolicLink(atPath: src.path) {
try? fm.createSymbolicLink(atPath: dst.path, withDestinationPath: dest)
}
}
}
private static func copyFile(
_ src: URL,
_ dst: URL,
_ copied: inout Int64,
_ token: DropCancellationToken,
_ report: (Bool) -> Void
) throws {
let fm = FileManager.default
guard fm.createFile(atPath: dst.path, contents: nil) else {
throw NSError(
domain: NSPOSIXErrorDomain,
code: Int(EIO),
@ -42,26 +152,13 @@ enum DropProgressCopier {
let chunkSize = 1 * 1024 * 1024 // 1 MiB amortizes syscalls, still
// streams progress and bounds cancellation latency on fast disks.
let reportInterval: TimeInterval = 0.05
var lastReport = Date(timeIntervalSince1970: 0)
var copied: Int64 = 0
while true {
if token.isCancelled { throw DropCopyCancelled() }
let chunk = input.readData(ofLength: chunkSize)
if chunk.isEmpty { break }
try output.write(contentsOf: chunk)
copied += Int64(chunk.count)
let now = Date()
if now.timeIntervalSince(lastReport) >= reportInterval {
progress(copied)
lastReport = now
}
report(false)
}
// Always fire a final callback so the UI reaches 100% even when the
// file finished inside the throttle window.
progress(copied)
}
}

View File

@ -147,8 +147,10 @@ extension DropProgressToast {
let bcf = ByteCountFormatter()
bcf.countStyle = .file
let copiedStr = bcf.string(fromByteCount: max(0, copied))
let totalStr = total > 0 ? bcf.string(fromByteCount: total) : "?"
let prefix = count > 1 ? "[\(index)/\(count)] " : ""
return "\(prefix)\(copiedStr) of \(totalStr)"
// Unknown total (stat failed / genuinely empty): show just what's copied
// rather than the awkward "0 bytes of ?".
guard total > 0 else { return "\(prefix)\(copiedStr)" }
return "\(prefix)\(copiedStr) of \(bcf.string(fromByteCount: total))"
}
}

View File

@ -10,15 +10,11 @@ import Foundation
/// All methods MUST be called on the main thread. Callers driving copies
/// from a background queue should hop via `DispatchQueue.main.async` first.
///
/// Lifecycle per file:
/// begin(...) -> panel appears (or retargets) and slides
/// in if it wasn't already visible
/// update(...) -> bar advances; no-op if not visible
/// finish(success:cancelled:) -> brief "Done"/"Cancelled"/"Copy failed"
/// state, then auto-hide after ~0.8 s
///
/// Multiple files dropped in one gesture serially reuse the same panel and
/// increment the [i/N] counter without re-animating.
/// Every file gets a monotonically increasing `sessionID` (see
/// `DropSession.next()`). `update`/`finish`/`setFinalDestination` ignore any
/// call whose `sessionID` is not the one the most recent `begin` installed,
/// so a slow guest-relocation result for file 1 can't clobber or hide the
/// toast while file 2 of the same drop is still copying.
///
/// AppKit panel construction, positioning, and detail-string formatting live
/// in `DropProgressToast+Panel.swift`.
@ -39,6 +35,10 @@ final class DropProgressToast {
private var pendingFinalText: String = ""
private var didApplyFinalText: Bool = false
/// Identifies the file currently driving the toast. Stale callbacks (from a
/// previous file's async relocation) carry an older id and are ignored.
private var currentSessionID: Int = -1
/// Cancellation token for the copy currently driving the toast. Cleared
/// once the user clicks or `finish` is called, so a late click after the
/// copy already completed does nothing.
@ -56,7 +56,8 @@ final class DropProgressToast {
totalBytes: Int64,
index: Int,
count: Int,
cancelToken: DropCancellationToken
cancelToken: DropCancellationToken,
sessionID: Int
) {
ensurePanel()
hideWorkItem?.cancel()
@ -66,6 +67,7 @@ final class DropProgressToast {
anchorWindow = parent
currentToken = cancelToken
currentSessionID = sessionID
titleLabel.stringValue = filename
detailLabel.stringValue = formatDetail(copied: 0, total: totalBytes, index: index, count: count)
@ -98,8 +100,9 @@ final class DropProgressToast {
}
/// Update the progress bar and detail line. No-op if the panel isn't
/// visible (i.e. `begin` was never called or `finish` already hid it).
func update(copied: Int64, total: Int64, index: Int, count: Int) {
/// visible or the call belongs to a superseded file.
func update(copied: Int64, total: Int64, index: Int, count: Int, sessionID: Int) {
guard sessionID == currentSessionID else { return }
guard let panel = panel, panel.isVisible else { return }
if total > 0 {
progressBar.isIndeterminate = false
@ -112,9 +115,10 @@ final class DropProgressToast {
/// basename of the folder the file is in. Upgrades the pending final text
/// to "Copied to <folder>" so the delayed apply uses it; if the apply
/// already fired (relocation was slow), patches the label directly. No-op
/// if the panel already hid. Pushes the hide schedule out a bit so the
/// new text gets time to be read.
func setFinalDestination(_ folderName: String) {
/// if the panel already hid or this is a stale (superseded) callback.
/// Schedules the (short) hide now that the real destination is known.
func setFinalDestination(_ folderName: String, sessionID: Int) {
guard sessionID == currentSessionID else { return }
guard let panel = panel, panel.isVisible, !folderName.isEmpty else { return }
pendingFinalText = "Copied to \(folderName)"
if didApplyFinalText {
@ -130,11 +134,23 @@ final class DropProgressToast {
}
/// Flash a final state and schedule the panel to hide. Pass `cancelled:
/// true` when the copy ended because the user clicked ; that surfaces
/// "Cancelled" instead of "Copy failed". `destinationFolder` (only honored
/// on success) is the basename of the folder the file ended up in shown
/// as "Copied to <folder>" so the user knows where their file is.
func finish(success: Bool, destinationFolder: String? = nil, cancelled: Bool = false) {
/// true` when the copy ended because the user clicked . `destinationFolder`
/// (only honored on success) is shown as "Copied to <folder>".
///
/// When `awaitingRelocation` is true the file copied locally but the guest
/// agent is still being asked where it should land; we keep the toast up on
/// a long fallback timer (longer than the RPC's 5 s deadline) and let
/// `setFinalDestination` drive the real, short hide. That way the user
/// always sees the final destination instead of the toast vanishing at 2 s
/// while a slow/again-prompting agent is still working.
func finish(
success: Bool,
destinationFolder: String? = nil,
cancelled: Bool = false,
awaitingRelocation: Bool = false,
sessionID: Int
) {
guard sessionID == currentSessionID else { return }
guard let panel = panel else { return }
cancelButton.isEnabled = false
currentToken = nil
@ -160,9 +176,10 @@ final class DropProgressToast {
self.detailLabel.stringValue = self.pendingFinalText
self.didApplyFinalText = true
}
// 2 s baseline lets the guest-agent relocation (2 s RPC timeout) report
// back and `setFinalDestination` upgrade the label before we hide.
hideDelay = 2.0
// Awaiting the guest agent: hold on a fallback timer that outlives the
// 5 s RPC deadline; setFinalDestination cancels it and hides shortly
// after the real destination is known. Otherwise hide at the usual 2 s.
hideDelay = awaitingRelocation ? 6.5 : 2.0
} else if cancelled {
detailLabel.stringValue = "Cancelled"
hideDelay = 0.8

View File

@ -227,18 +227,25 @@ enum GuestDropSynthesis {
throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr)
}
// Parse the `tartdrop-dest=<basename>` line the script prints after `mv`.
// Tolerate other stdout (a future agent build might add a banner) by
// scanning lines instead of demanding an exact match.
guard let dest = Self.parseDestinationFolder(stdout: stdout) else {
throw GuestDropError.unexpectedOutput(stdout)
}
return GuestDropOutcome(destinationFolderName: dest)
}
/// Pulls the `tartdrop-dest=<basename>` line the script prints after `mv`
/// out of the agent's stdout. Tolerates other stdout (a future agent build
/// might add a banner) by scanning lines instead of demanding an exact
/// match, and takes the *last* such line so a trailing real value wins.
/// Returns nil when no non-empty value is present. Pure unit-tested.
static func parseDestinationFolder(stdout: String) -> String? {
var folderName: String?
for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
if line.hasPrefix("tartdrop-dest=") {
folderName = String(line.dropFirst("tartdrop-dest=".count))
}
}
guard let dest = folderName, !dest.isEmpty else {
throw GuestDropError.unexpectedOutput(stdout)
}
return GuestDropOutcome(destinationFolderName: dest)
guard let dest = folderName, !dest.isEmpty else { return nil }
return dest
}
}

View File

@ -144,4 +144,24 @@ final class DirectoryShareTests: XCTestCase {
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
}
// "Dropped Files" is reserved for the drag-and-drop share: a user --dir
// with that exact name would silently clobber it, so collect() rejects it
// when the drop zone is active.
func testReservedDroppedFilesNameRejectedWithDropZone() throws {
let url = URL(filePath: "/tmp/dropzone-test")
XCTAssertThrowsError(
try DirectoryShare.collect(
dirArgs: ["Dropped Files:/Users/admin/stuff"],
dropZoneURL: url
)
)
}
// Without drag-and-drop active the name carries no special meaning.
func testDroppedFilesNameAllowedWithoutDropZone() throws {
let shares = try DirectoryShare.collect(dirArgs: ["Dropped Files:/Users/admin/stuff"])
XCTAssertEqual(shares.count, 1)
XCTAssertEqual(shares[0].name, "Dropped Files")
}
}

View File

@ -0,0 +1,122 @@
import XCTest
@testable import tart
final class DropProgressCopierTests: XCTestCase {
private var tmp: URL!
override func setUpWithError() throws {
tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("droptest-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
addTeardownBlock { [tmp] in
try? FileManager.default.removeItem(at: tmp!)
}
}
private func write(_ name: String, _ bytes: Int) throws -> URL {
let url = tmp.appendingPathComponent(name)
try Data(repeating: 0x41, count: bytes).write(to: url)
return url
}
func testCopiesRegularFileAndReportsFinalProgress() throws {
let src = try write("src.bin", 3 * 1024 * 1024 + 7)
let dst = tmp.appendingPathComponent("out.bin")
var last: Int64 = -1
try DropProgressCopier.copyTree(
from: src, to: dst, totalBytes: 0, token: DropCancellationToken()
) { last = $0 }
XCTAssertEqual(last, 3 * 1024 * 1024 + 7, "final callback must report the full size")
XCTAssertEqual(
try Data(contentsOf: dst).count, 3 * 1024 * 1024 + 7
)
}
func testReplacesExistingDestination() throws {
let src = try write("src.bin", 128)
let dst = tmp.appendingPathComponent("out.bin")
try Data(repeating: 0x42, count: 999).write(to: dst) // stale, larger
try DropProgressCopier.copyTree(
from: src, to: dst, totalBytes: 0, token: DropCancellationToken()
) { _ in }
XCTAssertEqual(try Data(contentsOf: dst), Data(repeating: 0x41, count: 128))
}
func testEmptyFileStillFiresFinalProgress() throws {
let src = try write("empty.bin", 0)
let dst = tmp.appendingPathComponent("out.bin")
var calls = 0
var last: Int64 = -1
try DropProgressCopier.copyTree(
from: src, to: dst, totalBytes: 0, token: DropCancellationToken()
) { calls += 1; last = $0 }
XCTAssertGreaterThanOrEqual(calls, 1)
XCTAssertEqual(last, 0)
XCTAssertTrue(FileManager.default.fileExists(atPath: dst.path))
}
func testCancellationThrowsAndRemovesPartial() throws {
let src = try write("src.bin", 8 * 1024 * 1024)
let dst = tmp.appendingPathComponent("out.bin")
let token = DropCancellationToken()
token.cancel()
XCTAssertThrowsError(
try DropProgressCopier.copyTree(
from: src, to: dst, totalBytes: 0, token: token
) { _ in }
) { error in
XCTAssertTrue(error is DropCopyCancelled)
}
XCTAssertFalse(
FileManager.default.fileExists(atPath: dst.path),
"a cancelled copy must not leave a partial file in the drop zone"
)
}
func testErrorRemovesPartialDestination() throws {
let missing = tmp.appendingPathComponent("does-not-exist.bin")
let dst = tmp.appendingPathComponent("out.bin")
XCTAssertThrowsError(
try DropProgressCopier.copyTree(
from: missing, to: dst, totalBytes: 0, token: DropCancellationToken()
) { _ in }
)
XCTAssertFalse(
FileManager.default.fileExists(atPath: dst.path),
"a failed copy must not leave a half-written file visible to the guest"
)
}
func testCopiesDirectoryTreeRecursively() throws {
let srcDir = tmp.appendingPathComponent("bundle", isDirectory: true)
let sub = srcDir.appendingPathComponent("Contents", isDirectory: true)
try FileManager.default.createDirectory(at: sub, withIntermediateDirectories: true)
try Data(repeating: 0x41, count: 10).write(to: srcDir.appendingPathComponent("a.txt"))
try Data(repeating: 0x41, count: 20).write(to: sub.appendingPathComponent("b.txt"))
XCTAssertEqual(DropProgressCopier.totalSize(of: srcDir), 30)
let dst = tmp.appendingPathComponent("bundle-copy", isDirectory: true)
var last: Int64 = -1
try DropProgressCopier.copyTree(
from: srcDir, to: dst, totalBytes: 30, token: DropCancellationToken()
) { last = $0 }
XCTAssertEqual(last, 30)
XCTAssertEqual(try Data(contentsOf: dst.appendingPathComponent("a.txt")).count, 10)
XCTAssertEqual(
try Data(contentsOf: dst.appendingPathComponent("Contents/b.txt")).count, 20
)
}
func testTotalSizeOfRegularFile() throws {
let src = try write("sized.bin", 4242)
XCTAssertEqual(DropProgressCopier.totalSize(of: src), 4242)
}
}

View File

@ -0,0 +1,44 @@
import XCTest
@testable import tart
final class GuestDropParseTests: XCTestCase {
func testParsesSimpleLine() {
XCTAssertEqual(
GuestDropSynthesis.parseDestinationFolder(stdout: "tartdrop-dest=Desktop\n"),
"Desktop"
)
}
func testIgnoresBannerLinesAndTakesTheValue() {
let out = "tart-guest-agent v1.2\nsome diagnostic noise\ntartdrop-dest=Documents\n"
XCTAssertEqual(GuestDropSynthesis.parseDestinationFolder(stdout: out), "Documents")
}
func testLastValueWins() {
let out = "tartdrop-dest=Old\ntartdrop-dest=New\n"
XCTAssertEqual(GuestDropSynthesis.parseDestinationFolder(stdout: out), "New")
}
func testHandlesCRLF() {
XCTAssertEqual(
GuestDropSynthesis.parseDestinationFolder(stdout: "noise\r\ntartdrop-dest=Downloads\r\n"),
"Downloads"
)
}
func testNoLineReturnsNil() {
XCTAssertNil(GuestDropSynthesis.parseDestinationFolder(stdout: "just some output\n"))
XCTAssertNil(GuestDropSynthesis.parseDestinationFolder(stdout: ""))
}
func testEmptyValueReturnsNil() {
XCTAssertNil(GuestDropSynthesis.parseDestinationFolder(stdout: "tartdrop-dest=\n"))
}
func testFolderNameWithSpaces() {
XCTAssertEqual(
GuestDropSynthesis.parseDestinationFolder(stdout: "tartdrop-dest=My Project\n"),
"My Project"
)
}
}