From fa92ca48ed9925cc3e852794847a4444e4ffc970 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:14:39 +0200 Subject: [PATCH] asif: use DiskImageKit on macOS 27 ASIF drives for Apple Virtualization are created, inspected and resized through the private DiskImages2 framework, loaded by path and called via NSInvocation. macOS 27 ships DiskImageKit, a public Swift-only framework that covers the same operations and is what Virtualization.framework now consumes directly. Add UTMAppleDiskImage, a Swift wrapper that uses DiskImageKit on macOS 27 and later and falls back to the existing UTMASIFImage helper on macOS 13 through 26. The legacy path is confined to one private extension so it can be deleted together with UTMASIFImage once the minimum host is macOS 27. DiskImageKit is weak linked because the deployment target is below macOS 27. Callers switch to the wrapper; the resize path no longer force-unwraps the helper. Assisted-by: Claude:claude-fable-5-1 --- Configuration/UTMConfigurationDrive.swift | 6 +- Platform/Shared/VMWizardState.swift | 2 +- Platform/UTMData.swift | 20 +-- .../macOS/VMConfigAppleDriveCreateView.swift | 6 +- Services/UTMAppleDiskImage.swift | 150 ++++++++++++++++++ UTM.xcodeproj/project.pbxproj | 8 + 6 files changed, 165 insertions(+), 27 deletions(-) create mode 100644 Services/UTMAppleDiskImage.swift diff --git a/Configuration/UTMConfigurationDrive.swift b/Configuration/UTMConfigurationDrive.swift index 36fb352a..3cc1988d 100644 --- a/Configuration/UTMConfigurationDrive.swift +++ b/Configuration/UTMConfigurationDrive.swift @@ -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 } diff --git a/Platform/Shared/VMWizardState.swift b/Platform/Shared/VMWizardState.swift index 7fd9bf7e..ee520c9d 100644 --- a/Platform/Shared/VMWizardState.swift +++ b/Platform/Shared/VMWizardState.swift @@ -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) diff --git a/Platform/UTMData.swift b/Platform/UTMData.swift index 50f96d2a..861630a9 100644 --- a/Platform/UTMData.swift +++ b/Platform/UTMData.swift @@ -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 diff --git a/Platform/macOS/VMConfigAppleDriveCreateView.swift b/Platform/macOS/VMConfigAppleDriveCreateView.swift index db382f61..ff383c05 100644 --- a/Platform/macOS/VMConfigAppleDriveCreateView.swift +++ b/Platform/macOS/VMConfigAppleDriveCreateView.swift @@ -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 { diff --git a/Services/UTMAppleDiskImage.swift b/Services/UTMAppleDiskImage.swift new file mode 100644 index 00000000..62968bc1 --- /dev/null +++ b/Services/UTMAppleDiskImage.swift @@ -0,0 +1,150 @@ +// +// 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 + +/// 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: - 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) + } +} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 9f8b3729..3e4b9111 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -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 = ""; }; CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = ""; }; CE68E5472E3C3E0A006B3645 /* VMWizardOSClassicMacView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWizardOSClassicMacView.swift; sourceTree = ""; }; @@ -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 */,