mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
Merge pull request #7880 from utmapp/apple/diskimagekit
apple: adopt DiskImageKit and add disposable mode on macOS 27
This commit is contained in:
@@ -241,9 +241,31 @@ extension UTMAppleConfiguration {
|
||||
|
||||
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
|
||||
@MainActor extension UTMAppleConfiguration {
|
||||
func appleVZConfiguration(forValidation: Bool = false) throws -> VZVirtualMachineConfiguration {
|
||||
/// Point the firmware variable stores at copies so a disposable run cannot change the originals
|
||||
@available(macOS 27, *)
|
||||
private func redirectFirmwareStorage(of vzconfig: VZVirtualMachineConfiguration, to directoryURL: URL) throws {
|
||||
let fileManager = FileManager.default
|
||||
if let efi = vzconfig.bootLoader as? VZEFIBootLoader, let variableStore = efi.variableStore {
|
||||
let copyURL = directoryURL.appendingPathComponent(variableStore.url.lastPathComponent)
|
||||
try fileManager.copyItem(at: variableStore.url, to: copyURL)
|
||||
efi.variableStore = VZEFIVariableStore(url: copyURL)
|
||||
}
|
||||
#if arch(arm64)
|
||||
if let platform = vzconfig.platform as? VZMacPlatformConfiguration, let auxiliaryStorage = platform.auxiliaryStorage {
|
||||
let copyURL = directoryURL.appendingPathComponent(auxiliaryStorage.url.lastPathComponent)
|
||||
try fileManager.copyItem(at: auxiliaryStorage.url, to: copyURL)
|
||||
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: copyURL)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// - Parameter disposableOverlayDirectoryURL: If set, guest writes go to ephemeral layers in this directory and nothing in the package is modified (macOS 27+)
|
||||
func appleVZConfiguration(forValidation: Bool = false, disposableOverlayDirectoryURL: URL? = nil) throws -> VZVirtualMachineConfiguration {
|
||||
let vzconfig = VZVirtualMachineConfiguration()
|
||||
try system.fillVZConfiguration(vzconfig)
|
||||
if #available(macOS 27, *), let disposableOverlayDirectoryURL = disposableOverlayDirectoryURL {
|
||||
try redirectFirmwareStorage(of: vzconfig, to: disposableOverlayDirectoryURL)
|
||||
}
|
||||
if !sharedDirectories.isEmpty {
|
||||
let fsConfig = VZVirtioFileSystemDeviceConfiguration(tag: shareDirectoryTag)
|
||||
fsConfig.share = UTMAppleConfigurationSharedDirectory.makeDirectoryShare(from: sharedDirectories)
|
||||
@@ -251,7 +273,7 @@ extension UTMAppleConfiguration {
|
||||
}
|
||||
if !forValidation {
|
||||
vzconfig.storageDevices = try drives.compactMap { drive in
|
||||
guard let attachment = try drive.vzDiskImage(useFsWorkAround: system.boot.operatingSystem == .linux) else {
|
||||
guard let attachment = try drive.vzDiskImage(useFsWorkAround: system.boot.operatingSystem == .linux, disposableOverlayDirectoryURL: disposableOverlayDirectoryURL) else {
|
||||
return nil
|
||||
}
|
||||
if #available(macOS 13, *), drive.isExternal {
|
||||
|
||||
@@ -101,17 +101,24 @@ struct UTMAppleConfigurationDrive: UTMConfigurationDrive {
|
||||
try container.encode(id, forKey: .identifier)
|
||||
}
|
||||
|
||||
func vzDiskImage(useFsWorkAround: Bool = false) throws -> VZDiskImageStorageDeviceAttachment? {
|
||||
if let imageURL = imageURL {
|
||||
// Use cached caching mode for virtio drive to prevent fs corruption on linux when possible
|
||||
if !isNvme, useFsWorkAround {
|
||||
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly, cachingMode: .cached, synchronizationMode: .full)
|
||||
} else {
|
||||
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly)
|
||||
}
|
||||
} else {
|
||||
/// Create the storage attachment for this drive
|
||||
/// - Parameters:
|
||||
/// - useFsWorkAround: Use cached caching mode for virtio drives to prevent fs corruption on Linux
|
||||
/// - disposableOverlayDirectoryURL: If set, guest writes go to an ephemeral layer in this directory and the image is never modified (macOS 27+)
|
||||
func vzDiskImage(useFsWorkAround: Bool = false, disposableOverlayDirectoryURL: URL? = nil) throws -> VZDiskImageStorageDeviceAttachment? {
|
||||
guard let imageURL = imageURL else {
|
||||
return nil
|
||||
}
|
||||
// Use cached caching mode for virtio drive to prevent fs corruption on linux when possible
|
||||
let useCachedMode = !isNvme && useFsWorkAround
|
||||
if #available(macOS 27, *), let overlayDirectoryURL = disposableOverlayDirectoryURL, !isReadOnly {
|
||||
let overlayURL = overlayDirectoryURL.appendingPathComponent("\(id).asif")
|
||||
return try UTMAppleDiskImage.attachment(for: imageURL, ephemeralOverlayAt: overlayURL, cachingMode: useCachedMode ? .cached : .automatic, synchronizationMode: .full)
|
||||
} else if useCachedMode {
|
||||
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly, cachingMode: .cached, synchronizationMode: .full)
|
||||
} else {
|
||||
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
|
||||
@@ -132,12 +132,8 @@ extension UTMConfigurationDrive {
|
||||
#if os(macOS)
|
||||
@available(macOS 13, *)
|
||||
private func createAsifImage(at newURL: URL, size sizeMib: Int) async throws {
|
||||
let numBlocks = sizeMib * Int(bytesInMib) / 512
|
||||
guard let asif = UTMASIFImage.sharedInstance() else {
|
||||
throw UTMConfigurationError.cannotCreateDiskImage
|
||||
}
|
||||
try await Task.detached {
|
||||
try asif.createBlank(with: newURL, numBlocks: numBlocks)
|
||||
try UTMAppleDiskImage.createASIF(at: newURL, sizeMib: sizeMib)
|
||||
}.value
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ struct UTMStartActionIntent: AppIntent, UTMIntent {
|
||||
@Parameter(title: "Recovery", description: "Boot into recovery mode. Only supported on Apple Virtualization backend.", default: false)
|
||||
var isRecovery: Bool
|
||||
|
||||
@Parameter(title: "Disposible", description: "Do not save any changes to disk. Only supported on QEMU backend.", default: false)
|
||||
@Parameter(title: "Disposible", description: "Do not save any changes to disk.", default: false)
|
||||
var isDisposible: Bool
|
||||
|
||||
@MainActor
|
||||
@@ -76,7 +76,7 @@ struct UTMStartActionIntent: AppIntent, UTMIntent {
|
||||
#endif
|
||||
}
|
||||
if isDisposible {
|
||||
guard vm is UTMQemuVirtualMachine else {
|
||||
guard type(of: vm).capabilities.supportsDisposibleMode else {
|
||||
throw UTMIntentError.unsupportedBackend
|
||||
}
|
||||
options.insert(.bootDisposibleMode)
|
||||
|
||||
@@ -80,7 +80,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||
}
|
||||
#endif
|
||||
|
||||
if let _ = vm.config as? UTMQemuConfiguration {
|
||||
if let wrapped = vm.wrapped, type(of: wrapped).capabilities.supportsDisposibleMode {
|
||||
Button {
|
||||
data.run(vm: vm, options: .bootDisposibleMode)
|
||||
} label: {
|
||||
|
||||
@@ -350,7 +350,7 @@ struct AlertMessage: Identifiable {
|
||||
if #available(macOS 14, *), useNvmeAsDiskInterface {
|
||||
newDisk.isNvme = true
|
||||
}
|
||||
if #available(macOS 26, *), UTMASIFImage.sharedInstance() != nil {
|
||||
if UTMAppleDiskImage.isASIFSupported {
|
||||
newDisk.isASIF = true
|
||||
}
|
||||
config.drives.append(newDisk)
|
||||
|
||||
+4
-16
@@ -942,27 +942,15 @@ enum AlertItem: Identifiable {
|
||||
|
||||
@available(macOS 14, *)
|
||||
func appleDriveInfo(for driveUrl: URL) -> (format: String?, size: Int64?) {
|
||||
var format: String? = nil
|
||||
var size: Int64? = nil
|
||||
guard let info = try? UTMASIFImage.sharedInstance()?.retrieveInfo(driveUrl) else {
|
||||
return (format, size)
|
||||
guard let info = try? UTMAppleDiskImage.info(for: driveUrl) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
if let _format = info["Image Format"] as? String {
|
||||
format = _format
|
||||
}
|
||||
if let _sizeInfo = info["Size Info"] as? [String: Any] {
|
||||
if let _totalBytes = _sizeInfo["Total Bytes"] as? Int64 {
|
||||
size = _totalBytes
|
||||
}
|
||||
}
|
||||
return (format, size)
|
||||
return (info.format.localizedDescription, info.size)
|
||||
}
|
||||
|
||||
@available(macOS 14, *)
|
||||
func resizeAppleDrive(for driveUrl: URL, sizeInMib: Int) throws {
|
||||
let bytesinMib = 1048576
|
||||
let size = Int(sizeInMib * bytesinMib)
|
||||
try UTMASIFImage.sharedInstance()!.resize(with: driveUrl, size: size)
|
||||
try UTMAppleDiskImage.resize(driveUrl, toSizeMib: sizeInMib)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -317,6 +317,7 @@ enum UTMQuitPolicy: Int {
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
CapsLockRemapper.shared.recoverAtLaunch()
|
||||
UTMAppleVirtualMachine.removeStaleDisposableOverlayDirectories()
|
||||
if isDockIconHidden {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,11 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
|
||||
}
|
||||
|
||||
var defaultSubtitle: String {
|
||||
""
|
||||
if appleVM.isRunningAsDisposible {
|
||||
return NSLocalizedString("Disposable Mode", comment: "VMDisplayAppleWindowController")
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private var isSharePathAlertShownOnce = false
|
||||
|
||||
@@ -109,7 +109,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
||||
private func stop(isKill: Bool = false) {
|
||||
showConfirmAlert(NSLocalizedString("This may corrupt the VM and any unsaved changes will be lost. To quit safely, shut down from the guest.", comment: "VMDisplayWindowController")) {
|
||||
self.enterSuspended(isBusy: true) // early indicator
|
||||
if self.vm.registryEntry.isSuspended {
|
||||
if self.vm.registryEntry.isSuspended && !self.vm.isRunningAsDisposible {
|
||||
self.vm.requestVmDeleteState()
|
||||
}
|
||||
self.vm.requestVmStop(force: isKill)
|
||||
@@ -426,7 +426,7 @@ extension VMDisplayWindowController: NSWindowDelegate {
|
||||
guard !isSecondary else {
|
||||
return true
|
||||
}
|
||||
guard !(vm.state == .stopped || (vm.state == .paused && vm.registryEntry.isSuspended)) else {
|
||||
guard !(vm.state == .stopped || (vm.state == .paused && vm.registryEntry.isSuspended && !vm.isRunningAsDisposible)) else {
|
||||
return true
|
||||
}
|
||||
if let snapshotUnsupportedError = vm.snapshotUnsupportedError {
|
||||
|
||||
@@ -111,7 +111,7 @@ extension UTMData {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
if wrapped.registryEntry.isSuspended {
|
||||
if wrapped.registryEntry.isSuspended && !wrapped.isRunningAsDisposible {
|
||||
try? await wrapped.deleteSnapshot(name: nil)
|
||||
}
|
||||
if vm.state == .started || vm.state == .paused {
|
||||
|
||||
@@ -66,7 +66,7 @@ private struct VMMenuItem: View {
|
||||
data.stop(vm: vm)
|
||||
}
|
||||
Button("Suspend") {
|
||||
let isSnapshot = (vm.wrapped as? UTMQemuVirtualMachine)?.isRunningAsDisposible ?? false
|
||||
let isSnapshot = vm.wrapped?.isRunningAsDisposible ?? false
|
||||
vm.wrapped!.requestVmPause(save: !isSnapshot)
|
||||
}
|
||||
Button("Reset") {
|
||||
|
||||
@@ -24,11 +24,7 @@ struct VMConfigAppleDriveCreateView: View {
|
||||
@State private var isGiB: Bool = true
|
||||
|
||||
private var isASIFSupported: Bool {
|
||||
if #available(macOS 26, *) {
|
||||
return UTMASIFImage.sharedInstance() != nil
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
UTMAppleDiskImage.isASIFSupported
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// Copyright © 2026 Turing Software, LLC. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DiskImageKit
|
||||
import Virtualization
|
||||
|
||||
/// Creates, inspects and resizes disk images for Apple Virtualization drives (ASIF and RAW).
|
||||
///
|
||||
/// On macOS 27 and later this uses DiskImageKit. On older hosts it falls back to the private
|
||||
/// DiskImages2 framework through `UTMASIFImage`. Once the minimum supported host is macOS 27,
|
||||
/// delete the legacy extension at the bottom of this file along with `UTMASIFImage.h/.m` and
|
||||
/// its entry in `Swift-Bridging-Header.h`.
|
||||
enum UTMAppleDiskImage {
|
||||
enum Format: Equatable {
|
||||
case asif
|
||||
case raw
|
||||
case layered
|
||||
case other(String)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .asif: return NSLocalizedString("ASIF", comment: "UTMAppleDiskImage")
|
||||
case .raw: return NSLocalizedString("RAW", comment: "UTMAppleDiskImage")
|
||||
case .layered: return NSLocalizedString("Layered", comment: "UTMAppleDiskImage")
|
||||
case .other(let name): return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Info {
|
||||
let format: Format
|
||||
/// Size of the virtual disk in bytes
|
||||
let size: Int64
|
||||
}
|
||||
|
||||
private static let bytesInMib = 1048576
|
||||
/// Block size used when creating images, matches the legacy DiskImages2 path
|
||||
private static let blockSize = 512
|
||||
|
||||
/// True if ASIF images can be created on this host
|
||||
static var isASIFSupported: Bool {
|
||||
if #available(macOS 27, *) {
|
||||
return true
|
||||
} else if #available(macOS 26, *) {
|
||||
return UTMASIFImage.sharedInstance() != nil
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new blank ASIF image
|
||||
/// - Parameters:
|
||||
/// - url: Location of the new image, must not exist
|
||||
/// - sizeMib: Size of the virtual disk in MiB
|
||||
@available(macOS 13, *)
|
||||
static func createASIF(at url: URL, sizeMib: Int) throws {
|
||||
let blockCount = sizeMib * bytesInMib / blockSize
|
||||
if #available(macOS 27, *) {
|
||||
_ = try DiskImageKit.DiskImage(creating: .asif(url: url, blockCount: blockCount, blockSize: .bytes512))
|
||||
} else {
|
||||
try legacyCreateASIF(at: url, blockCount: blockCount)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the format and size of an existing image
|
||||
/// - Parameter url: Location of the image
|
||||
@available(macOS 14, *)
|
||||
static func info(for url: URL) throws -> Info {
|
||||
if #available(macOS 27, *) {
|
||||
let image = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly))
|
||||
let format: Format
|
||||
switch image.format {
|
||||
case .asif: format = .asif
|
||||
case .raw: format = .raw
|
||||
case .stack: format = .layered
|
||||
@unknown default: format = .other(String(describing: image.format))
|
||||
}
|
||||
return Info(format: format, size: Int64(image.size))
|
||||
} else {
|
||||
return try legacyInfo(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the size of the virtual disk of an existing image
|
||||
/// - Parameters:
|
||||
/// - url: Location of the image
|
||||
/// - sizeMib: New size of the virtual disk in MiB
|
||||
@available(macOS 14, *)
|
||||
static func resize(_ url: URL, toSizeMib sizeMib: Int) throws {
|
||||
if #available(macOS 27, *) {
|
||||
let image = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readWrite))
|
||||
try image.truncate(blockCount: sizeMib * bytesInMib / image.blockSize.rawValue)
|
||||
} else {
|
||||
try legacyResize(url, toSizeMib: sizeMib)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layered images (macOS 27+)
|
||||
|
||||
@available(macOS 27, *)
|
||||
extension UTMAppleDiskImage {
|
||||
/// Create an attachment whose guest writes land in a new copy-on-write layer
|
||||
///
|
||||
/// The image at `url` is opened read-only and is never modified. Any existing file at `overlayURL` is replaced.
|
||||
/// - Parameters:
|
||||
/// - url: Location of the base image
|
||||
/// - overlayURL: Location of the overlay layer to create
|
||||
/// - cachingMode: Caching mode of the attachment
|
||||
/// - synchronizationMode: Synchronization mode of the attachment
|
||||
static func attachment(for url: URL, ephemeralOverlayAt overlayURL: URL, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full) throws -> VZDiskImageStorageDeviceAttachment {
|
||||
try? FileManager.default.removeItem(at: overlayURL)
|
||||
let base = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly))
|
||||
let stack = try base.appending(ASIFCreationConfiguration.layer(url: overlayURL, type: .overlay))
|
||||
return try VZDiskImageStorageDeviceAttachment(diskImage: stack, cachingMode: cachingMode, synchronizationMode: synchronizationMode)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy DiskImages2 (macOS 13 to 26)
|
||||
|
||||
/// Remove this extension together with `UTMASIFImage` when the minimum host is macOS 27.
|
||||
private extension UTMAppleDiskImage {
|
||||
@available(macOS 13, *)
|
||||
static func legacyCreateASIF(at url: URL, blockCount: Int) throws {
|
||||
guard let asif = UTMASIFImage.sharedInstance() else {
|
||||
throw UTMAppleConfigurationError.featureNotSupported
|
||||
}
|
||||
try asif.createBlank(with: url, numBlocks: blockCount)
|
||||
}
|
||||
|
||||
@available(macOS 14, *)
|
||||
static func legacyInfo(for url: URL) throws -> Info {
|
||||
guard let asif = UTMASIFImage.sharedInstance() else {
|
||||
throw UTMAppleConfigurationError.featureNotSupported
|
||||
}
|
||||
let info = try asif.retrieveInfo(url)
|
||||
guard let sizeInfo = info["Size Info"] as? [String: Any], let totalBytes = sizeInfo["Total Bytes"] as? Int64 else {
|
||||
throw UTMAppleConfigurationError.featureNotSupported
|
||||
}
|
||||
let format: Format
|
||||
switch info["Image Format"] as? String {
|
||||
case "ASIF": format = .asif
|
||||
case "RAW", "RAW*": format = .raw
|
||||
case .some(let name): format = .other(name)
|
||||
case .none: format = .other(NSLocalizedString("Unknown", comment: "UTMAppleDiskImage"))
|
||||
}
|
||||
return Info(format: format, size: totalBytes)
|
||||
}
|
||||
|
||||
@available(macOS 14, *)
|
||||
static func legacyResize(_ url: URL, toSizeMib sizeMib: Int) throws {
|
||||
guard let asif = UTMASIFImage.sharedInstance() else {
|
||||
throw UTMAppleConfigurationError.featureNotSupported
|
||||
}
|
||||
try asif.resize(with: url, size: sizeMib * bytesInMib)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,11 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
}
|
||||
|
||||
var supportsDisposibleMode: Bool {
|
||||
false
|
||||
if #available(macOS 27, *) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var supportsRecoveryMode: Bool {
|
||||
@@ -59,7 +63,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
|
||||
private(set) var isShortcut: Bool = false
|
||||
|
||||
let isRunningAsDisposible: Bool = false
|
||||
var isRunningAsDisposible: Bool {
|
||||
disposableOverlayDirectoryURL != nil
|
||||
}
|
||||
|
||||
weak var delegate: (any UTMVirtualMachineDelegate)?
|
||||
|
||||
@@ -118,6 +124,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
|
||||
private var removableDrives: [String: Any] = [:]
|
||||
|
||||
/// Holds the ephemeral copy-on-write layers while running without saving changes
|
||||
private var disposableOverlayDirectoryURL: URL?
|
||||
|
||||
/// `UTMAppleUSBPassthroughState` on macOS 27, always nil on older versions
|
||||
private var usbPassthroughState: Any?
|
||||
|
||||
@@ -188,7 +197,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
}
|
||||
}
|
||||
}
|
||||
try? updateLastModified()
|
||||
if !isRunningAsDisposible {
|
||||
try? updateLastModified()
|
||||
}
|
||||
}
|
||||
|
||||
func start(options: UTMVirtualMachineStartOptions = []) async throws {
|
||||
@@ -196,9 +207,16 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
return
|
||||
}
|
||||
state = .starting
|
||||
let isDisposible = options.contains(.bootDisposibleMode)
|
||||
do {
|
||||
let isSuspended = await registryEntry.isSuspended
|
||||
let isRestoring = isSuspended && !options.contains(.bootRecovery)
|
||||
// a disposable run boots fresh and leaves any saved state untouched
|
||||
let isRestoring = isSuspended && !isDisposible && !options.contains(.bootRecovery)
|
||||
// discard the overlays of a run that did not stop cleanly
|
||||
removeDisposableOverlayDirectory()
|
||||
if isDisposible {
|
||||
try createDisposableOverlayDirectory()
|
||||
}
|
||||
try await beginAccessingResources()
|
||||
try await createAppleVM(isRestoring: isRestoring)
|
||||
if isRestoring {
|
||||
@@ -236,11 +254,46 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
await stopUsbPassthrough()
|
||||
}
|
||||
await releaseFailedAppleVM()
|
||||
removeDisposableOverlayDirectory()
|
||||
state = .stopped
|
||||
try? await deleteSnapshot()
|
||||
if !isDisposible {
|
||||
try? await deleteSnapshot()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a directory for the ephemeral copy-on-write layers of a disposable run
|
||||
private func createDisposableOverlayDirectory() throws {
|
||||
guard #available(macOS 27, *) else {
|
||||
throw UTMAppleVirtualMachineError.operationNotAvailable
|
||||
}
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("disposable-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
disposableOverlayDirectoryURL = url
|
||||
}
|
||||
|
||||
/// Discard the ephemeral copy-on-write layers of a disposable run
|
||||
private func removeDisposableOverlayDirectory() {
|
||||
if let url = disposableOverlayDirectoryURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
disposableOverlayDirectoryURL = nil
|
||||
}
|
||||
|
||||
/// Discard the ephemeral copy-on-write layers left behind by a previous process that did not stop cleanly
|
||||
///
|
||||
/// Call this before any virtual machine is started.
|
||||
static func removeStaleDisposableOverlayDirectories() {
|
||||
let fileManager = FileManager.default
|
||||
let temporaryDirectoryURL = fileManager.temporaryDirectory
|
||||
guard let contents = try? fileManager.contentsOfDirectory(at: temporaryDirectoryURL, includingPropertiesForKeys: nil) else {
|
||||
return
|
||||
}
|
||||
for url in contents where url.lastPathComponent.hasPrefix("disposable-") {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func _forceStop() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
@@ -538,7 +591,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
let serialPort = UTMSerialPort(portNamed: name, readFileHandle: slaveTtyHandle, writeFileHandle: slaveTtyHandle, terminalFileHandle: terminalTtyHandle)
|
||||
config.serials[i].interface = serialPort
|
||||
}
|
||||
let vzConfig = try config.appleVZConfiguration()
|
||||
let vzConfig = try config.appleVZConfiguration(disposableOverlayDirectoryURL: disposableOverlayDirectoryURL)
|
||||
var usbControllerDelegate: AnyObject?
|
||||
if #available(macOS 27, *), hasUsbRedirection {
|
||||
if isRestoring {
|
||||
@@ -564,6 +617,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if isRunningAsDisposible {
|
||||
snapshotUnsupportedError = UTMAppleVirtualMachineError.cannotSaveInDisposibleMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,6 +840,8 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
|
||||
}
|
||||
removableDrives.removeAll()
|
||||
sharedDirectoriesChanged = nil
|
||||
// must happen before the screenshot is saved so a disposable run leaves the package untouched
|
||||
removeDisposableOverlayDirectory()
|
||||
Task { @MainActor in
|
||||
stopAccesingResources()
|
||||
if #available(macOS 27, *) {
|
||||
@@ -932,10 +990,14 @@ extension UTMAppleVirtualMachine {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
let attachment = try newDrive.vzDiskImage()!
|
||||
if state == .started {
|
||||
// detach first so a disposable run can replace the overlay of the old medium
|
||||
try await detachDrive(id: drive.id)
|
||||
let attachment = try newDrive.vzDiskImage(disposableOverlayDirectoryURL: disposableOverlayDirectoryURL)!
|
||||
try await attachDrive(attachment, imageURL: url, id: drive.id)
|
||||
} else {
|
||||
// make sure the new medium can be opened before remembering it
|
||||
_ = try newDrive.vzDiskImage()!
|
||||
}
|
||||
let file = try UTMRegistryEntry.File(url: url)
|
||||
await registryEntry.setExternalDrive(file, forId: drive.id)
|
||||
@@ -972,7 +1034,7 @@ extension UTMAppleVirtualMachine {
|
||||
guard drive.isExternal else {
|
||||
return
|
||||
}
|
||||
guard let attachment = try drive.vzDiskImage() else {
|
||||
guard let attachment = try drive.vzDiskImage(disposableOverlayDirectoryURL: disposableOverlayDirectoryURL) else {
|
||||
return
|
||||
}
|
||||
let configuration = VZUSBMassStorageDeviceConfiguration(attachment: attachment)
|
||||
@@ -1293,6 +1355,7 @@ enum UTMAppleVirtualMachineError: Error {
|
||||
case usbDeviceNotFound(String)
|
||||
case usbDeviceInUse(String)
|
||||
case usbDeviceNotConnected(String)
|
||||
case cannotSaveInDisposibleMode
|
||||
}
|
||||
|
||||
extension UTMAppleVirtualMachineError: LocalizedError {
|
||||
@@ -1316,6 +1379,8 @@ extension UTMAppleVirtualMachineError: LocalizedError {
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The USB device '%@' is already connected to another virtual machine.", comment: "UTMAppleVirtualMachine"), name)
|
||||
case .usbDeviceNotConnected(let name):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The USB device '%@' is not connected to this virtual machine.", comment: "UTMAppleVirtualMachine"), name)
|
||||
case .cannotSaveInDisposibleMode:
|
||||
return NSLocalizedString("Suspend state cannot be saved when running without saving changes.", comment: "UTMAppleVirtualMachine")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */; };
|
||||
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */; };
|
||||
CE7A1C062EFA0A01001671E9 /* AccessoryAccess.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */; };
|
||||
CE1D15AD2F6B100100D15C01 /* DiskImageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||
CE6804852E4E5D84001671E9 /* UTMScriptingInputImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */; };
|
||||
CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; };
|
||||
CE68E5452E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; };
|
||||
@@ -2040,6 +2042,8 @@
|
||||
CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBDevice.swift; sourceTree = "<group>"; };
|
||||
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = "<group>"; };
|
||||
CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AccessoryAccess.framework; path = System/Library/Frameworks/AccessoryAccess.framework; sourceTree = SDKROOT; };
|
||||
CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleDiskImage.swift; sourceTree = "<group>"; };
|
||||
CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DiskImageKit.framework; path = System/Library/Frameworks/DiskImageKit.framework; sourceTree = SDKROOT; };
|
||||
CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingInputImpl.swift; sourceTree = "<group>"; };
|
||||
CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = "<group>"; };
|
||||
CE68E5472E3C3E0A006B3645 /* VMWizardOSClassicMacView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWizardOSClassicMacView.swift; sourceTree = "<group>"; };
|
||||
@@ -2362,6 +2366,7 @@
|
||||
CE0B6F0124AD677200FE012D /* libgsttypefindfunctions.a in Frameworks */,
|
||||
848308D5278A1F2200E3E474 /* Virtualization.framework in Frameworks */,
|
||||
CE7A1C062EFA0A01001671E9 /* AccessoryAccess.framework in Frameworks */,
|
||||
CE1D15AD2F6B100100D15C01 /* DiskImageKit.framework in Frameworks */,
|
||||
CE0B6EE124AD677200FE012D /* gstrtsp-1.0.0.framework in Frameworks */,
|
||||
848F71E6277A2466006A0240 /* SwiftTerm in Frameworks */,
|
||||
CE03D08824D90F0700F76B84 /* gio-2.0.0.framework in Frameworks */,
|
||||
@@ -2676,6 +2681,7 @@
|
||||
84937EFE28960789003148F4 /* zstd.1.framework */,
|
||||
848308D4278A1F2200E3E474 /* Virtualization.framework */,
|
||||
CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */,
|
||||
CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */,
|
||||
8453DCB0278CE33E0037A0DA /* qemu-img.framework */,
|
||||
CE5451A426AF5F0F008594E5 /* EGL.framework */,
|
||||
CE5451A226AF5F0F008594E5 /* epoxy.0.framework */,
|
||||
@@ -3000,6 +3006,7 @@
|
||||
CE68047D2E493D71001671E9 /* UTMUSBManager.swift */,
|
||||
CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */,
|
||||
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */,
|
||||
CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */,
|
||||
CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */,
|
||||
CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */,
|
||||
4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */,
|
||||
@@ -4175,6 +4182,7 @@
|
||||
CE6804802E493D71001671E9 /* UTMUSBManager.swift in Sources */,
|
||||
CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */,
|
||||
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */,
|
||||
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */,
|
||||
CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
|
||||
83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
|
||||
84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user