From 8546f07ea8b5d8771d65adfd16fe211188d9a13e Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 11 May 2026 16:39:41 +0200 Subject: [PATCH 01/20] feat: Implement drag and drop functionality --- Sources/tart/Commands/Run.swift | 341 +++++++++++++++++++++++++++----- 1 file changed, 288 insertions(+), 53 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 2e89590..e7a946a 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -3,6 +3,7 @@ import Cocoa import Darwin import Dispatch import SwiftUI +import UniformTypeIdentifiers import Virtualization import OpenTelemetryApi import System @@ -95,6 +96,11 @@ struct Run: AsyncParsableCommand { discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS.")) var noClipboard: Bool = false + @Flag(help: ArgumentHelp( + "Disable drag and drop support in the built-in UI.", + discussion: "When drag and drop is enabled (the default), files dragged onto the VM window are copied to a shared directory. macOS guests can access the dropped files at /Volumes/My Shared Files/Dropped Files/.")) + var noDragAndDrop: Bool = false + #if arch(arm64) @Flag(help: "Boot into recovery mode") #endif @@ -408,11 +414,27 @@ struct Run: AsyncParsableCommand { // Parse root disk options let diskOptions = DiskOptions(rootDiskOpts) + // Determine whether the built-in GUI window will be shown + let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics + let willShowBuiltInUI = !noGraphics && !useVNCWithoutGraphics + + // Create a temporary drop zone directory that is shared with the VM and used + // as the destination for files dragged onto the built-in UI window. + // The directory lives in ~/.tart/tmp/ and will be cleaned up by tart's GC. + var dropZoneURL: URL? = nil + if willShowBuiltInUI && !noDragAndDrop { + if #available(macOS 13, *) { + let dropDir = try Config().tartTmpDir.appendingPathComponent("dropzone-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dropDir, withIntermediateDirectories: true) + dropZoneURL = dropDir + } + } + vm = try VM( vmDir: vmDir, network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(), additionalStorageDevices: try additionalDiskAttachments(), - directorySharingDevices: directoryShares() + rosettaDirectoryShare(), + directorySharingDevices: directoryShares(dropZoneURL: dropZoneURL) + rosettaDirectoryShare(), serialPorts: serialPorts, suspendable: suspendable, nested: nested, @@ -593,7 +615,6 @@ struct Run: AsyncParsableCommand { } sigusr2Src.activate() - let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics if noGraphics || useVNCWithoutGraphics { // Enter the main event loop without bringing up any UI, // waiting for the VM to exit. @@ -601,7 +622,7 @@ struct Run: AsyncParsableCommand { NSApplication.shared.run() } else { - runUI(suspendable, captureSystemKeys) + runUI(suspendable, captureSystemKeys, dropZoneURL: dropZoneURL) } } @@ -682,8 +703,30 @@ struct Run: AsyncParsableCommand { } } - func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] { - if dir.isEmpty { + func directoryShares(dropZoneURL: URL? = nil) throws -> [VZDirectorySharingDeviceConfiguration] { + var allDirectoryShares: [DirectoryShare] = [] + + for rawDir in dir { + allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir)) + } + + // Append the drag-and-drop drop zone as an unnamed share so that when it is + // the only share on the automount tag it becomes a VZSingleDirectoryShare and + // its contents appear directly at /Volumes/My Shared Files/ in the guest. + // When merged with other --dir shares the auto-naming logic below gives it + // the friendly name "Dropped Files". + if let dropZoneURL = dropZoneURL { + if #available(macOS 13, *) { + allDirectoryShares.append(DirectoryShare( + name: nil, + path: dropZoneURL, + readOnly: false, + mountTag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag + )) + } + } + + if allDirectoryShares.isEmpty { return [] } @@ -691,30 +734,38 @@ struct Run: AsyncParsableCommand { throw UnsupportedOSError("directory sharing", "is") } - var allDirectoryShares: [DirectoryShare] = [] - - for rawDir in dir { - allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir)) - } - - return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in + return try Dictionary(grouping: allDirectoryShares, by: { $0.mountTag }).map { mountTag, shares in let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag) - var allNamedShares = true - for directoryShare in directoryShares { - if directoryShare.name == nil { - allNamedShares = false + let allNamed = shares.allSatisfy { $0.name != nil } + + if shares.count == 1 && !allNamed { + // Single unnamed share: expose its contents directly at the mount root + sharingDevice.share = VZSingleDirectoryShare(directory: try shares[0].createConfiguration()) + } else if !allNamed { + // Multiple shares with some unnamed: only allow if exactly one is unnamed + // (happens when an unnamed --dir share is combined with the named drop zone) + let unnamedCount = shares.filter { $0.name == nil }.count + if unnamedCount > 1 { + throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named") } - } - if directoryShares.count == 1 && directoryShares.first!.name == nil { - let directoryShare = directoryShares.first! - let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration()) - sharingDevice.share = singleDirectoryShare - } else if !allNamedShares { - throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named") + // Auto-name the single unnamed share. The drop zone path ends with + // "dropzone-" so give it the user-friendly name "Dropped Files"; + // all other unnamed shares get their directory's last path component. + var directories: [String: VZSharedDirectory] = [:] + for share in shares { + let autoName = share.path.lastPathComponent.hasPrefix("dropzone-") + ? "Dropped Files" + : share.path.lastPathComponent + directories[share.name ?? autoName] = try share.createConfiguration() + } + sharingDevice.share = VZMultipleDirectoryShare(directories: directories) } else { - var directories: [String : VZSharedDirectory] = Dictionary() - try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() } + // All named: create a multi-directory share + var directories: [String: VZSharedDirectory] = [:] + for share in shares { + directories[share.name!] = try share.createConfiguration() + } sharingDevice.share = VZMultipleDirectoryShare(directories: directories) } @@ -751,41 +802,52 @@ struct Run: AsyncParsableCommand { #endif } - private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) { + private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool, dropZoneURL: URL?) { MainApp.suspendable = suspendable MainApp.capturesSystemKeys = captureSystemKeys + MainApp.dropZoneURL = dropZoneURL MainApp.main() } } +// MARK: - VM window content with drag-and-drop handled at the SwiftUI layer + +/// Top-level SwiftUI view for the VM window. Drag-and-drop is handled here +/// using SwiftUI's .onDrop so it fires on NSHostingView before any AppKit +/// subview (including VZVirtualMachineView) can intercept the drag session. +struct VMWindowView: View { + var body: some View { + VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys, dropZoneURL: MainApp.dropZoneURL) + .onAppear { + NSWindow.allowsAutomaticWindowTabbing = false + } + .onDisappear { + let ret = kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) + if ret != 0 { + NSApplication.shared.terminate(nil) + } + } + } +} + struct MainApp: App { static var suspendable: Bool = false static var capturesSystemKeys: Bool = false + static var dropZoneURL: URL? = nil @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate var body: some Scene { WindowGroup(vm!.name) { - Group { - VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear { - NSWindow.allowsAutomaticWindowTabbing = false - }.onDisappear { - let ret = kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) - if ret != 0 { - // Fallback to the old termination method that doesn't - // propagate the cancellation to Task's in case graceful - // termination via kill(2) is not successful - NSApplication.shared.terminate(self) - } - } - }.frame( - minWidth: CGFloat(vm!.config.display.width), - idealWidth: CGFloat(vm!.config.display.width), - maxWidth: .infinity, - minHeight: CGFloat(vm!.config.display.height), - idealHeight: CGFloat(vm!.config.display.height), - maxHeight: .infinity - ) + VMWindowView() + .frame( + minWidth: CGFloat(vm!.config.display.width), + idealWidth: CGFloat(vm!.config.display.width), + maxWidth: .infinity, + minHeight: CGFloat(vm!.config.display.height), + idealHeight: CGFloat(vm!.config.display.height), + maxHeight: .infinity + ) }.commands { // Remove some standard menu options CommandGroup(replacing: .help, addition: {}) @@ -867,14 +929,14 @@ struct AboutTart: View { } struct VMView: NSViewRepresentable { - typealias NSViewType = VZVirtualMachineView + typealias NSViewType = VMContainerView @ObservedObject var vm: VM var capturesSystemKeys: Bool + var dropZoneURL: URL? - func makeNSView(context: Context) -> NSViewType { - let machineView = VZVirtualMachineView() - + func makeNSView(context: Context) -> VMContainerView { + let machineView = TartVirtualMachineView() machineView.capturesSystemKeys = capturesSystemKeys // If not specified, enable automatic display @@ -886,11 +948,176 @@ struct VMView: NSViewRepresentable { machineView.automaticallyReconfiguresDisplay = true } - return machineView + if let dropZoneURL = dropZoneURL { + machineView.dropZoneURL = dropZoneURL + } + + return VMContainerView(machineView: machineView) } - func updateNSView(_ nsView: NSViewType, context: Context) { - nsView.virtualMachine = vm.virtualMachine + func updateNSView(_ nsView: VMContainerView, context: Context) { + nsView.machineView.virtualMachine = vm.virtualMachine + } +} + +// MARK: - Drag and Drop + +/// Layer-backed highlight overlay. Added as a subview of TartVirtualMachineView +/// so its CALayer composites on top of Metal rendering. hitTest returns nil so +/// it is completely transparent to pointer events. +class DragHighlightView: NSView { + private let fillLayer = CALayer() + private let borderLayer = CAShapeLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = CGColor.clear + + fillLayer.backgroundColor = CGColor.clear + layer?.addSublayer(fillLayer) + + borderLayer.fillColor = CGColor.clear + borderLayer.lineWidth = 8 + borderLayer.strokeColor = NSColor.controlAccentColor.cgColor + borderLayer.isHidden = true + layer?.addSublayer(borderLayer) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + var isActive: Bool = false { + didSet { + let accent = NSColor.controlAccentColor + fillLayer.backgroundColor = isActive + ? accent.withAlphaComponent(0.15).cgColor + : CGColor.clear + borderLayer.isHidden = !isActive + } + } + + override var isOpaque: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func layout() { + super.layout() + fillLayer.frame = bounds + borderLayer.frame = bounds + borderLayer.path = CGPath(rect: bounds.insetBy(dx: 4, dy: 4), transform: nil) + } +} + +/// VZVirtualMachineView subclass. Hosts the DragHighlightView as an internal +/// subview so the highlight renders above the Metal framebuffer. +class TartVirtualMachineView: VZVirtualMachineView { + var dropZoneURL: URL? + let highlight = DragHighlightView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + highlight.autoresizingMask = [.width, .height] + addSubview(highlight) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func layout() { + super.layout() + highlight.frame = bounds + } +} + +/// Wraps TartVirtualMachineView. +/// Drag-and-drop is registered in viewDidMoveToWindow (not init) so AppKit +/// records the registration with the window. All NSDraggingDestination methods +/// live here so we don't depend on VZVirtualMachineView accepting overrides. +class VMContainerView: NSView { + let machineView: TartVirtualMachineView + + init(machineView: TartVirtualMachineView) { + self.machineView = machineView + super.init(frame: .zero) + addSubview(machineView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + machineView.frame = bounds + } + + // Register AFTER the view is inserted into a window so AppKit records + // the registration against the live window drag-destination table. + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil, machineView.dropZoneURL != nil else { return } + registerForDraggedTypes([ + .fileURL, + NSPasteboard.PasteboardType("NSFilenamesPboardType"), + ]) + fputs("tart: drag-and-drop registered (drop zone: \(machineView.dropZoneURL!.path))\n", stderr) + } + + // MARK: NSDraggingDestination + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + fputs("tart: draggingEntered — types: \(sender.draggingPasteboard.types ?? [])\n", stderr) + guard machineView.dropZoneURL != nil, !fileURLs(from: sender).isEmpty else { return [] } + machineView.highlight.isActive = true + return .copy + } + + override func wantsPeriodicDraggingUpdates() -> Bool { false } + + override func draggingExited(_ sender: NSDraggingInfo?) { + machineView.highlight.isActive = false + } + + override func draggingEnded(_ sender: NSDraggingInfo) { + machineView.highlight.isActive = false + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let dropZoneURL = machineView.dropZoneURL else { return false } + let urls = fileURLs(from: sender) + guard !urls.isEmpty else { return false } + fputs("tart: dropping \(urls.count) file(s) to \(dropZoneURL.path)\n", stderr) + for url in urls { + let dest = dropZoneURL.appendingPathComponent(url.lastPathComponent) + do { + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.copyItem(at: url, to: dest) + fputs("tart: copied \(url.lastPathComponent)\n", stderr) + } catch { + let e = error; let n = url.lastPathComponent + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Failed to copy \"\(n)\" to the VM" + alert.informativeText = e.localizedDescription + alert.alertStyle = .warning + alert.runModal() + } + } + } + return true + } + + private func fileURLs(from info: NSDraggingInfo) -> [URL] { + info.draggingPasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL] ?? [] } } @@ -1032,6 +1259,14 @@ struct DirectoryShare { let readOnly: Bool let mountTag: String + // Programmatic initializer for internal use (e.g. the drag-and-drop drop zone) + init(name: String?, path: URL, readOnly: Bool, mountTag: String) { + self.name = name + self.path = path + self.readOnly = readOnly + self.mountTag = mountTag + } + init(parseFrom: String) throws { var parseFrom = parseFrom From 2c5037ea508056be354dc2ae612f267fcc93a683 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Wed, 13 May 2026 11:44:01 +0200 Subject: [PATCH 02/20] refactor: harden drag-and-drop drop zone lifecycle and copy path - Hold a FileLock on the dropzone so a concurrent tart command's Config.gc() can't delete it from under the running VM, and remove the directory explicitly on VM exit (defer doesn't fire through Foundation.exit). - Extract DirectoryShare.collect() and add the drop zone as a named "Dropped Files" share so the share-builder logic stays simple. - Copy dropped files on a background queue so large drops don't freeze the VM framebuffer (the view that handles the drop also renders the VM). - Reject unnamed --dir combined with drag-and-drop with a clear error pointing at both fixes; document the same in --no-drag-and-drop help. - Add DirectoryShare.collect() tests covering empty, named, drop-zone-only, unnamed-conflict, and custom-mount-tag cases. --- Sources/tart/Commands/Run.swift | 187 ++++++++++++---------- Tests/TartTests/DirecotryShareTests.swift | 73 +++++++++ 2 files changed, 174 insertions(+), 86 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index e7a946a..fa94de6 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -3,7 +3,6 @@ import Cocoa import Darwin import Dispatch import SwiftUI -import UniformTypeIdentifiers import Virtualization import OpenTelemetryApi import System @@ -98,7 +97,13 @@ struct Run: AsyncParsableCommand { @Flag(help: ArgumentHelp( "Disable drag and drop support in the built-in UI.", - discussion: "When drag and drop is enabled (the default), files dragged onto the VM window are copied to a shared directory. macOS guests can access the dropped files at /Volumes/My Shared Files/Dropped Files/.")) + discussion: """ + When drag and drop is enabled (the default), files dragged onto the VM window are copied to a host directory that is shared with the guest as a virtio-fs mount. + + On macOS guests, the dropped files appear at "/Volumes/My Shared Files/Dropped Files/". On Linux guests, the share is mounted manually via "mount -t virtiofs com.apple.virtio-fs.automount /mount/point", and dropped files appear under "Dropped Files" within that mount point. + + Note: drag and drop adds a named directory share. If you also pass an unnamed --dir share (e.g. --dir=/some/path) you'll need to name it (--dir="name:/some/path") or pass --no-drag-and-drop. + """)) var noDragAndDrop: Bool = false #if arch(arm64) @@ -419,14 +424,21 @@ struct Run: AsyncParsableCommand { let willShowBuiltInUI = !noGraphics && !useVNCWithoutGraphics // Create a temporary drop zone directory that is shared with the VM and used - // as the destination for files dragged onto the built-in UI window. - // The directory lives in ~/.tart/tmp/ and will be cleaned up by tart's GC. + // as the destination for files dragged onto the built-in UI window. The + // FileLock is held for the lifetime of `tart run` so that the GC pass in + // concurrent tart commands (Config.gc()) does not delete the directory + // underneath a running VM. The drop zone is removed when the VM exits + // (see the cleanup call in the Task below). var dropZoneURL: URL? = nil + var dropZoneLock: FileLock? = nil if willShowBuiltInUI && !noDragAndDrop { if #available(macOS 13, *) { let dropDir = try Config().tartTmpDir.appendingPathComponent("dropzone-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dropDir, withIntermediateDirectories: true) + let lock = try FileLock(lockURL: dropDir) + try lock.lock() dropZoneURL = dropDir + dropZoneLock = lock } } @@ -478,6 +490,17 @@ struct Run: AsyncParsableCommand { // now VM state will return "running" so we can unlock try storageLock.unlock() + // Removes the drop zone directory before process exit. `defer` would not + // fire through Foundation.exit, so this is called explicitly. Capturing + // dropZoneLock keeps the FileLock alive (its fd is released on close(2) + // in deinit) until cleanup runs. + let cleanupDropZone: () -> Void = { [dropZoneURL, dropZoneLock] in + if let dropZoneURL = dropZoneURL { + try? FileManager.default.removeItem(at: dropZoneURL) + } + try? dropZoneLock?.unlock() + } + let task = Task { do { var resume = false @@ -547,6 +570,7 @@ struct Run: AsyncParsableCommand { try vncImpl.stop() } + cleanupDropZone() OTel.shared.flush() Foundation.exit(0) } catch { @@ -555,6 +579,7 @@ struct Run: AsyncParsableCommand { fputs("\(error)\n", stderr) + cleanupDropZone() OTel.shared.flush() Foundation.exit(1) } @@ -704,27 +729,7 @@ struct Run: AsyncParsableCommand { } func directoryShares(dropZoneURL: URL? = nil) throws -> [VZDirectorySharingDeviceConfiguration] { - var allDirectoryShares: [DirectoryShare] = [] - - for rawDir in dir { - allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir)) - } - - // Append the drag-and-drop drop zone as an unnamed share so that when it is - // the only share on the automount tag it becomes a VZSingleDirectoryShare and - // its contents appear directly at /Volumes/My Shared Files/ in the guest. - // When merged with other --dir shares the auto-naming logic below gives it - // the friendly name "Dropped Files". - if let dropZoneURL = dropZoneURL { - if #available(macOS 13, *) { - allDirectoryShares.append(DirectoryShare( - name: nil, - path: dropZoneURL, - readOnly: false, - mountTag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag - )) - } - } + let allDirectoryShares = try DirectoryShare.collect(dirArgs: dir, dropZoneURL: dropZoneURL) if allDirectoryShares.isEmpty { return [] @@ -734,38 +739,30 @@ struct Run: AsyncParsableCommand { throw UnsupportedOSError("directory sharing", "is") } - return try Dictionary(grouping: allDirectoryShares, by: { $0.mountTag }).map { mountTag, shares in + return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag) - let allNamed = shares.allSatisfy { $0.name != nil } - - if shares.count == 1 && !allNamed { - // Single unnamed share: expose its contents directly at the mount root - sharingDevice.share = VZSingleDirectoryShare(directory: try shares[0].createConfiguration()) - } else if !allNamed { - // Multiple shares with some unnamed: only allow if exactly one is unnamed - // (happens when an unnamed --dir share is combined with the named drop zone) - let unnamedCount = shares.filter { $0.name == nil }.count - if unnamedCount > 1 { - throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named") + var allNamedShares = true + for directoryShare in directoryShares { + if directoryShare.name == nil { + allNamedShares = false } - // Auto-name the single unnamed share. The drop zone path ends with - // "dropzone-" so give it the user-friendly name "Dropped Files"; - // all other unnamed shares get their directory's last path component. - var directories: [String: VZSharedDirectory] = [:] - for share in shares { - let autoName = share.path.lastPathComponent.hasPrefix("dropzone-") - ? "Dropped Files" - : share.path.lastPathComponent - directories[share.name ?? autoName] = try share.createConfiguration() + } + if directoryShares.count == 1 && directoryShares.first!.name == nil { + let directoryShare = directoryShares.first! + let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration()) + sharingDevice.share = singleDirectoryShare + } else if !allNamedShares { + // If drag-and-drop added the "Dropped Files" share and the conflict is + // with an unnamed --dir from the user, point them at the resolution. + if directoryShares.contains(where: { $0.name == "Dropped Files" }), + let unnamed = directoryShares.first(where: { $0.name == nil }) { + throw ValidationError("unnamed --dir share (\(unnamed.path.path)) cannot be combined with drag-and-drop. Either name the share (e.g. --dir=\"name:\(unnamed.path.path)\") or pass --no-drag-and-drop.") } - sharingDevice.share = VZMultipleDirectoryShare(directories: directories) + throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named") } else { - // All named: create a multi-directory share - var directories: [String: VZSharedDirectory] = [:] - for share in shares { - directories[share.name!] = try share.createConfiguration() - } + var directories: [String : VZSharedDirectory] = Dictionary() + try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() } sharingDevice.share = VZMultipleDirectoryShare(directories: directories) } @@ -810,11 +807,12 @@ struct Run: AsyncParsableCommand { } } -// MARK: - VM window content with drag-and-drop handled at the SwiftUI layer +// MARK: - VM window content -/// Top-level SwiftUI view for the VM window. Drag-and-drop is handled here -/// using SwiftUI's .onDrop so it fires on NSHostingView before any AppKit -/// subview (including VZVirtualMachineView) can intercept the drag session. +/// Top-level SwiftUI view for the VM window. Owns the onAppear/onDisappear +/// hooks that disable window tabbing and trigger graceful shutdown on close. +/// Drag-and-drop is handled by VMContainerView (an AppKit NSView wrapping +/// VZVirtualMachineView) further down the view tree. struct VMWindowView: View { var body: some View { VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys, dropZoneURL: MainApp.dropZoneURL) @@ -1031,12 +1029,13 @@ class TartVirtualMachineView: VZVirtualMachineView { } } -/// Wraps TartVirtualMachineView. -/// Drag-and-drop is registered in viewDidMoveToWindow (not init) so AppKit -/// records the registration with the window. All NSDraggingDestination methods -/// live here so we don't depend on VZVirtualMachineView accepting overrides. +/// Wraps TartVirtualMachineView and owns the AppKit drag-destination +/// registration. Drag-and-drop is registered in viewDidMoveToWindow (not +/// init) so AppKit records the registration against the live window's +/// drag-destination table. class VMContainerView: NSView { let machineView: TartVirtualMachineView + private let copyQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-copy", qos: .userInitiated) init(machineView: TartVirtualMachineView) { self.machineView = machineView @@ -1053,22 +1052,15 @@ class VMContainerView: NSView { machineView.frame = bounds } - // Register AFTER the view is inserted into a window so AppKit records - // the registration against the live window drag-destination table. override func viewDidMoveToWindow() { super.viewDidMoveToWindow() guard window != nil, machineView.dropZoneURL != nil else { return } - registerForDraggedTypes([ - .fileURL, - NSPasteboard.PasteboardType("NSFilenamesPboardType"), - ]) - fputs("tart: drag-and-drop registered (drop zone: \(machineView.dropZoneURL!.path))\n", stderr) + registerForDraggedTypes([.fileURL]) } // MARK: NSDraggingDestination override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - fputs("tart: draggingEntered — types: \(sender.draggingPasteboard.types ?? [])\n", stderr) guard machineView.dropZoneURL != nil, !fileURLs(from: sender).isEmpty else { return [] } machineView.highlight.isActive = true return .copy @@ -1090,23 +1082,26 @@ class VMContainerView: NSView { guard let dropZoneURL = machineView.dropZoneURL else { return false } let urls = fileURLs(from: sender) guard !urls.isEmpty else { return false } - fputs("tart: dropping \(urls.count) file(s) to \(dropZoneURL.path)\n", stderr) - for url in urls { - let dest = dropZoneURL.appendingPathComponent(url.lastPathComponent) - do { - if FileManager.default.fileExists(atPath: dest.path) { - try FileManager.default.removeItem(at: dest) - } - try FileManager.default.copyItem(at: url, to: dest) - fputs("tart: copied \(url.lastPathComponent)\n", stderr) - } catch { - let e = error; let n = url.lastPathComponent - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "Failed to copy \"\(n)\" to the VM" - alert.informativeText = e.localizedDescription - alert.alertStyle = .warning - alert.runModal() + // 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). + copyQueue.async { + for url in urls { + let dest = dropZoneURL.appendingPathComponent(url.lastPathComponent) + do { + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.copyItem(at: url, to: dest) + } catch { + 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() + } } } } @@ -1259,7 +1254,7 @@ struct DirectoryShare { let readOnly: Bool let mountTag: String - // Programmatic initializer for internal use (e.g. the drag-and-drop drop zone) + // Programmatic initializer for internal use (e.g. the drag-and-drop drop zone). init(name: String?, path: URL, readOnly: Bool, mountTag: String) { self.name = name self.path = path @@ -1267,6 +1262,26 @@ struct DirectoryShare { self.mountTag = mountTag } + // Builds the list of DirectoryShare instances from --dir command-line arguments, + // appending the drag-and-drop drop zone (if provided) as a named "Dropped Files" + // share on the default macOS automount tag so it consistently appears at + // /Volumes/My Shared Files/Dropped Files/ on macOS guests. + static func collect(dirArgs: [String], dropZoneURL: URL? = nil) throws -> [DirectoryShare] { + var result: [DirectoryShare] = [] + for rawDir in dirArgs { + result.append(try DirectoryShare(parseFrom: rawDir)) + } + if let dropZoneURL = dropZoneURL { + result.append(DirectoryShare( + name: "Dropped Files", + path: dropZoneURL, + readOnly: false, + mountTag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag + )) + } + return result + } + init(parseFrom: String) throws { var parseFrom = parseFrom diff --git a/Tests/TartTests/DirecotryShareTests.swift b/Tests/TartTests/DirecotryShareTests.swift index 062da00..0035d55 100644 --- a/Tests/TartTests/DirecotryShareTests.swift +++ b/Tests/TartTests/DirecotryShareTests.swift @@ -52,6 +52,79 @@ final class DirectoryShareTests: XCTestCase { XCTAssertEqual(inverseRoShare.mountTag, "foo-bar") } + func testProgrammaticInit() throws { + let url = URL(filePath: "/tmp/dropzone-test") + let share = DirectoryShare(name: "Dropped Files", path: url, readOnly: false, mountTag: "test-tag") + XCTAssertEqual(share.name, "Dropped Files") + XCTAssertEqual(share.path, url) + XCTAssertFalse(share.readOnly) + XCTAssertEqual(share.mountTag, "test-tag") + } + + func testCollectWithoutDropZone() throws { + let shares = try DirectoryShare.collect(dirArgs: ["build:/Users/admin/build"]) + XCTAssertEqual(shares.count, 1) + XCTAssertEqual(shares[0].name, "build") + } + + func testCollectEmptyWithoutDropZone() throws { + let shares = try DirectoryShare.collect(dirArgs: []) + XCTAssertTrue(shares.isEmpty) + } + + func testCollectWithDropZoneOnly() throws { + let url = URL(filePath: "/tmp/dropzone-test") + let shares = try DirectoryShare.collect(dirArgs: [], dropZoneURL: url) + XCTAssertEqual(shares.count, 1) + XCTAssertEqual(shares[0].name, "Dropped Files") + XCTAssertEqual(shares[0].path, url) + XCTAssertFalse(shares[0].readOnly) + XCTAssertEqual(shares[0].mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag) + } + + func testCollectWithDropZoneAndNamedDirs() throws { + let url = URL(filePath: "/tmp/dropzone-test") + let shares = try DirectoryShare.collect( + dirArgs: ["src:/Users/admin/src", "build:/Users/admin/build:ro"], + dropZoneURL: url + ) + XCTAssertEqual(shares.count, 3) + XCTAssertEqual(shares[0].name, "src") + XCTAssertEqual(shares[1].name, "build") + XCTAssertTrue(shares[1].readOnly) + XCTAssertEqual(shares[2].name, "Dropped Files") + XCTAssertEqual(shares[2].path, url) + } + + // When --dir is unnamed and drag-and-drop is on, both shares end up on the + // automount tag — collection succeeds but the downstream sharing-device + // builder will reject the combination with a clearer error than before. + func testCollectWithDropZoneAndUnnamedDir() throws { + let url = URL(filePath: "/tmp/dropzone-test") + let shares = try DirectoryShare.collect( + dirArgs: ["/Users/admin/build"], + dropZoneURL: url + ) + XCTAssertEqual(shares.count, 2) + XCTAssertNil(shares[0].name) + XCTAssertEqual(shares[1].name, "Dropped Files") + } + + // An unnamed --dir with an explicit non-default mount tag is on a different + // device from the drop zone, so it doesn't conflict. + func testCollectWithDropZoneAndUnnamedDirOnCustomTag() throws { + let url = URL(filePath: "/tmp/dropzone-test") + let shares = try DirectoryShare.collect( + dirArgs: ["/Users/admin/build:tag=custom"], + dropZoneURL: url + ) + XCTAssertEqual(shares.count, 2) + XCTAssertNil(shares[0].name) + XCTAssertEqual(shares[0].mountTag, "custom") + XCTAssertEqual(shares[1].name, "Dropped Files") + XCTAssertEqual(shares[1].mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag) + } + func testURL() throws { let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz") XCTAssertNil(archiveWithoutNameOrOptions.name) From 474fd47b6fff1d36430160522cf6ad474a628c95 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Wed, 13 May 2026 12:48:29 +0200 Subject: [PATCH 03/20] feat: scaffold guest-side drop synthesis (host plumbing only) Adds the host-side hook for the forthcoming tart-guest-agent DragAndDrop RPC that will perform a real drag-and-drop at the cursor's position inside the guest instead of dumping files into a generic share folder. - DropGeometry.normalize: pure helper turning a view-local drop point into top-left (0..1) coordinates the guest agent can map onto its screen. - VMContainerView.performDragOperation: capture draggingLocation on the main thread (only valid here), compute normalized point, then dispatch the copy off-main as before. - synthesizeGuestDrop: documented no-op stub called from the copy completion path. When the agent RPC lands, this becomes the real call site; until then it returns false and the existing share-folder copy is the user-visible result. No behavior change for users today. --- Sources/tart/Commands/Run.swift | 66 ++++++++++++++++++ Tests/TartTests/DropGeometryTests.swift | 90 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 Tests/TartTests/DropGeometryTests.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index fa94de6..236f5fe 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -960,6 +960,27 @@ struct VMView: NSViewRepresentable { // MARK: - Drag and Drop +/// Pure coordinate-conversion helpers. Kept as a stand-alone enum so it can be +/// unit-tested without dragging in AppKit/SwiftUI types. +enum DropGeometry { + /// Normalizes a drop point given in a view's coordinate space to (0..1, 0..1) + /// with (0, 0) at top-left and (1, 1) at bottom-right. AppKit views default + /// to a bottom-left origin; we flip Y so the guest agent receives a top-down + /// coordinate that matches CGEvent's global screen space without needing to + /// know the host view's orientation. + static func normalize(point: CGPoint, inViewBoundsSize size: CGSize, isViewFlipped: Bool) -> CGPoint { + let width = max(size.width, 1) + let height = max(size.height, 1) + let xNorm = point.x / width + let yLocal = point.y / height + let yNorm = isViewFlipped ? yLocal : (1 - yLocal) + return CGPoint( + x: max(0, min(1, xNorm)), + y: max(0, min(1, yNorm)) + ) + } +} + /// Layer-backed highlight overlay. Added as a subview of TartVirtualMachineView /// so its CALayer composites on top of Metal rendering. hitTest returns nil so /// it is completely transparent to pointer events. @@ -1082,6 +1103,18 @@ class VMContainerView: NSView { guard let dropZoneURL = machineView.dropZoneURL else { return false } let urls = fileURLs(from: sender) guard !urls.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. + let localPoint = self.convert(sender.draggingLocation, from: nil) + let normalizedPoint = DropGeometry.normalize( + point: localPoint, + inViewBoundsSize: self.bounds.size, + isViewFlipped: self.isFlipped + ) + // 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). copyQueue.async { @@ -1092,6 +1125,15 @@ class VMContainerView: NSView { try FileManager.default.removeItem(at: dest) } try FileManager.default.copyItem(at: url, to: dest) + + // After the file lands in the shared drop zone, ask the guest agent + // to perform a real drop at the cursor's normalized position. Today + // this is a no-op (returns false) so the user-visible result remains + // "file appears in /Volumes/My Shared Files/Dropped Files/". + let destPath = dest.path + Task { + _ = await synthesizeGuestDrop(path: destPath, atNormalized: normalizedPoint) + } } catch { let name = url.lastPathComponent let message = error.localizedDescription @@ -1116,6 +1158,30 @@ class VMContainerView: NSView { } } +/// Asks the in-guest tart-guest-agent to perform a drag-and-drop of `path` at +/// the given normalized coordinates (origin top-left, (1, 1) bottom-right) so +/// that whatever window the cursor is over receives the file as a real drop. +/// +/// Returns true if the guest agent reports a successful drop; in that case the +/// caller treats the share-folder copy as a side effect rather than the +/// user-visible result. Returns false when the agent is unreachable, returns +/// an error, or the corresponding RPC isn't available — the caller's existing +/// fallback (file accessible at /Volumes/My Shared Files/Dropped Files/) then +/// remains the user-visible outcome. +/// +/// Today this is a no-op stub. Wiring it up requires: +/// 1. A new `DragAndDrop` RPC in tart-guest-agent-proto. +/// 2. A handler in tart-guest-agent (likely NSView.beginDraggingSession from +/// a hidden 1x1 NSWindow at the cursor's screen position, with an +/// AppleScript-into-Finder fallback). +/// 3. Pulling the running VM's controlSocketURL into the call site (cf. +/// Exec.swift) and reusing the existing AgentAsyncClient pattern. +private func synthesizeGuestDrop(path: String, atNormalized normalized: CGPoint) async -> Bool { + _ = path + _ = normalized + return false +} + struct AdditionalDisk { let configuration: VZStorageDeviceConfiguration diff --git a/Tests/TartTests/DropGeometryTests.swift b/Tests/TartTests/DropGeometryTests.swift new file mode 100644 index 0000000..5d0ec35 --- /dev/null +++ b/Tests/TartTests/DropGeometryTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import tart + +final class DropGeometryTests: XCTestCase { + func testTopLeftOfUnflippedView() throws { + // Bottom-up view: a point at (0, viewHeight) is the top-left corner. + let point = DropGeometry.normalize( + point: CGPoint(x: 0, y: 1000), + inViewBoundsSize: CGSize(width: 1280, height: 1000), + isViewFlipped: false + ) + XCTAssertEqual(point.x, 0, accuracy: 0.0001) + XCTAssertEqual(point.y, 0, accuracy: 0.0001) + } + + func testBottomRightOfUnflippedView() throws { + let point = DropGeometry.normalize( + point: CGPoint(x: 1280, y: 0), + inViewBoundsSize: CGSize(width: 1280, height: 1000), + isViewFlipped: false + ) + XCTAssertEqual(point.x, 1, accuracy: 0.0001) + XCTAssertEqual(point.y, 1, accuracy: 0.0001) + } + + func testCenterOfUnflippedView() throws { + let point = DropGeometry.normalize( + point: CGPoint(x: 640, y: 500), + inViewBoundsSize: CGSize(width: 1280, height: 1000), + isViewFlipped: false + ) + XCTAssertEqual(point.x, 0.5, accuracy: 0.0001) + XCTAssertEqual(point.y, 0.5, accuracy: 0.0001) + } + + func testTopLeftOfFlippedView() throws { + // Flipped view: y increases downward, so (0, 0) is already top-left. + let point = DropGeometry.normalize( + point: CGPoint(x: 0, y: 0), + inViewBoundsSize: CGSize(width: 1280, height: 1000), + isViewFlipped: true + ) + XCTAssertEqual(point.x, 0, accuracy: 0.0001) + XCTAssertEqual(point.y, 0, accuracy: 0.0001) + } + + func testBottomRightOfFlippedView() throws { + let point = DropGeometry.normalize( + point: CGPoint(x: 1280, y: 1000), + inViewBoundsSize: CGSize(width: 1280, height: 1000), + isViewFlipped: true + ) + XCTAssertEqual(point.x, 1, accuracy: 0.0001) + XCTAssertEqual(point.y, 1, accuracy: 0.0001) + } + + // The guest agent expects coordinates in [0, 1]; out-of-bounds points (which + // AppKit can occasionally deliver right at the view edge) get clamped so the + // agent never receives a negative or >1 value. + func testClampsBelowZero() throws { + let point = DropGeometry.normalize( + point: CGPoint(x: -5, y: 1010), + inViewBoundsSize: CGSize(width: 1280, height: 1000), + isViewFlipped: false + ) + XCTAssertEqual(point.x, 0) + XCTAssertEqual(point.y, 0) + } + + func testClampsAboveOne() throws { + let point = DropGeometry.normalize( + point: CGPoint(x: 1500, y: -100), + inViewBoundsSize: CGSize(width: 1280, height: 1000), + isViewFlipped: false + ) + XCTAssertEqual(point.x, 1) + XCTAssertEqual(point.y, 1) + } + + // Zero-sized view shouldn't divide by zero; max(size, 1) protects us. + func testZeroSizedViewDoesNotCrash() throws { + let point = DropGeometry.normalize( + point: CGPoint(x: 0, y: 0), + inViewBoundsSize: .zero, + isViewFlipped: false + ) + XCTAssertEqual(point.x, 0) + XCTAssertEqual(point.y, 1) // y flipped: (1 - 0/1) = 1, clamped + } +} From 6af39e807f242e62a38adb1b5a6f6f9d29dea40c Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Wed, 13 May 2026 15:07:17 +0200 Subject: [PATCH 04/20] feat: relocate dropped files to frontmost Finder folder, not the share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guest-drop synthesis only ran `open -R` on the file's location in the "Dropped Files" share. That just revealed it in the share, which was the user-visible bug we were trying to fix: dropped files appeared in `/Volumes/My Shared Files/Dropped Files/` instead of somewhere natural. Now the agent's exec script asks Finder for the frontmost window's POSIX path via `osascript`, moves the file out of the share into that folder, and then reveals it. If no Finder window is open, the path isn't writable, or it points back at the share itself, the script falls back to `~/Desktop`. Filename collisions get suffixed (`foo.txt` → `foo 2.txt`, …) instead of clobbering. osascript here runs in the agent's user-GUI session, so the first drop triggers an Automation→Finder TCC prompt in the guest the user approves once. Verified end-to-end via `tart exec` (same RPC path the drop handler uses): no Finder window → ~/Desktop, Finder on Downloads → Downloads, Finder on Dropped Files itself → ~/Desktop fallback. --- Sources/tart/Commands/Run.swift | 78 +++++++---- Sources/tart/GuestDropSynthesis.swift | 185 ++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 25 deletions(-) create mode 100644 Sources/tart/GuestDropSynthesis.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 236f5fe..97c1e73 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -647,7 +647,12 @@ struct Run: AsyncParsableCommand { NSApplication.shared.run() } else { - runUI(suspendable, captureSystemKeys, dropZoneURL: dropZoneURL) + runUI( + suspendable, + captureSystemKeys, + dropZoneURL: dropZoneURL, + controlSocketURL: dropZoneURL != nil ? vmDir.controlSocketURL : nil + ) } } @@ -799,10 +804,11 @@ struct Run: AsyncParsableCommand { #endif } - private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool, dropZoneURL: URL?) { + private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool, dropZoneURL: URL?, controlSocketURL: URL?) { MainApp.suspendable = suspendable MainApp.capturesSystemKeys = captureSystemKeys MainApp.dropZoneURL = dropZoneURL + MainApp.controlSocketURL = controlSocketURL MainApp.main() } } @@ -832,6 +838,7 @@ struct MainApp: App { static var suspendable: Bool = false static var capturesSystemKeys: Bool = false static var dropZoneURL: URL? = nil + static var controlSocketURL: URL? = nil @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate @@ -1115,6 +1122,10 @@ class VMContainerView: NSView { isViewFlipped: self.isFlipped ) + // Snapshot the control socket URL on the main actor so the background + // Task can use it without crossing actor boundaries. + let controlSocketURL = MainApp.controlSocketURL + // 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). copyQueue.async { @@ -1127,12 +1138,16 @@ class VMContainerView: NSView { try FileManager.default.copyItem(at: url, to: dest) // After the file lands in the shared drop zone, ask the guest agent - // to perform a real drop at the cursor's normalized position. Today - // this is a no-op (returns false) so the user-visible result remains - // "file appears in /Volumes/My Shared Files/Dropped Files/". + // to make the drop visible — Finder-window duplicate when possible, + // reveal-in-Finder otherwise. If the agent isn't reachable, the + // file in the share folder is still the fallback. let destPath = dest.path Task { - _ = await synthesizeGuestDrop(path: destPath, atNormalized: normalizedPoint) + _ = await synthesizeGuestDrop( + hostFilePath: destPath, + atNormalized: normalizedPoint, + controlSocketURL: controlSocketURL + ) } } catch { let name = url.lastPathComponent @@ -1158,28 +1173,41 @@ class VMContainerView: NSView { } } -/// Asks the in-guest tart-guest-agent to perform a drag-and-drop of `path` at -/// the given normalized coordinates (origin top-left, (1, 1) bottom-right) so -/// that whatever window the cursor is over receives the file as a real drop. +/// 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. /// -/// Returns true if the guest agent reports a successful drop; in that case the -/// caller treats the share-folder copy as a side effect rather than the -/// user-visible result. Returns false when the agent is unreachable, returns -/// an error, or the corresponding RPC isn't available — the caller's existing -/// fallback (file accessible at /Volumes/My Shared Files/Dropped Files/) then -/// remains the user-visible outcome. +/// `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. /// -/// Today this is a no-op stub. Wiring it up requires: -/// 1. A new `DragAndDrop` RPC in tart-guest-agent-proto. -/// 2. A handler in tart-guest-agent (likely NSView.beginDraggingSession from -/// a hidden 1x1 NSWindow at the cursor's screen position, with an -/// AppleScript-into-Finder fallback). -/// 3. Pulling the running VM's controlSocketURL into the call site (cf. -/// Exec.swift) and reusing the existing AgentAsyncClient pattern. -private func synthesizeGuestDrop(path: String, atNormalized normalized: CGPoint) async -> Bool { - _ = path +/// 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. +private func synthesizeGuestDrop( + hostFilePath: String, + atNormalized normalized: CGPoint, + controlSocketURL: URL? +) async -> Bool { _ = normalized - return false + guard let controlSocketURL = controlSocketURL else { return false } + + 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 + ) + _ = outcome + return true + } catch { + return false + } } struct AdditionalDisk { diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift new file mode 100644 index 0000000..cc57321 --- /dev/null +++ b/Sources/tart/GuestDropSynthesis.swift @@ -0,0 +1,185 @@ +import Foundation +import NIOPosix +import GRPC +import Cirruslabs_TartGuestAgent_Apple_Swift +import Cirruslabs_TartGuestAgent_Grpc_Swift + +enum GuestDropOutcome { + /// Guest agent revealed the file in Finder. A Finder window opens pointing + /// at the file's containing folder with the file selected, giving the user + /// immediate visual feedback that the drop landed. + case revealed +} + +/// Errors that the host treats as "agent path didn't work; fall back to +/// share-folder behavior." We intentionally swallow these in the caller so +/// the existing share-folder copy remains the visible result. +enum GuestDropError: Error { + case agentUnreachable + case execFailed(exitCode: Int32, stderr: String) + case unexpectedOutput(String) + case timedOut +} + +/// Asks the in-guest tart-guest-agent to relocate `guestFilePath` from the +/// "Dropped Files" share into a user-visible location, then reveal it in +/// Finder. The result is that a drag-and-drop drop appears either in the +/// folder of the user's frontmost Finder window — matching the intuition +/// "I dragged it here, it's here now" — or on the Desktop as a fallback. +/// +/// Under the hood we run a single `/bin/sh -c` script in the guest via the +/// agent's Exec RPC. The script uses `osascript` to query Finder for the +/// frontmost window's POSIX path; this runs in the agent's user-GUI session +/// (the `--run-agent` invocation), so the first drop will produce a TCC +/// Automation→Finder prompt in the guest that the user must approve once. +/// If Finder has no window open, isn't responding, or returns the same +/// folder the file is already in (e.g. the user is staring at "Dropped +/// Files"), we fall back to `~/Desktop`. The file is finally revealed with +/// `open -R` so Finder pops a window with it selected. +enum GuestDropSynthesis { + /// Shell script body executed inside the guest. The dropped file's guest + /// path is passed as `$1` via the `sh -c CMD -- $1` convention (with + /// `tartdrop` as `$0` so error messages identify us). + /// + /// Exit codes: 0 on success, non-zero on failure (caller treats any + /// non-zero as "fall back to the share-folder copy that's already on disk"). + private static let relocateAndRevealScript = #""" + set -e + src=$1 + if [ -z "$src" ] || [ ! -e "$src" ]; then + echo "tartdrop: missing or nonexistent source: $src" >&2 + exit 2 + fi + name=$(basename "$src") + src_dir=$(dirname "$src") + + # Ask Finder for the frontmost window's folder. Suppress stderr so a TCC + # denial or "no windows" error doesn't pollute the agent log; we detect + # those by an empty result. The agent runs in the user's GUI session, so + # osascript here goes through the user's TCC consent (Automation→Finder). + dest_dir=$(/usr/bin/osascript <<'OSA' 2>/dev/null || true + tell application "Finder" + if (count of Finder windows) is 0 then return "" + try + return POSIX path of (target of front Finder window as alias) + on error + return "" + end try + end tell + OSA + ) + # AppleScript appends a trailing slash to folder POSIX paths. + dest_dir=${dest_dir%/} + + # Reject empty / nonexistent / non-writable destinations, and reject the + # source's own parent (which would either be a no-op rename or leave us + # putting the file right back into "Dropped Files"). + if [ -z "$dest_dir" ] || [ ! -d "$dest_dir" ] || [ ! -w "$dest_dir" ] || [ "$dest_dir" = "$src_dir" ]; then + dest_dir=$HOME/Desktop + fi + + # Pick a non-clobbering filename: "foo.txt" → "foo 2.txt", "foo 3.txt"… + final=$dest_dir/$name + if [ -e "$final" ]; then + stem=${name%.*} + ext=${name##*.} + if [ "$stem" = "$name" ]; then + i=2 + while [ -e "$dest_dir/$name $i" ]; do i=$((i+1)); done + final="$dest_dir/$name $i" + else + i=2 + while [ -e "$dest_dir/$stem $i.$ext" ]; do i=$((i+1)); done + final="$dest_dir/$stem $i.$ext" + fi + fi + + mv -- "$src" "$final" + /usr/bin/open -R "$final" + """# + + static func perform( + controlSocketURL: URL, + guestFilePath: String + ) async throws -> GuestDropOutcome { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + + // Work around the 104-byte UDS path limit by chdir'ing to the socket's + // parent directory and connecting via the relative name (same trick as + // Exec.swift). Restore cwd afterwards so we don't surprise the rest of + // the host process. + let originalCWD = FileManager.default.currentDirectoryPath + if let baseURL = controlSocketURL.baseURL { + FileManager.default.changeCurrentDirectoryPath(baseURL.path()) + } + defer { FileManager.default.changeCurrentDirectoryPath(originalCWD) } + + let channel: GRPCChannel + do { + channel = try GRPCChannelPool.with( + target: .unixDomainSocket(controlSocketURL.relativePath), + transportSecurity: .plaintext, + eventLoopGroup: group + ) + } catch { + throw GuestDropError.agentUnreachable + } + defer { try? channel.close().wait() } + + let callOptions = CallOptions(timeLimit: .timeout(.seconds(8))) + let client = AgentAsyncClient(channel: channel, defaultCallOptions: callOptions) + let execCall = client.makeExecCall() + + // Pass the guest path as $1 (with "tartdrop" as $0 so error messages + // identify us). The script does the Finder-window lookup, picks a + // destination, moves the file out of "Dropped Files", and reveals it. + let command = ExecRequest.with { + $0.type = .command(ExecRequest.Command.with { + $0.name = "/bin/sh" + $0.args = ["-c", relocateAndRevealScript, "tartdrop", guestFilePath] + $0.interactive = false + $0.tty = false + }) + } + + do { + try await execCall.requestStream.send(command) + execCall.requestStream.finish() + } catch let error as GRPCConnectionPoolError { + _ = error + throw GuestDropError.agentUnreachable + } + + var stdout = "" + var stderr = "" + var exitCode: Int32 = -1 + + do { + for try await response in execCall.responseStream { + switch response.type { + case .standardOutput(let chunk): + stdout += String(data: chunk.data, encoding: .utf8) ?? "" + case .standardError(let chunk): + stderr += String(data: chunk.data, encoding: .utf8) ?? "" + case .exit(let exit): + exitCode = exit.code + default: + continue + } + } + } catch let error as GRPCStatus { + if error.code == .deadlineExceeded { + throw GuestDropError.timedOut + } + throw GuestDropError.execFailed(exitCode: -1, stderr: error.localizedDescription) + } + + if exitCode != 0 { + throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr) + } + + _ = stdout + return .revealed + } +} From 8c684f52e41c47061521fe44376283bc0cf34079 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Wed, 13 May 2026 15:13:10 +0200 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20show=20progress-bar=20toast=20dur?= =?UTF-8?q?ing=20host=E2=86=92guest=20drop=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FileManager.copyItem` is opaque to the user: a 5 GB drop just freezes the cursor for ten seconds with no visible signal that anything is happening. Replace it with a chunked `FileHandle` copy (1 MiB chunks, 50 ms-throttled progress callbacks) and render a borderless HUD panel anchored to the VM window bottom — filename, determinate bar, and a "[i/N] copied / total" detail line. The panel auto-hides ~0.8 s after the final byte so the user sees the "Done" state. Multi-file drops reuse the same panel and increment the [i/N] counter, since the existing copy loop already serializes files. --- Sources/tart/Commands/Run.swift | 43 ++++- Sources/tart/DropProgressToast.swift | 261 +++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 Sources/tart/DropProgressToast.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 97c1e73..fd2307c 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -1122,20 +1122,48 @@ class VMContainerView: NSView { isViewFlipped: self.isFlipped ) - // Snapshot the control socket URL on the main actor so the background - // Task can use it without crossing actor boundaries. + // 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 // 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 instead of a frozen UI. + let totalFiles = urls.count copyQueue.async { - for url in urls { + for (idx, url) in urls.enumerated() { 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 + ) + } + do { - if FileManager.default.fileExists(atPath: dest.path) { - try FileManager.default.removeItem(at: dest) + try DropProgressCopier.copy(from: url, to: dest, totalBytes: totalBytes) { copied in + DispatchQueue.main.async { + DropProgressToast.shared.update( + copied: copied, + total: totalBytes, + index: fileIndex, + count: totalFiles + ) + } + } + + DispatchQueue.main.async { + DropProgressToast.shared.finish(success: true) } - try FileManager.default.copyItem(at: url, to: dest) // After the file lands in the shared drop zone, ask the guest agent // to make the drop visible — Finder-window duplicate when possible, @@ -1150,6 +1178,9 @@ class VMContainerView: NSView { ) } } catch { + DispatchQueue.main.async { + DropProgressToast.shared.finish(success: false) + } let name = url.lastPathComponent let message = error.localizedDescription DispatchQueue.main.async { diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift new file mode 100644 index 0000000..a0eaa9d --- /dev/null +++ b/Sources/tart/DropProgressToast.swift @@ -0,0 +1,261 @@ +import AppKit +import Foundation + +/// Borderless HUD-style panel shown over the VM window during a host→guest +/// drag-and-drop copy. Displays the filename, a determinate progress bar, and +/// a "[i/N] copied / total" detail line; auto-hides shortly after `finish`. +/// +/// All methods MUST be called on the main thread. Callers driving copies from +/// a background queue should hop via `DispatchQueue.main.async` first. +/// +/// Lifecycle: +/// begin(...) -> panel appears as a child of the VM window +/// update(...) -> progress bar advances (no-op if not visible) +/// finish(success:) -> brief "Done" / "Failed" message, then auto-hide +/// +/// Multiple files dropped in one gesture are copied serially by the caller; +/// `begin` may be invoked again before the previous `finish` has hidden the +/// panel — that just retargets the existing panel to the new file. +final class DropProgressToast { + static let shared = DropProgressToast() + + private var panel: NSPanel? + private var progressBar: NSProgressIndicator! + private var titleLabel: NSTextField! + private var detailLabel: NSTextField! + private var hideWorkItem: DispatchWorkItem? + private weak var anchorWindow: NSWindow? + + private init() {} + + /// Show the toast (or retarget it to a new file) and reset the progress bar. + /// `parent` is the VM window; the panel becomes a child of it so it tracks + /// movement and z-order. `totalBytes <= 0` flips the bar to indeterminate. + func begin(parent: NSWindow?, filename: String, totalBytes: Int64, index: Int, count: Int) { + ensurePanel() + hideWorkItem?.cancel() + hideWorkItem = nil + + anchorWindow = parent + titleLabel.stringValue = filename + detailLabel.stringValue = formatDetail(copied: 0, total: totalBytes, index: index, count: count) + + if totalBytes > 0 { + progressBar.isIndeterminate = false + progressBar.minValue = 0 + progressBar.maxValue = Double(totalBytes) + progressBar.doubleValue = 0 + } else { + progressBar.isIndeterminate = true + } + progressBar.startAnimation(nil) + + positionPanel() + guard let panel = panel else { return } + if let parent = parent { + // addChildWindow re-parents harmlessly even if already attached. + parent.addChildWindow(panel, ordered: .above) + } else { + panel.orderFront(nil) + } + } + + /// 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) { + guard let panel = panel, panel.isVisible else { return } + if total > 0 { + progressBar.isIndeterminate = false + progressBar.doubleValue = Double(min(copied, total)) + } + detailLabel.stringValue = formatDetail(copied: copied, total: total, index: index, count: count) + } + + /// Flash a "Done" or "Copy failed" state and schedule the panel to hide + /// after a short delay so the user sees the final state. + func finish(success: Bool) { + guard let panel = panel else { return } + if success { + progressBar.isIndeterminate = false + progressBar.doubleValue = progressBar.maxValue + detailLabel.stringValue = "Done" + } else { + detailLabel.stringValue = "Copy failed" + } + progressBar.stopAnimation(nil) + + let work = DispatchWorkItem { [weak self] in + self?.hide() + } + hideWorkItem = work + // 0.8s lets the user register the final state without lingering forever. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8, execute: work) + _ = panel // silence warning about unused panel + } + + private func hide() { + guard let panel = panel else { return } + if let parent = panel.parent { + parent.removeChildWindow(panel) + } + panel.orderOut(nil) + } + + private func ensurePanel() { + if panel != nil { return } + + let contentRect = NSRect(x: 0, y: 0, width: 360, height: 78) + let p = NSPanel( + contentRect: contentRect, + styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: false + ) + p.isFloatingPanel = true + p.becomesKeyOnlyIfNeeded = true + p.hidesOnDeactivate = false + p.isReleasedWhenClosed = false + p.backgroundColor = .clear + p.isOpaque = false + p.hasShadow = true + p.level = .floating + + // Vibrant rounded background — HUD-style. + let effect = NSVisualEffectView(frame: contentRect) + effect.material = .hudWindow + effect.blendingMode = .behindWindow + effect.state = .active + effect.wantsLayer = true + effect.layer?.cornerRadius = 14 + effect.layer?.masksToBounds = true + p.contentView = effect + + let title = NSTextField(labelWithString: "") + title.font = .systemFont(ofSize: 13, weight: .semibold) + title.textColor = .labelColor + title.lineBreakMode = .byTruncatingMiddle + title.translatesAutoresizingMaskIntoConstraints = false + title.setContentHuggingPriority(.defaultLow, for: .horizontal) + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + effect.addSubview(title) + + let bar = NSProgressIndicator() + bar.style = .bar + bar.isIndeterminate = false + bar.controlSize = .small + bar.translatesAutoresizingMaskIntoConstraints = false + effect.addSubview(bar) + + let detail = NSTextField(labelWithString: "") + detail.font = .systemFont(ofSize: 11, weight: .regular) + detail.textColor = .secondaryLabelColor + detail.translatesAutoresizingMaskIntoConstraints = false + detail.setContentHuggingPriority(.defaultLow, for: .horizontal) + detail.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + effect.addSubview(detail) + + NSLayoutConstraint.activate([ + title.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 14), + title.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -14), + title.topAnchor.constraint(equalTo: effect.topAnchor, constant: 10), + + bar.leadingAnchor.constraint(equalTo: title.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: title.trailingAnchor), + bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8), + + detail.leadingAnchor.constraint(equalTo: title.leadingAnchor), + detail.trailingAnchor.constraint(equalTo: title.trailingAnchor), + detail.topAnchor.constraint(equalTo: bar.bottomAnchor, constant: 6), + detail.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -10), + ]) + + self.panel = p + self.titleLabel = title + self.detailLabel = detail + self.progressBar = bar + } + + /// Center the panel along the VM window's bottom edge with a small inset. + /// Falls back to no-op if we don't have an anchor window. + private func positionPanel() { + guard let panel = panel, let parent = anchorWindow else { return } + let parentFrame = parent.frame + let size = panel.frame.size + let inset: CGFloat = 16 + let origin = NSPoint( + x: parentFrame.midX - size.width / 2, + y: parentFrame.minY + inset + ) + panel.setFrameOrigin(origin) + } + + private func formatDetail(copied: Int64, total: Int64, index: Int, count: Int) -> String { + 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)" + } +} + +/// Chunked file copy with throttled progress callbacks. Used by the drag-and- +/// drop handler to feed `DropProgressToast` without freezing the VM render +/// view (the previous synchronous `FileManager.copyItem` blocked the same +/// queue/view). +/// +/// - Removes any existing file at `dst` first (drops semantically replace). +/// - 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 if either side fails; partial output at `dst` is left in place +/// so the caller can decide how to surface the error. +enum DropProgressCopier { + static func copy( + from src: URL, + to dst: URL, + totalBytes: Int64, + 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) + } + guard FileManager.default.createFile(atPath: dst.path, contents: nil) else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(EIO), + userInfo: [NSLocalizedDescriptionKey: "Could not create destination file \(dst.path)"] + ) + } + + let input = try FileHandle(forReadingFrom: src) + let output = try FileHandle(forWritingTo: dst) + defer { + try? input.close() + try? output.close() + } + + let chunkSize = 1 * 1024 * 1024 // 1 MiB — large enough to amortize syscalls, + // small enough to stream progress on fast disks. + let reportInterval: TimeInterval = 0.05 + var lastReport = Date(timeIntervalSince1970: 0) + var copied: Int64 = 0 + + while true { + 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 + } + } + // Always fire a final callback so the UI reaches 100% even when the file + // finished inside the throttle window. + progress(copied) + } +} From 83be4532004e664f15f6ddc14891c06961c075b9 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Wed, 13 May 2026 15:26:43 +0200 Subject: [PATCH 06/20] feat: move drop toast to top-right and add cancel button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the drop progress HUD: - Position: was bottom-center of the VM window, now top-right with a quick (~0.18 s) slide-in from the right edge — reads as a macOS notification banner. Subsequent files in a multi-file drop retarget the panel in place without replaying the animation. - Cancel: small ⊗ close button in the toast's top-right corner. Click flips a `DropCancellationToken` shared with the chunked copier, which polls between 1 MiB chunks and throws `DropCopyCancelled` on the next boundary (sub-100 ms latency on fast disks). The drop handler removes the half-copied destination, the toast shows "Cancelled", and any remaining files in a multi-file drop are skipped instead of starting. --- Sources/tart/Commands/Run.swift | 30 +++- Sources/tart/DropProgressToast.swift | 213 +++++++++++++++++++++------ 2 files changed, 192 insertions(+), 51 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index fd2307c..29f576a 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -1128,13 +1128,22 @@ class VMContainerView: NSView { 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 instead of a frozen UI. + // 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 @@ -1145,12 +1154,18 @@ class VMContainerView: NSView { filename: url.lastPathComponent, totalBytes: totalBytes, index: fileIndex, - count: totalFiles + count: totalFiles, + cancelToken: cancelToken ) } do { - try DropProgressCopier.copy(from: url, to: dest, totalBytes: totalBytes) { copied in + try DropProgressCopier.copy( + from: url, + to: dest, + totalBytes: totalBytes, + token: cancelToken + ) { copied in DispatchQueue.main.async { DropProgressToast.shared.update( copied: copied, @@ -1177,6 +1192,15 @@ class VMContainerView: NSView { controlSocketURL: controlSocketURL ) } + } 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) diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift index a0eaa9d..5eefbf9 100644 --- a/Sources/tart/DropProgressToast.swift +++ b/Sources/tart/DropProgressToast.swift @@ -1,21 +1,49 @@ import AppKit import Foundation +/// Thread-safe cancellation flag for an in-flight host→guest copy. The toast +/// holds one of these and flips it when the user clicks the close button; +/// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`. +final class DropCancellationToken { + private let lock = NSLock() + private var _cancelled = false + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return _cancelled + } + + func cancel() { + lock.lock() + defer { lock.unlock() } + _cancelled = true + } +} + +/// Sentinel thrown by `DropProgressCopier.copy` when the caller's token is +/// flipped mid-copy. Distinct from a real I/O failure so the drop handler can +/// suppress the "Failed to copy" alert in this case. +struct DropCopyCancelled: Error {} + /// Borderless HUD-style panel shown over the VM window during a host→guest -/// drag-and-drop copy. Displays the filename, a determinate progress bar, and -/// a "[i/N] copied / total" detail line; auto-hides shortly after `finish`. +/// drag-and-drop copy. Renders like a macOS notification banner: anchored to +/// the top-right of the VM window, slides in from the right edge, shows the +/// filename / determinate bar / "[i/N] copied / total" detail line, and has +/// a close (⊗) button that cancels the in-flight copy. /// -/// All methods MUST be called on the main thread. Callers driving copies from -/// a background queue should hop via `DispatchQueue.main.async` first. +/// All methods MUST be called on the main thread. Callers driving copies +/// from a background queue should hop via `DispatchQueue.main.async` first. /// -/// Lifecycle: -/// begin(...) -> panel appears as a child of the VM window -/// update(...) -> progress bar advances (no-op if not visible) -/// finish(success:) -> brief "Done" / "Failed" message, then auto-hide +/// 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 are copied serially by the caller; -/// `begin` may be invoked again before the previous `finish` has hidden the -/// panel — that just retargets the existing panel to the new file. +/// Multiple files dropped in one gesture serially reuse the same panel and +/// increment the [i/N] counter without re-animating. final class DropProgressToast { static let shared = DropProgressToast() @@ -23,22 +51,42 @@ final class DropProgressToast { private var progressBar: NSProgressIndicator! private var titleLabel: NSTextField! private var detailLabel: NSTextField! + private var cancelButton: NSButton! private var hideWorkItem: DispatchWorkItem? private weak var anchorWindow: NSWindow? + /// 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. + private var currentToken: DropCancellationToken? + private init() {} - /// Show the toast (or retarget it to a new file) and reset the progress bar. - /// `parent` is the VM window; the panel becomes a child of it so it tracks - /// movement and z-order. `totalBytes <= 0` flips the bar to indeterminate. - func begin(parent: NSWindow?, filename: String, totalBytes: Int64, index: Int, count: Int) { + /// Show the toast (or retarget it to a new file) and reset the progress + /// bar. `parent` is the VM window; the panel becomes a child of it so it + /// tracks movement and z-order. `cancelToken` is what the close button + /// flips on click. `totalBytes <= 0` switches the bar to indeterminate. + func begin( + parent: NSWindow?, + filename: String, + totalBytes: Int64, + index: Int, + count: Int, + cancelToken: DropCancellationToken + ) { ensurePanel() hideWorkItem?.cancel() hideWorkItem = nil + let wasAlreadyVisible = (panel?.isVisible == true) + anchorWindow = parent + currentToken = cancelToken + titleLabel.stringValue = filename detailLabel.stringValue = formatDetail(copied: 0, total: totalBytes, index: index, count: count) + cancelButton.isHidden = false + cancelButton.isEnabled = true if totalBytes > 0 { progressBar.isIndeterminate = false @@ -50,18 +98,23 @@ final class DropProgressToast { } progressBar.startAnimation(nil) - positionPanel() guard let panel = panel else { return } if let parent = parent { - // addChildWindow re-parents harmlessly even if already attached. + // Re-parenting is harmless if we're already a child of `parent`. parent.addChildWindow(panel, ordered: .above) + } + if wasAlreadyVisible { + // Subsequent files in a multi-file drop: just retarget the existing + // panel in place, don't replay the slide-in. + positionPanel(animated: false) } else { + positionPanel(animated: true) panel.orderFront(nil) } } - /// 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). + /// 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) { guard let panel = panel, panel.isVisible else { return } if total > 0 { @@ -71,26 +124,39 @@ final class DropProgressToast { detailLabel.stringValue = formatDetail(copied: copied, total: total, index: index, count: count) } - /// Flash a "Done" or "Copy failed" state and schedule the panel to hide - /// after a short delay so the user sees the final state. - func finish(success: Bool) { + /// 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". + func finish(success: Bool, cancelled: Bool = false) { guard let panel = panel else { return } if success { progressBar.isIndeterminate = false progressBar.doubleValue = progressBar.maxValue detailLabel.stringValue = "Done" + } else if cancelled { + detailLabel.stringValue = "Cancelled" } else { detailLabel.stringValue = "Copy failed" } progressBar.stopAnimation(nil) + cancelButton.isEnabled = false + currentToken = nil let work = DispatchWorkItem { [weak self] in self?.hide() } hideWorkItem = work - // 0.8s lets the user register the final state without lingering forever. + // 0.8 s lets the user register the final state without lingering. DispatchQueue.main.asyncAfter(deadline: .now() + 0.8, execute: work) - _ = panel // silence warning about unused panel + _ = panel + } + + @objc private func cancelClicked() { + // Flip the token; the copy loop notices on its next chunk boundary and + // throws DropCopyCancelled, which the caller turns into finish(cancelled:). + currentToken?.cancel() + cancelButton.isEnabled = false + detailLabel.stringValue = "Cancelling…" } private func hide() { @@ -104,7 +170,7 @@ final class DropProgressToast { private func ensurePanel() { if panel != nil { return } - let contentRect = NSRect(x: 0, y: 0, width: 360, height: 78) + let contentRect = NSRect(x: 0, y: 0, width: 320, height: 78) let p = NSPanel( contentRect: contentRect, styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], @@ -120,7 +186,7 @@ final class DropProgressToast { p.hasShadow = true p.level = .floating - // Vibrant rounded background — HUD-style. + // Vibrant rounded background — HUD-style, matches notification banners. let effect = NSVisualEffectView(frame: contentRect) effect.material = .hudWindow effect.blendingMode = .behindWindow @@ -139,6 +205,25 @@ final class DropProgressToast { title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) effect.addSubview(title) + // Top-right close button. SF Symbol `xmark.circle.fill` in secondary + // label color so it reads as "dismiss" rather than competing with the + // filename for attention. + let btn = NSButton() + btn.isBordered = false + btn.bezelStyle = .regularSquare + btn.imagePosition = .imageOnly + btn.imageScaling = .scaleProportionallyDown + btn.image = NSImage( + systemSymbolName: "xmark.circle.fill", + accessibilityDescription: "Cancel copy" + ) + btn.contentTintColor = .secondaryLabelColor + btn.target = self + btn.action = #selector(cancelClicked) + btn.translatesAutoresizingMaskIntoConstraints = false + btn.toolTip = "Cancel copy" + effect.addSubview(btn) + let bar = NSProgressIndicator() bar.style = .bar bar.isIndeterminate = false @@ -155,16 +240,25 @@ final class DropProgressToast { effect.addSubview(detail) NSLayoutConstraint.activate([ + // Title spans from the left padding to just before the close button. title.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 14), - title.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -14), + title.trailingAnchor.constraint(equalTo: btn.leadingAnchor, constant: -6), title.topAnchor.constraint(equalTo: effect.topAnchor, constant: 10), + // Close button: 18x18, hugging the top-right corner. + btn.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -10), + btn.centerYAnchor.constraint(equalTo: title.centerYAnchor), + btn.widthAnchor.constraint(equalToConstant: 18), + btn.heightAnchor.constraint(equalToConstant: 18), + + // Progress bar spans the full width below the title row. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor), - bar.trailingAnchor.constraint(equalTo: title.trailingAnchor), + bar.trailingAnchor.constraint(equalTo: btn.trailingAnchor), bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8), - detail.leadingAnchor.constraint(equalTo: title.leadingAnchor), - detail.trailingAnchor.constraint(equalTo: title.trailingAnchor), + // Detail line under the bar. + detail.leadingAnchor.constraint(equalTo: bar.leadingAnchor), + detail.trailingAnchor.constraint(equalTo: bar.trailingAnchor), detail.topAnchor.constraint(equalTo: bar.bottomAnchor, constant: 6), detail.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -10), ]) @@ -173,20 +267,38 @@ final class DropProgressToast { self.titleLabel = title self.detailLabel = detail self.progressBar = bar + self.cancelButton = btn } - /// Center the panel along the VM window's bottom edge with a small inset. - /// Falls back to no-op if we don't have an anchor window. - private func positionPanel() { + /// Position the panel in the VM window's top-right corner with a small + /// inset. When `animated` is true (first appearance of a drop session), + /// the panel starts off-screen-right and slides into place over ~0.18 s, + /// mimicking macOS notification banners. + private func positionPanel(animated: Bool) { guard let panel = panel, let parent = anchorWindow else { return } let parentFrame = parent.frame let size = panel.frame.size - let inset: CGFloat = 16 - let origin = NSPoint( - x: parentFrame.midX - size.width / 2, - y: parentFrame.minY + inset + let rightInset: CGFloat = 16 + // Clear the titlebar plus a bit of breathing room. + let topInset: CGFloat = 50 + + let target = NSPoint( + x: parentFrame.maxX - size.width - rightInset, + y: parentFrame.maxY - size.height - topInset ) - panel.setFrameOrigin(origin) + + if animated { + // Start off-screen to the right, then slide in. + let start = NSPoint(x: parentFrame.maxX + 8, y: target.y) + panel.setFrameOrigin(start) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.18 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrameOrigin(target) + } + } else { + panel.setFrameOrigin(target) + } } private func formatDetail(copied: Int64, total: Int64, index: Int, count: Int) -> String { @@ -199,21 +311,24 @@ final class DropProgressToast { } } -/// Chunked file copy with throttled progress callbacks. Used by the drag-and- -/// drop handler to feed `DropProgressToast` without freezing the VM render -/// view (the previous synchronous `FileManager.copyItem` blocked the same -/// queue/view). +/// 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. /// /// - Removes any existing file at `dst` 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 if either side fails; partial output at `dst` is left in place -/// so the caller can decide how to surface the error. +/// - 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). enum DropProgressCopier { static func copy( from src: URL, to dst: URL, totalBytes: Int64, + token: DropCancellationToken, progress: (Int64) -> Void ) throws { _ = totalBytes // accepted for future use (ETA, average rate, etc.) @@ -236,13 +351,15 @@ enum DropProgressCopier { try? output.close() } - let chunkSize = 1 * 1024 * 1024 // 1 MiB — large enough to amortize syscalls, - // small enough to stream progress on fast disks. + 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) @@ -254,8 +371,8 @@ enum DropProgressCopier { lastReport = now } } - // Always fire a final callback so the UI reaches 100% even when the file - // finished inside the throttle window. + // Always fire a final callback so the UI reaches 100% even when the + // file finished inside the throttle window. progress(copied) } } From 084e69b644d17aab1e4cb61109c3f942331a8fb1 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Wed, 13 May 2026 15:41:19 +0200 Subject: [PATCH 07/20] fix: float drop toast above the VM window, not inside it The notification banner used to sit inside the VM window's top-right corner, overlapping guest content. Move it outside the window: the toast's bottom edge now sits 10 pt above the VM window's top edge, still right-aligned. The slide-in animation is unchanged. If the VM window is jammed against the top of the screen and there's no room above for the toast, fall back to the old "inside top-right" position so we never clip the menu bar. --- Sources/tart/DropProgressToast.swift | 39 ++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift index 5eefbf9..c5424b4 100644 --- a/Sources/tart/DropProgressToast.swift +++ b/Sources/tart/DropProgressToast.swift @@ -270,22 +270,39 @@ final class DropProgressToast { self.cancelButton = btn } - /// Position the panel in the VM window's top-right corner with a small - /// inset. When `animated` is true (first appearance of a drop session), - /// the panel starts off-screen-right and slides into place over ~0.18 s, - /// mimicking macOS notification banners. + /// Position the panel just *above* the VM window's top edge, right-aligned + /// to the window's right edge — i.e. the toast floats outside the VM + /// frame, like a macOS notification banner perched on top of the app. + /// When `animated` is true (first appearance of a drop session) the panel + /// starts off-screen-right and slides into place over ~0.18 s. + /// + /// If the VM window is jammed against the top of the screen and there + /// isn't room for the toast above it, we fall back to sitting inside the + /// window's top-right corner so the toast never clips the menu bar. private func positionPanel(animated: Bool) { guard let panel = panel, let parent = anchorWindow else { return } let parentFrame = parent.frame let size = panel.frame.size - let rightInset: CGFloat = 16 - // Clear the titlebar plus a bit of breathing room. - let topInset: CGFloat = 50 + let rightInset: CGFloat = 8 // align toast's right edge ~flush with window + let gapAbove: CGFloat = 10 // breathing room between toast and titlebar - let target = NSPoint( - x: parentFrame.maxX - size.width - rightInset, - y: parentFrame.maxY - size.height - topInset - ) + // Right-aligned to the VM window's right edge. + let targetX = parentFrame.maxX - size.width - rightInset + + // Default: bottom of toast `gapAbove` above the top of the VM window. + var targetY = parentFrame.maxY + gapAbove + + // Clamp so the toast never overlaps the menu bar on the active screen. + if let screen = parent.screen ?? NSScreen.screens.first { + let menuBarBottom = screen.visibleFrame.maxY + if targetY + size.height > menuBarBottom { + // No room above: tuck into the window's top-right corner under the + // titlebar instead. 50 pt clears the title bar plus a bit of inset. + targetY = parentFrame.maxY - size.height - 50 + } + } + + let target = NSPoint(x: targetX, y: targetY) if animated { // Start off-screen to the right, then slide in. From 2db423c4044a4b947ca3b7832d6b620cd45ab9f0 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 18 May 2026 11:15:23 +0200 Subject: [PATCH 08/20] fix: overlay drop toast inside VM window with fade-in and synced "Done" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes to the drop progress toast: 1. Position the toast over the VM window's content (top-right, 44 pt below the titlebar) instead of floating outside above the window. This reverts the layout intent of the prior "float above" commit; the toast remains an NSPanel child-windowed to the VM window so it tracks z-order and movement. 2. Replace the broken slide-in animation with a fade-in. The previous animation called `panel.animator().setFrameOrigin(target)`, which is a silent no-op on NSWindow — the panel jumped to the off-screen-right start position and stayed there. Use `animator().alphaValue` instead, which is actually animatable on NSWindow. 3. Sync the "Done" text with the bar reaching 100%. NSProgressIndicator has an undocumented ~0.3 s smooth-fill animation when doubleValue jumps, so showing "Done" simultaneously with setting maxValue made the text lead the bar. Delay "Done" by 0.35 s and extend the hide delay to 1.05 s so "Done" still gets ~0.7 s of visibility. Also reset the bar in `hide` so a subsequent drop doesn't briefly flash the previous final state before resetting to 0. --- Sources/tart/DropProgressToast.swift | 73 ++++++++++++++-------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift index c5424b4..a5b42e0 100644 --- a/Sources/tart/DropProgressToast.swift +++ b/Sources/tart/DropProgressToast.swift @@ -129,25 +129,35 @@ final class DropProgressToast { /// "Cancelled" instead of "Copy failed". func finish(success: Bool, cancelled: Bool = false) { guard let panel = panel else { return } + cancelButton.isEnabled = false + currentToken = nil + progressBar.stopAnimation(nil) + + let hideDelay: TimeInterval if success { progressBar.isIndeterminate = false progressBar.doubleValue = progressBar.maxValue - detailLabel.stringValue = "Done" + // NSProgressIndicator has an undocumented ~0.3 s smooth-fill animation + // when doubleValue jumps. Delay "Done" until the bar visibly catches up + // so the text doesn't lead the fill. Extend the hide so "Done" still + // gets ~0.7 s of visibility once it appears. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.detailLabel.stringValue = "Done" + } + hideDelay = 1.05 } else if cancelled { detailLabel.stringValue = "Cancelled" + hideDelay = 0.8 } else { detailLabel.stringValue = "Copy failed" + hideDelay = 0.8 } - progressBar.stopAnimation(nil) - cancelButton.isEnabled = false - currentToken = nil let work = DispatchWorkItem { [weak self] in self?.hide() } hideWorkItem = work - // 0.8 s lets the user register the final state without lingering. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + hideDelay, execute: work) _ = panel } @@ -165,6 +175,11 @@ final class DropProgressToast { parent.removeChildWindow(panel) } panel.orderOut(nil) + // Reset progress so the next drop doesn't flash the previous "Done" state + // and then animate from 100% → 0% as `begin` resets it. + progressBar.stopAnimation(nil) + progressBar.isIndeterminate = false + progressBar.doubleValue = 0 } private func ensurePanel() { @@ -270,51 +285,37 @@ final class DropProgressToast { self.cancelButton = btn } - /// Position the panel just *above* the VM window's top edge, right-aligned - /// to the window's right edge — i.e. the toast floats outside the VM - /// frame, like a macOS notification banner perched on top of the app. + /// Position the panel inside the VM window's top-right corner, just below + /// the titlebar, so it overlays the VM display. The toast remains a host + /// NSPanel (child-windowed to the VM window) but visually sits on top of + /// the guest content rather than floating outside the window's frame. /// When `animated` is true (first appearance of a drop session) the panel - /// starts off-screen-right and slides into place over ~0.18 s. - /// - /// If the VM window is jammed against the top of the screen and there - /// isn't room for the toast above it, we fall back to sitting inside the - /// window's top-right corner so the toast never clips the menu bar. + /// fades in over ~0.18 s; otherwise it snaps into place. private func positionPanel(animated: Bool) { guard let panel = panel, let parent = anchorWindow else { return } let parentFrame = parent.frame let size = panel.frame.size - let rightInset: CGFloat = 8 // align toast's right edge ~flush with window - let gapAbove: CGFloat = 10 // breathing room between toast and titlebar + let rightInset: CGFloat = 12 // inset from window's right edge + let topInset: CGFloat = 44 // clears standard titlebar + small gap - // Right-aligned to the VM window's right edge. let targetX = parentFrame.maxX - size.width - rightInset + let targetY = parentFrame.maxY - size.height - topInset + let targetFrame = NSRect(x: targetX, y: targetY, width: size.width, height: size.height) - // Default: bottom of toast `gapAbove` above the top of the VM window. - var targetY = parentFrame.maxY + gapAbove - - // Clamp so the toast never overlaps the menu bar on the active screen. - if let screen = parent.screen ?? NSScreen.screens.first { - let menuBarBottom = screen.visibleFrame.maxY - if targetY + size.height > menuBarBottom { - // No room above: tuck into the window's top-right corner under the - // titlebar instead. 50 pt clears the title bar plus a bit of inset. - targetY = parentFrame.maxY - size.height - 50 - } - } - - let target = NSPoint(x: targetX, y: targetY) + panel.setFrame(targetFrame, display: true) if animated { - // Start off-screen to the right, then slide in. - let start = NSPoint(x: parentFrame.maxX + 8, y: target.y) - panel.setFrameOrigin(start) + // Fade in from transparent. `animator().alphaValue` is the documented + // animatable property on NSWindow (unlike setFrameOrigin, which is a + // silent no-op via the animator proxy). + panel.alphaValue = 0 NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.18 ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().setFrameOrigin(target) + panel.animator().alphaValue = 1 } } else { - panel.setFrameOrigin(target) + panel.alphaValue = 1 } } From 0cc0969652f0cb75840d671bd55524330d4b51bf Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 18 May 2026 12:23:05 +0200 Subject: [PATCH 09/20] feat: show drop destination in toast and unblock guest agent on dev builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a host→guest drop completes, the toast now updates from "Done" to "Copied to " (e.g. "Desktop", "Documents") once the guest agent has finished relocating the file out of the share. When the agent isn't reachable, the toast falls back to "Copied to Shared Files" so the user always sees a sensible destination. Three things had to come together: 1. Toast surfaces the destination `GuestDropSynthesis.perform` now returns a `GuestDropOutcome` carrying the basename of the destination folder (the in-guest script appends a `tartdrop-dest=` line after `mv`). The relocation runs as a fire-and-forget Task off the copy queue and patches the toast via a new `setFinalDestination` method — which uses a shared `pendingFinalText` slot so a fast relocation result doesn't get clobbered by the delayed "Done" placeholder. 2. gRPC timeout shortened so failures fit in the toast window The exec-call timeout drops from 8 s to 5 s. Steady-state calls finish in well under 500 ms; 5 s leaves headroom for a first-run osascript blocked on a Finder Automation TCC prompt inside the guest. The baseline hide on success grows to 2 s so the destination update has a chance to land before the panel disappears. 3. Dev builds of tart can now host the guest agent `CI.version` returns `"SNAPSHOT"` for non-tagged builds, which gave the VM a console port named `tart-version-SNAPSHOT`. The guest agent parses that suffix as a semver and falls back to `unix.Kill(getppid, SIGTERM)` when it can't — which fails with EPERM against launchd and prints "operation not permitted" every 10 s forever. A new `CI.deviceVersion` always emits a valid semver with major ≥ 2 (`"99.0.0"` for SNAPSHOT) and VM.swift uses it for the port name. `99.0.0` without a `-prerelease` suffix is required because macOS's BSD tty layer rejects the dotted+hyphenated form and refuses to expose the device under `/dev/cu.*`. --- Sources/tart/CI/CI.swift | 9 +++++ Sources/tart/Commands/Run.swift | 32 ++++++++++----- Sources/tart/DropProgressToast.swift | 56 +++++++++++++++++++++++---- Sources/tart/GuestDropSynthesis.swift | 34 ++++++++++++---- Sources/tart/VM.swift | 2 +- 5 files changed, 106 insertions(+), 27 deletions(-) diff --git a/Sources/tart/CI/CI.swift b/Sources/tart/CI/CI.swift index f0cf9f5..34b1753 100644 --- a/Sources/tart/CI/CI.swift +++ b/Sources/tart/CI/CI.swift @@ -5,6 +5,15 @@ struct CI { rawVersion.expanded() ? rawVersion : "SNAPSHOT" } + /// Same as `version`, but always a valid semver with major ≥ 2 so the guest + /// agent's `/dev/cu.tart-version-` Tart-detection probe accepts it. + /// For non-tagged dev builds we'd otherwise emit `SNAPSHOT`, which the agent + /// can't parse — it then thinks it isn't running on Tart and bails out with + /// "operation not permitted" when it tries to SIGTERM its launchd parent. + static var deviceVersion: String { + rawVersion.expanded() ? rawVersion : "99.0.0" + } + static var release: String? { rawVersion.expanded() ? "tart@\(rawVersion)" : nil } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 29f576a..fb6d6b5 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -1176,21 +1176,24 @@ class VMContainerView: NSView { } } + // 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) } - // After the file lands in the shared drop zone, ask the guest agent - // to make the drop visible — Finder-window duplicate when possible, - // reveal-in-Finder otherwise. If the agent isn't reachable, the - // file in the share folder is still the fallback. let destPath = dest.path Task { - _ = await synthesizeGuestDrop( + 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 @@ -1242,13 +1245,23 @@ class VMContainerView: NSView { /// 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 -> Bool { +) async -> String { _ = normalized - guard let controlSocketURL = controlSocketURL else { return false } + guard let controlSocketURL = controlSocketURL else { return "Shared Files" } let filename = (hostFilePath as NSString).lastPathComponent let guestPath = "/Volumes/My Shared Files/Dropped Files/" + filename @@ -1258,10 +1271,9 @@ private func synthesizeGuestDrop( controlSocketURL: controlSocketURL, guestFilePath: guestPath ) - _ = outcome - return true + return outcome.destinationFolderName } catch { - return false + return "Shared Files" } } diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift index a5b42e0..adc727b 100644 --- a/Sources/tart/DropProgressToast.swift +++ b/Sources/tart/DropProgressToast.swift @@ -54,6 +54,12 @@ final class DropProgressToast { private var cancelButton: NSButton! private var hideWorkItem: DispatchWorkItem? private weak var anchorWindow: NSWindow? + /// Text that the delayed-final-text work item should apply when it fires. + /// finish() sets this to "Done", setFinalDestination() upgrades it to + /// "Copied to " — whichever value is current at +0.35 s wins, so a + /// fast relocation result doesn't get clobbered by the placeholder. + private var pendingFinalText: String = "" + private var didApplyFinalText: Bool = false /// Cancellation token for the copy currently driving the toast. Cleared /// once the user clicks ⊗ or `finish` is called, so a late click after the @@ -124,10 +130,33 @@ final class DropProgressToast { detailLabel.stringValue = formatDetail(copied: copied, total: total, index: index, count: count) } + /// Called once the guest-agent relocation completes (or fails) with the + /// 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) { + guard let panel = panel, panel.isVisible, !folderName.isEmpty else { return } + pendingFinalText = "Copied to \(folderName)" + if didApplyFinalText { + detailLabel.stringValue = pendingFinalText + } + hideWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.hide() + } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 1.1, execute: work) + _ = panel + } + /// 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". - func finish(success: Bool, cancelled: Bool = false) { + /// "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) { guard let panel = panel else { return } cancelButton.isEnabled = false currentToken = nil @@ -138,13 +167,24 @@ final class DropProgressToast { progressBar.isIndeterminate = false progressBar.doubleValue = progressBar.maxValue // NSProgressIndicator has an undocumented ~0.3 s smooth-fill animation - // when doubleValue jumps. Delay "Done" until the bar visibly catches up - // so the text doesn't lead the fill. Extend the hide so "Done" still - // gets ~0.7 s of visibility once it appears. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in - self?.detailLabel.stringValue = "Done" + // when doubleValue jumps. Delay the final text until the bar visibly + // catches up so the text doesn't lead the fill. setFinalDestination + // may upgrade `pendingFinalText` to "Copied to " in that + // window; the work item picks up whatever value is current at +0.35 s. + if let folder = destinationFolder, !folder.isEmpty { + pendingFinalText = "Copied to \(folder)" + } else { + pendingFinalText = "Done" } - hideDelay = 1.05 + didApplyFinalText = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self = self else { return } + 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 } else if cancelled { detailLabel.stringValue = "Cancelled" hideDelay = 0.8 diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift index cc57321..da99021 100644 --- a/Sources/tart/GuestDropSynthesis.swift +++ b/Sources/tart/GuestDropSynthesis.swift @@ -4,11 +4,11 @@ import GRPC import Cirruslabs_TartGuestAgent_Apple_Swift import Cirruslabs_TartGuestAgent_Grpc_Swift -enum GuestDropOutcome { - /// Guest agent revealed the file in Finder. A Finder window opens pointing - /// at the file's containing folder with the file selected, giving the user - /// immediate visual feedback that the drop landed. - case revealed +struct GuestDropOutcome { + /// Basename of the folder the file was moved into, e.g. "Desktop" or + /// "Documents". The host shows this in the toast so the user knows where + /// the dropped file ended up. + let destinationFolderName: String } /// Errors that the host treats as "agent path didn't work; fall back to @@ -96,6 +96,9 @@ enum GuestDropSynthesis { mv -- "$src" "$final" /usr/bin/open -R "$final" + # Last line of stdout is the destination folder's basename so the host + # can show "Copied to Desktop" / "Copied to Documents" in the toast. + printf 'tartdrop-dest=%s\n' "$(basename "$dest_dir")" """# static func perform( @@ -127,7 +130,11 @@ enum GuestDropSynthesis { } defer { try? channel.close().wait() } - let callOptions = CallOptions(timeLimit: .timeout(.seconds(8))) + // 5 s: enough headroom for a first-run osascript that's blocked on a + // user TCC Automation prompt inside the guest, while still failing fast + // when the agent isn't running so the toast can show "Copied to Shared + // Files" before hiding. Steady-state calls finish in well under 500 ms. + let callOptions = CallOptions(timeLimit: .timeout(.seconds(5))) let client = AgentAsyncClient(channel: channel, defaultCallOptions: callOptions) let execCall = client.makeExecCall() @@ -179,7 +186,18 @@ enum GuestDropSynthesis { throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr) } - _ = stdout - return .revealed + // 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. + 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) } } diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index fc8fc7a..b1d4475 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -429,7 +429,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // A dummy console device useful for implementing // host feature checks in the guest agent software. let consolePort = VZVirtioConsolePortConfiguration() - consolePort.name = "tart-version-\(CI.version)" + consolePort.name = "tart-version-\(CI.deviceVersion)" let consoleDevice = VZVirtioConsoleDeviceConfiguration() consoleDevice.ports[0] = consolePort From 9e5f65b8da1dda53a7b83d6bbf13fca1b6bba266 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 18 May 2026 13:02:16 +0200 Subject: [PATCH 10/20] feat: drop file into the Finder window under the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the guest-agent relocation script always asked Finder for the *front* window and dropped there. That meant dropping on the bare Desktop while a Downloads window happened to be frontmost put the file in Downloads — the opposite of user intent. Plumb the drop point (already captured as a normalized 0..1 top-left coordinate by DropGeometry.normalize) through synthesizeGuestDrop and GuestDropSynthesis.perform into the in-guest script. The script now converts the point to guest-screen pixels using the desktop window's bounds, walks `every Finder window` in front-to-back order, and returns the folder of the first window whose bounds contain the point. If no window does, dest_dir stays empty and the existing fallback drops the file on ~/Desktop — which is correct for a drop on bare Desktop. The AppleScript body is piped to `osascript -` via a single-quoted bash variable (rather than a heredoc) so it survives Swift's multi-line string indentation rules cleanly. --- Sources/tart/Commands/Run.swift | 5 +- Sources/tart/GuestDropSynthesis.swift | 97 +++++++++++++++++++-------- 2 files changed, 72 insertions(+), 30 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index fb6d6b5..0d2f334 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -1260,7 +1260,6 @@ private func synthesizeGuestDrop( atNormalized normalized: CGPoint, controlSocketURL: URL? ) async -> String { - _ = normalized guard let controlSocketURL = controlSocketURL else { return "Shared Files" } let filename = (hostFilePath as NSString).lastPathComponent @@ -1269,10 +1268,12 @@ private func synthesizeGuestDrop( do { let outcome = try await GuestDropSynthesis.perform( controlSocketURL: controlSocketURL, - guestFilePath: guestPath + guestFilePath: guestPath, + normalizedDropPoint: normalized ) return outcome.destinationFolderName } catch { + NSLog("[GuestDrop] relocate failed for \(guestPath): \(error)") return "Shared Files" } } diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift index da99021..a3a5130 100644 --- a/Sources/tart/GuestDropSynthesis.swift +++ b/Sources/tart/GuestDropSynthesis.swift @@ -37,15 +37,27 @@ enum GuestDropError: Error { /// Files"), we fall back to `~/Desktop`. The file is finally revealed with /// `open -R` so Finder pops a window with it selected. enum GuestDropSynthesis { - /// Shell script body executed inside the guest. The dropped file's guest - /// path is passed as `$1` via the `sh -c CMD -- $1` convention (with - /// `tartdrop` as `$0` so error messages identify us). + /// Shell script body executed inside the guest. Args: + /// $1 = guest path of the just-copied file (in the share) + /// $2 = drop X, normalized 0..1, top-left origin (optional) + /// $3 = drop Y, normalized 0..1, top-left origin (optional) + /// `$0` is set to "tartdrop" so error messages identify us. + /// + /// The script picks the destination by *position*: it converts the + /// normalized drop point into guest-screen pixels and asks Finder which of + /// its open windows contains that point. If no Finder window does (i.e. the + /// user dropped on bare Desktop), the file goes to ~/Desktop. The front + /// window is intentionally *not* the default fallback — dropping on the + /// Desktop while a Downloads window happens to be frontmost should land on + /// the Desktop, not in Downloads. /// /// Exit codes: 0 on success, non-zero on failure (caller treats any /// non-zero as "fall back to the share-folder copy that's already on disk"). private static let relocateAndRevealScript = #""" set -e src=$1 + norm_x=${2:-} + norm_y=${3:-} if [ -z "$src" ] || [ ! -e "$src" ]; then echo "tartdrop: missing or nonexistent source: $src" >&2 exit 2 @@ -53,27 +65,50 @@ enum GuestDropSynthesis { name=$(basename "$src") src_dir=$(dirname "$src") - # Ask Finder for the frontmost window's folder. Suppress stderr so a TCC - # denial or "no windows" error doesn't pollute the agent log; we detect - # those by an empty result. The agent runs in the user's GUI session, so - # osascript here goes through the user's TCC consent (Automation→Finder). - dest_dir=$(/usr/bin/osascript <<'OSA' 2>/dev/null || true - tell application "Finder" - if (count of Finder windows) is 0 then return "" - try - return POSIX path of (target of front Finder window as alias) - on error - return "" - end try - end tell - OSA - ) - # AppleScript appends a trailing slash to folder POSIX paths. - dest_dir=${dest_dir%/} + # Resolve destination by where the drop landed. The agent runs in the + # user's GUI session so osascript goes through TCC (Automation → Finder). + # Suppress stderr so a denied prompt or "no windows" error doesn't pollute + # the agent log; an empty result means "drop on Desktop". + # The AppleScript body is piped to `osascript -` so we don't need a shell + # heredoc (whose terminator must sit at column 0 — incompatible with + # Swift's multi-line string indentation rules). + osa_script='on run argv + set nx to (item 1 of argv) as real + set ny to (item 2 of argv) as real + tell application "Finder" + set sb to bounds of window of desktop + set sw to (item 3 of sb) - (item 1 of sb) + set sh to (item 4 of sb) - (item 2 of sb) + set dx to (nx * sw) as integer + set dy to (ny * sh) as integer + try + set wins to every Finder window + repeat with i from 1 to count of wins + set w to item i of wins + set b to bounds of w + set L to (item 1 of b) as integer + set T to (item 2 of b) as integer + set R to (item 3 of b) as integer + set Bv to (item 4 of b) as integer + if (dx >= L) and (dx <= R) and (dy >= T) and (dy <= Bv) then + return POSIX path of ((target of w) as alias) + end if + end repeat + return "" + on error + return "" + end try + end tell + end run' - # Reject empty / nonexistent / non-writable destinations, and reject the - # source's own parent (which would either be a no-op rename or leave us - # putting the file right back into "Dropped Files"). + dest_dir="" + if [ -n "$norm_x" ] && [ -n "$norm_y" ]; then + dest_dir=$(printf '%s' "$osa_script" \ + | /usr/bin/osascript - "$norm_x" "$norm_y" 2>/dev/null || true) + dest_dir=${dest_dir%/} + fi + + # Empty / nonexistent / non-writable / source's own parent → bare Desktop. if [ -z "$dest_dir" ] || [ ! -d "$dest_dir" ] || [ ! -w "$dest_dir" ] || [ "$dest_dir" = "$src_dir" ]; then dest_dir=$HOME/Desktop fi @@ -103,7 +138,8 @@ enum GuestDropSynthesis { static func perform( controlSocketURL: URL, - guestFilePath: String + guestFilePath: String, + normalizedDropPoint: CGPoint? = nil ) async throws -> GuestDropOutcome { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) defer { try? group.syncShutdownGracefully() } @@ -138,13 +174,18 @@ enum GuestDropSynthesis { let client = AgentAsyncClient(channel: channel, defaultCallOptions: callOptions) let execCall = client.makeExecCall() - // Pass the guest path as $1 (with "tartdrop" as $0 so error messages - // identify us). The script does the Finder-window lookup, picks a - // destination, moves the file out of "Dropped Files", and reveals it. + // Pass guest path as $1 and, if available, the normalized drop point as + // $2/$3 so the script can pick the Finder window under the cursor instead + // of the frontmost one. "tartdrop" is $0 so error messages identify us. + var scriptArgs = ["tartdrop", guestFilePath] + if let point = normalizedDropPoint { + scriptArgs.append(String(format: "%.6f", point.x)) + scriptArgs.append(String(format: "%.6f", point.y)) + } let command = ExecRequest.with { $0.type = .command(ExecRequest.Command.with { $0.name = "/bin/sh" - $0.args = ["-c", relocateAndRevealScript, "tartdrop", guestFilePath] + $0.args = ["-c", relocateAndRevealScript] + scriptArgs $0.interactive = false $0.tty = false }) From 93d5708c082027116ba56cfe67bc3123c2897815 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 18 May 2026 14:48:18 +0200 Subject: [PATCH 11/20] refactor: split DropProgressToast into focused sub-250-line files DropProgressToast.swift (436 lines) bundled four responsibilities. Split verbatim into: - DropCancellationToken.swift - cancel token + DropCopyCancelled sentinel - DropProgressCopier.swift - chunked file copier - DropProgressToast.swift - toast state/coordination (lifecycle API) - DropProgressToast+Panel.swift - AppKit panel construction/positioning Each file is well under 250 lines and single-responsibility. The only non-move change is widening the members the +Panel extension references from private to internal; no behavior, signatures, or logic changed. --- Sources/tart/DropCancellationToken.swift | 26 +++ Sources/tart/DropProgressCopier.swift | 67 ++++++ Sources/tart/DropProgressToast+Panel.swift | 154 +++++++++++++ Sources/tart/DropProgressToast.swift | 254 +-------------------- 4 files changed, 257 insertions(+), 244 deletions(-) create mode 100644 Sources/tart/DropCancellationToken.swift create mode 100644 Sources/tart/DropProgressCopier.swift create mode 100644 Sources/tart/DropProgressToast+Panel.swift diff --git a/Sources/tart/DropCancellationToken.swift b/Sources/tart/DropCancellationToken.swift new file mode 100644 index 0000000..9cd594b --- /dev/null +++ b/Sources/tart/DropCancellationToken.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Thread-safe cancellation flag for an in-flight host→guest copy. The toast +/// holds one of these and flips it when the user clicks the close button; +/// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`. +final class DropCancellationToken { + private let lock = NSLock() + private var _cancelled = false + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return _cancelled + } + + func cancel() { + lock.lock() + defer { lock.unlock() } + _cancelled = true + } +} + +/// Sentinel thrown by `DropProgressCopier.copy` when the caller's token is +/// flipped mid-copy. Distinct from a real I/O failure so the drop handler can +/// suppress the "Failed to copy" alert in this case. +struct DropCopyCancelled: Error {} diff --git a/Sources/tart/DropProgressCopier.swift b/Sources/tart/DropProgressCopier.swift new file mode 100644 index 0000000..fd685ff --- /dev/null +++ b/Sources/tart/DropProgressCopier.swift @@ -0,0 +1,67 @@ +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. +/// +/// - Removes any existing file at `dst` 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). +enum DropProgressCopier { + static func copy( + 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) + } + guard FileManager.default.createFile(atPath: dst.path, contents: nil) else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(EIO), + userInfo: [NSLocalizedDescriptionKey: "Could not create destination file \(dst.path)"] + ) + } + + let input = try FileHandle(forReadingFrom: src) + let output = try FileHandle(forWritingTo: dst) + defer { + try? input.close() + try? output.close() + } + + 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 + } + } + // 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 new file mode 100644 index 0000000..db6a0f6 --- /dev/null +++ b/Sources/tart/DropProgressToast+Panel.swift @@ -0,0 +1,154 @@ +import AppKit +import Foundation + +/// AppKit view construction, positioning, and detail-string formatting for +/// `DropProgressToast`. Split out from the toast's state/coordination logic so +/// each file stays focused; follows the codebase's `Type+Feature.swift` +/// extension-file convention. +extension DropProgressToast { + func ensurePanel() { + if panel != nil { return } + + let contentRect = NSRect(x: 0, y: 0, width: 320, height: 78) + let p = NSPanel( + contentRect: contentRect, + styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: false + ) + p.isFloatingPanel = true + p.becomesKeyOnlyIfNeeded = true + p.hidesOnDeactivate = false + p.isReleasedWhenClosed = false + p.backgroundColor = .clear + p.isOpaque = false + p.hasShadow = true + p.level = .floating + + // Vibrant rounded background — HUD-style, matches notification banners. + let effect = NSVisualEffectView(frame: contentRect) + effect.material = .hudWindow + effect.blendingMode = .behindWindow + effect.state = .active + effect.wantsLayer = true + effect.layer?.cornerRadius = 14 + effect.layer?.masksToBounds = true + p.contentView = effect + + let title = NSTextField(labelWithString: "") + title.font = .systemFont(ofSize: 13, weight: .semibold) + title.textColor = .labelColor + title.lineBreakMode = .byTruncatingMiddle + title.translatesAutoresizingMaskIntoConstraints = false + title.setContentHuggingPriority(.defaultLow, for: .horizontal) + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + effect.addSubview(title) + + // Top-right close button. SF Symbol `xmark.circle.fill` in secondary + // label color so it reads as "dismiss" rather than competing with the + // filename for attention. + let btn = NSButton() + btn.isBordered = false + btn.bezelStyle = .regularSquare + btn.imagePosition = .imageOnly + btn.imageScaling = .scaleProportionallyDown + btn.image = NSImage( + systemSymbolName: "xmark.circle.fill", + accessibilityDescription: "Cancel copy" + ) + btn.contentTintColor = .secondaryLabelColor + btn.target = self + btn.action = #selector(cancelClicked) + btn.translatesAutoresizingMaskIntoConstraints = false + btn.toolTip = "Cancel copy" + effect.addSubview(btn) + + let bar = NSProgressIndicator() + bar.style = .bar + bar.isIndeterminate = false + bar.controlSize = .small + bar.translatesAutoresizingMaskIntoConstraints = false + effect.addSubview(bar) + + let detail = NSTextField(labelWithString: "") + detail.font = .systemFont(ofSize: 11, weight: .regular) + detail.textColor = .secondaryLabelColor + detail.translatesAutoresizingMaskIntoConstraints = false + detail.setContentHuggingPriority(.defaultLow, for: .horizontal) + detail.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + effect.addSubview(detail) + + NSLayoutConstraint.activate([ + // Title spans from the left padding to just before the close button. + title.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 14), + title.trailingAnchor.constraint(equalTo: btn.leadingAnchor, constant: -6), + title.topAnchor.constraint(equalTo: effect.topAnchor, constant: 10), + + // Close button: 18x18, hugging the top-right corner. + btn.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -10), + btn.centerYAnchor.constraint(equalTo: title.centerYAnchor), + btn.widthAnchor.constraint(equalToConstant: 18), + btn.heightAnchor.constraint(equalToConstant: 18), + + // Progress bar spans the full width below the title row. + bar.leadingAnchor.constraint(equalTo: title.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: btn.trailingAnchor), + bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8), + + // Detail line under the bar. + detail.leadingAnchor.constraint(equalTo: bar.leadingAnchor), + detail.trailingAnchor.constraint(equalTo: bar.trailingAnchor), + detail.topAnchor.constraint(equalTo: bar.bottomAnchor, constant: 6), + detail.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -10), + ]) + + self.panel = p + self.titleLabel = title + self.detailLabel = detail + self.progressBar = bar + self.cancelButton = btn + } + + /// Position the panel inside the VM window's top-right corner, just below + /// the titlebar, so it overlays the VM display. The toast remains a host + /// NSPanel (child-windowed to the VM window) but visually sits on top of + /// the guest content rather than floating outside the window's frame. + /// When `animated` is true (first appearance of a drop session) the panel + /// fades in over ~0.18 s; otherwise it snaps into place. + func positionPanel(animated: Bool) { + guard let panel = panel, let parent = anchorWindow else { return } + let parentFrame = parent.frame + let size = panel.frame.size + let rightInset: CGFloat = 12 // inset from window's right edge + let topInset: CGFloat = 44 // clears standard titlebar + small gap + + let targetX = parentFrame.maxX - size.width - rightInset + let targetY = parentFrame.maxY - size.height - topInset + let targetFrame = NSRect(x: targetX, y: targetY, width: size.width, height: size.height) + + panel.setFrame(targetFrame, display: true) + + if animated { + // Fade in from transparent. `animator().alphaValue` is the documented + // animatable property on NSWindow (unlike setFrameOrigin, which is a + // silent no-op via the animator proxy). + panel.alphaValue = 0 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.18 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + } + } else { + panel.alphaValue = 1 + } + } + + func formatDetail(copied: Int64, total: Int64, index: Int, count: Int) -> String { + 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)" + } +} diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift index adc727b..951d358 100644 --- a/Sources/tart/DropProgressToast.swift +++ b/Sources/tart/DropProgressToast.swift @@ -1,31 +1,6 @@ import AppKit import Foundation -/// Thread-safe cancellation flag for an in-flight host→guest copy. The toast -/// holds one of these and flips it when the user clicks the close button; -/// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`. -final class DropCancellationToken { - private let lock = NSLock() - private var _cancelled = false - - var isCancelled: Bool { - lock.lock() - defer { lock.unlock() } - return _cancelled - } - - func cancel() { - lock.lock() - defer { lock.unlock() } - _cancelled = true - } -} - -/// Sentinel thrown by `DropProgressCopier.copy` when the caller's token is -/// flipped mid-copy. Distinct from a real I/O failure so the drop handler can -/// suppress the "Failed to copy" alert in this case. -struct DropCopyCancelled: Error {} - /// Borderless HUD-style panel shown over the VM window during a host→guest /// drag-and-drop copy. Renders like a macOS notification banner: anchored to /// the top-right of the VM window, slides in from the right edge, shows the @@ -44,16 +19,19 @@ struct DropCopyCancelled: Error {} /// /// Multiple files dropped in one gesture serially reuse the same panel and /// increment the [i/N] counter without re-animating. +/// +/// AppKit panel construction, positioning, and detail-string formatting live +/// in `DropProgressToast+Panel.swift`. final class DropProgressToast { static let shared = DropProgressToast() - private var panel: NSPanel? - private var progressBar: NSProgressIndicator! - private var titleLabel: NSTextField! - private var detailLabel: NSTextField! - private var cancelButton: NSButton! + var panel: NSPanel? + var progressBar: NSProgressIndicator! + var titleLabel: NSTextField! + var detailLabel: NSTextField! + var cancelButton: NSButton! private var hideWorkItem: DispatchWorkItem? - private weak var anchorWindow: NSWindow? + weak var anchorWindow: NSWindow? /// Text that the delayed-final-text work item should apply when it fires. /// finish() sets this to "Done", setFinalDestination() upgrades it to /// "Copied to " — whichever value is current at +0.35 s wins, so a @@ -201,7 +179,7 @@ final class DropProgressToast { _ = panel } - @objc private func cancelClicked() { + @objc func cancelClicked() { // Flip the token; the copy loop notices on its next chunk boundary and // throws DropCopyCancelled, which the caller turns into finish(cancelled:). currentToken?.cancel() @@ -221,216 +199,4 @@ final class DropProgressToast { progressBar.isIndeterminate = false progressBar.doubleValue = 0 } - - private func ensurePanel() { - if panel != nil { return } - - let contentRect = NSRect(x: 0, y: 0, width: 320, height: 78) - let p = NSPanel( - contentRect: contentRect, - styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], - backing: .buffered, - defer: false - ) - p.isFloatingPanel = true - p.becomesKeyOnlyIfNeeded = true - p.hidesOnDeactivate = false - p.isReleasedWhenClosed = false - p.backgroundColor = .clear - p.isOpaque = false - p.hasShadow = true - p.level = .floating - - // Vibrant rounded background — HUD-style, matches notification banners. - let effect = NSVisualEffectView(frame: contentRect) - effect.material = .hudWindow - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 14 - effect.layer?.masksToBounds = true - p.contentView = effect - - let title = NSTextField(labelWithString: "") - title.font = .systemFont(ofSize: 13, weight: .semibold) - title.textColor = .labelColor - title.lineBreakMode = .byTruncatingMiddle - title.translatesAutoresizingMaskIntoConstraints = false - title.setContentHuggingPriority(.defaultLow, for: .horizontal) - title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - effect.addSubview(title) - - // Top-right close button. SF Symbol `xmark.circle.fill` in secondary - // label color so it reads as "dismiss" rather than competing with the - // filename for attention. - let btn = NSButton() - btn.isBordered = false - btn.bezelStyle = .regularSquare - btn.imagePosition = .imageOnly - btn.imageScaling = .scaleProportionallyDown - btn.image = NSImage( - systemSymbolName: "xmark.circle.fill", - accessibilityDescription: "Cancel copy" - ) - btn.contentTintColor = .secondaryLabelColor - btn.target = self - btn.action = #selector(cancelClicked) - btn.translatesAutoresizingMaskIntoConstraints = false - btn.toolTip = "Cancel copy" - effect.addSubview(btn) - - let bar = NSProgressIndicator() - bar.style = .bar - bar.isIndeterminate = false - bar.controlSize = .small - bar.translatesAutoresizingMaskIntoConstraints = false - effect.addSubview(bar) - - let detail = NSTextField(labelWithString: "") - detail.font = .systemFont(ofSize: 11, weight: .regular) - detail.textColor = .secondaryLabelColor - detail.translatesAutoresizingMaskIntoConstraints = false - detail.setContentHuggingPriority(.defaultLow, for: .horizontal) - detail.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - effect.addSubview(detail) - - NSLayoutConstraint.activate([ - // Title spans from the left padding to just before the close button. - title.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 14), - title.trailingAnchor.constraint(equalTo: btn.leadingAnchor, constant: -6), - title.topAnchor.constraint(equalTo: effect.topAnchor, constant: 10), - - // Close button: 18x18, hugging the top-right corner. - btn.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -10), - btn.centerYAnchor.constraint(equalTo: title.centerYAnchor), - btn.widthAnchor.constraint(equalToConstant: 18), - btn.heightAnchor.constraint(equalToConstant: 18), - - // Progress bar spans the full width below the title row. - bar.leadingAnchor.constraint(equalTo: title.leadingAnchor), - bar.trailingAnchor.constraint(equalTo: btn.trailingAnchor), - bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8), - - // Detail line under the bar. - detail.leadingAnchor.constraint(equalTo: bar.leadingAnchor), - detail.trailingAnchor.constraint(equalTo: bar.trailingAnchor), - detail.topAnchor.constraint(equalTo: bar.bottomAnchor, constant: 6), - detail.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -10), - ]) - - self.panel = p - self.titleLabel = title - self.detailLabel = detail - self.progressBar = bar - self.cancelButton = btn - } - - /// Position the panel inside the VM window's top-right corner, just below - /// the titlebar, so it overlays the VM display. The toast remains a host - /// NSPanel (child-windowed to the VM window) but visually sits on top of - /// the guest content rather than floating outside the window's frame. - /// When `animated` is true (first appearance of a drop session) the panel - /// fades in over ~0.18 s; otherwise it snaps into place. - private func positionPanel(animated: Bool) { - guard let panel = panel, let parent = anchorWindow else { return } - let parentFrame = parent.frame - let size = panel.frame.size - let rightInset: CGFloat = 12 // inset from window's right edge - let topInset: CGFloat = 44 // clears standard titlebar + small gap - - let targetX = parentFrame.maxX - size.width - rightInset - let targetY = parentFrame.maxY - size.height - topInset - let targetFrame = NSRect(x: targetX, y: targetY, width: size.width, height: size.height) - - panel.setFrame(targetFrame, display: true) - - if animated { - // Fade in from transparent. `animator().alphaValue` is the documented - // animatable property on NSWindow (unlike setFrameOrigin, which is a - // silent no-op via the animator proxy). - panel.alphaValue = 0 - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0.18 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().alphaValue = 1 - } - } else { - panel.alphaValue = 1 - } - } - - private func formatDetail(copied: Int64, total: Int64, index: Int, count: Int) -> String { - 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)" - } -} - -/// 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. -/// -/// - Removes any existing file at `dst` 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). -enum DropProgressCopier { - static func copy( - 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) - } - guard FileManager.default.createFile(atPath: dst.path, contents: nil) else { - throw NSError( - domain: NSPOSIXErrorDomain, - code: Int(EIO), - userInfo: [NSLocalizedDescriptionKey: "Could not create destination file \(dst.path)"] - ) - } - - let input = try FileHandle(forReadingFrom: src) - let output = try FileHandle(forWritingTo: dst) - defer { - try? input.close() - try? output.close() - } - - 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 - } - } - // Always fire a final callback so the UI reaches 100% even when the - // file finished inside the throttle window. - progress(copied) - } } From 7af5d839fa13271a0b8c8f3005c97275c4090a24 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 18 May 2026 15:29:05 +0200 Subject: [PATCH 12/20] chore: fix DirectoryShareTests filename typo (Direcotry -> Directory) --- .../{DirecotryShareTests.swift => DirectoryShareTests.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Tests/TartTests/{DirecotryShareTests.swift => DirectoryShareTests.swift} (100%) diff --git a/Tests/TartTests/DirecotryShareTests.swift b/Tests/TartTests/DirectoryShareTests.swift similarity index 100% rename from Tests/TartTests/DirecotryShareTests.swift rename to Tests/TartTests/DirectoryShareTests.swift From 3bae4c62cf1b37c5c53036731a94aeba610b5bf8 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 18 May 2026 15:29:05 +0200 Subject: [PATCH 13/20] style: apply SwiftFormat indent to GuestDropSynthesis.swift Pre-existing 2-space indentation drift; no semantic change. Brings the file in line with the project's mandated SwiftFormat config. --- Sources/tart/GuestDropSynthesis.swift | 158 +++++++++++++------------- 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift index a3a5130..a3b3787 100644 --- a/Sources/tart/GuestDropSynthesis.swift +++ b/Sources/tart/GuestDropSynthesis.swift @@ -54,87 +54,87 @@ enum GuestDropSynthesis { /// Exit codes: 0 on success, non-zero on failure (caller treats any /// non-zero as "fall back to the share-folder copy that's already on disk"). private static let relocateAndRevealScript = #""" - set -e - src=$1 - norm_x=${2:-} - norm_y=${3:-} - if [ -z "$src" ] || [ ! -e "$src" ]; then - echo "tartdrop: missing or nonexistent source: $src" >&2 - exit 2 + set -e + src=$1 + norm_x=${2:-} + norm_y=${3:-} + if [ -z "$src" ] || [ ! -e "$src" ]; then + echo "tartdrop: missing or nonexistent source: $src" >&2 + exit 2 + fi + name=$(basename "$src") + src_dir=$(dirname "$src") + + # Resolve destination by where the drop landed. The agent runs in the + # user's GUI session so osascript goes through TCC (Automation → Finder). + # Suppress stderr so a denied prompt or "no windows" error doesn't pollute + # the agent log; an empty result means "drop on Desktop". + # The AppleScript body is piped to `osascript -` so we don't need a shell + # heredoc (whose terminator must sit at column 0 — incompatible with + # Swift's multi-line string indentation rules). + osa_script='on run argv + set nx to (item 1 of argv) as real + set ny to (item 2 of argv) as real + tell application "Finder" + set sb to bounds of window of desktop + set sw to (item 3 of sb) - (item 1 of sb) + set sh to (item 4 of sb) - (item 2 of sb) + set dx to (nx * sw) as integer + set dy to (ny * sh) as integer + try + set wins to every Finder window + repeat with i from 1 to count of wins + set w to item i of wins + set b to bounds of w + set L to (item 1 of b) as integer + set T to (item 2 of b) as integer + set R to (item 3 of b) as integer + set Bv to (item 4 of b) as integer + if (dx >= L) and (dx <= R) and (dy >= T) and (dy <= Bv) then + return POSIX path of ((target of w) as alias) + end if + end repeat + return "" + on error + return "" + end try + end tell + end run' + + dest_dir="" + if [ -n "$norm_x" ] && [ -n "$norm_y" ]; then + dest_dir=$(printf '%s' "$osa_script" \ + | /usr/bin/osascript - "$norm_x" "$norm_y" 2>/dev/null || true) + dest_dir=${dest_dir%/} + fi + + # Empty / nonexistent / non-writable / source's own parent → bare Desktop. + if [ -z "$dest_dir" ] || [ ! -d "$dest_dir" ] || [ ! -w "$dest_dir" ] || [ "$dest_dir" = "$src_dir" ]; then + dest_dir=$HOME/Desktop + fi + + # Pick a non-clobbering filename: "foo.txt" → "foo 2.txt", "foo 3.txt"… + final=$dest_dir/$name + if [ -e "$final" ]; then + stem=${name%.*} + ext=${name##*.} + if [ "$stem" = "$name" ]; then + i=2 + while [ -e "$dest_dir/$name $i" ]; do i=$((i+1)); done + final="$dest_dir/$name $i" + else + i=2 + while [ -e "$dest_dir/$stem $i.$ext" ]; do i=$((i+1)); done + final="$dest_dir/$stem $i.$ext" fi - name=$(basename "$src") - src_dir=$(dirname "$src") + fi - # Resolve destination by where the drop landed. The agent runs in the - # user's GUI session so osascript goes through TCC (Automation → Finder). - # Suppress stderr so a denied prompt or "no windows" error doesn't pollute - # the agent log; an empty result means "drop on Desktop". - # The AppleScript body is piped to `osascript -` so we don't need a shell - # heredoc (whose terminator must sit at column 0 — incompatible with - # Swift's multi-line string indentation rules). - osa_script='on run argv - set nx to (item 1 of argv) as real - set ny to (item 2 of argv) as real - tell application "Finder" - set sb to bounds of window of desktop - set sw to (item 3 of sb) - (item 1 of sb) - set sh to (item 4 of sb) - (item 2 of sb) - set dx to (nx * sw) as integer - set dy to (ny * sh) as integer - try - set wins to every Finder window - repeat with i from 1 to count of wins - set w to item i of wins - set b to bounds of w - set L to (item 1 of b) as integer - set T to (item 2 of b) as integer - set R to (item 3 of b) as integer - set Bv to (item 4 of b) as integer - if (dx >= L) and (dx <= R) and (dy >= T) and (dy <= Bv) then - return POSIX path of ((target of w) as alias) - end if - end repeat - return "" - on error - return "" - end try - end tell - end run' - - dest_dir="" - if [ -n "$norm_x" ] && [ -n "$norm_y" ]; then - dest_dir=$(printf '%s' "$osa_script" \ - | /usr/bin/osascript - "$norm_x" "$norm_y" 2>/dev/null || true) - dest_dir=${dest_dir%/} - fi - - # Empty / nonexistent / non-writable / source's own parent → bare Desktop. - if [ -z "$dest_dir" ] || [ ! -d "$dest_dir" ] || [ ! -w "$dest_dir" ] || [ "$dest_dir" = "$src_dir" ]; then - dest_dir=$HOME/Desktop - fi - - # Pick a non-clobbering filename: "foo.txt" → "foo 2.txt", "foo 3.txt"… - final=$dest_dir/$name - if [ -e "$final" ]; then - stem=${name%.*} - ext=${name##*.} - if [ "$stem" = "$name" ]; then - i=2 - while [ -e "$dest_dir/$name $i" ]; do i=$((i+1)); done - final="$dest_dir/$name $i" - else - i=2 - while [ -e "$dest_dir/$stem $i.$ext" ]; do i=$((i+1)); done - final="$dest_dir/$stem $i.$ext" - fi - fi - - mv -- "$src" "$final" - /usr/bin/open -R "$final" - # Last line of stdout is the destination folder's basename so the host - # can show "Copied to Desktop" / "Copied to Documents" in the toast. - printf 'tartdrop-dest=%s\n' "$(basename "$dest_dir")" - """# + mv -- "$src" "$final" + /usr/bin/open -R "$final" + # Last line of stdout is the destination folder's basename so the host + # can show "Copied to Desktop" / "Copied to Documents" in the toast. + printf 'tartdrop-dest=%s\n' "$(basename "$dest_dir")" + """# static func perform( controlSocketURL: URL, From 100fc1e8bc6460d1b011bb0d959801e6220c478a Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Tue, 19 May 2026 09:35:30 +0200 Subject: [PATCH 14/20] 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// 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. --- Sources/tart/Commands/Run.swift | 196 +++---------- Sources/tart/DropHandler.swift | 266 ++++++++++++++++++ Sources/tart/DropProgressCopier.swift | 157 +++++++++-- Sources/tart/DropProgressToast+Panel.swift | 6 +- Sources/tart/DropProgressToast.swift | 63 +++-- Sources/tart/GuestDropSynthesis.swift | 21 +- Tests/TartTests/DirectoryShareTests.swift | 20 ++ Tests/TartTests/DropProgressCopierTests.swift | 122 ++++++++ Tests/TartTests/GuestDropParseTests.swift | 44 +++ 9 files changed, 681 insertions(+), 214 deletions(-) create mode 100644 Sources/tart/DropHandler.swift create mode 100644 Tests/TartTests/DropProgressCopierTests.swift create mode 100644 Tests/TartTests/GuestDropParseTests.swift 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" + ) + } +} From c596672c5aca2f5a286a0b392df71db475bb8ad6 Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Tue, 19 May 2026 10:33:09 +0200 Subject: [PATCH 15/20] 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// 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. --- Sources/tart/DropHandler.swift | 161 ++++++++++++++++++++------------- 1 file changed, 99 insertions(+), 62 deletions(-) diff --git a/Sources/tart/DropHandler.swift b/Sources/tart/DropHandler.swift index 269ede0..0946b80 100644 --- a/Sources/tart/DropHandler.swift +++ b/Sources/tart/DropHandler.swift @@ -41,16 +41,24 @@ final class RelocationGate { } } -/// 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 +/// No promised file could be received (every provider errored or timed out). +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 -/// to relocate each file under the cursor. +/// to relocate them under the cursor. /// /// Design notes addressing prior edge cases: -/// - Each file gets its own `dropRoot//` subdir, so same-named files in +/// - Each item 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. +/// - Plain file/dir drags stream through `DropProgressCopier.copyTree`, which +/// 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 /// `RelocationGate`, bounding guest RPC/TCC pressure and making teardown /// safe. @@ -58,6 +66,11 @@ final class RelocationGate { /// - 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 enum Item { + case file(URL) + case promise(NSFilePromiseReceiver) + } + private let dropRoot: URL private let controlSocketURL: URL? private let isMacGuest: Bool @@ -82,42 +95,63 @@ final class DropHandler { ) { let cancelToken = DropCancellationToken() let box = WindowBox(parentWindow) + let items: [Item] = fileURLs.map(Item.file) + promiseReceivers.map(Item.promise) + guard !items.isEmpty else { return } 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 + let total = items.count - for (idx, src) in sources.enumerated() { + for (idx, item) in items.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 - ) + // Display name is known up front for both kinds; a promise provider + // may de-duplicate on write, so relocation uses the *actual* names. + let displayName: String + switch item { + case .file(let src): displayName = src.lastPathComponent + case .promise(let r): displayName = r.fileNames.first ?? "Dropped file" } do { try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true) - try DropProgressCopier.copyTree( - from: src, to: dest, totalBytes: totalBytes, token: cancelToken - ) { copied in + + 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.update( - copied: copied, total: totalBytes, - index: idx + 1, count: total, sessionID: sessionID + DropProgressToast.shared.begin( + parent: box.window, filename: displayName, totalBytes: totalBytes, + index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID ) } + 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 + ) + } + } + 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 @@ -129,7 +163,9 @@ final class DropHandler { sessionID: sessionID ) } - relocate(subdir: subdir, fileName: name, normalizedPoint: normalizedPoint, sessionID: sessionID) + for name in writtenNames { + relocate(subdir: subdir, fileName: name, normalizedPoint: normalizedPoint, sessionID: sessionID) + } } catch is DropCopyCancelled { try? FileManager.default.removeItem(at: subdir) DispatchQueue.main.async { @@ -137,10 +173,10 @@ final class DropHandler { } break } catch { - // copyTree already removed the partial output; drop the now-empty - // subdir too and remember the failure for one combined alert. + // copyTree already removed any partial output; drop the subdir too + // and remember the failure for one combined alert. try? FileManager.default.removeItem(at: subdir) - failures.append("\(name): \(error.localizedDescription)") + failures.append("\(displayName): \(error.localizedDescription)") DispatchQueue.main.async { DropProgressToast.shared.finish(success: false, sessionID: sessionID) } @@ -197,11 +233,16 @@ final class DropHandler { } 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. + // A successful relocation `mv`s the file out of the share (a cross-FS + // move that unlinks the host-side source). Reap the subdir only once + // 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 { - try? FileManager.default.removeItem(at: subdir) + let remaining = (try? FileManager.default.contentsOfDirectory(atPath: subdir.path)) ?? [] + if remaining.isEmpty { + try? FileManager.default.removeItem(at: subdir) + } } let resolved = folder DispatchQueue.main.async { @@ -212,48 +253,44 @@ final class DropHandler { // 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], + /// Receives `receiver`'s promised files *directly into* `subdir` (which is + /// already the shared drop-zone location), so the source app writes them + /// exactly once — no host-side staging-then-copy. Returns the URLs actually + /// written. Throws `DropCopyCancelled` if the user cancelled, or + /// `DropPromiseFailed` if the provider produced nothing. + private func receivePromise( + _ receiver: NSFilePromiseReceiver, + into subdir: URL, 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 [] - } - + ) throws -> [URL] { let opQueue = OperationQueue() let lock = NSLock() var urls: [URL] = [] + var firstError: Error? 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: staging, options: [:], operationQueue: opQueue) { url, error in - if error == nil { - lock.lock() - urls.append(url) - lock.unlock() - } - sem.signal() + receiver.receivePromisedFiles(atDestination: subdir, options: [:], operationQueue: opQueue) { url, error in + lock.lock() + if let error = error { + if firstError == nil { firstError = error } + } else { + 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. + // 30 s headroom per file for first-run providers (e.g. Photos exporting + // originals) while still failing fast if a provider never calls back. for _ in 0.. Date: Tue, 19 May 2026 10:59:44 +0200 Subject: [PATCH 16/20] feat: live byte progress and best-effort cancel for file-promise drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers two of the file-promise trade-offs (A + B from the review): A. Progress: poll bytes streamed into the (shared) per-item subdir while the opaque receivePromisedFiles runs, feeding the toast's live size readout instead of a dead spinner. Best-effort — providers that write a temp file and atomically rename only become visible at the end. B. Cancellation: DropCancellationToken gains an onCancel hook; the promise path cancels its operation queue and unblocks the wait loop on ⊗ instead of sitting on the 30 s timeout. Cooperative providers abort; either way we stop waiting and clean up at once. C (true determinate % via NSItemProvider.loadFileRepresentation) is intentionally NOT implemented: macOS AppKit drags don't vend NSItemProvider, and NSFilePromiseReceiver exposes no Progress/cancel surface, so a real percentage isn't reachable without an undocumented hack. The size poller is the honest ceiling. Also: capture the dispatch group locally in RelocationGate.drain to drop a Sendable-capture warning. Adds DropCancellationTokenTests. --- Sources/tart/DropCancellationToken.swift | 27 +++++++- Sources/tart/DropHandler.swift | 65 ++++++++++++++++--- .../DropCancellationTokenTests.swift | 47 ++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 Tests/TartTests/DropCancellationTokenTests.swift diff --git a/Sources/tart/DropCancellationToken.swift b/Sources/tart/DropCancellationToken.swift index 9cd594b..a0382e6 100644 --- a/Sources/tart/DropCancellationToken.swift +++ b/Sources/tart/DropCancellationToken.swift @@ -3,9 +3,14 @@ import Foundation /// Thread-safe cancellation flag for an in-flight host→guest copy. The toast /// holds one of these and flips it when the user clicks the close button; /// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`. +/// +/// `onCancel` lets callers react to cancellation imperatively — used by the +/// file-promise path, whose `receivePromisedFiles` API has no cancellation +/// parameter, to tear down its operation queue and unblock its wait loop. final class DropCancellationToken { private let lock = NSLock() private var _cancelled = false + private var handlers: [() -> Void] = [] var isCancelled: Bool { lock.lock() @@ -15,8 +20,28 @@ final class DropCancellationToken { func cancel() { lock.lock() - defer { lock.unlock() } + if _cancelled { + lock.unlock() + return + } _cancelled = true + let toRun = handlers + handlers = [] + lock.unlock() + toRun.forEach { $0() } + } + + /// Invoke `handler` as soon as the token is cancelled — immediately if it + /// already is. Handlers run once, outside the lock. + func onCancel(_ handler: @escaping () -> Void) { + lock.lock() + if _cancelled { + lock.unlock() + handler() + return + } + handlers.append(handler) + lock.unlock() } } diff --git a/Sources/tart/DropHandler.swift b/Sources/tart/DropHandler.swift index 0946b80..f315d30 100644 --- a/Sources/tart/DropHandler.swift +++ b/Sources/tart/DropHandler.swift @@ -32,9 +32,10 @@ final class RelocationGate { /// Wait up to `timeout` seconds for outstanding relocations. Bridged off /// the caller's actor so it never blocks the main thread. func drain(timeout: TimeInterval) async { + let group = self.group await withCheckedContinuation { (cont: CheckedContinuation) in DispatchQueue.global().async { - _ = self.group.wait(timeout: .now() + timeout) + _ = group.wait(timeout: .now() + timeout) cont.resume() } } @@ -142,16 +143,23 @@ final class DropHandler { writtenNames = [displayName] case .promise(let receiver): - // Promises carry no byte progress; totalBytes 0 → indeterminate - // bar while the source app writes straight into the share. + // The promise API exposes no total size, so the bar stays + // indeterminate; the detail line shows the live byte count we + // poll off the (shared) subdir as the source app streams in. DispatchQueue.main.async { DropProgressToast.shared.begin( parent: box.window, filename: displayName, totalBytes: 0, index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID ) } - writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken) - .map { $0.lastPathComponent } + writtenNames = try receivePromise(receiver, into: subdir, token: cancelToken) { copied in + DispatchQueue.main.async { + DropProgressToast.shared.update( + copied: copied, total: 0, + index: idx + 1, count: total, sessionID: sessionID + ) + } + }.map { $0.lastPathComponent } } let waiting = relocationPossible @@ -258,18 +266,54 @@ final class DropHandler { /// exactly once — no host-side staging-then-copy. Returns the URLs actually /// written. Throws `DropCopyCancelled` if the user cancelled, or /// `DropPromiseFailed` if the provider produced nothing. + /// + /// `progress` is fed the bytes streamed into `subdir` so far, polled while + /// the receive is in flight — the `NSFilePromiseReceiver` API itself + /// reports nothing until each file is fully written. Best-effort: a + /// provider that writes to a temp path and atomically renames into place + /// only becomes visible at the end. private func receivePromise( _ receiver: NSFilePromiseReceiver, into subdir: URL, - token: DropCancellationToken + token: DropCancellationToken, + progress: @escaping (Int64) -> Void ) throws -> [URL] { let opQueue = OperationQueue() let lock = NSLock() var urls: [URL] = [] var firstError: Error? + var polling = true let sem = DispatchSemaphore(value: 0) let expected = max(1, receiver.fileNames.count) + // B: receivePromisedFiles has no cancellation parameter. On ⊗, cancel the + // operation queue (cooperative providers honor it) and wake the wait loop + // immediately instead of sitting on the 30 s timeout. + token.onCancel { + opQueue.cancelAllOperations() + for _ in 0.. Date: Wed, 20 May 2026 09:46:08 +0200 Subject: [PATCH 17/20] fix: stop DropProgressCopier from tripping Swift's exclusivity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inner `report` closure captured `copied` from the enclosing scope, but `copyFile`/`copyInto` hold the same variable as `inout` while running — so the next progress callback overlapped the still-active inout access and aborted the process with a fatal access conflict mid-drop. Pass the value in instead of capturing it. --- Sources/tart/DropProgressCopier.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/tart/DropProgressCopier.swift b/Sources/tart/DropProgressCopier.swift index 96fb5b9..83b5478 100644 --- a/Sources/tart/DropProgressCopier.swift +++ b/Sources/tart/DropProgressCopier.swift @@ -61,10 +61,14 @@ enum DropProgressCopier { var lastReport = Date(timeIntervalSince1970: 0) let reportInterval: TimeInterval = 0.05 - func report(force: Bool) { + // `copiedSoFar` is passed in rather than captured: while `copyFile` / + // `copyInto` hold `copied` as `inout`, any closure that *also* captured + // `copied` would trip Swift's runtime exclusivity check on the next + // progress callback. + func report(_ copiedSoFar: Int64, force: Bool) { let now = Date() if force || now.timeIntervalSince(lastReport) >= reportInterval { - progress(copied) + progress(copiedSoFar) lastReport = now } } @@ -89,7 +93,7 @@ enum DropProgressCopier { try copyFile(src, dst, &copied, token, report) } - report(force: true) + report(copied, force: true) } catch { try? fm.removeItem(at: dst) throw error @@ -103,7 +107,7 @@ enum DropProgressCopier { _ dst: URL, _ copied: inout Int64, _ token: DropCancellationToken, - _ report: (Bool) -> Void + _ report: (Int64, Bool) -> Void ) throws { let fm = FileManager.default let values = try? src.resourceValues(forKeys: walkKeySet) @@ -132,7 +136,7 @@ enum DropProgressCopier { _ dst: URL, _ copied: inout Int64, _ token: DropCancellationToken, - _ report: (Bool) -> Void + _ report: (Int64, Bool) -> Void ) throws { let fm = FileManager.default guard fm.createFile(atPath: dst.path, contents: nil) else { @@ -158,7 +162,7 @@ enum DropProgressCopier { if chunk.isEmpty { break } try output.write(contentsOf: chunk) copied += Int64(chunk.count) - report(false) + report(copied, false) } } } From daed6eee825e3ff9e6429b96c03f49a4fbde569f Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Wed, 20 May 2026 10:36:49 +0200 Subject: [PATCH 18/20] fix: parse CRLF stdout from guest agent in GuestDropParseTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split(whereSeparator: { $0 == "\n" || $0 == "\r" }) never matches CRLF because Swift treats "\r\n" as a single Character (grapheme cluster U+000D, U+000A), so the closure — which compares against the single-codepoint characters "\n" and "\r" — fires for neither. With CRLF input the entire stdout becomes one "line" that doesn't have the tartdrop-dest= prefix, and the parser returns nil. Switch to String.enumerateLines, which handles LF, CR, and CRLF. --- Sources/tart/GuestDropSynthesis.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift index c29ca76..83dd83a 100644 --- a/Sources/tart/GuestDropSynthesis.swift +++ b/Sources/tart/GuestDropSynthesis.swift @@ -239,8 +239,10 @@ enum GuestDropSynthesis { /// 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? { + // enumerateLines handles LF, CR, and CRLF — split(whereSeparator:) misses + // CRLF because Swift treats "\r\n" as a single Character. var folderName: String? - for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) { + stdout.enumerateLines { line, _ in if line.hasPrefix("tartdrop-dest=") { folderName = String(line.dropFirst("tartdrop-dest=".count)) } From 2fb91b61cbae33375f619762dc1dfcddc210d2ec Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 8 Jun 2026 09:29:12 +0200 Subject: [PATCH 20/20] fix(drop): propagate directory-read failures during drop copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `copyTree`/`copyInto` treated `contentsOfDirectory` errors as an empty listing (`try? … ?? []`), so a permission/I/O failure inside a dropped folder was silently ignored and the drop reported success — producing an incomplete copy in the guest with no warning (data-lossy for users who expect the whole folder to transfer). Let the read error propagate instead. copyTree's do/catch already removes the partial `dst` and rethrows, so an unreadable subtree now aborts the drop and cleans up rather than half-completing. Adds a regression test covering an unreadable nested directory. --- Sources/tart/DropProgressCopier.swift | 15 +++++--- Tests/TartTests/DropProgressCopierTests.swift | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/Sources/tart/DropProgressCopier.swift b/Sources/tart/DropProgressCopier.swift index 83b5478..0da8318 100644 --- a/Sources/tart/DropProgressCopier.swift +++ b/Sources/tart/DropProgressCopier.swift @@ -81,9 +81,13 @@ enum DropProgressCopier { 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( + // Surface (don't swallow) read failures: a permission/I/O error here + // means an incomplete copy, so let it propagate to the do/catch below, + // which removes the partial `dst` and rethrows rather than reporting a + // silent success. + 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) @@ -113,9 +117,12 @@ enum DropProgressCopier { let values = try? src.resourceValues(forKeys: walkKeySet) if values?.isDirectory == true { try fm.createDirectory(at: dst, withIntermediateDirectories: true) - let children = (try? fm.contentsOfDirectory( + // Propagate read failures so an unreadable nested directory aborts the + // drop (cleaned up by copyTree's catch) instead of silently producing a + // partial tree in the guest. + 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) diff --git a/Tests/TartTests/DropProgressCopierTests.swift b/Tests/TartTests/DropProgressCopierTests.swift index 8cf8e78..2b77aba 100644 --- a/Tests/TartTests/DropProgressCopierTests.swift +++ b/Tests/TartTests/DropProgressCopierTests.swift @@ -119,4 +119,38 @@ final class DropProgressCopierTests: XCTestCase { let src = try write("sized.bin", 4242) XCTAssertEqual(DropProgressCopier.totalSize(of: src), 4242) } + + func testUnreadableNestedDirectoryThrowsAndRemovesPartial() throws { + // A nested directory whose contents can't be listed (permission denied) + // must abort the copy and clean up — not silently produce a partial tree + // in the guest and report success. + try XCTSkipIf(getuid() == 0, "root bypasses POSIX permissions; can't exercise the denied-read path") + + let srcDir = tmp.appendingPathComponent("locked-bundle", isDirectory: true) + let sealed = srcDir.appendingPathComponent("sealed", isDirectory: true) + try FileManager.default.createDirectory(at: sealed, withIntermediateDirectories: true) + try Data(repeating: 0x41, count: 10).write(to: srcDir.appendingPathComponent("a.txt")) + try Data(repeating: 0x41, count: 20).write(to: sealed.appendingPathComponent("secret.txt")) + + // Drop read/exec on the nested dir so contentsOfDirectory(at:) fails. + // Restore perms before the suite's tmp teardown so cleanup can recurse in + // (teardown blocks run LIFO, so this runs before the setUp cleanup). + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: sealed.path) + addTeardownBlock { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: sealed.path) + } + + let dst = tmp.appendingPathComponent("locked-copy", isDirectory: true) + XCTAssertThrowsError( + try DropProgressCopier.copyTree( + from: srcDir, to: dst, totalBytes: 30, token: DropCancellationToken() + ) { _ in } + ) { error in + XCTAssertFalse(error is DropCopyCancelled, "should surface the read error, not a cancellation") + } + XCTAssertFalse( + FileManager.default.fileExists(atPath: dst.path), + "an unreadable nested directory must not leave a partial tree in the drop zone" + ) + } }