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 */,