diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index dd627c22..02bcdd8b 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -352,6 +352,10 @@ F4FC98392BB386A000E511C9 /* ContinuousProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC98382BB386A000E511C9 /* ContinuousProgressIndicator.swift */; }; F4FC983B2BB386B500E511C9 /* MaskProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC983A2BB386B500E511C9 /* MaskProgressView.swift */; }; F4FC983D2BB386DD00E511C9 /* VMProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC983C2BB386DD00E511C9 /* VMProgressOverlay.swift */; }; + VB02ASIFTEST00001A0101 /* DiskResizeSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB02ASIFTEST00002A0102 /* DiskResizeSupportTests.swift */; }; + VB02ASIFTEST00003A0103 /* VirtualCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4BE9C6527FF053A00B648F8 /* VirtualCore.framework */; }; + VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */; }; + VB01DISKRESIZ00004A0104 /* VBVirtualMachine+DiskResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -439,6 +443,13 @@ remoteGlobalIDString = F4C189DF2848F59F00335EC7; remoteInfo = VirtualWormhole; }; + VB02ASIFTEST00004A0104 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F4BE9C4627FF052100B648F8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F4BE9C6427FF053A00B648F8; + remoteInfo = VirtualCore; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -873,6 +884,9 @@ F4FC98382BB386A000E511C9 /* ContinuousProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinuousProgressIndicator.swift; sourceTree = ""; }; F4FC983A2BB386B500E511C9 /* MaskProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaskProgressView.swift; sourceTree = ""; }; F4FC983C2BB386DD00E511C9 /* VMProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMProgressOverlay.swift; sourceTree = ""; }; + VB02ASIFTEST00002A0102 /* DiskResizeSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskResizeSupportTests.swift; sourceTree = ""; }; + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBDiskResizer.swift; sourceTree = ""; }; + VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VBVirtualMachine+DiskResize.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -950,6 +964,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + VB02ASIFTEST00003A0103 /* VirtualCore.framework in Frameworks */, F4D305A029B8DB700006E748 /* VirtualWormhole.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1629,6 +1644,7 @@ F4DE1C102D6F642E00603527 /* VBStorageDeviceContainer.swift */, F46FFBA72804F07400D61023 /* VBNVRAMVariable.swift */, F4D725FD286677B8001818F7 /* VBVirtualMachine+Metadata.swift */, + VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */, F4D0F71428667984004D5782 /* VBVirtualMachine+Screenshot.swift */, ); path = Models; @@ -1922,6 +1938,7 @@ F485B91E2BB2F4AC004B3C2B /* Bundle+Version.swift */, F444D1332BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift */, F453C4BA2DF231B7007EAD5F /* PreventTerminationAssertion.swift */, + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */, ); path = Utilities; sourceTree = ""; @@ -1952,6 +1969,7 @@ isa = PBXGroup; children = ( F4D305A829B8E70A0006E748 /* Resources */, + VB02ASIFTEST00002A0102 /* DiskResizeSupportTests.swift */, F4D3059E29B8DB700006E748 /* WormholePacketTests.swift */, ); path = VirtualWormholeTests; @@ -2272,6 +2290,7 @@ buildRules = ( ); dependencies = ( + VB02ASIFTEST00005A0105 /* PBXTargetDependency */, F4D305A229B8DB700006E748 /* PBXTargetDependency */, ); name = VirtualWormholeTests; @@ -2701,6 +2720,7 @@ F453C4A22DF1D7F6007EAD5F /* SimulatedRestoreBackend.swift in Sources */, F4DE1C0B2D6F54E700603527 /* VBSavedStateMetadata+Clone.swift in Sources */, F4D725FE286677B8001818F7 /* VBVirtualMachine+Metadata.swift in Sources */, + VB01DISKRESIZ00004A0104 /* VBVirtualMachine+DiskResize.swift in Sources */, F4A21BF428033102001072B8 /* VBError.swift in Sources */, F4D0F71F2867517A004D5782 /* AppUpdateChannel.swift in Sources */, F49FD8842DFB727B0019D638 /* VMImporter+Helpers.swift in Sources */, @@ -2725,6 +2745,7 @@ F485B91D2BB2F0D9004B3C2B /* ProcessInfo+ECID.swift in Sources */, F444D1342BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift in Sources */, F4C237502888AF67001FF286 /* LogStreamer.swift in Sources */, + VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */, F4F9B41A284CE37C00F21737 /* Logging.swift in Sources */, F4B5C5D728870619005AA632 /* ConfigurationModels+Validation.swift in Sources */, F4A7FB3B2BB5E79100E4C12A /* DirectoryObserver.swift in Sources */, @@ -2801,6 +2822,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + VB02ASIFTEST00001A0101 /* DiskResizeSupportTests.swift in Sources */, F4D3059F29B8DB700006E748 /* WormholePacketTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2872,6 +2894,11 @@ target = F4C189DF2848F59F00335EC7 /* VirtualWormhole */; targetProxy = F4D305A129B8DB700006E748 /* PBXContainerItemProxy */; }; + VB02ASIFTEST00005A0105 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F4BE9C6427FF053A00B648F8 /* VirtualCore */; + targetProxy = VB02ASIFTEST00004A0104 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index e6057723..b1c49224 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -111,6 +111,15 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { } } } + + public var displayName: String { + switch self { + case .raw: "Raw Image" + case .dmg: "Disk Image (DMG)" + case .sparse: "Sparse Image" + case .asif: "Apple Sparse Image Format (ASIF)" + } + } } public var id: String = UUID().uuidString @@ -135,6 +144,47 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { format: .raw ) } + + public var canBeResized: Bool { + switch format { + case .raw, .sparse: + true + case .asif: + if #available(macOS 26, *) { + true + } else { + false + } + case .dmg: + false + } + } + + public static func maximumSelectableSize( + configuredMaximum: UInt64, + minimumSize: UInt64, + existingImageSize: UInt64?, + availableSpace: UInt64?, + volumeCapacity: UInt64? + ) -> UInt64 { + let availableLimit = availableSpace.map { available in + (existingImageSize ?? 0) + available + } ?? configuredMaximum + + let capacityLimit = volumeCapacity ?? configuredMaximum + let storageLimit = min(availableLimit, capacityLimit) + + return max(minimumSize, min(configuredMaximum, storageLimit)) + } + + public static func requiresResizeConfirmation( + isExistingDiskImage: Bool, + canResize: Bool, + originalSize: UInt64, + proposedSize: UInt64 + ) -> Bool { + isExistingDiskImage && canResize && proposedSize > originalSize + } } /// Configures a storage device. @@ -202,6 +252,11 @@ public struct VBStorageDevice: Identifiable, Hashable, Codable { ) } + public var canBeResized: Bool { + guard case .managedImage(let image) = backing else { return false } + return image.canBeResized + } + public var displayName: String { guard !isBootVolume else { return "Boot" } diff --git a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift new file mode 100644 index 00000000..8a264712 --- /dev/null +++ b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift @@ -0,0 +1,121 @@ +// +// VBVirtualMachine+DiskResize.swift +// VirtualCore +// +// Created by VirtualBuddy on 25/05/26. +// + +import Foundation +import OSLog + +private let diskResizeLogger = Logger(for: VBVirtualMachine.self, label: "DiskResize") + +public extension VBVirtualMachine { + + typealias DiskResizeProgressHandler = @MainActor (_ message: String) -> Void + + /// Checks if any disk images need resizing based on configuration vs actual size + mutating func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws { + let config = configuration + + guard metadata.hasPendingDiskImageResizes else { return } + + let pendingImageIDs = metadata.pendingDiskImageResizeIDs + + func report(_ message: String) async { + guard let progressHandler else { return } + await MainActor.run { + progressHandler(message) + } + } + + let resizableDevices = config.hardware.storageDevices.compactMap { device -> (VBStorageDevice, VBManagedDiskImage)? in + guard case .managedImage(let image) = device.backing else { return nil } + guard pendingImageIDs.contains(image.id) else { return nil } + guard image.canBeResized else { return nil } + return (device, image) + } + + guard !resizableDevices.isEmpty else { + metadata.pendingDiskImageResizeIDs.removeAll() + return + } + + let formatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useTB] + formatter.countStyle = .binary + formatter.includesUnit = true + return formatter + }() + + for (index, entry) in resizableDevices.enumerated() { + let (device, image) = entry + let position = index + 1 + let total = resizableDevices.count + let deviceName = device.displayName + + await report("Checking \(deviceName) (\(position)/\(total))...") + + let imageURL = diskImageURL(for: image) + + guard FileManager.default.fileExists(atPath: imageURL.path) else { + await report("Skipping \(deviceName): disk image not found.") + metadata.clearPendingDiskImageResize(for: image) + continue + } + + let actualSize = try await VBDiskResizer.currentImageSize(at: imageURL, format: image.format) + + if image.size > actualSize { + let targetDescription = formatter.string(fromByteCount: Int64(image.size)) + await report("Expanding \(deviceName) to \(targetDescription) (\(position)/\(total))...") + + try await resizeDiskImage(image, to: image.size) + + await report("\(deviceName) expanded successfully.") + metadata.clearPendingDiskImageResize(for: image) + } else if image.size < actualSize { + let actualDescription = formatter.string(fromByteCount: Int64(actualSize)) + await report("\(deviceName) exceeds the configured size (\(actualDescription)); no changes made.") + metadata.clearPendingDiskImageResize(for: image) + } else { + let currentDescription = formatter.string(fromByteCount: Int64(actualSize)) + if VBDiskResizer.shouldReconcilePartitions( + configuredSize: image.size, + actualSize: actualSize, + format: image.format + ) { + await report("Verifying \(deviceName) partition layout (\(position)/\(total))...") + try await VBDiskResizer.reconcilePartitions(at: imageURL, format: image.format) + } + await report("\(deviceName) already uses \(currentDescription).") + metadata.clearPendingDiskImageResize(for: image) + } + } + + await report("Disk image checks complete.") + } + + /// Resizes a managed disk image to the specified size + private func resizeDiskImage(_ image: VBManagedDiskImage, to newSize: UInt64) async throws { + let imageURL = diskImageURL(for: image) + diskResizeLogger.debug("Resizing disk image at \(imageURL.path, privacy: .public) to \(newSize, privacy: .public) bytes") + + try await VBDiskResizer.resizeDiskImage( + at: imageURL, + format: image.format, + newSize: newSize + ) + + diskResizeLogger.debug("Successfully resized disk image at \(imageURL.path, privacy: .public) to \(newSize, privacy: .public) bytes") + } + + /// Checks if a managed disk image has FileVault (locked volumes) enabled. + /// - Parameter image: The managed disk image to check. + /// - Returns: `true` if the disk image has FileVault-protected (locked) volumes, `false` otherwise. + func checkFileVaultForDiskImage(_ image: VBManagedDiskImage) async -> Bool { + let imageURL = diskImageURL(for: image) + return await VBDiskResizer.checkFileVaultStatus(at: imageURL, format: image.format) + } +} diff --git a/VirtualCore/Source/Models/VBVirtualMachine.swift b/VirtualCore/Source/Models/VBVirtualMachine.swift index 1bf5c23b..b6f8fa23 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine.swift @@ -17,6 +17,8 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { public var lastBootDate: Date? = nil @DecodableDefault.EmptyPlaceholder public var backgroundHash: BlurHashToken = .virtualBuddyBackground + @DecodableDefault.EmptyList + public var pendingDiskImageResizeIDs = Set() /// If this VM was imported from some other app, contains the name of the ``VMImporter`` that was used. public var importedFromAppName: String? = nil @@ -26,6 +28,30 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { /// The original local file URL that was specified (or set after a successful download from ``remoteInstallImageURL``). public private(set) var installImageURL: URL? = nil + public init( + uuid: UUID = UUID(), + version: Int = Self.currentVersion, + installFinished: Bool = false, + firstBootDate: Date? = nil, + lastBootDate: Date? = nil, + backgroundHash: BlurHashToken = .virtualBuddyBackground, + pendingDiskImageResizeIDs: Set = [], + importedFromAppName: String? = nil, + remoteInstallImageURL: URL? = nil, + installImageURL: URL? = nil + ) { + self.uuid = uuid + self.version = version + self.installFinished = installFinished + self.firstBootDate = firstBootDate + self.lastBootDate = lastBootDate + self.backgroundHash = backgroundHash + self.pendingDiskImageResizeIDs = pendingDiskImageResizeIDs + self.importedFromAppName = importedFromAppName + self.remoteInstallImageURL = remoteInstallImageURL + self.installImageURL = installImageURL + } + /** Usage of the same property for both local and remote restore image URLs has been the source of recurring bugs in the past. Example: https://github.com/insidegui/VirtualBuddy/pull/395 @@ -46,6 +72,18 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { guard backgroundHash == .virtualBuddyBackground else { return } backgroundHash = .virtualBuddyBackgroundLinux } + + public var hasPendingDiskImageResizes: Bool { + !pendingDiskImageResizeIDs.isEmpty + } + + public mutating func markDiskImageResizePending(for image: VBManagedDiskImage) { + pendingDiskImageResizeIDs.insert(image.id) + } + + public mutating func clearPendingDiskImageResize(for image: VBManagedDiskImage) { + pendingDiskImageResizeIDs.remove(image.id) + } } public var id: String { bundleURL.absoluteString } @@ -78,6 +116,10 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { get { _installRestoreData } set { _installRestoreData = newValue } } + + public var hasPendingDiskImageResizes: Bool { + metadata.hasPendingDiskImageResizes + } } diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift new file mode 100644 index 00000000..ae8d6fde --- /dev/null +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -0,0 +1,1297 @@ +// +// VBDiskResizer.swift +// VirtualCore +// +// Created by VirtualBuddy on 22/08/25. +// + +import Foundation +import zlib + +public enum VBDiskResizeError: LocalizedError { + case diskImageNotFound(URL) + case unsupportedImageFormat(VBManagedDiskImage.Format) + case insufficientSpace(required: UInt64, available: UInt64) + case cannotShrinkDisk + case systemCommandFailed(String, Int32) + case invalidSize(UInt64) + case apfsVolumesLocked(container: String) + + public var errorDescription: String? { + switch self { + case .diskImageNotFound(let url): + return "Disk image not found at path: \(url.path)" + case .unsupportedImageFormat(let format): + return "Resizing is not supported for \(format.displayName) format" + case .insufficientSpace(let required, let available): + let formatter = ByteCountFormatter() + formatter.countStyle = .file + let requiredStr = formatter.string(fromByteCount: Int64(required)) + let availableStr = formatter.string(fromByteCount: Int64(available)) + return "Insufficient disk space. Required: \(requiredStr), Available: \(availableStr)" + case .cannotShrinkDisk: + return "Cannot shrink disk image. Only expansion is supported for safety reasons." + case .systemCommandFailed(let command, let exitCode): + return "System command '\(command)' failed with exit code \(exitCode)" + case .invalidSize(let size): + return "Invalid size: \(size) bytes. Size must be larger than current disk size." + case .apfsVolumesLocked(let container): + return "The APFS container \(container) contains locked volumes. Unlock the disk (for example by signing into the FileVault-protected guest) and run 'diskutil apfs resizeContainer disk0s2 0' inside the guest to complete the resize." + } + } +} + +private extension FileHandle { + func vbWriteAll(_ data: Data) throws { + if #available(macOS 10.15.4, *) { + try self.write(contentsOf: data) + } else { + self.write(data) + } + } + + func vbRead(upToCount count: Int) throws -> Data? { + if #available(macOS 10.15.4, *) { + return try self.read(upToCount: count) + } else { + return self.readData(ofLength: count) + } + } + + func vbSeek(to offset: UInt64) throws { + if #available(macOS 10.15.4, *) { + _ = try self.seek(toOffset: offset) + } else { + self.seek(toFileOffset: offset) + } + } + + func vbSynchronize() throws { + if #available(macOS 10.15.4, *) { + try self.synchronize() + } else { + self.synchronizeFile() + } + } +} + +public struct VBDiskResizer { + private struct APFSContainerInfo { + let container: String + let physicalStore: String? + let hasLockedVolumes: Bool + } + + private struct APFSContainerDetails { + let capacityCeiling: UInt64 + let physicalStoreSize: UInt64 + } + + struct DiskImageProcessCommand { + let executablePath: String + let arguments: [String] + } + + private static func sanitizeDeviceIdentifier(_ identifier: String) -> String { + if identifier.hasPrefix("/dev/") { + return String(identifier.dropFirst(5)) + } + return identifier + } + + public static func canResizeFormat(_ format: VBManagedDiskImage.Format) -> Bool { + switch format { + case .raw, .sparse: + return true + case .asif: + if #available(macOS 26, *) { + return true + } else { + return false + } + case .dmg: + return false + } + } + + static func shouldReconcilePartitions( + configuredSize: UInt64, + actualSize: UInt64, + format: VBManagedDiskImage.Format + ) -> Bool { + guard configuredSize == actualSize else { return false } + + switch format { + case .raw, .sparse: + return true + case .asif: + if #available(macOS 26, *) { + return true + } else { + return false + } + case .dmg: + return false + } + } + + static func fileVaultAttachCommand(for format: VBManagedDiskImage.Format, at url: URL) -> DiskImageProcessCommand? { + switch format { + case .raw: + return DiskImageProcessCommand( + executablePath: "/usr/bin/hdiutil", + arguments: ["attach", "-imagekey", "diskimage-class=CRawDiskImage", "-nomount", url.path] + ) + case .sparse: + return DiskImageProcessCommand( + executablePath: "/usr/bin/hdiutil", + arguments: ["attach", "-nomount", url.path] + ) + case .asif: + if #available(macOS 26, *) { + return DiskImageProcessCommand( + executablePath: "/usr/sbin/diskutil", + arguments: ["image", "attach", "--nomount", url.path] + ) + } else { + return nil + } + case .dmg: + return nil + } + } + + static func fileVaultDetachCommand(for format: VBManagedDiskImage.Format, deviceNode: String) -> DiskImageProcessCommand? { + switch format { + case .raw, .sparse: + return DiskImageProcessCommand( + executablePath: "/usr/bin/hdiutil", + arguments: ["detach", deviceNode] + ) + case .asif: + if #available(macOS 26, *) { + return DiskImageProcessCommand( + executablePath: "/usr/sbin/diskutil", + arguments: ["eject", deviceNode] + ) + } else { + return nil + } + case .dmg: + return nil + } + } + + /// Checks if a disk image has FileVault (locked volumes) enabled. + /// This attaches the disk image temporarily to inspect its APFS containers. + /// - Parameters: + /// - url: The URL of the disk image to check. + /// - format: The format of the disk image. + /// - Returns: `true` if the disk image has FileVault-protected (locked) volumes, `false` otherwise. + public static func checkFileVaultStatus(at url: URL, format: VBManagedDiskImage.Format) async -> Bool { + guard canResizeFormat(format) else { return false } + guard FileManager.default.fileExists(atPath: url.path) else { return false } + guard let attachCommand = fileVaultAttachCommand(for: format, at: url) else { return false } + + // Attach the disk image without mounting + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: attachCommand.executablePath) + attachProcess.arguments = attachCommand.arguments + + let attachPipe = Pipe() + attachProcess.standardOutput = attachPipe + attachProcess.standardError = Pipe() + + do { + try attachProcess.run() + attachProcess.waitUntilExit() + } catch { + NSLog("Failed to attach disk image for FileVault check: \(error)") + return false + } + + guard attachProcess.terminationStatus == 0 else { + NSLog("Disk image attach failed for FileVault check with exit code \(attachProcess.terminationStatus)") + return false + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + guard let deviceNode = deviceNode(fromDiskImageAttachOutput: attachOutput) else { + NSLog("Could not extract device node for FileVault check") + return false + } + + defer { + if let detachCommand = fileVaultDetachCommand(for: format, deviceNode: deviceNode) { + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: detachCommand.executablePath) + detachProcess.arguments = detachCommand.arguments + + do { + try detachProcess.run() + detachProcess.waitUntilExit() + } catch { + NSLog("Failed to detach disk image after FileVault check: \(error)") + } + } + } + + // Check for locked volumes using the APFS list + if let containerInfo = await findAPFSContainerUsingAPFSList(deviceNode: deviceNode) { + return containerInfo.hasLockedVolumes + } + + return false + } + + public static func resizeDiskImage( + at url: URL, + format: VBManagedDiskImage.Format, + newSize: UInt64 + ) async throws { + guard canResizeFormat(format) else { + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + guard FileManager.default.fileExists(atPath: url.path) else { + throw VBDiskResizeError.diskImageNotFound(url) + } + + let currentSize = try await currentImageSize(at: url, format: format) + guard newSize > currentSize else { + throw VBDiskResizeError.cannotShrinkDisk + } + + try await expandImageInPlace(at: url, format: format, newSize: newSize) + + // After resizing the disk image, attempt to expand the partition + try await expandPartitionsInDiskImage(at: url, format: format) + } + + static func reconcilePartitions(at url: URL, format: VBManagedDiskImage.Format) async throws { + guard canResizeFormat(format) else { + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + guard FileManager.default.fileExists(atPath: url.path) else { + throw VBDiskResizeError.diskImageNotFound(url) + } + + try await expandPartitionsInDiskImage(at: url, format: format) + } + + static func currentImageSize(at url: URL, format: VBManagedDiskImage.Format) async throws -> UInt64 { + switch format { + case .raw: + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.size] as? UInt64 ?? 0 + + case .sparse: + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + process.arguments = ["imageinfo", "-plist", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", process.terminationStatus) + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let size = plist["Total Bytes"] as? UInt64 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", -1) + } + + return size + + case .asif: + if #available(macOS 26, *) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["image", "info", "--plist", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("diskutil image info", process.terminationStatus) + } + + return try imageSize(fromDiskutilImageInfoPlist: pipe.fileHandleForReading.readDataToEndOfFile()) + } else { + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + case .dmg: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + static func imageSize(fromDiskutilImageInfoPlist data: Data) throws -> UInt64 { + guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] else { + throw VBDiskResizeError.systemCommandFailed("diskutil image info", -1) + } + + if let sizeInfo = plist["Size Info"] as? [String: Any], + let size = unsignedIntegerValue(sizeInfo["Total Bytes"]) { + return size + } + + if let size = unsignedIntegerValue(plist["Total Bytes"]) { + return size + } + + throw VBDiskResizeError.systemCommandFailed("diskutil image info", -1) + } + + private static func unsignedIntegerValue(_ value: Any?) -> UInt64? { + switch value { + case let value as UInt64: + return value + case let value as Int: + return value >= 0 ? UInt64(value) : nil + case let value as NSNumber: + return value.uint64Value + default: + return nil + } + } + + private static func expandImageInPlace(at url: URL, format: VBManagedDiskImage.Format, newSize: UInt64) async throws { + let parentDir = url.deletingLastPathComponent() + let availableSpace = try await getAvailableSpace(at: parentDir) + + // Get current file size + let currentSize = try await currentImageSize(at: url, format: format) + let additionalSpaceNeeded = newSize > currentSize ? newSize - currentSize : 0 + + guard availableSpace >= additionalSpaceNeeded else { + throw VBDiskResizeError.insufficientSpace(required: additionalSpaceNeeded, available: availableSpace) + } + + switch format { + case .sparse: + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + + let sizeInSectors = newSize / 512 + process.arguments = ["resize", "-size", "\(sizeInSectors)s", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errorData = pipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw VBDiskResizeError.systemCommandFailed("hdiutil resize: \(errorString)", process.terminationStatus) + } + + case .raw: + try await expandRawImageInPlace(at: url, newSize: newSize) + try adjustGPTLayoutForRawImage(at: url, newSize: newSize) + + case .asif: + if #available(macOS 26, *) { + try await resizeASIFImage(at: url, newSize: newSize) + } else { + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + case .dmg: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + @available(macOS 26, *) + private static func resizeASIFImage(at url: URL, newSize: UInt64) async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["image", "resize", "--size", "\(newSize)B", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "Unknown error" + if output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: "ASIF disk image") + } + throw VBDiskResizeError.systemCommandFailed("diskutil image resize: \(output)", process.terminationStatus) + } + } + + private static func expandRawImageInPlace(at url: URL, newSize: UInt64) async throws { + let fileHandle = try FileHandle(forWritingTo: url) + defer { fileHandle.closeFile() } + + let result = ftruncate(fileHandle.fileDescriptor, Int64(newSize)) + guard result == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", result) + } + } + + private static func getAvailableSpace(at url: URL) async throws -> UInt64 { + let resourceValues = try url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) + return UInt64(resourceValues.volumeAvailableCapacity ?? 0) + } + + /// Expands partitions within a disk image to use the newly available space + private static func expandPartitionsInDiskImage(at url: URL, format: VBManagedDiskImage.Format) async throws { + NSLog("Attempting to expand partitions in disk image at \(url.path)") + + switch format { + case .raw: + // For RAW images, we need to mount and resize using diskutil + try await expandPartitionsInRawImage(at: url) + + case .sparse: + // For sparse images, we can work with them directly + try await expandPartitionsInSparseImage(at: url) + + case .asif: + if #available(macOS 26, *) { + try await expandPartitionsInASIFImage(at: url) + } else { + NSLog("Skipping partition expansion for unsupported format: \(format)") + } + + case .dmg: + // Unsupported formats — partition expansion is skipped + NSLog("Skipping partition expansion for unsupported format: \(format)") + } + } + + private static func expandPartitionsInRawImage(at url: URL) async throws { + // Mount the disk image as a device + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + attachProcess.arguments = ["attach", "-imagekey", "diskimage-class=CRawDiskImage", "-nomount", url.path] + + let attachPipe = Pipe() + attachProcess.standardOutput = attachPipe + attachProcess.standardError = Pipe() + + try attachProcess.run() + attachProcess.waitUntilExit() + + guard attachProcess.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil attach", attachProcess.terminationStatus) + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + // Extract device node (e.g., /dev/disk4) + guard let deviceNode = deviceNode(fromDiskImageAttachOutput: attachOutput) else { + throw VBDiskResizeError.systemCommandFailed("Could not extract device node", -1) + } + + defer { + // Detach the disk image when done + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + detachProcess.arguments = ["detach", deviceNode] + try? detachProcess.run() + detachProcess.waitUntilExit() + } + + // Resize the partition using diskutil + try await resizePartitionOnDevice(deviceNode: deviceNode) + } + + private static func expandPartitionsInSparseImage(at url: URL) async throws { + // Mount the sparse image and resize its partitions + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + attachProcess.arguments = ["attach", "-nomount", url.path] + + let attachPipe = Pipe() + attachProcess.standardOutput = attachPipe + attachProcess.standardError = Pipe() + + try attachProcess.run() + attachProcess.waitUntilExit() + + guard attachProcess.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil attach", attachProcess.terminationStatus) + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + guard let deviceNode = deviceNode(fromDiskImageAttachOutput: attachOutput) else { + throw VBDiskResizeError.systemCommandFailed("Could not extract device node", -1) + } + + defer { + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + detachProcess.arguments = ["detach", deviceNode] + try? detachProcess.run() + detachProcess.waitUntilExit() + } + + try await resizePartitionOnDevice(deviceNode: deviceNode) + } + + @available(macOS 26, *) + private static func expandPartitionsInASIFImage(at url: URL) async throws { + guard let attachCommand = fileVaultAttachCommand(for: .asif, at: url) else { + throw VBDiskResizeError.unsupportedImageFormat(.asif) + } + + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: attachCommand.executablePath) + attachProcess.arguments = attachCommand.arguments + + let attachPipe = Pipe() + attachProcess.standardOutput = attachPipe + attachProcess.standardError = Pipe() + + try attachProcess.run() + attachProcess.waitUntilExit() + + guard attachProcess.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("diskutil image attach", attachProcess.terminationStatus) + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + guard let deviceNode = deviceNode(fromDiskImageAttachOutput: attachOutput) else { + throw VBDiskResizeError.systemCommandFailed("Could not extract device node", -1) + } + + defer { + if let detachCommand = fileVaultDetachCommand(for: .asif, deviceNode: deviceNode) { + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: detachCommand.executablePath) + detachProcess.arguments = detachCommand.arguments + try? detachProcess.run() + detachProcess.waitUntilExit() + } + } + + try await resizePartitionOnDevice(deviceNode: deviceNode) + } + + static func deviceNode(fromDiskImageAttachOutput output: String) -> String? { + // hdiutil/diskutil can list synthesized APFS devices before the backing disk. + let lines = output.components(separatedBy: .newlines) + var fallbackDeviceNode: String? + + for line in lines { + let components = line.split(whereSeparator: { $0.isWhitespace }) + guard let firstComponent = components.first else { continue } + + let deviceNode = String(firstComponent) + guard deviceNode.hasPrefix("/dev/disk") else { continue } + + if fallbackDeviceNode == nil { + fallbackDeviceNode = deviceNode + } + + if isWholeDiskDeviceNode(deviceNode), + line.contains("partition_scheme") { + return deviceNode + } + } + + return fallbackDeviceNode + } + + private static func isWholeDiskDeviceNode(_ deviceNode: String) -> Bool { + let prefix = "/dev/disk" + guard deviceNode.hasPrefix(prefix) else { return false } + + let suffix = deviceNode.dropFirst(prefix.count) + return !suffix.isEmpty && suffix.allSatisfy(\.isNumber) + } + + private static func resizePartitionOnDevice(deviceNode: String) async throws { + NSLog("Attempting to resize partition on device \(deviceNode)") + + // First, get partition information + let listProcess = Process() + listProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + listProcess.arguments = ["list", deviceNode] + + let listPipe = Pipe() + listProcess.standardOutput = listPipe + listProcess.standardError = Pipe() + + try listProcess.run() + listProcess.waitUntilExit() + + guard listProcess.terminationStatus == 0 else { + NSLog("Warning: Could not list partitions on \(deviceNode)") + return + } + + let listOutput = String(data: listPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + NSLog("Partition layout for \(deviceNode):\n\(listOutput)") + + // First, check if we need to use diskutil apfs list to find the APFS container + // This is needed when the partition is an APFS volume rather than a container + // Also check if the device itself is an APFS container (common for VM disk images) + if let apfsContainerFromList = await findAPFSContainerUsingAPFSList(deviceNode: deviceNode) { + if apfsContainerFromList.hasLockedVolumes { + throw VBDiskResizeError.apfsVolumesLocked(container: apfsContainerFromList.container) + } + let targetDescription = apfsContainerFromList.physicalStore ?? apfsContainerFromList.container + NSLog("Found APFS container using 'diskutil apfs list': \(apfsContainerFromList.container) (store: \(targetDescription))") + try await resizeAPFSContainer(apfsContainerFromList) + } else if let apfsContainer = findAPFSContainer(in: listOutput, deviceNode: deviceNode) { + let targetDescription = apfsContainer.physicalStore ?? apfsContainer.container + NSLog("Found APFS container: \(apfsContainer.container) (store: \(targetDescription))") + try await resizeAPFSContainer(apfsContainer) + } else if listOutput.contains("Apple_APFS") { + // The disk might be an APFS container itself (common for VM images) + // Try to resize it directly + NSLog("Disk appears to have APFS partitions, attempting to resize \(deviceNode) as container") + let cleanDevice = sanitizeDeviceIdentifier(deviceNode) + let containerInfo = APFSContainerInfo(container: cleanDevice, physicalStore: nil, hasLockedVolumes: false) + try await resizeAPFSContainer(containerInfo) + } else { + NSLog("Warning: Could not find a resizable APFS container on \(deviceNode)") + } + } + + private static func resizeAPFSContainer(_ info: APFSContainerInfo) async throws { + if info.hasLockedVolumes { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + + let resizeTarget = info.physicalStore ?? info.container + + let primaryResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) + + if primaryResult.status == 0 { + NSLog("Successfully expanded APFS container target \(resizeTarget)") + } else { + if primaryResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + NSLog("Initial APFS container resize at \(resizeTarget) did not apply (will reconcile via nudge): \(primaryResult.output)") + } + + // When resizing using the physical store, issue a follow-up pass on the logical container to + // encourage APFS to grow the volumes to the new ceiling. Ignore failures in this follow-up. + if info.physicalStore != nil && info.container != resizeTarget { + let containerTarget = info.container + let containerResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", containerTarget, "0"]) + + if containerResult.status == 0 { + NSLog("Performed follow-up resize on APFS container \(containerTarget)") + } else { + if containerResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + NSLog("Follow-up resize on container \(containerTarget) deferred (will reconcile via nudge if needed)") + } + } + + try await ensureAPFSContainerMaximized(info: info) + } + + private static func findAPFSContainerUsingAPFSList(deviceNode: String) async -> APFSContainerInfo? { + let apfsListProcess = Process() + apfsListProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + apfsListProcess.arguments = ["apfs", "list", "-plist"] + + let apfsListPipe = Pipe() + apfsListProcess.standardOutput = apfsListPipe + apfsListProcess.standardError = Pipe() + + do { + try apfsListProcess.run() + apfsListProcess.waitUntilExit() + } catch { + NSLog("Failed to run 'diskutil apfs list -plist': \(error)") + return nil + } + + guard apfsListProcess.terminationStatus == 0 else { + NSLog("'diskutil apfs list -plist' failed with exit code \(apfsListProcess.terminationStatus)") + return nil + } + + let data = apfsListPipe.fileHandleForReading.readDataToEndOfFile() + guard + let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let containers = plist["Containers"] as? [[String: Any]] + else { + NSLog("Failed to parse 'diskutil apfs list -plist' output") + return nil + } + + let cleanDeviceNode = sanitizeDeviceIdentifier(deviceNode) + var candidates: [(info: APFSContainerInfo, size: UInt64, isMainContainer: Bool)] = [] + + for container in containers { + guard let containerRef = container["ContainerReference"] as? String else { continue } + let volumes = container["Volumes"] as? [[String: Any]] ?? [] + let roles = volumes.compactMap { $0["Roles"] as? [String] }.flatMap { $0 } + let hasLockedVolumes = volumes.contains { ($0["Locked"] as? Bool) == true } + + // Detect MAIN container: has "System" or "Data" role (the boot/data container) + let hasSystemOrData = roles.contains(where: { $0 == "System" }) || roles.contains(where: { $0 == "Data" }) + + // Detect ISC container: has "xART" or "Hardware" roles (unique to Internal Shared Cache) + let hasISCRoles = roles.contains(where: { $0 == "xART" }) || roles.contains(where: { $0 == "Hardware" }) + + // The main container is the one with System/Data and NOT ISC + let isMainContainer = hasSystemOrData && !hasISCRoles + + let physicalStores = container["PhysicalStores"] as? [[String: Any]] ?? [] + for store in physicalStores { + guard let storeIdentifier = store["DeviceIdentifier"] as? String else { continue } + guard storeIdentifier.hasPrefix(cleanDeviceNode) || containerRef == cleanDeviceNode else { continue } + let size = store["Size"] as? UInt64 ?? 0 + let info = APFSContainerInfo(container: containerRef, physicalStore: storeIdentifier, hasLockedVolumes: hasLockedVolumes) + candidates.append((info: info, size: size, isMainContainer: isMainContainer)) + NSLog("APFS candidate: container=\(containerRef), store=\(storeIdentifier), size=\(size), isMain=\(isMainContainer), hasSystemOrData=\(hasSystemOrData), hasISCRoles=\(hasISCRoles), roles=\(roles)") + } + + if containerRef == cleanDeviceNode { + let size = (physicalStores.first?["Size"] as? UInt64) ?? 0 + let info = APFSContainerInfo(container: containerRef, physicalStore: nil, hasLockedVolumes: hasLockedVolumes) + candidates.append((info: info, size: size, isMainContainer: isMainContainer)) + } + } + + guard !candidates.isEmpty else { + NSLog("No APFS container found in 'diskutil apfs list' for device \(cleanDeviceNode)") + return nil + } + + // Selection priority: + // 1. Find the MAIN container (has System/Data, not ISC) that is unlocked + // 2. Fall back to largest unlocked container + // 3. Fall back to any container + + let selected: (info: APFSContainerInfo, size: UInt64, isMainContainer: Bool)? + + // First priority: unlocked main container + if let mainUnlocked = candidates.first(where: { $0.isMainContainer && !$0.info.hasLockedVolumes }) { + selected = mainUnlocked + NSLog("Selected unlocked main APFS container: \(mainUnlocked.info.container)") + } + // Second priority: any main container (even if locked) + else if let mainAny = candidates.first(where: { $0.isMainContainer }) { + selected = mainAny + NSLog("Selected main APFS container (locked): \(mainAny.info.container)") + } + // Third priority: largest unlocked non-main container + else if let largestUnlocked = candidates.filter({ !$0.info.hasLockedVolumes }).max(by: { $0.size < $1.size }) { + selected = largestUnlocked + NSLog("Selected largest unlocked APFS container: \(largestUnlocked.info.container)") + } + // Last resort: any container + else { + selected = candidates.first + NSLog("Selected fallback APFS container: \(selected?.info.container ?? "none")") + } + + if let selected = selected { + NSLog("Final APFS container selection: \(selected.info.container) (store: \(selected.info.physicalStore ?? "none"), size: \(selected.size), isMain: \(selected.isMainContainer))") + } + + return selected?.info + } + + private static func findAPFSContainer(in diskutilOutput: String, deviceNode: String) -> APFSContainerInfo? { + let lines = diskutilOutput.components(separatedBy: .newlines) + var foundContainers: [(info: APFSContainerInfo, isMain: Bool)] = [] // (partition, containerRef, isMainContainer) + + // Look for APFS Container entries with their container references + // Format: "2: Apple_APFS Container disk11 47.8 GB disk8s2" + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Skip header and empty lines + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + // Look for Apple_APFS entries (but not ISC or Recovery) + if trimmed.contains("Apple_APFS") && !trimmed.contains("Apple_APFS_Recovery") { + let components = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + + // Find partition number + var partitionNum: String? + var containerRef: String? + + for (index, component) in components.enumerated() { + // Get partition number (e.g., "2:" -> "2") + if component.hasSuffix(":") { + partitionNum = String(component.dropLast()) + } + + // Look for "Container disk" pattern + if component == "Container" && index + 1 < components.count { + let nextComponent = components[index + 1] + if nextComponent.hasPrefix("disk") { + containerRef = nextComponent + } + } + } + + if let partition = partitionNum { + let partitionDevice = sanitizeDeviceIdentifier("\(deviceNode)s\(partition)") + let isMainContainer = !trimmed.contains("Apple_APFS_ISC") + + let containerIdentifier = sanitizeDeviceIdentifier(containerRef ?? partitionDevice) + let info = APFSContainerInfo(container: containerIdentifier, physicalStore: partitionDevice, hasLockedVolumes: false) + foundContainers.append((info: info, isMain: isMainContainer)) + + NSLog("Found APFS partition: \(partitionDevice) -> Container: \(containerIdentifier) (main: \(isMainContainer))") + } + } + } + + // Prefer main containers over ISC containers + if let mainContainer = foundContainers.first(where: { $0.isMain }) { + NSLog("Using main APFS container: \(mainContainer.info.container)") + return APFSContainerInfo(container: mainContainer.info.container, physicalStore: mainContainer.info.physicalStore, hasLockedVolumes: false) + } else if let anyContainer = foundContainers.first { + NSLog("Using fallback APFS container: \(anyContainer.info.container)") + return APFSContainerInfo(container: anyContainer.info.container, physicalStore: anyContainer.info.physicalStore, hasLockedVolumes: false) + } + + NSLog("No APFS container found in diskutil output") + return nil + } + + private static func ensureAPFSContainerMaximized(info: APFSContainerInfo) async throws { + if info.hasLockedVolumes { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + + guard let details = try fetchAPFSContainerDetails(container: info.container) else { + return + } + + let physicalSize = details.physicalStoreSize + let capacity = details.capacityCeiling + let tolerance: UInt64 = 1 * 1024 * 1024 // 1 MB tolerance to account for rounding + + if physicalSize > capacity + tolerance { + NSLog("APFS container \(info.container) ceiling (\(capacity)) is below physical store size (\(physicalSize)); nudging container") + try await nudgeAPFSContainer(info: info, physicalSize: physicalSize) + + if let postDetails = try fetchAPFSContainerDetails(container: info.container) { + NSLog("Post-nudge container ceiling: \(postDetails.capacityCeiling) (store: \(postDetails.physicalStoreSize))") + } + } + } + + private static func fetchAPFSContainerDetails(container: String) throws -> APFSContainerDetails? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["apfs", "list", "-plist", container] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + NSLog("Failed to query APFS container \(container): \(output)") + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let containers = plist["Containers"] as? [[String: Any]], + let first = containers.first, + let capacity = first["CapacityCeiling"] as? UInt64, + let stores = first["PhysicalStores"] as? [[String: Any]], + let store = stores.first, + let storeSize = store["Size"] as? UInt64 + else { + NSLog("Could not parse APFS container details for \(container)") + return nil + } + + return APFSContainerDetails(capacityCeiling: capacity, physicalStoreSize: storeSize) + } + + private static func nudgeAPFSContainer(info: APFSContainerInfo, physicalSize: UInt64) async throws { + let alignment: UInt64 = 4096 + let shrinkDelta: UInt64 = 32 * 1024 * 1024 // 32 MB nudge to ensure actual size change + let resizeTarget = info.physicalStore ?? info.container + + guard physicalSize > alignment else { return } + + let tentativeShrink = physicalSize > shrinkDelta ? physicalSize - shrinkDelta : physicalSize - alignment + let alignedShrink = max((tentativeShrink / alignment) * alignment, alignment) + + let shrinkArg = "\(alignedShrink)B" + let shrinkResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, shrinkArg]) + + if shrinkResult.status != 0 { + NSLog("APFS shrink nudge for \(resizeTarget) failed: \(shrinkResult.output)") + if shrinkResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + + let growResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) + if growResult.status != 0 { + NSLog("APFS grow after nudge for \(resizeTarget) failed: \(growResult.output)") + if growResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + } + + private static func runDiskutilCommand(arguments: [String]) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + } catch { + NSLog("Failed to run diskutil \(arguments.joined(separator: " ")): \(error)") + return (-1, "\(error)") + } + + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, output) + } + + private static func adjustGPTLayoutForRawImage(at url: URL, newSize: UInt64) throws { + try GPTLayoutAdjuster(imageURL: url, newSize: newSize).perform() + } + + private struct GPTLayoutAdjuster { + let imageURL: URL + let newSize: UInt64 + + private let sectorSize: UInt64 = 512 + private let mainContainerGUID = UUID(uuidString: "7C3457EF-0000-11AA-AA11-00306543ECAC")! + private let recoveryGUID = UUID(uuidString: "52637672-7900-11AA-AA11-00306543ECAC")! + + func perform() throws { + guard newSize % sectorSize == 0 else { + throw VBDiskResizeError.systemCommandFailed("New disk size must be 512-byte aligned", -1) + } + + let fileHandle = try FileHandle(forUpdating: imageURL) + defer { try? fileHandle.close() } + + let headerOffset = sectorSize + try fileHandle.vbSeek(to: headerOffset) + let headerData = try readExactly(fileHandle: fileHandle, length: Int(sectorSize)) + + var header = GPTHeader(data: headerData) + let entriesOffset = UInt64(header.partitionEntriesLBA) * sectorSize + let entriesLength = Int(header.numberOfEntries) * Int(header.entrySize) + + try fileHandle.vbSeek(to: entriesOffset) + var entries = try readExactly(fileHandle: fileHandle, length: entriesLength) + + guard + let mainIndex = findPartitionIndex(in: entries, guid: mainContainerGUID, entrySize: Int(header.entrySize), preferLargest: true), + let recoveryIndex = findPartitionIndex(in: entries, guid: recoveryGUID, entrySize: Int(header.entrySize), preferLargest: false) + else { + throw NSError(domain: "VBDiskResizer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not locate APFS partitions in GPT"]) + } + + let mainLast = readUInt64LittleEndian(from: entries, offset: mainIndex * Int(header.entrySize) + 40) + let recoveryFirst = readUInt64LittleEndian(from: entries, offset: recoveryIndex * Int(header.entrySize) + 32) + let recoveryLast = readUInt64LittleEndian(from: entries, offset: recoveryIndex * Int(header.entrySize) + 40) + + let recoveryLength = recoveryLast - recoveryFirst + 1 + + let totalSectors = newSize / sectorSize + let newBackupLBA = totalSectors - 1 + let backupEntriesLBA = newBackupLBA - 32 + var newLastUsable = backupEntriesLBA - 8 + var newRecoveryFirst = newLastUsable - (recoveryLength - 1) + + let alignment: UInt64 = 8 + let remainder = newRecoveryFirst % alignment + if remainder != 0 { + newRecoveryFirst -= remainder + newLastUsable = newRecoveryFirst + recoveryLength - 1 + } + + let newMainLast = newRecoveryFirst - 1 + + guard newMainLast > mainLast else { + // Nothing to do if the main container already occupies the space + return + } + + try copySectors( + fileHandle: fileHandle, + from: recoveryFirst, + to: newRecoveryFirst, + count: recoveryLength, + sectorSize: sectorSize + ) + + try zeroSectors( + fileHandle: fileHandle, + start: recoveryFirst, + count: recoveryLength, + sectorSize: sectorSize + ) + + writeUInt64LittleEndian( + &entries, + offset: mainIndex * Int(header.entrySize) + 40, + value: newMainLast + ) + + writeUInt64LittleEndian( + &entries, + offset: recoveryIndex * Int(header.entrySize) + 32, + value: newRecoveryFirst + ) + + writeUInt64LittleEndian( + &entries, + offset: recoveryIndex * Int(header.entrySize) + 40, + value: newLastUsable + ) + + header.backupLBA = newBackupLBA + header.lastUsableLBA = newLastUsable + header.partitionEntriesCRC32 = crc32(of: entries) + + try fileHandle.vbSeek(to: entriesOffset) + try fileHandle.vbWriteAll(entries) + + let primaryHeaderData = header.serialized(sectorSize: sectorSize, isBackup: false) + try fileHandle.vbSeek(to: headerOffset) + try fileHandle.vbWriteAll(primaryHeaderData) + + let backupEntriesOffset = backupEntriesLBA * sectorSize + try fileHandle.vbSeek(to: backupEntriesOffset) + try fileHandle.vbWriteAll(entries) + + let backupHeaderData = header.serialized(sectorSize: sectorSize, isBackup: true) + try fileHandle.vbSeek(to: newBackupLBA * sectorSize) + try fileHandle.vbWriteAll(backupHeaderData) + + try fileHandle.vbSynchronize() + } + + private func readExactly(fileHandle: FileHandle, length: Int) throws -> Data { + let data = try fileHandle.vbRead(upToCount: length) ?? Data() + guard data.count == length else { + throw NSError(domain: "VBDiskResizer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read expected GPT data"]) + } + return data + } + + private func findPartitionIndex(in entries: Data, guid: UUID, entrySize: Int, preferLargest: Bool) -> Int? { + var bestIndex: Int? + var bestLength: UInt64 = 0 + + for index in 0..<(entries.count / entrySize) { + let base = index * entrySize + let typeData = entries.subdata(in: base..<(base + 16)) + guard let entryGUID = uuidFromGPTBytes(typeData), entryGUID == guid else { + continue + } + + if !preferLargest { + return index + } + + let first = readUInt64LittleEndian(from: entries, offset: base + 32) + let last = readUInt64LittleEndian(from: entries, offset: base + 40) + let length = last >= first ? last - first : 0 + if length > bestLength { + bestLength = length + bestIndex = index + } + } + + return preferLargest ? bestIndex : nil + } + + private func copySectors(fileHandle: FileHandle, from: UInt64, to: UInt64, count: UInt64, sectorSize: UInt64) throws { + let bufferSize: UInt64 = 4 * 1024 * 1024 + var remaining = count * sectorSize + var readOffset = from * sectorSize + var writeOffset = to * sectorSize + + while remaining > 0 { + let chunk = Int(min(bufferSize, remaining)) + try fileHandle.vbSeek(to: readOffset) + let data = try readExactly(fileHandle: fileHandle, length: chunk) + + try fileHandle.vbSeek(to: writeOffset) + try fileHandle.vbWriteAll(data) + + remaining -= UInt64(chunk) + readOffset += UInt64(chunk) + writeOffset += UInt64(chunk) + } + } + + private func zeroSectors(fileHandle: FileHandle, start: UInt64, count: UInt64, sectorSize: UInt64) throws { + let bufferSize: UInt64 = 4 * 1024 * 1024 + var remaining = count * sectorSize + var offset = start * sectorSize + let zeroChunk = Data(count: Int(min(bufferSize, remaining))) + + while remaining > 0 { + let chunk = Int(min(UInt64(zeroChunk.count), remaining)) + try fileHandle.vbSeek(to: offset) + try fileHandle.vbWriteAll(zeroChunk.prefix(chunk)) + + remaining -= UInt64(chunk) + offset += UInt64(chunk) + } + } + } + + private struct GPTHeader { + var signature: UInt64 + var revision: UInt32 + var headerSize: UInt32 + var headerCRC32: UInt32 + var reserved: UInt32 + var currentLBA: UInt64 + var backupLBA: UInt64 + var firstUsableLBA: UInt64 + var lastUsableLBA: UInt64 + var diskGUID: Data + var partitionEntriesLBA: UInt64 + var numberOfEntries: UInt32 + var entrySize: UInt32 + var partitionEntriesCRC32: UInt32 + + init(data: Data) { + signature = readUInt64LittleEndian(from: data, offset: 0) + revision = readUInt32LittleEndian(from: data, offset: 8) + headerSize = readUInt32LittleEndian(from: data, offset: 12) + headerCRC32 = readUInt32LittleEndian(from: data, offset: 16) + reserved = readUInt32LittleEndian(from: data, offset: 20) + currentLBA = readUInt64LittleEndian(from: data, offset: 24) + backupLBA = readUInt64LittleEndian(from: data, offset: 32) + firstUsableLBA = readUInt64LittleEndian(from: data, offset: 40) + lastUsableLBA = readUInt64LittleEndian(from: data, offset: 48) + diskGUID = data.subdata(in: 56..<72) + partitionEntriesLBA = readUInt64LittleEndian(from: data, offset: 72) + numberOfEntries = readUInt32LittleEndian(from: data, offset: 80) + entrySize = readUInt32LittleEndian(from: data, offset: 84) + partitionEntriesCRC32 = readUInt32LittleEndian(from: data, offset: 88) + } + + func serialized(sectorSize: UInt64, isBackup: Bool) -> Data { + var data = Data(count: Int(sectorSize)) + writeUInt64LittleEndian(&data, offset: 0, value: signature) + writeUInt32LittleEndian(&data, offset: 8, value: revision) + writeUInt32LittleEndian(&data, offset: 12, value: headerSize) + writeUInt32LittleEndian(&data, offset: 16, value: 0) // placeholder for CRC + writeUInt32LittleEndian(&data, offset: 20, value: reserved) + let current = isBackup ? backupLBA : currentLBA + let backup = isBackup ? currentLBA : backupLBA + writeUInt64LittleEndian(&data, offset: 24, value: current) + writeUInt64LittleEndian(&data, offset: 32, value: backup) + writeUInt64LittleEndian(&data, offset: 40, value: firstUsableLBA) + writeUInt64LittleEndian(&data, offset: 48, value: lastUsableLBA) + data.replaceSubrange(56..<72, with: diskGUID) + let entriesLBA = isBackup ? (backupLBA - 32) : partitionEntriesLBA + writeUInt64LittleEndian(&data, offset: 72, value: entriesLBA) + writeUInt32LittleEndian(&data, offset: 80, value: numberOfEntries) + writeUInt32LittleEndian(&data, offset: 84, value: entrySize) + writeUInt32LittleEndian(&data, offset: 88, value: partitionEntriesCRC32) + + let crc = crc32(of: data.prefix(Int(headerSize))) + writeUInt32LittleEndian(&data, offset: 16, value: crc) + return data + } + } + + private static func crc32(of data: Data) -> UInt32 { + data.withUnsafeBytes { buffer -> UInt32 in + guard let base = buffer.bindMemory(to: UInt8.self).baseAddress else { return 0 } + return UInt32(zlib.crc32(0, base, uInt(buffer.count))) + } + } + + private static func uuidFromGPTBytes(_ data: Data) -> UUID? { + guard data.count == 16 else { return nil } + let a = readUInt32LittleEndian(from: data, offset: 0) + let b = readUInt16LittleEndian(from: data, offset: 4) + let c = readUInt16LittleEndian(from: data, offset: 6) + let tail = Array(data[8..<16]) + let uuidString = String( + format: "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", + a, b, c, + tail[0], tail[1], + tail[2], tail[3], + tail[4], tail[5], tail[6], tail[7] + ) + return UUID(uuidString: uuidString) + } + + private static func readUInt64LittleEndian(from data: Data, offset: Int) -> UInt64 { + let range = offset..<(offset + 8) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt64.self) }.littleEndian + } + + private static func readUInt32LittleEndian(from data: Data, offset: Int) -> UInt32 { + let range = offset..<(offset + 4) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt32.self) }.littleEndian + } + + private static func readUInt16LittleEndian(from data: Data, offset: Int) -> UInt16 { + let range = offset..<(offset + 2) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt16.self) }.littleEndian + } + + private static func writeUInt64LittleEndian(_ data: inout Data, offset: Int, value: UInt64) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 8), with: bytes) + } + } + + private static func writeUInt32LittleEndian(_ data: inout Data, offset: Int, value: UInt32) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 4), with: bytes) + } + } + + private static func writeUInt16LittleEndian(_ data: inout Data, offset: Int, value: UInt16) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 2), with: bytes) + } + } + +} diff --git a/VirtualCore/Source/Utilities/VolumeUtils.swift b/VirtualCore/Source/Utilities/VolumeUtils.swift index a5113d9a..394fa2f0 100644 --- a/VirtualCore/Source/Utilities/VolumeUtils.swift +++ b/VirtualCore/Source/Utilities/VolumeUtils.swift @@ -56,6 +56,17 @@ public extension URL { } } + /// The full capacity of the volume that contains this URL. + var totalDiskSpaceOnVolume: UInt64? { + do { + let attrs = try FileManager.default.attributesOfFileSystem(forPath: path) + guard let totalSize = attrs[.systemSize] as? UInt64 else { return nil } + return totalSize + } catch { + return nil + } + } + /// User-friendly name for the volume that contains this URL. var containingVolumeName: String? { guard let volumeURL = (try? resourceValues(forKeys: [.volumeURLKey]))?.volume else { return nil } diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index fc11f1c3..1c17e985 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -62,6 +62,7 @@ public struct VMSessionOptions: Hashable, Codable { public enum VMState: Equatable { case idle case starting(_ message: String?) + case resizingDisk(_ message: String?) case running(VZVirtualMachine) case paused(VZVirtualMachine) case savingState(VZVirtualMachine) @@ -171,6 +172,24 @@ public final class VMController: ObservableObject { await waitForGuestDiskImageReadyIfNeeded() + if virtualMachineModel.hasPendingDiskImageResizes { + do { + state = .resizingDisk("Preparing disk resize...") + var updatedModel = virtualMachineModel + try await updatedModel.checkAndResizeDiskImages { message in + self.state = .resizingDisk(message) + } + virtualMachineModel = updatedModel + state = .starting("Starting virtual machine...") + } catch { + logger.warning("Failed to resize disk images: \(error, privacy: .public)") + presentDiskResizeError(error) + state = .starting("Starting virtual machine...") + } + } else { + state = .starting("Starting virtual machine...") + } + try await updatingState { let newInstance = try createInstance() self.instance = newInstance @@ -201,6 +220,23 @@ public final class VMController: ObservableObject { } } + private func presentDiskResizeError(_ error: Error) { + let alert = NSAlert() + + if case let VBDiskResizeError.apfsVolumesLocked(container) = error { + alert.messageText = "Unlock FileVault to Finish Resizing" + alert.informativeText = "VirtualBuddy enlarged the disk image, but the APFS container \(container) is still locked. Start the guest, sign in to unlock FileVault, then use Disk Utility (or run 'diskutil apfs resizeContainer disk0s2 0') inside the guest to claim the newly added space." + alert.alertStyle = .informational + } else { + alert.messageText = "Disk Resize Failed" + alert.informativeText = "VirtualBuddy couldn't resize disk images before startup. The virtual machine will continue starting.\n\n\(error.localizedDescription)" + alert.alertStyle = .warning + } + + alert.addButton(withTitle: "OK") + alert.runModal() + } + /// Checks whether this virtual machine's network devices share a MAC address with any running virtual machine, /// asking the conflict handler how to proceed if so. /// @@ -437,6 +473,7 @@ public extension VMState { switch lhs { case .idle: return rhs.isIdle case .starting: return rhs.isStarting + case .resizingDisk: return rhs.isResizingDisk case .running: return rhs.isRunning case .paused: return rhs.isPaused case .stopped: return rhs.isStopped @@ -455,6 +492,10 @@ public extension VMState { guard case .starting = self else { return false } return true } + var isResizingDisk: Bool { + guard case .resizingDisk = self else { return false } + return true + } var isRunning: Bool { guard case .running = self else { return false } diff --git a/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift b/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift index 2a3321fd..1ee9dcd9 100644 --- a/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift +++ b/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift @@ -20,7 +20,7 @@ struct InstallConfigurationStepView: View { } var body: some View { - VMConfigurationSheet(configuration: $vm.configuration, customConfirmationButtonAction: { configuration in + VMConfigurationSheet(configuration: $vm.configuration, metadata: $vm.metadata, customConfirmationButtonAction: { configuration in var updatedVM = vm updatedVM.configuration = configuration self.vm = updatedVM diff --git a/VirtualUI/Source/Session/Components/VirtualMachineControls.swift b/VirtualUI/Source/Session/Components/VirtualMachineControls.swift index 898a2c2b..92113934 100644 --- a/VirtualUI/Source/Session/Components/VirtualMachineControls.swift +++ b/VirtualUI/Source/Session/Components/VirtualMachineControls.swift @@ -36,7 +36,7 @@ struct VirtualMachineControls: View { var body: some View { Group { switch controller.state { - case .idle, .paused, .stopped, .savingState, .restoringState, .stateSaveCompleted: + case .idle, .paused, .stopped, .savingState, .restoringState, .stateSaveCompleted, .resizingDisk: Button { runToolbarAction { if controller.state.canResume { @@ -48,7 +48,7 @@ struct VirtualMachineControls: View { } label: { Image(systemName: "play") } - .disabled(controller.state.isSavingState || controller.state.isRestoringState) + .disabled(controller.state.isSavingState || controller.state.isRestoringState || controller.state.isResizingDisk) case .starting, .running: if #available(macOS 14.0, *), controller.virtualMachineModel.supportsStateRestoration { Button { diff --git a/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift b/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift index 5e6db525..9823f27c 100644 --- a/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift +++ b/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift @@ -64,7 +64,8 @@ struct VMSessionConfigurationView: View { .airMaterialBackground(visualEffect: .hudWindow, glassEffect: .clear, in: shape) .sheet(isPresented: $isShowingVMSettings) { VMConfigurationSheet( - configuration: $controller.virtualMachineModel.configuration + configuration: $controller.virtualMachineModel.configuration, + metadata: $controller.virtualMachineModel.metadata ) .environmentObject(VMConfigurationViewModel(vm, resolvedRestoreImage: resolvedRestoreImage)) } diff --git a/VirtualUI/Source/Session/VirtualMachineSessionView.swift b/VirtualUI/Source/Session/VirtualMachineSessionView.swift index ee21f963..a3913b33 100644 --- a/VirtualUI/Source/Session/VirtualMachineSessionView.swift +++ b/VirtualUI/Source/Session/VirtualMachineSessionView.swift @@ -97,6 +97,18 @@ public struct VirtualMachineSessionView: View { .frame(maxWidth: 400) } } + case .resizingDisk(let message): + VStack(spacing: 12) { + ProgressView() + + if let message { + Text(message) + .foregroundStyle(.secondary) + .font(.subheadline) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + } + } case .running(let vm): vmView(with: vm) case .paused(let vm), .savingState(let vm), .restoringState(let vm, _), .stateSaveCompleted(let vm, _): @@ -127,6 +139,11 @@ public struct VirtualMachineSessionView: View { switch controller.state { case .paused: circularStartButton + case .resizingDisk(let message): + VMProgressOverlay( + message: message ?? "Resizing Disk Image", + duration: 30 + ) case .savingState, .stateSaveCompleted: VMProgressOverlay( message: controller.state.isStateSaveCompleted ? "State Saved!" : "Saving Virtual Machine State", diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index e47cc0dd..7a0b65cd 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -10,25 +10,29 @@ import VirtualCore struct ManagedDiskImageEditor: View { @State private var image: VBManagedDiskImage + let virtualMachine: VBVirtualMachine var minimumSize: UInt64 var isExistingDiskImage: Bool var onSave: (VBManagedDiskImage) -> Void var isBootVolume: Bool + var canResize: Bool - init(image: VBManagedDiskImage, isExistingDiskImage: Bool, isForBootVolume: Bool, onSave: @escaping (VBManagedDiskImage) -> Void) { + init(image: VBManagedDiskImage, virtualMachine: VBVirtualMachine, isExistingDiskImage: Bool, isForBootVolume: Bool, onSave: @escaping (VBManagedDiskImage) -> Void) { self._image = .init(wrappedValue: image) + self.virtualMachine = virtualMachine self.isExistingDiskImage = isExistingDiskImage self.onSave = onSave let fallbackMinimumSize = isForBootVolume ? VBManagedDiskImage.minimumBootDiskImageSize : VBManagedDiskImage.minimumExtraDiskImageSize self.minimumSize = isExistingDiskImage ? image.size : fallbackMinimumSize self.isBootVolume = isForBootVolume + self.canResize = isExistingDiskImage && image.canBeResized } private let formatter: ByteCountFormatter = { let f = ByteCountFormatter() f.allowedUnits = [.useGB, .useMB, .useTB] f.formattingContext = .standalone - f.countStyle = .file + f.countStyle = .binary return f }() @@ -37,6 +41,8 @@ struct ManagedDiskImageEditor: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var viewModel: VMConfigurationViewModel + var body: some View { VStack(alignment: .leading) { VStack(alignment: .leading) { @@ -50,23 +56,55 @@ struct ManagedDiskImageEditor: View { } } - let maximumSize = isBootVolume ? VBManagedDiskImage.maximumBootDiskImageSize : VBManagedDiskImage.maximumExtraDiskImageSize - NumericPropertyControl( - value: $image.size.gbStorageValue, - range: minimumSize.gbStorageValue...maximumSize.gbStorageValue, - hideSlider: isExistingDiskImage, - label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", - formatter: NumberFormatter.numericPropertyControlDefault - ) - .disabled(isExistingDiskImage) - .foregroundColor(sizeWarning != nil ? .yellow : .primary) + HStack(alignment: .top) { + NumericPropertyControl( + value: $image.size.gbStorageValue, + range: selectableSizeRangeInGigabytes, + step: 1, + hideSlider: isExistingDiskImage && !canResize, + label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", + formatter: NumberFormatter.numericPropertyControlDefault + ) + .disabled(isExistingDiskImage && !canResize) + .foregroundColor(sizeWarning != nil ? .yellow : .primary) + + if isExistingDiskImage && canResize { + Stepper( + value: $image.size.gbStorageValue, + in: selectableSizeRangeInGigabytes, + step: 1 + ) { EmptyView() } + .labelsHidden() + .disabled(!canIncreaseSize) + .help("Adjust disk size by 1 GB") + } + } VStack(alignment: .leading, spacing: 8) { + if isExistingDiskImage && canResize { + HStack(spacing: 8) { + Button("Use Maximum") { + image.size = maximumSelectableSize + } + .controlSize(.small) + .disabled(!canIncreaseSize || image.size == maximumSelectableSize) + + if let storageLimitMessage { + Text(storageLimitMessage) + } + } + } + if !isExistingDiskImage, !isBootVolume { Text("You'll have to use Disk Utility in the guest operating system to initialize the disk image. If you see an error after it boots up, choose the \"Initialize\" option.") .foregroundColor(.yellow) } + if isExistingDiskImage && canResize { + Text("This \(image.format.displayName) can be expanded. After resizing, you may need to expand the partition using Disk Utility in the guest operating system.") + .foregroundColor(.blue) + } + if let sizeWarning { Text(sizeWarning) .foregroundColor(.yellow) @@ -89,8 +127,63 @@ struct ManagedDiskImageEditor: View { .lineLimit(nil) } .onChange(of: image) { _, newValue in + viewModel.updateDiskImageResizeConfirmation( + for: newValue, + originalSize: minimumSize, + deviceName: deviceName, + isExistingDiskImage: isExistingDiskImage, + canResize: canResize + ) onSave(newValue) } + .onAppear { + image.size = image.size.limited(to: selectableSizeRange) + } + } + + private var configuredMaximumSize: UInt64 { + isBootVolume ? VBManagedDiskImage.maximumBootDiskImageSize : VBManagedDiskImage.maximumExtraDiskImageSize + } + + private var maximumSelectableSize: UInt64 { + let libraryURL = VBSettingsContainer.current.settings.libraryURL + + let rawMaximum = VBManagedDiskImage.maximumSelectableSize( + configuredMaximum: configuredMaximumSize, + minimumSize: minimumSize, + existingImageSize: isExistingDiskImage ? minimumSize : nil, + availableSpace: libraryURL.freeDiskSpaceOnVolume, + volumeCapacity: libraryURL.totalDiskSpaceOnVolume + ) + + let gigabyteAlignedMaximum = UInt64(rawMaximum.gbStorageValue) * .storageGigabyte + return max(minimumSize, gigabyteAlignedMaximum) + } + + private var selectableSizeRange: ClosedRange { + minimumSize...maximumSelectableSize + } + + private var selectableSizeRangeInGigabytes: ClosedRange { + minimumSize.gbStorageValue...maximumSelectableSize.gbStorageValue + } + + private var canIncreaseSize: Bool { + maximumSelectableSize > minimumSize + } + + private var deviceName: String { + isBootVolume ? "Boot" : image.filename + } + + private var storageLimitMessage: String? { + guard canResize else { return nil } + guard let availableSpace = VBSettingsContainer.current.settings.libraryURL.freeDiskSpaceOnVolume else { return nil } + + let availableDescription = formatter.string(fromByteCount: Int64(availableSpace)) + let maximumDescription = formatter.string(fromByteCount: Int64(maximumSelectableSize)) + + return "Up to \(maximumDescription), based on \(availableDescription) free on \(volumeDescription)." } private var sizeMessagePrefix: String? { @@ -98,32 +191,40 @@ struct ManagedDiskImageEditor: View { } private var sizeChangeInfo: String { - if isBootVolume { - return "Be sure to reserve enough space, since it won't be possible to change the size of the disk later." - } else { - return "It's not possible to change the size of an existing storage device." + switch (isBootVolume, canResize) { + case (true, true): + "Boot disk can be expanded, but not shrunk. Choose your size carefully." + case (true, false): + "Be sure to reserve enough space, since it won't be possible to change the size of the disk later." + case (false, true): + "This disk can be expanded to a larger size, but cannot be shrunk." + case (false, false): + "It's not possible to change the size of an existing storage device." } } private var sizeMessage: String { if isExistingDiskImage { - return sizeChangeInfo + sizeChangeInfo } else { - return "\(sizeMessagePrefix ?? "")After adding the storage device, it won't be possible to change the size of its disk image with VirtualBuddy." + "\(sizeMessagePrefix ?? "")After adding the storage device, it won't be possible to change the size of its disk image with VirtualBuddy." } } private var sizeWarning: String? { guard !VBSettingsContainer.current.libraryVolumeCanFit(image.size) else { return nil } - let volumeDescription: String + + return "The volume \(volumeDescription) doesn't have enough free space to fit the full size of the disk image." + } + + private var volumeDescription: String { if let volumeName = VBSettingsContainer.current.settings.libraryURL.containingVolumeName { - volumeDescription = "\"\(volumeName)\"" + return "\"\(volumeName)\"" } else { - volumeDescription = "where your library is stored" + return "where your library is stored" } - - return "The volume \(volumeDescription) doesn't have enough free space to fit the full size of the disk image." } + } #if DEBUG diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift index b6fe545f..1dab8c5e 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift @@ -120,6 +120,13 @@ struct StorageDeviceListItem: View { Spacer() + if device.canBeResized { + Image(systemName: "arrow.up.right.and.arrow.down.left") + .font(.caption) + .foregroundStyle(.secondary) + .help("This disk can be resized") + } + Button { configureDevice() } label: { @@ -127,7 +134,7 @@ struct StorageDeviceListItem: View { } .help("Device settings") .buttonStyle(.plain) - .disabled(device.isBootVolume) + .disabled(device.isBootVolume && !device.canBeResized) } .padding(.leading, 6) .opacity(device.isEnabled ? 1 : 0.8) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift index bf5589c3..a725ef3d 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift @@ -22,9 +22,13 @@ struct StorageDeviceDetailView: View { } @State private var isLoading = false + @State private var showResizeConfirmation = false + @State private var showFileVaultError = false + @State private var fileVaultErrorMessage = "" + @State private var isPreparingDiskResize = false private var canSave: Bool { - guard !isLoading else { return false } + guard !isLoading, !isPreparingDiskResize else { return false } if imageType == .managed { return device.managedImage != nil @@ -63,7 +67,7 @@ struct StorageDeviceDetailView: View { HStack { Button("Cancel") { - dismiss() + cancel() } .keyboardShortcut(.cancelAction) @@ -77,9 +81,68 @@ struct StorageDeviceDetailView: View { } .padding(.top) } + .alert("Resize Disk Image", isPresented: $showResizeConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Resize") { + confirmDiskResizeAndSave() + } + } message: { + Text(viewModel.diskImageResizeConfirmationMessage(for: device.managedImage, formatter: diskResizeFormatter)) + } + .alert("FileVault Enabled", isPresented: $showFileVaultError) { + Button("OK", role: .cancel) { } + } message: { + Text(fileVaultErrorMessage) + } } + private let diskResizeFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useTB] + formatter.formattingContext = .standalone + formatter.countStyle = .binary + return formatter + }() + + private func cancel() { + if let image = device.managedImage { + viewModel.clearPendingDiskImageResize(for: image) + } + + dismiss() + } + private func save() { + if viewModel.hasPendingDiskImageResizeConfirmation(for: device.managedImage) { + showResizeConfirmation = true + return + } + + saveDevice() + } + + private func confirmDiskResizeAndSave() { + isPreparingDiskResize = true + + Task { + if let deviceName = await viewModel.firstFileVaultProtectedPendingResizeName(for: device.managedImage) { + await MainActor.run { + fileVaultErrorMessage = "The \(deviceName) disk has FileVault encryption enabled. To resize the disk, you must first disable FileVault in the guest operating system's System Settings, then restart the virtual machine before attempting to resize again." + showFileVaultError = true + isPreparingDiskResize = false + } + return + } + + await MainActor.run { + viewModel.confirmPendingDiskImageResizes(for: device.managedImage) + isPreparingDiskResize = false + saveDevice() + } + } + } + + private func saveDevice() { isLoading = true Task { @@ -202,6 +265,7 @@ struct StorageDeviceDetailView: View { case .managedImage(let image): ManagedDiskImageEditor( image: image, + virtualMachine: viewModel.vm, isExistingDiskImage: device.diskImageExists(for: viewModel.vm), isForBootVolume: device.isBootVolume, onSave: { device.update(with: $0, type: .size) } diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift b/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift index 87bd6700..d1049d8c 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift @@ -22,20 +22,42 @@ public struct VMConfigurationSheet: View { /// Setting this saves the configuration. @Binding private var savedConfiguration: VBMacConfiguration + @Binding private var savedMetadata: VBVirtualMachine.Metadata + private var appliesMetadataChanges: Bool + @State private var showValidationErrors = false + @State private var showResizeConfirmation = false + @State private var showFileVaultError = false + @State private var fileVaultErrorMessage = "" + @State private var isPreparingDiskResize = false private var showsCancelButton: Bool { viewModel.context == .postInstall } private var customConfirmationButtonAction: ((VBMacConfiguration) -> Void)? = nil + + private let diskResizeFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useTB] + formatter.formattingContext = .standalone + formatter.countStyle = .binary + return formatter + }() /// Initializes the VM configuration sheet, bound to a VM configuration model. /// - Parameter configuration: The binding that will be updated when the user saves the configuration by clicking the "Done" button. - public init(configuration: Binding) { - self.init(configuration: configuration, showingValidationErrors: false) + public init(configuration: Binding, metadata: Binding? = nil) { + self.init(configuration: configuration, metadata: metadata, showingValidationErrors: false) } - init(configuration: Binding, showingValidationErrors: Bool = false, customConfirmationButtonAction: ((VBMacConfiguration) -> Void)? = nil) { + init( + configuration: Binding, + metadata: Binding? = nil, + showingValidationErrors: Bool = false, + customConfirmationButtonAction: ((VBMacConfiguration) -> Void)? = nil + ) { self.initialConfiguration = configuration.wrappedValue self._savedConfiguration = configuration + self._savedMetadata = metadata ?? .constant(VBVirtualMachine.Metadata()) + self.appliesMetadataChanges = metadata != nil self._showValidationErrors = .init(wrappedValue: showingValidationErrors) self.customConfirmationButtonAction = customConfirmationButtonAction } @@ -81,7 +103,7 @@ public struct VMConfigurationSheet: View { validateAndSave() } .keyboardShortcut(.defaultAction) - .disabled(showValidationErrors) + .disabled(showValidationErrors || isPreparingDiskResize) } } .onChange(of: viewModel.config) { _, newValue in @@ -93,6 +115,19 @@ public struct VMConfigurationSheet: View { } } } + .alert("Resize Disk Image", isPresented: $showResizeConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Resize") { + confirmDiskResizeAndSave() + } + } message: { + Text(viewModel.diskImageResizeConfirmationMessage(formatter: diskResizeFormatter)) + } + .alert("FileVault Enabled", isPresented: $showFileVaultError) { + Button("OK", role: .cancel) { } + } message: { + Text(fileVaultErrorMessage) + } } @ViewBuilder @@ -110,16 +145,58 @@ public struct VMConfigurationSheet: View { let state = await viewModel.validate() guard state.allowsSaving else { return } - - savedConfiguration = viewModel.config - - if let customConfirmationButtonAction { - customConfirmationButtonAction(savedConfiguration) - } else { - dismiss() + + if viewModel.hasPendingDiskImageResizeConfirmations { + await MainActor.run { + showValidationErrors = false + showResizeConfirmation = true + } + return + } + + await MainActor.run { + showValidationErrors = false + saveConfiguration() + } + } + } + + private func confirmDiskResizeAndSave() { + isPreparingDiskResize = true + + Task { + if let deviceName = await viewModel.firstFileVaultProtectedPendingResizeName() { + await MainActor.run { + fileVaultErrorMessage = "The \(deviceName) disk has FileVault encryption enabled. To resize the disk, you must first disable FileVault in the guest operating system's System Settings, then restart the virtual machine before attempting to resize again." + showFileVaultError = true + isPreparingDiskResize = false + } + return + } + + await MainActor.run { + viewModel.confirmPendingDiskImageResizes() + isPreparingDiskResize = false + saveConfiguration() } } } + + private func saveConfiguration() { + savedConfiguration = viewModel.config + + if appliesMetadataChanges { + var metadata = savedMetadata + viewModel.applyPendingDiskImageResizeIDs(to: &metadata) + savedMetadata = metadata + } + + if let customConfirmationButtonAction { + customConfirmationButtonAction(savedConfiguration) + } else { + dismiss() + } + } } diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift index c3e8db52..e2ecdf20 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift @@ -161,7 +161,12 @@ struct VMConfigurationView: View { private var bootDisk: some View { ConfigurationSection(.constant(false), collapsingDisabled: true) { if let image = (try? viewModel.vm.bootDevice)?.managedImage { - ManagedDiskImageEditor(image: image, isExistingDiskImage: false, isForBootVolume: true) { image in + ManagedDiskImageEditor( + image: image, + virtualMachine: viewModel.vm, + isExistingDiskImage: false, + isForBootVolume: true + ) { image in viewModel.updateBootStorageDevice(with: image) } } else { diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift index 415b455c..ee6da5dc 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift @@ -13,6 +13,15 @@ public enum VMConfigurationContext: Int { case postInstall } +struct PendingDiskImageResizeConfirmation: Identifiable, Hashable { + var image: VBManagedDiskImage + var originalSize: UInt64 + var proposedSize: UInt64 + var deviceName: String + + var id: String { image.id } +} + public final class VMConfigurationViewModel: ObservableObject { @Published var config: VBMacConfiguration { @@ -39,6 +48,10 @@ public final class VMConfigurationViewModel: ObservableObject { @Published private(set) var vm: VBVirtualMachine + @Published private(set) var pendingDiskImageResizeIDs = Set() + + @Published private(set) var pendingDiskImageResizeConfirmations = [String: PendingDiskImageResizeConfirmation]() + public let context: VMConfigurationContext public init(_ vm: VBVirtualMachine, context: VMConfigurationContext = .postInstall, resolvedRestoreImage: ResolvedRestoreImage? = nil) { @@ -82,6 +95,96 @@ public final class VMConfigurationViewModel: ObservableObject { device.backing = .managedImage(image) config.hardware.addOrUpdate(device) } + + func markDiskImageResizePending(for image: VBManagedDiskImage) { + pendingDiskImageResizeIDs.insert(image.id) + } + + func clearPendingDiskImageResize(for image: VBManagedDiskImage) { + pendingDiskImageResizeIDs.remove(image.id) + pendingDiskImageResizeConfirmations.removeValue(forKey: image.id) + } + + func updateDiskImageResizeConfirmation( + for image: VBManagedDiskImage, + originalSize: UInt64, + deviceName: String, + isExistingDiskImage: Bool, + canResize: Bool + ) { + guard VBManagedDiskImage.requiresResizeConfirmation( + isExistingDiskImage: isExistingDiskImage, + canResize: canResize, + originalSize: originalSize, + proposedSize: image.size + ) else { + clearPendingDiskImageResize(for: image) + return + } + + pendingDiskImageResizeConfirmations[image.id] = PendingDiskImageResizeConfirmation( + image: image, + originalSize: originalSize, + proposedSize: image.size, + deviceName: deviceName + ) + } + + var hasPendingDiskImageResizeConfirmations: Bool { + !pendingDiskImageResizeConfirmations.isEmpty + } + + func hasPendingDiskImageResizeConfirmation(for image: VBManagedDiskImage?) -> Bool { + guard let image else { return false } + return pendingDiskImageResizeConfirmations[image.id] != nil + } + + func diskImageResizeConfirmationMessage(for image: VBManagedDiskImage? = nil, formatter: ByteCountFormatter) -> String { + let confirmations = sortedPendingDiskImageResizeConfirmations(for: image) + + if confirmations.count == 1, let confirmation = confirmations.first { + let originalSize = formatter.string(fromByteCount: Int64(confirmation.originalSize)) + let proposedSize = formatter.string(fromByteCount: Int64(confirmation.proposedSize)) + + return "This will resize the disk image from \(originalSize) to \(proposedSize). The resize will run automatically the next time the virtual machine starts and may take some time. This operation cannot be undone." + } + + guard !confirmations.isEmpty else { return "" } + + return "This will resize \(confirmations.count) disk images. The resize will run automatically the next time the virtual machine starts and may take some time. This operation cannot be undone." + } + + func firstFileVaultProtectedPendingResizeName(for image: VBManagedDiskImage? = nil) async -> String? { + for confirmation in sortedPendingDiskImageResizeConfirmations(for: image) { + if await vm.checkFileVaultForDiskImage(confirmation.image) { + return confirmation.deviceName + } + } + + return nil + } + + func confirmPendingDiskImageResizes(for image: VBManagedDiskImage? = nil) { + for confirmation in sortedPendingDiskImageResizeConfirmations(for: image) { + markDiskImageResizePending(for: confirmation.image) + pendingDiskImageResizeConfirmations.removeValue(forKey: confirmation.image.id) + } + } + + private func sortedPendingDiskImageResizeConfirmations(for image: VBManagedDiskImage? = nil) -> [PendingDiskImageResizeConfirmation] { + if let image { + guard let confirmation = pendingDiskImageResizeConfirmations[image.id] else { return [] } + return [confirmation] + } + + return pendingDiskImageResizeConfirmations.values.sorted { $0.deviceName < $1.deviceName } + } + + func applyPendingDiskImageResizeIDs(to metadata: inout VBVirtualMachine.Metadata) { + for imageID in pendingDiskImageResizeIDs { + metadata.pendingDiskImageResizeIDs.insert(imageID) + } + } } diff --git a/VirtualWormholeTests/DiskResizeSupportTests.swift b/VirtualWormholeTests/DiskResizeSupportTests.swift new file mode 100644 index 00000000..4a039231 --- /dev/null +++ b/VirtualWormholeTests/DiskResizeSupportTests.swift @@ -0,0 +1,224 @@ +// +// DiskResizeSupportTests.swift +// VirtualWormholeTests +// +// Created by VirtualBuddy on 26/05/26. +// + +import XCTest +@testable import VirtualCore + +final class DiskResizeSupportTests: XCTestCase { + + @MainActor + func testDiskResizeCheckDoesNothingWithoutPendingMetadataFlag() async throws { + let bundleURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(VBVirtualMachine.bundleExtension) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + var vm = try VBVirtualMachine(bundleURL: bundleURL, isNewInstall: true) + let image = VBManagedDiskImage(id: "boot-disk", filename: "Disk", size: 2 * .storageGigabyte, format: .raw) + vm.configuration.hardware.storageDevices = [ + VBStorageDevice( + id: "boot", + isBootVolume: true, + isReadOnly: false, + isUSBMassStorageDevice: false, + backing: .managedImage(image) + ) + ] + + var messages = [String]() + try await vm.checkAndResizeDiskImages { message in + messages.append(message) + } + + XCTAssertTrue(messages.isEmpty) + } + + func testMetadataTracksPendingDiskResizeIDs() { + let image = VBManagedDiskImage(id: "boot-disk", filename: "Disk", size: .storageGigabyte, format: .raw) + var metadata = VBVirtualMachine.Metadata() + + XCTAssertFalse(metadata.hasPendingDiskImageResizes) + + metadata.markDiskImageResizePending(for: image) + + XCTAssertTrue(metadata.hasPendingDiskImageResizes) + XCTAssertEqual(metadata.pendingDiskImageResizeIDs, ["boot-disk"]) + + metadata.clearPendingDiskImageResize(for: image) + + XCTAssertFalse(metadata.hasPendingDiskImageResizes) + } + + func testSelectableResizeLimitUsesOnlyAvailableHostSpace() { + let currentSize = 64 * UInt64.storageGigabyte + let maximumSize = 512 * UInt64.storageGigabyte + let availableSpace = 24 * UInt64.storageGigabyte + + let limit = VBManagedDiskImage.maximumSelectableSize( + configuredMaximum: maximumSize, + minimumSize: currentSize, + existingImageSize: currentSize, + availableSpace: availableSpace, + volumeCapacity: 256 * .storageGigabyte + ) + + XCTAssertEqual(limit, 88 * .storageGigabyte) + } + + func testSelectableResizeLimitNeverFallsBelowMinimumSize() { + let currentSize = 128 * UInt64.storageGigabyte + + let limit = VBManagedDiskImage.maximumSelectableSize( + configuredMaximum: 512 * .storageGigabyte, + minimumSize: currentSize, + existingImageSize: currentSize, + availableSpace: 4 * .storageGigabyte, + volumeCapacity: 96 * .storageGigabyte + ) + + XCTAssertEqual(limit, currentSize) + } + + func testResizeConfirmationIsOnlyRequiredForExplicitExpansion() { + XCTAssertTrue( + VBManagedDiskImage.requiresResizeConfirmation( + isExistingDiskImage: true, + canResize: true, + originalSize: 64 * .storageGigabyte, + proposedSize: 128 * .storageGigabyte + ) + ) + + XCTAssertFalse( + VBManagedDiskImage.requiresResizeConfirmation( + isExistingDiskImage: true, + canResize: true, + originalSize: 64 * .storageGigabyte, + proposedSize: 64 * .storageGigabyte + ) + ) + + XCTAssertFalse( + VBManagedDiskImage.requiresResizeConfirmation( + isExistingDiskImage: false, + canResize: true, + originalSize: 64 * .storageGigabyte, + proposedSize: 128 * .storageGigabyte + ) + ) + } + + func testASIFResizeSupportMatchesPlatformSupport() { + let image = VBManagedDiskImage(filename: "Disk", size: .storageGigabyte, format: .asif) + + if #available(macOS 26, *) { + XCTAssertTrue(VBDiskResizer.canResizeFormat(.asif)) + XCTAssertTrue(image.canBeResized) + } else { + XCTAssertFalse(VBDiskResizer.canResizeFormat(.asif)) + XCTAssertFalse(image.canBeResized) + } + } + + func testDMGRemainsUnsupportedForResize() { + let image = VBManagedDiskImage(filename: "Disk", size: .storageGigabyte, format: .dmg) + + XCTAssertFalse(VBDiskResizer.canResizeFormat(.dmg)) + XCTAssertFalse(image.canBeResized) + } + + func testDiskutilImageInfoParserReadsASIFTotalBytes() throws { + let plist = """ + + + + + Image Format + ASIF + Size Info + + Total Bytes + 2000003072 + + + + """ + + let size = try VBDiskResizer.imageSize(fromDiskutilImageInfoPlist: Data(plist.utf8)) + + XCTAssertEqual(size, 2_000_003_072) + } + + func testASIFFileVaultCheckUsesDiskutilImageAttachOnSupportedSystems() { + let url = URL(fileURLWithPath: "/tmp/Disk.asif") + + if #available(macOS 26, *) { + let command = VBDiskResizer.fileVaultAttachCommand(for: .asif, at: url) + XCTAssertEqual(command?.executablePath, "/usr/sbin/diskutil") + XCTAssertEqual(command?.arguments, ["image", "attach", "--nomount", url.path]) + + let detachCommand = VBDiskResizer.fileVaultDetachCommand(for: .asif, deviceNode: "/dev/disk8") + XCTAssertEqual(detachCommand?.executablePath, "/usr/sbin/diskutil") + XCTAssertEqual(detachCommand?.arguments, ["eject", "/dev/disk8"]) + } else { + XCTAssertNil(VBDiskResizer.fileVaultAttachCommand(for: .asif, at: url)) + XCTAssertNil(VBDiskResizer.fileVaultDetachCommand(for: .asif, deviceNode: "/dev/disk8")) + } + } + + func testRawFileVaultCheckKeepsHdiutilAttachCommand() { + let url = URL(fileURLWithPath: "/tmp/Disk.img") + + let command = VBDiskResizer.fileVaultAttachCommand(for: .raw, at: url) + XCTAssertEqual(command?.executablePath, "/usr/bin/hdiutil") + XCTAssertEqual(command?.arguments, ["attach", "-imagekey", "diskimage-class=CRawDiskImage", "-nomount", url.path]) + + let detachCommand = VBDiskResizer.fileVaultDetachCommand(for: .raw, deviceNode: "/dev/disk4") + XCTAssertEqual(detachCommand?.executablePath, "/usr/bin/hdiutil") + XCTAssertEqual(detachCommand?.arguments, ["detach", "/dev/disk4"]) + } + + func testAttachOutputParserPrefersBackingDiskOverSynthesizedAPFSDevices() { + let output = """ + /dev/disk10 \tEF57347C-0000-11AA-AA11-0030654\t + /dev/disk10s1 \t41504653-0000-11AA-AA11-0030654\t + /dev/disk10s2 \t41504653-0000-11AA-AA11-0030654\t + /dev/disk8 \tGUID_partition_scheme \t + /dev/disk8s1 \tApple_APFS_ISC \t + /dev/disk8s2 \tApple_APFS \t + /dev/disk8s3 \tApple_APFS_Recovery \t + """ + + XCTAssertEqual(VBDiskResizer.deviceNode(fromDiskImageAttachOutput: output), "/dev/disk8") + } + + func testRawDiskAtConfiguredSizeStillReconcilesPartitions() { + let size = UInt64.storageGigabyte + + XCTAssertTrue(VBDiskResizer.shouldReconcilePartitions(configuredSize: size, actualSize: size, format: .raw)) + } + + func testASIFDiskAtConfiguredSizeReconcilesPartitionsOnSupportedSystems() { + let size = UInt64.storageGigabyte + + if #available(macOS 26, *) { + XCTAssertTrue(VBDiskResizer.shouldReconcilePartitions(configuredSize: size, actualSize: size, format: .asif)) + } else { + XCTAssertFalse(VBDiskResizer.shouldReconcilePartitions(configuredSize: size, actualSize: size, format: .asif)) + } + } + + func testGrowingDiskDoesNotRunSeparatePartitionReconciliationFirst() { + XCTAssertFalse( + VBDiskResizer.shouldReconcilePartitions( + configuredSize: 2 * .storageGigabyte, + actualSize: .storageGigabyte, + format: .raw + ) + ) + } +}