mirror of https://github.com/cirruslabs/tart.git
Merge 2fb91b61cb into 6ada2b955d
This commit is contained in:
commit
4a22d3cb69
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <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
|
||||
}
|
||||
}
|
||||
|
|
@ -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=<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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue