diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index f43cd724d..08afd4b6e 100644 --- a/Configuration/UTMAppleConfiguration.swift +++ b/Configuration/UTMAppleConfiguration.swift @@ -275,6 +275,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject { @Published var isSerialEnabled: Bool @Published var isConsoleDisplay: Bool + @Published var isRunAsSnapshot: Bool @available(macOS 12, *) var isKeyboardEnabled: Bool { @@ -337,6 +338,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject { case isConsoleDisplay case isKeyboardEnabled case isPointingEnabled + case isRunAsSnapshot } init() { @@ -357,6 +359,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject { isAppleVirtualization = true isSerialEnabled = false isConsoleDisplay = false + isRunAsSnapshot = false memorySize = 4 * 1024 * 1024 * 1024 cpuCount = 4 } @@ -398,6 +401,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject { isEntropyEnabled = try values.decode(Bool.self, forKey: .isEntropyEnabled) isSerialEnabled = try values.decode(Bool.self, forKey: .isSerialEnabled) isConsoleDisplay = try values.decode(Bool.self, forKey: .isConsoleDisplay) + isRunAsSnapshot = try values.decode(Bool.self, forKey: .isRunAsSnapshot) name = try values.decode(String.self, forKey: .name) architecture = try values.decode(String.self, forKey: .architecture) icon = try values.decodeIfPresent(String.self, forKey: .icon) @@ -435,6 +439,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject { try container.encode(isEntropyEnabled, forKey: .isEntropyEnabled) try container.encode(isSerialEnabled, forKey: .isSerialEnabled) try container.encode(isConsoleDisplay, forKey: .isConsoleDisplay) + try container.encode(isRunAsSnapshot, forKey: .isRunAsSnapshot) try container.encode(name, forKey: .name) try container.encode(architecture, forKey: .architecture) try container.encodeIfPresent(icon, forKey: .icon) @@ -616,6 +621,29 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject { } return urls } + + /// Remove the snapshot URL image, this can be done as part of VM cleanup + func cleanupDriveSnapshot() throws { + for i in diskImages.indices { + try diskImages[i].cleanupDriveSnapshot() + } + } + + /// Perform a snapshot clone of the current image URL to the snapshot URL + /// this is required for the snapshotURL image to "work" + func setupDriveSnapshot() throws { + for i in diskImages.indices { + // Apply --snapshot on all volumes if its configured systemwide + if self.isRunAsSnapshot { + diskImages[i].runAsSnapshot = true + } + + // Setup the --snapshot on a per drive level + if diskImages[i].runAsSnapshot { + try diskImages[i].setupDriveSnapshot() + } + } + } } struct Bootloader: Codable { @@ -864,6 +892,7 @@ struct DiskImage: Codable, Hashable, Identifiable { var sizeMib: Int var isReadOnly: Bool var isExternal: Bool + var runAsSnapshot: Bool var imageURL: URL? private var uuid = UUID() // for identifiable @@ -871,6 +900,7 @@ struct DiskImage: Codable, Hashable, Identifiable { case sizeMib case isReadOnly case isExternal + case runAsSnapshot case imagePath case imageBookmark } @@ -891,12 +921,14 @@ struct DiskImage: Codable, Hashable, Identifiable { sizeMib = newSize isReadOnly = false isExternal = false + runAsSnapshot = false } - init(importImage url: URL, isReadOnly: Bool = false, isExternal: Bool = false) { + init(importImage url: URL, isReadOnly: Bool = false, isExternal: Bool = false, runAsSnapshot: Bool = false) { self.imageURL = url self.isReadOnly = isReadOnly self.isExternal = isExternal + self.runAsSnapshot = runAsSnapshot if let attributes = try? url.resourceValues(forKeys: [.fileSizeKey]), let fileSize = attributes.fileSize { sizeMib = fileSize / bytesInMib } else { @@ -912,6 +944,7 @@ struct DiskImage: Codable, Hashable, Identifiable { sizeMib = try container.decode(Int.self, forKey: .sizeMib) isReadOnly = try container.decode(Bool.self, forKey: .isReadOnly) isExternal = try container.decode(Bool.self, forKey: .isExternal) + runAsSnapshot = try container.decode(Bool.self, forKey: .runAsSnapshot) if !isExternal, let imagePath = try container.decodeIfPresent(String.self, forKey: .imagePath) { imageURL = dataURL.appendingPathComponent(imagePath) } else if let bookmark = try container.decodeIfPresent(Data.self, forKey: .imageBookmark) { @@ -925,6 +958,7 @@ struct DiskImage: Codable, Hashable, Identifiable { try container.encode(sizeMib, forKey: .sizeMib) try container.encode(isReadOnly, forKey: .isReadOnly) try container.encode(isExternal, forKey: .isExternal) + try container.encode(runAsSnapshot, forKey: .runAsSnapshot) if !isExternal { try container.encodeIfPresent(imageURL?.lastPathComponent, forKey: .imagePath) } else { @@ -941,7 +975,43 @@ struct DiskImage: Codable, Hashable, Identifiable { } } + /// Returns the snapshot equivalent URL for the current image + /// Does not actually prepare the snapshot (this is done via setupDriveSnapshot) + func snapshotURL() throws -> URL? { + return imageURL?.appendingPathComponent(".snapshot") + } + + /// Remove the snapshot URL image, this can be done as part of VM cleanup + func cleanupDriveSnapshot() throws { + if let snapshotURL = try snapshotURL() { + // The file may not exists, if so nothing happens + try FileManager.default.removeItem(at: snapshotURL) + } + } + + /// Perform a snapshot clone of the current image URL to the snapshot URL + /// this is required for the snapshotURL image to "work" + func setupDriveSnapshot() throws { + // Perform any needed cleanup first + try cleanupDriveSnapshot() + + // and make a copy of the provided imageURL + if let snapshotURL = try snapshotURL(), let imageURL = imageURL { + // lets setup the snapshot file + // AFAICT this does a shallow copy on APFS drives + try FileManager.default.copyItem(at: imageURL, to: snapshotURL) + } + } + + /// Return the VZDiskImageStorageDeviceAttachment using the snapshotURL if runAsSnapshot is enabled + /// else returns using the imageURL if its configured. func vzDiskImage() throws -> VZDiskImageStorageDeviceAttachment? { + if runAsSnapshot, let snapshotURL = try snapshotURL() { + return try VZDiskImageStorageDeviceAttachment(url: snapshotURL, readOnly: isReadOnly) + } else { + return nil + } + if let imageURL = imageURL { return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly) } else { diff --git a/Managers/UTMAppleVirtualMachine.swift b/Managers/UTMAppleVirtualMachine.swift index e7ec08f32..f35dd741e 100644 --- a/Managers/UTMAppleVirtualMachine.swift +++ b/Managers/UTMAppleVirtualMachine.swift @@ -190,6 +190,12 @@ import Virtualization } } } + + + // This perform any cleanup for the "--snapshot" feature, + // if it was initialized previously + try appleConfig.cleanupDriveSnapshot() + } override func vmStop(force: Bool) async throws { @@ -324,6 +330,10 @@ import Virtualization fsConfig.share = self?.makeDirectoryShare(from: newShares) } } + + // This perform any reset's needed for the "--snapshot" feature (if its in use) + try appleConfig.setupDriveSnapshot() + apple = VZVirtualMachine(configuration: appleConfig.apple, queue: vmQueue) apple.delegate = self } diff --git a/Platform/macOS/VMConfigAppleDriveDetailsView.swift b/Platform/macOS/VMConfigAppleDriveDetailsView.swift index f4e1e2159..5ebab5410 100644 --- a/Platform/macOS/VMConfigAppleDriveDetailsView.swift +++ b/Platform/macOS/VMConfigAppleDriveDetailsView.swift @@ -25,6 +25,7 @@ struct VMConfigAppleDriveDetailsView: View { TextField("Name", text: .constant(diskImage.imageURL?.lastPathComponent ?? NSLocalizedString("(New Drive)", comment: "VMConfigAppleDriveDetailsView"))) .disabled(true) Toggle("Read Only?", isOn: $diskImage.isReadOnly) + Toggle("Run using a snapshot? (similar to qemu --snapshot)", isOn: $diskImage.runAsSnapshot) Button(action: onDelete) { Label("Delete Drive", systemImage: "externaldrive.badge.minus") .foregroundColor(.red) diff --git a/Platform/macOS/VMConfigAppleSystemView.swift b/Platform/macOS/VMConfigAppleSystemView.swift index 5cd3f6f0c..62f4e6ae1 100644 --- a/Platform/macOS/VMConfigAppleSystemView.swift +++ b/Platform/macOS/VMConfigAppleSystemView.swift @@ -75,6 +75,9 @@ struct VMConfigAppleSystemView: View { Toggle("Enable Keyboard", isOn: $config.isKeyboardEnabled) Toggle("Enable Pointer", isOn: $config.isPointingEnabled) } + + // System wide --snapshot toggle, if enabled this would be set as "true" on all drives + Toggle("Enable 'Run using a snapshot' on all drives", isOn: $config.isRunAsSnapshot) } } }