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 2e89590..342f078 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -95,6 +95,17 @@ 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 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) @Flag(help: "Boot into recovery mode") #endif @@ -408,11 +419,34 @@ 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 + // 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 + } + } + 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, @@ -456,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 @@ -525,6 +570,10 @@ 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) } catch { @@ -533,6 +582,8 @@ struct Run: AsyncParsableCommand { fputs("\(error)\n", stderr) + await RelocationGate.shared.drain(timeout: 6) + cleanupDropZone() OTel.shared.flush() Foundation.exit(1) } @@ -593,7 +644,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 +651,12 @@ struct Run: AsyncParsableCommand { NSApplication.shared.run() } else { - runUI(suspendable, captureSystemKeys) + runUI( + suspendable, + captureSystemKeys, + dropZoneURL: dropZoneURL, + controlSocketURL: dropZoneURL != nil ? vmDir.controlSocketURL : nil + ) } } @@ -682,8 +737,10 @@ struct Run: AsyncParsableCommand { } } - func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] { - if dir.isEmpty { + func directoryShares(dropZoneURL: URL? = nil) throws -> [VZDirectorySharingDeviceConfiguration] { + let allDirectoryShares = try DirectoryShare.collect(dirArgs: dir, dropZoneURL: dropZoneURL) + + if allDirectoryShares.isEmpty { return [] } @@ -691,12 +748,6 @@ 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 let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag) @@ -711,6 +762,12 @@ struct Run: AsyncParsableCommand { 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.") + } throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named") } else { var directories: [String : VZSharedDirectory] = Dictionary() @@ -751,41 +808,55 @@ struct Run: AsyncParsableCommand { #endif } - private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) { + 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() } } +// MARK: - VM window content + +/// 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) + .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 + static var controlSocketURL: 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 +938,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 +957,209 @@ 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 + +/// 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. +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 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 var dropHandler: DropHandler? + + 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 + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + 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 || !promiseReceivers(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 dropHandler = dropHandler else { return false } + let urls = fileURLs(from: sender) + 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 + // 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 + ) + + dropHandler.handle( + fileURLs: urls, + promiseReceivers: promises, + normalizedPoint: normalizedPoint, + parentWindow: self.window + ) + return true + } + + private func fileURLs(from info: NSDraggingInfo) -> [URL] { + info.draggingPasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL] ?? [] + } + + private func promiseReceivers(from info: NSDraggingInfo) -> [NSFilePromiseReceiver] { + info.draggingPasteboard.readObjects( + forClasses: [NSFilePromiseReceiver.self], + options: nil + ) as? [NSFilePromiseReceiver] ?? [] } } @@ -1032,6 +1301,41 @@ 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 + } + + // 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 { + // "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, + readOnly: false, + mountTag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag + )) + } + return result + } + init(parseFrom: String) throws { var parseFrom = parseFrom @@ -1158,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/DropCancellationToken.swift b/Sources/tart/DropCancellationToken.swift new file mode 100644 index 0000000..a0382e6 --- /dev/null +++ b/Sources/tart/DropCancellationToken.swift @@ -0,0 +1,51 @@ +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() + defer { lock.unlock() } + return _cancelled + } + + func cancel() { + lock.lock() + 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() + } +} + +/// 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/DropHandler.swift b/Sources/tart/DropHandler.swift new file mode 100644 index 0000000..f315d30 --- /dev/null +++ b/Sources/tart/DropHandler.swift @@ -0,0 +1,350 @@ +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 { + let group = self.group + await withCheckedContinuation { (cont: CheckedContinuation) in + DispatchQueue.global().async { + _ = group.wait(timeout: .now() + timeout) + cont.resume() + } + } + } +} + +/// 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 them under the cursor. +/// +/// Design notes addressing prior edge cases: +/// - Each item gets its own `dropRoot//` subdir, so same-named files in +/// one gesture (or rapid re-drops) never collide on the share path. +/// - 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. +/// - 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 enum Item { + case file(URL) + case promise(NSFilePromiseReceiver) + } + + 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) + let items: [Item] = fileURLs.map(Item.file) + promiseReceivers.map(Item.promise) + guard !items.isEmpty else { return } + + copyQueue.async { [self] in + var failures: [String] = [] + let total = items.count + + for (idx, item) in items.enumerated() { + if cancelToken.isCancelled { break } + + let sessionID = DropSession.next() + let subdir = dropRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + // 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) + + let writtenNames: [String] + switch item { + case .file(let src): + let dest = subdir.appendingPathComponent(displayName) + let totalBytes = DropProgressCopier.totalSize(of: src) + DispatchQueue.main.async { + DropProgressToast.shared.begin( + parent: box.window, filename: displayName, totalBytes: totalBytes, + index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID + ) + } + try DropProgressCopier.copyTree( + 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): + // 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) { 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 + DispatchQueue.main.async { + DropProgressToast.shared.finish( + success: true, + destinationFolder: waiting ? nil : "the shared folder", + awaitingRelocation: waiting, + 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 { + DropProgressToast.shared.finish(success: false, cancelled: true, sessionID: sessionID) + } + break + } catch { + // 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("\(displayName): \(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() + + // 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 { + let remaining = (try? FileManager.default.contentsOfDirectory(atPath: subdir.path)) ?? [] + if remaining.isEmpty { + 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, …) + + /// 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. + /// + /// `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, + 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.. = [.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 { + let fm = FileManager.default + if fm.fileExists(atPath: dst.path) { + try fm.removeItem(at: dst) + } + + var copied: Int64 = 0 + var lastReport = Date(timeIntervalSince1970: 0) + let reportInterval: TimeInterval = 0.05 + + // `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(copiedSoFar) + 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. + // 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) + try copyInto(child, childDst, &copied, token, report) + } + } else { + try copyFile(src, dst, &copied, token, report) + } + + report(copied, 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: (Int64, 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) + // 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) + } + } 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: (Int64, Bool) -> Void + ) throws { + let fm = FileManager.default + guard fm.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. + 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) + report(copied, false) + } + } +} diff --git a/Sources/tart/DropProgressToast+Panel.swift b/Sources/tart/DropProgressToast+Panel.swift new file mode 100644 index 0000000..7ce08c9 --- /dev/null +++ b/Sources/tart/DropProgressToast+Panel.swift @@ -0,0 +1,156 @@ +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 prefix = count > 1 ? "[\(index)/\(count)] " : "" + // 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 new file mode 100644 index 0000000..6ab7cb1 --- /dev/null +++ b/Sources/tart/DropProgressToast.swift @@ -0,0 +1,219 @@ +import AppKit +import Foundation + +/// 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 +/// 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. +/// +/// 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`. +final class DropProgressToast { + static let shared = DropProgressToast() + + var panel: NSPanel? + var progressBar: NSProgressIndicator! + var titleLabel: NSTextField! + var detailLabel: NSTextField! + var cancelButton: NSButton! + private var hideWorkItem: DispatchWorkItem? + 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 + + /// 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. + 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. `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, + sessionID: Int + ) { + ensurePanel() + hideWorkItem?.cancel() + hideWorkItem = nil + + let wasAlreadyVisible = (panel?.isVisible == true) + + anchorWindow = parent + currentToken = cancelToken + currentSessionID = sessionID + + 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 + progressBar.minValue = 0 + progressBar.maxValue = Double(totalBytes) + progressBar.doubleValue = 0 + } else { + progressBar.isIndeterminate = true + } + progressBar.startAnimation(nil) + + guard let panel = panel else { return } + if let parent = parent { + // 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 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 + progressBar.doubleValue = Double(min(copied, total)) + } + 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 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 { + 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 ⊗. `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 + progressBar.stopAnimation(nil) + + let hideDelay: TimeInterval + if success { + progressBar.isIndeterminate = false + progressBar.doubleValue = progressBar.maxValue + // NSProgressIndicator has an undocumented ~0.3 s smooth-fill animation + // 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" + } + 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 + } + // 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 + } else { + detailLabel.stringValue = "Copy failed" + hideDelay = 0.8 + } + + let work = DispatchWorkItem { [weak self] in + self?.hide() + } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + hideDelay, execute: work) + _ = panel + } + + @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() + cancelButton.isEnabled = false + detailLabel.stringValue = "Cancelling…" + } + + private func hide() { + guard let panel = panel else { return } + if let parent = panel.parent { + 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 + } +} diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift new file mode 100644 index 0000000..83dd83a --- /dev/null +++ b/Sources/tart/GuestDropSynthesis.swift @@ -0,0 +1,253 @@ +import Foundation +import NIOPosix +import GRPC +import Cirruslabs_TartGuestAgent_Apple_Swift +import Cirruslabs_TartGuestAgent_Grpc_Swift + +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 +/// 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. 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 + 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 + 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")" + """# + + static func perform( + controlSocketURL: URL, + guestFilePath: String, + normalizedDropPoint: CGPoint? = nil + ) 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() } + + // 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() + + // 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] + scriptArgs + $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) + } + + 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? { + // enumerateLines handles LF, CR, and CRLF — split(whereSeparator:) misses + // CRLF because Swift treats "\r\n" as a single Character. + var folderName: String? + stdout.enumerateLines { line, _ in + if line.hasPrefix("tartdrop-dest=") { + folderName = String(line.dropFirst("tartdrop-dest=".count)) + } + } + guard let dest = folderName, !dest.isEmpty else { return nil } + return 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 diff --git a/Tests/TartTests/DirecotryShareTests.swift b/Tests/TartTests/DirecotryShareTests.swift deleted file mode 100644 index 062da00..0000000 --- a/Tests/TartTests/DirecotryShareTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import XCTest -@testable import tart - -import Virtualization - -final class DirectoryShareTests: XCTestCase { - func testNamedParsing() throws { - let share = try DirectoryShare(parseFrom: "build:/Users/admin/build") - XCTAssertEqual(share.name, "build") - XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) - XCTAssertFalse(share.readOnly) - } - - func testNamedReadOnlyParsing() throws { - let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro") - XCTAssertEqual(share.name, "build") - XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) - XCTAssertTrue(share.readOnly) - } - - func testOptionalNameParsing() throws { - let share = try DirectoryShare(parseFrom: "/Users/admin/build") - XCTAssertNil(share.name) - XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) - XCTAssertFalse(share.readOnly) - } - - func testOptionalNameReadOnlyParsing() throws { - let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro") - XCTAssertNil(share.name) - XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) - XCTAssertTrue(share.readOnly) - } - - func testMountTagParsing() throws { - let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar") - XCTAssertNil(share.name) - XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) - XCTAssertFalse(share.readOnly) - XCTAssertEqual(share.mountTag, "foo-bar") - - let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar") - XCTAssertNil(roShare.name) - XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build")) - XCTAssertTrue(roShare.readOnly) - XCTAssertEqual(roShare.mountTag, "foo-bar") - - let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro") - XCTAssertNil(inverseRoShare.name) - XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build")) - XCTAssertTrue(inverseRoShare.readOnly) - XCTAssertEqual(inverseRoShare.mountTag, "foo-bar") - } - - func testURL() throws { - let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz") - XCTAssertNil(archiveWithoutNameOrOptions.name) - XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!) - XCTAssertFalse(archiveWithoutNameOrOptions.readOnly) - XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag) - - let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag") - XCTAssertNil(archiveWithOptions.name) - XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!) - XCTAssertTrue(archiveWithOptions.readOnly) - XCTAssertEqual(archiveWithOptions.mountTag, "sometag") - - let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag") - XCTAssertEqual(archiveWithNameAndOptions.name, "somename") - XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!) - XCTAssertTrue(archiveWithNameAndOptions.readOnly) - XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag") - } -} diff --git a/Tests/TartTests/DirectoryShareTests.swift b/Tests/TartTests/DirectoryShareTests.swift new file mode 100644 index 0000000..7aee5a3 --- /dev/null +++ b/Tests/TartTests/DirectoryShareTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import tart + +import Virtualization + +final class DirectoryShareTests: XCTestCase { + func testNamedParsing() throws { + let share = try DirectoryShare(parseFrom: "build:/Users/admin/build") + XCTAssertEqual(share.name, "build") + XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) + XCTAssertFalse(share.readOnly) + } + + func testNamedReadOnlyParsing() throws { + let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro") + XCTAssertEqual(share.name, "build") + XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) + XCTAssertTrue(share.readOnly) + } + + func testOptionalNameParsing() throws { + let share = try DirectoryShare(parseFrom: "/Users/admin/build") + XCTAssertNil(share.name) + XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) + XCTAssertFalse(share.readOnly) + } + + func testOptionalNameReadOnlyParsing() throws { + let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro") + XCTAssertNil(share.name) + XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) + XCTAssertTrue(share.readOnly) + } + + func testMountTagParsing() throws { + let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar") + XCTAssertNil(share.name) + XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) + XCTAssertFalse(share.readOnly) + XCTAssertEqual(share.mountTag, "foo-bar") + + let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar") + XCTAssertNil(roShare.name) + XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build")) + XCTAssertTrue(roShare.readOnly) + XCTAssertEqual(roShare.mountTag, "foo-bar") + + let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro") + XCTAssertNil(inverseRoShare.name) + XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build")) + XCTAssertTrue(inverseRoShare.readOnly) + 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) + XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!) + XCTAssertFalse(archiveWithoutNameOrOptions.readOnly) + XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag) + + let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag") + XCTAssertNil(archiveWithOptions.name) + XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!) + XCTAssertTrue(archiveWithOptions.readOnly) + XCTAssertEqual(archiveWithOptions.mountTag, "sometag") + + let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag") + XCTAssertEqual(archiveWithNameAndOptions.name, "somename") + XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!) + 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/DropCancellationTokenTests.swift b/Tests/TartTests/DropCancellationTokenTests.swift new file mode 100644 index 0000000..ca52d77 --- /dev/null +++ b/Tests/TartTests/DropCancellationTokenTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import tart + +final class DropCancellationTokenTests: XCTestCase { + func testFlagFlips() { + let t = DropCancellationToken() + XCTAssertFalse(t.isCancelled) + t.cancel() + XCTAssertTrue(t.isCancelled) + } + + func testOnCancelRunsWhenCancelled() { + let t = DropCancellationToken() + var fired = 0 + t.onCancel { fired += 1 } + XCTAssertEqual(fired, 0, "must not fire before cancel") + t.cancel() + XCTAssertEqual(fired, 1) + } + + func testOnCancelRunsImmediatelyIfAlreadyCancelled() { + let t = DropCancellationToken() + t.cancel() + var fired = 0 + t.onCancel { fired += 1 } + XCTAssertEqual(fired, 1, "late handler must run at once on an already-cancelled token") + } + + func testCancelIsIdempotentAndHandlersRunOnce() { + let t = DropCancellationToken() + var fired = 0 + t.onCancel { fired += 1 } + t.cancel() + t.cancel() + XCTAssertEqual(fired, 1, "handlers must run exactly once across repeated cancels") + } + + func testMultipleHandlersAllRun() { + let t = DropCancellationToken() + var a = false + var b = false + t.onCancel { a = true } + t.onCancel { b = true } + t.cancel() + XCTAssertTrue(a && b) + } +} 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 + } +} diff --git a/Tests/TartTests/DropProgressCopierTests.swift b/Tests/TartTests/DropProgressCopierTests.swift new file mode 100644 index 0000000..2b77aba --- /dev/null +++ b/Tests/TartTests/DropProgressCopierTests.swift @@ -0,0 +1,156 @@ +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) + } + + 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" + ) + } +} 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" + ) + } +}