diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 0d2f334..342f078 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -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/`) 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) diff --git a/Sources/tart/DropHandler.swift b/Sources/tart/DropHandler.swift new file mode 100644 index 0000000..269ede0 --- /dev/null +++ b/Sources/tart/DropHandler.swift @@ -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) 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//` 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.. = [.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) } } diff --git a/Sources/tart/DropProgressToast+Panel.swift b/Sources/tart/DropProgressToast+Panel.swift index db6a0f6..7ce08c9 100644 --- a/Sources/tart/DropProgressToast+Panel.swift +++ b/Sources/tart/DropProgressToast+Panel.swift @@ -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))" } } diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift index 951d358..6ab7cb1 100644 --- a/Sources/tart/DropProgressToast.swift +++ b/Sources/tart/DropProgressToast.swift @@ -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 " 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 " 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 ". + /// + /// 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 diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift index a3b3787..c29ca76 100644 --- a/Sources/tart/GuestDropSynthesis.swift +++ b/Sources/tart/GuestDropSynthesis.swift @@ -227,18 +227,25 @@ enum GuestDropSynthesis { throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr) } - // Parse the `tartdrop-dest=` 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=` 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 } } diff --git a/Tests/TartTests/DirectoryShareTests.swift b/Tests/TartTests/DirectoryShareTests.swift index 0035d55..7aee5a3 100644 --- a/Tests/TartTests/DirectoryShareTests.swift +++ b/Tests/TartTests/DirectoryShareTests.swift @@ -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") + } } diff --git a/Tests/TartTests/DropProgressCopierTests.swift b/Tests/TartTests/DropProgressCopierTests.swift new file mode 100644 index 0000000..8cf8e78 --- /dev/null +++ b/Tests/TartTests/DropProgressCopierTests.swift @@ -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) + } +} diff --git a/Tests/TartTests/GuestDropParseTests.swift b/Tests/TartTests/GuestDropParseTests.swift new file mode 100644 index 0000000..94e8600 --- /dev/null +++ b/Tests/TartTests/GuestDropParseTests.swift @@ -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" + ) + } +}