diff --git a/Sources/tart/Commands/Create.swift b/Sources/tart/Commands/Create.swift index 01f04e9e..0a2bc889 100644 --- a/Sources/tart/Commands/Create.swift +++ b/Sources/tart/Commands/Create.swift @@ -40,7 +40,7 @@ struct Create: AsyncParsableCommand { if #available(macOS 13, *) { _ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize) } else { - throw UnsupportedOSError() + throw UnsupportedOSError("Linux VMs", "are") } } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 42dd3bc6..2ec71687 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -37,13 +37,21 @@ struct Run: AsyncParsableCommand { @Flag var withSoftnet: Bool = false @Option(help: ArgumentHelp(""" - Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\") + Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\") """, discussion: """ Learn how to create a disk image using Disk Utility here: https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac - """)) + """, valueName: "path[:ro]")) var disk: [String] = [] + @Option(help: ArgumentHelp(""" + Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\") + """, discussion: """ + All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS, + while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point". + """, valueName: "name:path[:ro]")) + var dir: [String] = [] + func validate() throws { if vnc && vncExperimental { throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") @@ -56,7 +64,8 @@ struct Run: AsyncParsableCommand { vm = try VM( vmDir: vmDir, withSoftnet: withSoftnet, - additionalDiskAttachments: additionalDiskAttachments() + additionalDiskAttachments: additionalDiskAttachments(), + directoryShares: directoryShares() ) let vncImpl: VNC? = try { @@ -135,6 +144,34 @@ struct Run: AsyncParsableCommand { return result } + func directoryShares() throws -> [DirectoryShare] { + var result: [DirectoryShare] = [] + + for rawDir in dir { + let splits = rawDir.split(maxSplits: 2) { $0 == ":" } + + if splits.count < 2 { + throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated") + } + + var readOnly: Bool = false + + if splits.count == 3 { + if splits[2] == "ro" { + readOnly = true + } else { + throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"") + } + } + + let (name, path) = (String(splits[0]), String(splits[1])) + + result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: path), readOnly: readOnly)) + } + + return result + } + private func runUI() { let nsApp = NSApplication.shared nsApp.setActivationPolicy(.regular) diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 5a5919f1..60894721 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -11,7 +11,11 @@ struct DownloadFailed: Error { } struct UnsupportedOSError: Error, CustomStringConvertible { - private(set) var description: String = "error: Linux VMs are only supported on macOS 13.0 (Ventura) or newer" + let description: String + + init(_ what: String, _ plural: String) { + description = "error: \(what) \(plural) only supported on macOS 13.0 (Ventura) or newer" + } } struct UnsupportedArchitectureError: Error { @@ -34,7 +38,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { init(vmDir: VMDirectory, withSoftnet: Bool = false, - additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [] + additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [], + directoryShares: [DirectoryShare] = [] ) throws { name = vmDir.name config = try VMConfig.init(fromURL: vmDir.configURL) @@ -50,7 +55,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, vmConfig: config, - softnet: softnet, additionalDiskAttachments: additionalDiskAttachments) + softnet: softnet, additionalDiskAttachments: additionalDiskAttachments, + directoryShares: directoryShares) virtualMachine = VZVirtualMachine(configuration: configuration) super.init() @@ -149,7 +155,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, vmConfig: config, softnet: softnet, - additionalDiskAttachments: additionalDiskAttachments) + additionalDiskAttachments: additionalDiskAttachments, + directoryShares: []) virtualMachine = VZVirtualMachine(configuration: configuration) super.init() @@ -228,7 +235,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { nvramURL: URL, vmConfig: VMConfig, softnet: Softnet? = nil, - additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] + additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment], + directoryShares: [DirectoryShare] ) throws -> VZVirtualMachineConfiguration { let configuration = VZVirtualMachineConfiguration() @@ -278,6 +286,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Entropy configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] + // Directory share + if #available(macOS 13, *) { + var directories: [String : VZSharedDirectory] = Dictionary() + directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) } + + let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag + let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag) + sharingDevice.share = VZMultipleDirectoryShare(directories: directories) + + configuration.directorySharingDevices = [sharingDevice] + } else if !directoryShares.isEmpty { + throw UnsupportedOSError("directory sharing", "is") + } + try configuration.validate() return configuration @@ -298,3 +320,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { sema.signal() } } + +struct DirectoryShare { + let name: String + let path: URL + let readOnly: Bool +} diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index ad2c2f7e..43278493 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -94,7 +94,7 @@ struct VMConfig: Codable { if #available(macOS 13, *) { platform = try Linux(from: decoder) } else { - throw UnsupportedOSError() + throw UnsupportedOSError("Linux VMs", "are") } } cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)