mirror of https://github.com/cirruslabs/tart.git
feat: Implement drag and drop functionality
This commit is contained in:
parent
5287b597a1
commit
8546f07ea8
|
|
@ -3,6 +3,7 @@ import Cocoa
|
|||
import Darwin
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import Virtualization
|
||||
import OpenTelemetryApi
|
||||
import System
|
||||
|
|
@ -95,6 +96,11 @@ struct Run: AsyncParsableCommand {
|
|||
discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
|
||||
var noClipboard: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Disable drag and drop support in the built-in UI.",
|
||||
discussion: "When drag and drop is enabled (the default), files dragged onto the VM window are copied to a shared directory. macOS guests can access the dropped files at /Volumes/My Shared Files/Dropped Files/."))
|
||||
var noDragAndDrop: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
#endif
|
||||
|
|
@ -408,11 +414,27 @@ struct Run: AsyncParsableCommand {
|
|||
// Parse root disk options
|
||||
let diskOptions = DiskOptions(rootDiskOpts)
|
||||
|
||||
// Determine whether the built-in GUI window will be shown
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
let willShowBuiltInUI = !noGraphics && !useVNCWithoutGraphics
|
||||
|
||||
// Create a temporary drop zone directory that is shared with the VM and used
|
||||
// as the destination for files dragged onto the built-in UI window.
|
||||
// The directory lives in ~/.tart/tmp/ and will be cleaned up by tart's GC.
|
||||
var dropZoneURL: URL? = nil
|
||||
if willShowBuiltInUI && !noDragAndDrop {
|
||||
if #available(macOS 13, *) {
|
||||
let dropDir = try Config().tartTmpDir.appendingPathComponent("dropzone-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dropDir, withIntermediateDirectories: true)
|
||||
dropZoneURL = dropDir
|
||||
}
|
||||
}
|
||||
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalStorageDevices: try additionalDiskAttachments(),
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
directorySharingDevices: directoryShares(dropZoneURL: dropZoneURL) + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
nested: nested,
|
||||
|
|
@ -593,7 +615,6 @@ struct Run: AsyncParsableCommand {
|
|||
}
|
||||
sigusr2Src.activate()
|
||||
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
// Enter the main event loop without bringing up any UI,
|
||||
// waiting for the VM to exit.
|
||||
|
|
@ -601,7 +622,7 @@ struct Run: AsyncParsableCommand {
|
|||
|
||||
NSApplication.shared.run()
|
||||
} else {
|
||||
runUI(suspendable, captureSystemKeys)
|
||||
runUI(suspendable, captureSystemKeys, dropZoneURL: dropZoneURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -682,8 +703,30 @@ struct Run: AsyncParsableCommand {
|
|||
}
|
||||
}
|
||||
|
||||
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
if dir.isEmpty {
|
||||
func directoryShares(dropZoneURL: URL? = nil) throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
var allDirectoryShares: [DirectoryShare] = []
|
||||
|
||||
for rawDir in dir {
|
||||
allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir))
|
||||
}
|
||||
|
||||
// Append the drag-and-drop drop zone as an unnamed share so that when it is
|
||||
// the only share on the automount tag it becomes a VZSingleDirectoryShare and
|
||||
// its contents appear directly at /Volumes/My Shared Files/ in the guest.
|
||||
// When merged with other --dir shares the auto-naming logic below gives it
|
||||
// the friendly name "Dropped Files".
|
||||
if let dropZoneURL = dropZoneURL {
|
||||
if #available(macOS 13, *) {
|
||||
allDirectoryShares.append(DirectoryShare(
|
||||
name: nil,
|
||||
path: dropZoneURL,
|
||||
readOnly: false,
|
||||
mountTag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if allDirectoryShares.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
|
|
@ -691,30 +734,38 @@ struct Run: AsyncParsableCommand {
|
|||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
var allDirectoryShares: [DirectoryShare] = []
|
||||
|
||||
for rawDir in dir {
|
||||
allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir))
|
||||
}
|
||||
|
||||
return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in
|
||||
return try Dictionary(grouping: allDirectoryShares, by: { $0.mountTag }).map { mountTag, shares in
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag)
|
||||
|
||||
var allNamedShares = true
|
||||
for directoryShare in directoryShares {
|
||||
if directoryShare.name == nil {
|
||||
allNamedShares = false
|
||||
let allNamed = shares.allSatisfy { $0.name != nil }
|
||||
|
||||
if shares.count == 1 && !allNamed {
|
||||
// Single unnamed share: expose its contents directly at the mount root
|
||||
sharingDevice.share = VZSingleDirectoryShare(directory: try shares[0].createConfiguration())
|
||||
} else if !allNamed {
|
||||
// Multiple shares with some unnamed: only allow if exactly one is unnamed
|
||||
// (happens when an unnamed --dir share is combined with the named drop zone)
|
||||
let unnamedCount = shares.filter { $0.name == nil }.count
|
||||
if unnamedCount > 1 {
|
||||
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||
}
|
||||
}
|
||||
if directoryShares.count == 1 && directoryShares.first!.name == nil {
|
||||
let directoryShare = directoryShares.first!
|
||||
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||
sharingDevice.share = singleDirectoryShare
|
||||
} else if !allNamedShares {
|
||||
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||
// Auto-name the single unnamed share. The drop zone path ends with
|
||||
// "dropzone-<UUID>" so give it the user-friendly name "Dropped Files";
|
||||
// all other unnamed shares get their directory's last path component.
|
||||
var directories: [String: VZSharedDirectory] = [:]
|
||||
for share in shares {
|
||||
let autoName = share.path.lastPathComponent.hasPrefix("dropzone-")
|
||||
? "Dropped Files"
|
||||
: share.path.lastPathComponent
|
||||
directories[share.name ?? autoName] = try share.createConfiguration()
|
||||
}
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
} else {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
|
||||
// All named: create a multi-directory share
|
||||
var directories: [String: VZSharedDirectory] = [:]
|
||||
for share in shares {
|
||||
directories[share.name!] = try share.createConfiguration()
|
||||
}
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
}
|
||||
|
||||
|
|
@ -751,41 +802,52 @@ struct Run: AsyncParsableCommand {
|
|||
#endif
|
||||
}
|
||||
|
||||
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool, dropZoneURL: URL?) {
|
||||
MainApp.suspendable = suspendable
|
||||
MainApp.capturesSystemKeys = captureSystemKeys
|
||||
MainApp.dropZoneURL = dropZoneURL
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VM window content with drag-and-drop handled at the SwiftUI layer
|
||||
|
||||
/// Top-level SwiftUI view for the VM window. Drag-and-drop is handled here
|
||||
/// using SwiftUI's .onDrop so it fires on NSHostingView before any AppKit
|
||||
/// subview (including VZVirtualMachineView) can intercept the drag session.
|
||||
struct VMWindowView: View {
|
||||
var body: some View {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys, dropZoneURL: MainApp.dropZoneURL)
|
||||
.onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}
|
||||
.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT)
|
||||
if ret != 0 {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainApp: App {
|
||||
static var suspendable: Bool = false
|
||||
static var capturesSystemKeys: Bool = false
|
||||
static var dropZoneURL: URL? = nil
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
// termination via kill(2) is not successful
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}
|
||||
}.frame(
|
||||
minWidth: CGFloat(vm!.config.display.width),
|
||||
idealWidth: CGFloat(vm!.config.display.width),
|
||||
maxWidth: .infinity,
|
||||
minHeight: CGFloat(vm!.config.display.height),
|
||||
idealHeight: CGFloat(vm!.config.display.height),
|
||||
maxHeight: .infinity
|
||||
)
|
||||
VMWindowView()
|
||||
.frame(
|
||||
minWidth: CGFloat(vm!.config.display.width),
|
||||
idealWidth: CGFloat(vm!.config.display.width),
|
||||
maxWidth: .infinity,
|
||||
minHeight: CGFloat(vm!.config.display.height),
|
||||
idealHeight: CGFloat(vm!.config.display.height),
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
|
|
@ -867,14 +929,14 @@ struct AboutTart: View {
|
|||
}
|
||||
|
||||
struct VMView: NSViewRepresentable {
|
||||
typealias NSViewType = VZVirtualMachineView
|
||||
typealias NSViewType = VMContainerView
|
||||
|
||||
@ObservedObject var vm: VM
|
||||
var capturesSystemKeys: Bool
|
||||
var dropZoneURL: URL?
|
||||
|
||||
func makeNSView(context: Context) -> NSViewType {
|
||||
let machineView = VZVirtualMachineView()
|
||||
|
||||
func makeNSView(context: Context) -> VMContainerView {
|
||||
let machineView = TartVirtualMachineView()
|
||||
machineView.capturesSystemKeys = capturesSystemKeys
|
||||
|
||||
// If not specified, enable automatic display
|
||||
|
|
@ -886,11 +948,176 @@ struct VMView: NSViewRepresentable {
|
|||
machineView.automaticallyReconfiguresDisplay = true
|
||||
}
|
||||
|
||||
return machineView
|
||||
if let dropZoneURL = dropZoneURL {
|
||||
machineView.dropZoneURL = dropZoneURL
|
||||
}
|
||||
|
||||
return VMContainerView(machineView: machineView)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSViewType, context: Context) {
|
||||
nsView.virtualMachine = vm.virtualMachine
|
||||
func updateNSView(_ nsView: VMContainerView, context: Context) {
|
||||
nsView.machineView.virtualMachine = vm.virtualMachine
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag and Drop
|
||||
|
||||
/// Layer-backed highlight overlay. Added as a subview of TartVirtualMachineView
|
||||
/// so its CALayer composites on top of Metal rendering. hitTest returns nil so
|
||||
/// it is completely transparent to pointer events.
|
||||
class DragHighlightView: NSView {
|
||||
private let fillLayer = CALayer()
|
||||
private let borderLayer = CAShapeLayer()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = CGColor.clear
|
||||
|
||||
fillLayer.backgroundColor = CGColor.clear
|
||||
layer?.addSublayer(fillLayer)
|
||||
|
||||
borderLayer.fillColor = CGColor.clear
|
||||
borderLayer.lineWidth = 8
|
||||
borderLayer.strokeColor = NSColor.controlAccentColor.cgColor
|
||||
borderLayer.isHidden = true
|
||||
layer?.addSublayer(borderLayer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
var isActive: Bool = false {
|
||||
didSet {
|
||||
let accent = NSColor.controlAccentColor
|
||||
fillLayer.backgroundColor = isActive
|
||||
? accent.withAlphaComponent(0.15).cgColor
|
||||
: CGColor.clear
|
||||
borderLayer.isHidden = !isActive
|
||||
}
|
||||
}
|
||||
|
||||
override var isOpaque: Bool { false }
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
fillLayer.frame = bounds
|
||||
borderLayer.frame = bounds
|
||||
borderLayer.path = CGPath(rect: bounds.insetBy(dx: 4, dy: 4), transform: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// VZVirtualMachineView subclass. Hosts the DragHighlightView as an internal
|
||||
/// subview so the highlight renders above the Metal framebuffer.
|
||||
class TartVirtualMachineView: VZVirtualMachineView {
|
||||
var dropZoneURL: URL?
|
||||
let highlight = DragHighlightView()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
highlight.autoresizingMask = [.width, .height]
|
||||
addSubview(highlight)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
highlight.frame = bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps TartVirtualMachineView.
|
||||
/// Drag-and-drop is registered in viewDidMoveToWindow (not init) so AppKit
|
||||
/// records the registration with the window. All NSDraggingDestination methods
|
||||
/// live here so we don't depend on VZVirtualMachineView accepting overrides.
|
||||
class VMContainerView: NSView {
|
||||
let machineView: TartVirtualMachineView
|
||||
|
||||
init(machineView: TartVirtualMachineView) {
|
||||
self.machineView = machineView
|
||||
super.init(frame: .zero)
|
||||
addSubview(machineView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
machineView.frame = bounds
|
||||
}
|
||||
|
||||
// Register AFTER the view is inserted into a window so AppKit records
|
||||
// the registration against the live window drag-destination table.
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
guard window != nil, machineView.dropZoneURL != nil else { return }
|
||||
registerForDraggedTypes([
|
||||
.fileURL,
|
||||
NSPasteboard.PasteboardType("NSFilenamesPboardType"),
|
||||
])
|
||||
fputs("tart: drag-and-drop registered (drop zone: \(machineView.dropZoneURL!.path))\n", stderr)
|
||||
}
|
||||
|
||||
// MARK: NSDraggingDestination
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
fputs("tart: draggingEntered — types: \(sender.draggingPasteboard.types ?? [])\n", stderr)
|
||||
guard machineView.dropZoneURL != nil, !fileURLs(from: sender).isEmpty else { return [] }
|
||||
machineView.highlight.isActive = true
|
||||
return .copy
|
||||
}
|
||||
|
||||
override func wantsPeriodicDraggingUpdates() -> Bool { false }
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
machineView.highlight.isActive = false
|
||||
}
|
||||
|
||||
override func draggingEnded(_ sender: NSDraggingInfo) {
|
||||
machineView.highlight.isActive = false
|
||||
}
|
||||
|
||||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true }
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
guard let dropZoneURL = machineView.dropZoneURL else { return false }
|
||||
let urls = fileURLs(from: sender)
|
||||
guard !urls.isEmpty else { return false }
|
||||
fputs("tart: dropping \(urls.count) file(s) to \(dropZoneURL.path)\n", stderr)
|
||||
for url in urls {
|
||||
let dest = dropZoneURL.appendingPathComponent(url.lastPathComponent)
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
fputs("tart: copied \(url.lastPathComponent)\n", stderr)
|
||||
} catch {
|
||||
let e = error; let n = url.lastPathComponent
|
||||
DispatchQueue.main.async {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Failed to copy \"\(n)\" to the VM"
|
||||
alert.informativeText = e.localizedDescription
|
||||
alert.alertStyle = .warning
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func fileURLs(from info: NSDraggingInfo) -> [URL] {
|
||||
info.draggingPasteboard.readObjects(
|
||||
forClasses: [NSURL.self],
|
||||
options: [.urlReadingFileURLsOnly: true]
|
||||
) as? [URL] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1032,6 +1259,14 @@ struct DirectoryShare {
|
|||
let readOnly: Bool
|
||||
let mountTag: String
|
||||
|
||||
// Programmatic initializer for internal use (e.g. the drag-and-drop drop zone)
|
||||
init(name: String?, path: URL, readOnly: Bool, mountTag: String) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.readOnly = readOnly
|
||||
self.mountTag = mountTag
|
||||
}
|
||||
|
||||
init(parseFrom: String) throws {
|
||||
var parseFrom = parseFrom
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue