This commit is contained in:
Dal Rupnik 2026-06-08 09:29:19 +02:00 committed by GitHub
commit 4a22d3cb69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2062 additions and 115 deletions

View File

@ -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-<semver>` 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
}

View File

@ -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)

View File

@ -0,0 +1,51 @@
import Foundation
/// Thread-safe cancellation flag for an in-flight hostguest 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 {}

View File

@ -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<Void, Never>) 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/<uuid>/` 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..<expected { sem.signal() }
}
// A: poll bytes-on-disk so the toast shows a live, honest size during the
// opaque receive. Stops via `polling` on any exit (the defer below).
let pollQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-promise-poll")
func schedulePoll() {
pollQueue.asyncAfter(deadline: .now() + 0.15) {
lock.lock()
let go = polling
lock.unlock()
guard go else { return }
progress(DropProgressCopier.totalSize(of: subdir))
schedulePoll()
}
}
schedulePoll()
defer {
lock.lock()
polling = false
lock.unlock()
}
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 per file for first-run providers (e.g. Photos exporting
// originals) while still failing fast if a provider never calls back.
for _ in 0..<expected {
if token.isCancelled { throw DropCopyCancelled() }
if sem.wait(timeout: .now() + 30) == .timedOut { break }
}
if token.isCancelled { throw DropCopyCancelled() }
lock.lock()
let result = urls
let err = firstError
lock.unlock()
if result.isEmpty { throw err ?? DropPromiseFailed() }
return result
}
}
/// Carries an `NSWindow` (main-thread-only) through the background copy
/// closure. It is only ever read back on the main thread.
private final class WindowBox: @unchecked Sendable {
let window: NSWindow?
init(_ window: NSWindow?) { self.window = window }
}

View File

@ -0,0 +1,175 @@
import Foundation
/// Chunked file/directory copy with throttled progress callbacks and
/// cancellation. Used by the drag-and-drop handler to feed `DropProgressToast`
/// without freezing the VM render view.
///
/// - `copyTree` handles both regular files and directories (folders, `.app`
/// bundles, packages) directories are walked depth-first and every
/// regular file inside is streamed through the same chunked path.
/// - Any pre-existing item at `dst` is removed first (drops semantically
/// replace).
/// - Polls `token.isCancelled` between chunks; throws `DropCopyCancelled`
/// immediately on cancel.
/// - `progress(copiedSoFar)` is reported at most once every ~50 ms (cumulative
/// across the whole tree), plus a guaranteed final call so the bar always
/// reaches 100%.
/// - On any throw (I/O error or cancellation) the partial output at `dst` is
/// removed so a truncated file never becomes visible to the guest.
enum DropProgressCopier {
private static let walkKeys: [URLResourceKey] = [.isDirectoryKey, .isRegularFileKey]
private static let walkKeySet: Swift.Set<URLResourceKey> = [.isDirectoryKey, .isRegularFileKey]
/// Total byte size of `url`: the file size for a regular file, or the
/// recursive sum of regular-file sizes for a directory. Best-effort
/// unreadable entries contribute 0 (the bar just runs a touch fast).
static func totalSize(of url: URL) -> Int64 {
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return 0 }
if !isDir.boolValue {
return ((try? fm.attributesOfItem(atPath: url.path)[.size]) as? Int64) ?? 0
}
var total: Int64 = 0
if let en = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) {
for case let child as URL in en {
let v = try? child.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
if v?.isRegularFile == true { total += Int64(v?.fileSize ?? 0) }
}
}
return total
}
/// Copy `src` (file or directory) to `dst`. On any error the partial `dst`
/// is cleaned up before rethrowing, so callers never leave a half-written
/// item in the drop zone.
static func copyTree(
from src: URL,
to dst: URL,
totalBytes: Int64,
token: DropCancellationToken,
progress: (Int64) -> Void
) throws {
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)
}
}
}

View File

@ -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))"
}
}

View File

@ -0,0 +1,219 @@
import AppKit
import Foundation
/// Borderless HUD-style panel shown over the VM window during a hostguest
/// 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 <folder>" 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 <folder>" so the delayed apply uses it; if the apply
/// already fired (relocation was slow), patches the label directly. No-op
/// if the panel already hid 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 <folder>".
///
/// When `awaitingRelocation` is true the file copied locally but the guest
/// agent is still being asked where it should land; we keep the toast up on
/// a long fallback timer (longer than the RPC's 5 s deadline) and let
/// `setFinalDestination` drive the real, short hide. That way the user
/// always sees the final destination instead of the toast vanishing at 2 s
/// while a slow/again-prompting agent is still working.
func finish(
success: Bool,
destinationFolder: String? = nil,
cancelled: Bool = false,
awaitingRelocation: Bool = false,
sessionID: Int
) {
guard sessionID == currentSessionID else { return }
guard let panel = panel else { return }
cancelButton.isEnabled = false
currentToken = nil
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 <folder>" 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
}
}

View File

@ -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
/// AutomationFinder 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=<basename>` line the script prints after `mv`
/// out of the agent's stdout. Tolerates other stdout (a future agent build
/// might add a banner) by scanning lines instead of demanding an exact
/// match, and takes the *last* such line so a trailing real value wins.
/// Returns nil when no non-empty value is present. Pure unit-tested.
static func parseDestinationFolder(stdout: String) -> String? {
// 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
}
}

View File

@ -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

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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"
)
}
}

View File

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