diff --git a/.cirrus.yml b/.cirrus.yml index fde8e4e..dd65758 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -6,6 +6,7 @@ env: task: name: Test on Ventura alias: test + use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' persistent_worker: labels: name: dev-mini @@ -30,6 +31,7 @@ task: task: name: Lint alias: lint + use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' macos_instance: image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG lint_script: @@ -40,9 +42,10 @@ task: format: swiftformat task: + only_if: $CIRRUS_TAG == '' name: Build alias: build - only_if: $CIRRUS_TAG == '' + use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' macos_instance: image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG build_script: swift build --product tart @@ -51,11 +54,12 @@ task: path: .build/debug/tart task: - name: Release (Dry Run) only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin') + name: Release (Dry Run) depends_on: - lint - build + use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' macos_instance: image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG env: @@ -91,6 +95,7 @@ task: - lint - test - build + use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' macos_instance: image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG env: @@ -135,6 +140,7 @@ task: task: name: Deploy Documentation only_if: $CIRRUS_BRANCH == 'main' + use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' container: image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!] diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index 2cd6c8e..8ac1d69 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -44,8 +44,10 @@ struct Clone: AsyncParsableCommand { let lock = try FileLock(lockURL: Config().tartHomeDir) try lock.lock() - let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) + let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) + && sourceVM.state() != "suspended" try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) + try localStorage.move(newName, from: tmpVMDir) try lock.unlock() diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index 62f6526..599eeb8 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable { let Disk: Int let Display: String let Running: Bool + let State: String } struct Get: AsyncParsableCommand { @@ -25,7 +26,7 @@ struct Get: AsyncParsableCommand { let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, - Display: vmConfig.display.description, Running: try vmDir.running()) + Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state()) print(format.renderSingle(info)) } } diff --git a/Sources/tart/Commands/List.swift b/Sources/tart/Commands/List.swift index a8174e5..7cccbc5 100644 --- a/Sources/tart/Commands/List.swift +++ b/Sources/tart/Commands/List.swift @@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable { let Name: String let Size: Int let Running: Bool + let State: String } struct List: AsyncParsableCommand { @@ -36,13 +37,13 @@ struct List: AsyncParsableCommand { if source == nil || source == "local" { infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in - try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running()) + try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state()) }) } if source == nil || source == "oci" { infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in - try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running()) + try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state()) }) } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index bbbf85c..3f5e6db 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -93,10 +93,14 @@ struct Run: AsyncParsableCommand { discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet")) var netSoftnet: Bool = false - func validate() throws { + @Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\".")) + var suspendable: Bool = false + + mutating func validate() throws { if vnc && vncExperimental { throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") } + if netBridged != nil && netSoftnet { throw ValidationError("--net-bridged and --net-softnet are mutually exclusive") } @@ -108,7 +112,23 @@ struct Run: AsyncParsableCommand { @MainActor func run() async throws { - let vmDir = try VMStorageLocal().open(name) + let localStorage = VMStorageLocal() + let vmDir = try localStorage.open(name) + + let storageLock = try FileLock(lockURL: Config().tartHomeDir) + if try vmDir.state() == "suspended" { + try storageLock.lock() // lock before checking + let needToGenerateNewMac = try localStorage.list().contains { + // check if there is a running VM with the same MAC but different name + try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name + } + + if needToGenerateNewMac { + print("There is already a running VM with the same MAC address!") + print("Resetting VM to assign a new MAC address...") + try vmDir.regenerateMACAddress() + } + } if netSoftnet && isInteractiveSession() { try Softnet.configureSUIDBitIfNeeded() @@ -153,7 +173,8 @@ struct Run: AsyncParsableCommand { network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(), additionalDiskAttachments: additionalDiskAttachments, directorySharingDevices: directoryShares() + rosettaDirectoryShare(), - serialPorts: serialPorts + serialPorts: serialPorts, + suspendable: suspendable ) let vncImpl: VNC? = try { @@ -184,6 +205,9 @@ struct Run: AsyncParsableCommand { throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!") } + // now VM state will return "running" so we can unlock + try storageLock.unlock() + let task = Task { do { if let vncImpl = vncImpl { @@ -197,7 +221,19 @@ struct Run: AsyncParsableCommand { } } - try await vm!.run(recovery) + var resume = false + + if #available(macOS 14, *) { + if FileManager.default.fileExists(atPath: vmDir.stateURL.path) { + print("restoring VM state from a snapshot...") + try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL) + try FileManager.default.removeItem(at: vmDir.stateURL) + resume = true + print("resuming VM...") + } + } + + try await vm!.run(recovery: recovery, resume: resume) if let vncImpl = vncImpl { try vncImpl.stop() @@ -215,17 +251,50 @@ struct Run: AsyncParsableCommand { } } + // "tart stop" support let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT) sigintSrc.setEventHandler { task.cancel() } sigintSrc.activate() + // "tart suspend" / UI window closing support + signal(SIGUSR1, SIG_IGN) + let sigusr1Src = DispatchSource.makeSignalSource(signal: SIGUSR1) + sigusr1Src.setEventHandler { + Task { + do { + if #available(macOS 14, *) { + try vm!.configuration.validateSaveRestoreSupport() + + print("pausing VM to take a snapshot...") + try await vm!.virtualMachine.pause() + + print("creating a snapshot...") + try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL) + + print("snapshot created successfully! shutting down the VM...") + + task.cancel() + } else { + print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer")) + + Foundation.exit(1) + } + } catch (let e) { + print(RuntimeError.SuspendFailed(e.localizedDescription)) + + Foundation.exit(1) + } + } + } + sigusr1Src.activate() + let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics if noGraphics || useVNCWithoutGraphics { dispatchMain() } else { - runUI() + runUI(suspendable) } } @@ -384,7 +453,7 @@ struct Run: AsyncParsableCommand { return [device] } - private func runUI() { + private func runUI(_ suspendable: Bool) { let nsApp = NSApplication.shared nsApp.setActivationPolicy(.regular) nsApp.activate(ignoringOtherApps: true) @@ -392,6 +461,8 @@ struct Run: AsyncParsableCommand { nsApp.applicationIconImage = NSImage(data: AppIconData) struct MainApp: App { + static var disappearSignal: Int32 = SIGINT + @NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate var body: some Scene { @@ -400,7 +471,7 @@ struct Run: AsyncParsableCommand { VMView(vm: vm!).onAppear { NSWindow.allowsAutomaticWindowTabbing = false }.onDisappear { - let ret = kill(getpid(), SIGINT) + let ret = kill(getpid(), MainApp.disappearSignal) if ret != 0 { // Fallback to the old termination method that doesn't // propagate the cancellation to Task's in case graceful @@ -436,11 +507,17 @@ struct Run: AsyncParsableCommand { Button("Request Stop") { Task { try vm!.virtualMachine.requestStop() } } + if #available(macOS 14, *) { + Button("Suspend") { + kill(getpid(), SIGUSR1) + } + } } } } } + MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT MainApp.main() } } diff --git a/Sources/tart/Commands/Suspend.swift b/Sources/tart/Commands/Suspend.swift new file mode 100644 index 0000000..c5a638c --- /dev/null +++ b/Sources/tart/Commands/Suspend.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Foundation +import System +import SwiftDate + +struct Suspend: AsyncParsableCommand { + static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM") + + @Argument(help: "VM name") + var name: String + + func run() async throws { + let vmDir = try VMStorageLocal().open(name) + let lock = try PIDLock(lockURL: vmDir.configURL) + + // Find the VM's PID + var pid = try lock.pid() + if pid == 0 { + throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running") + } + + // Tell the "tart run" process to suspend the VM + let ret = kill(pid, SIGUSR1) + if ret != 0 { + throw RuntimeError.SuspendFailed("failed to send SIGUSR1 signal to the \"tart run\" process running VM \"\(name)\"") + } + } +} diff --git a/Sources/tart/Formatter/Format.swift b/Sources/tart/Formatter/Format.swift index 912d6be..9d19f2f 100644 --- a/Sources/tart/Formatter/Format.swift +++ b/Sources/tart/Formatter/Format.swift @@ -26,10 +26,16 @@ enum Format: String, ExpressibleByArgument, CaseIterable { } let table = TextTable { (item: T) in let mirroredObject = Mirror(reflecting: item) - return mirroredObject.children.enumerated().map { (_, element) in - let fieldName = element.label! - return Column(title: fieldName, value: element.value) - } + return mirroredObject.children.enumerated() + .filter {(_, element) in + // Deprecate the "Running" field: only make it available + // from JSON for backwards-compatibility + element.label! != "Running" + } + .map { (_, element) in + let fieldName = element.label! + return Column(title: fieldName, value: element.value) + } } return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" case .json: diff --git a/Sources/tart/Platform/Darwin.swift b/Sources/tart/Platform/Darwin.swift index e5806b5..59b4ef1 100644 --- a/Sources/tart/Platform/Darwin.swift +++ b/Sources/tart/Platform/Darwin.swift @@ -6,7 +6,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible { } } -struct Darwin: Platform { +struct Darwin: PlatformSuspendable { var ecid: VZMacMachineIdentifier var hardwareModel: VZMacHardwareModel @@ -107,6 +107,14 @@ struct Darwin: Platform { } } + func keyboardsSuspendable() -> [VZKeyboardConfiguration] { + if #available(macOS 14, *) { + return [VZMacKeyboardConfiguration()] + } else { + return [] + } + } + func pointingDevices() -> [VZPointingDeviceConfiguration] { if #available(macOS 13, *) { // Trackpad is only supported by guests starting with macOS Ventura @@ -115,4 +123,12 @@ struct Darwin: Platform { return [VZUSBScreenCoordinatePointingDeviceConfiguration()] } } + + func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] { + if #available(macOS 13, *) { + return [VZMacTrackpadConfiguration()] + } else { + return [] + } + } } diff --git a/Sources/tart/Platform/Platform.swift b/Sources/tart/Platform/Platform.swift index 9b8c04e..e862781 100644 --- a/Sources/tart/Platform/Platform.swift +++ b/Sources/tart/Platform/Platform.swift @@ -8,3 +8,8 @@ protocol Platform: Codable { func keyboards() -> [VZKeyboardConfiguration] func pointingDevices() -> [VZPointingDeviceConfiguration] } + +protocol PlatformSuspendable: Platform { + func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] + func keyboardsSuspendable() -> [VZKeyboardConfiguration] +} diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 01be2b5..52d15bd 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -57,6 +57,11 @@ struct Root: AsyncParsableCommand { } } + // Add commands that are only available on specific macOS versions + if #available(macOS 14, *) { + configuration.subcommands.append(Suspend.self) + } + // Ensure the default SIGINT handled is disabled, // otherwise there's a race between two handlers signal(SIGINT, SIG_IGN); diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 5ae98bc..20cf010 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -26,6 +26,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Virtualization.Framework's virtual machine @Published var virtualMachine: VZVirtualMachine + // Virtualization.Framework's virtual machine configuration + var configuration: VZVirtualMachineConfiguration + // Semaphore used to communicate with the VZVirtualMachineDelegate var sema = DispatchSemaphore(value: 0) @@ -41,7 +44,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { network: Network = NetworkShared(), additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [], directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [], - serialPorts: [VZSerialPortConfiguration] = [] + serialPorts: [VZSerialPortConfiguration] = [], + suspendable: Bool = false ) throws { name = vmDir.name config = try VMConfig.init(fromURL: vmDir.configURL) @@ -52,11 +56,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Initialize the virtual machine and its configuration self.network = network - let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, - nvramURL: vmDir.nvramURL, vmConfig: config, - network: network, additionalDiskAttachments: additionalDiskAttachments, - directorySharingDevices: directorySharingDevices, - serialPorts: serialPorts + configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, + nvramURL: vmDir.nvramURL, vmConfig: config, + network: network, additionalDiskAttachments: additionalDiskAttachments, + directorySharingDevices: directorySharingDevices, + serialPorts: serialPorts, + suspendable: suspendable ) virtualMachine = VZVirtualMachine(configuration: configuration) @@ -179,11 +184,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Initialize the virtual machine and its configuration self.network = network - let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, - vmConfig: config, network: network, - additionalDiskAttachments: additionalDiskAttachments, - directorySharingDevices: directorySharingDevices, - serialPorts: serialPorts + configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, + vmConfig: config, network: network, + additionalDiskAttachments: additionalDiskAttachments, + directorySharingDevices: directorySharingDevices, + serialPorts: serialPorts ) virtualMachine = VZVirtualMachine(configuration: configuration) @@ -220,10 +225,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { return try VM(vmDir: vmDir) } - func run(_ recovery: Bool) async throws { + func run(recovery: Bool, resume shouldResume: Bool) async throws { try network.run(sema) - try await start(recovery) + if shouldResume { + try await resume() + } else { + try await start(recovery) + } await withTaskCancellationHandler(operation: { // Wait for the VM to finish running @@ -253,6 +262,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } } + @MainActor + private func resume() async throws { + try await virtualMachine.resume() + } + @MainActor private func stop() async throws { try await self.virtualMachine.stop() @@ -265,7 +279,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { network: Network = NetworkShared(), additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment], directorySharingDevices: [VZDirectorySharingDeviceConfiguration], - serialPorts: [VZSerialPortConfiguration] + serialPorts: [VZSerialPortConfiguration], + suspendable: Bool = false ) throws -> VZVirtualMachineConfiguration { let configuration = VZVirtualMachineConfiguration() @@ -283,17 +298,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)] // Audio - let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() - let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration() - inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource() - let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration() - outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink() - soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration] - configuration.audioDevices = [soundDeviceConfiguration] + if !suspendable { + let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() + let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration() + inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource() + let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration() + outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink() + soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration] + configuration.audioDevices = [soundDeviceConfiguration] + } // Keyboard and mouse - configuration.keyboards = vmConfig.platform.keyboards() - configuration.pointingDevices = vmConfig.platform.pointingDevices() + if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable { + configuration.keyboards = platformSuspendable.keyboardsSuspendable() + configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable() + } else { + configuration.keyboards = vmConfig.platform.keyboards() + configuration.pointingDevices = vmConfig.platform.pointingDevices() + } // Networking let vio = VZVirtioNetworkDeviceConfiguration() @@ -307,7 +329,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) } // Entropy - configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] + if !suspendable { + configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] + } // Directory sharing devices configuration.directorySharingDevices = directorySharingDevices diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index acaa663..abf9e34 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -13,6 +13,9 @@ struct VMDirectory: Prunable { var nvramURL: URL { baseURL.appendingPathComponent("nvram.bin") } + var stateURL: URL { + baseURL.appendingPathComponent("state.vzvmsave") + } var explicitlyPulledMark: URL { baseURL.appendingPathComponent(".explicitly-pulled") @@ -39,6 +42,16 @@ struct VMDirectory: Prunable { return try lock.pid() != 0 } + func state() throws -> String { + if try running() { + return "running" + } else if FileManager.default.fileExists(atPath: stateURL.path) { + return "suspended" + } else { + return "stopped" + } + } + static func temporary() throws -> VMDirectory { let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false) @@ -79,6 +92,7 @@ struct VMDirectory: Prunable { try FileManager.default.copyItem(at: configURL, to: to.configURL) try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL) try FileManager.default.copyItem(at: diskURL, to: to.diskURL) + try? FileManager.default.copyItem(at: stateURL, to: to.stateURL) // Re-generate MAC address if generateMAC { @@ -94,6 +108,8 @@ struct VMDirectory: Prunable { var vmConfig = try VMConfig(fromURL: configURL) vmConfig.macAddress = VZMACAddress.randomLocallyAdministered() + // cleanup state if any + try? FileManager.default.removeItem(at: stateURL) try vmConfig.save(toURL: configURL) } diff --git a/Sources/tart/VMStorageHelper.swift b/Sources/tart/VMStorageHelper.swift index 3b5ec96..87300b7 100644 --- a/Sources/tart/VMStorageHelper.swift +++ b/Sources/tart/VMStorageHelper.swift @@ -58,6 +58,7 @@ enum RuntimeError : Error { case ImportFailed(_ message: String) case SoftnetFailed(_ message: String) case OCIStorageError(_ message: String) + case SuspendFailed(_ message: String) } protocol HasExitCode { @@ -101,6 +102,8 @@ extension RuntimeError : CustomStringConvertible { return "Softnet failed: \(message)" case .OCIStorageError(let message): return "OCI storage error: \(message)" + case .SuspendFailed(let message): + return "Failed to suspend the VM: \(message)" } } }