From 10a467b098fb8272bf025c40fbd30312093d0130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Fri, 22 May 2026 10:50:23 +0200 Subject: [PATCH 01/17] feat(storage): add disk image resize engine --- VirtualBuddy.xcodeproj/project.pbxproj | 4 + .../Configuration/ConfigurationModels.swift | 27 + .../Source/Utilities/VBDiskResizer.swift | 1060 +++++++++++++++++ 3 files changed, 1091 insertions(+) create mode 100644 VirtualCore/Source/Utilities/VBDiskResizer.swift diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index dd627c22..a1dddb8a 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -306,6 +306,7 @@ F4C18A5328491B9D00335EC7 /* VirtualWormhole.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F4C189E02848F59F00335EC7 /* VirtualWormhole.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F4C2374D2888A462001FF286 /* VolumeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374C2888A462001FF286 /* VolumeUtils.swift */; }; F4C237502888AF67001FF286 /* LogStreamer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374F2888AF67001FF286 /* LogStreamer.swift */; }; + VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */; }; F4C947BF2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */; }; F4C947D62E0B12D0001ACC91 /* String+AppleOSBuild.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */; }; F4C947DA2E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */; }; @@ -828,6 +829,7 @@ F4C18A4D28491B8500335EC7 /* VirtualBuddyGuest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VirtualBuddyGuest.entitlements; sourceTree = ""; }; F4C2374C2888A462001FF286 /* VolumeUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeUtils.swift; sourceTree = ""; }; F4C2374F2888AF67001FF286 /* LogStreamer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogStreamer.swift; sourceTree = ""; }; + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBDiskResizer.swift; sourceTree = ""; }; F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+ExtendedAttributes.swift"; sourceTree = ""; }; F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AppleOSBuild.swift"; sourceTree = ""; }; F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SoftwareCatalog+DownloadMatching.swift"; sourceTree = ""; }; @@ -1922,6 +1924,7 @@ F485B91E2BB2F4AC004B3C2B /* Bundle+Version.swift */, F444D1332BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift */, F453C4BA2DF231B7007EAD5F /* PreventTerminationAssertion.swift */, + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */, ); path = Utilities; sourceTree = ""; @@ -2725,6 +2728,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 */, diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index e6057723..778a47fb 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -111,6 +111,19 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { } } } + + public var displayName: String { + switch self { + case .raw: + return "Raw Image" + case .dmg: + return "Disk Image (DMG)" + case .sparse: + return "Sparse Image" + case .asif: + return "Apple Sparse Image Format (ASIF)" + } + } } public var id: String = UUID().uuidString @@ -135,6 +148,15 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { format: .raw ) } + + public var canBeResized: Bool { + switch format { + case .raw, .sparse: + return true + case .dmg, .asif: + return false + } + } } /// Configures a storage device. @@ -202,6 +224,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/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift new file mode 100644 index 00000000..ac16a37b --- /dev/null +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -0,0 +1,1060 @@ +// +// 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 + } + + 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 .dmg, .asif: + return false + } + } + + /// 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 } + + // Attach the disk image without mounting + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + + switch format { + case .raw: + attachProcess.arguments = ["attach", "-imagekey", "diskimage-class=CRawDiskImage", "-nomount", url.path] + case .sparse: + attachProcess.arguments = ["attach", "-nomount", url.path] + case .dmg, .asif: + return false + } + + 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("hdiutil attach failed for FileVault check with exit code \(attachProcess.terminationStatus)") + return false + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + guard let deviceNode = extractDeviceNode(from: attachOutput) else { + NSLog("Could not extract device node for FileVault check") + return false + } + + defer { + // Detach the disk image + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + detachProcess.arguments = ["detach", deviceNode] + try? detachProcess.run() + detachProcess.waitUntilExit() + } + + // 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 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 .dmg, .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + 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 .dmg, .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + 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 .dmg, .asif: + // 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 = extractDeviceNode(from: 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 = extractDeviceNode(from: 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) + } + + private static func extractDeviceNode(from hdiutilOutput: String) -> String? { + // hdiutil output format: "/dev/disk4 Apple_partition_scheme" + let lines = hdiutilOutput.components(separatedBy: .newlines) + for line in lines { + if line.contains("/dev/disk") { + let components = line.components(separatedBy: .whitespaces) + if let deviceNode = components.first, deviceNode.hasPrefix("/dev/disk") { + return deviceNode + } + } + } + return nil + } + + 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) + } + } + +} From e830701850be71ff5c3b306313af3bf3fbcc147b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Fri, 22 May 2026 10:51:31 +0200 Subject: [PATCH 02/17] feat(resize): run disk resizing before startup --- .../Models/VBVirtualMachine+Metadata.swift | 95 +++++++++++++++++++ .../Source/Virtualization/VMController.swift | 27 ++++++ .../Components/VirtualMachineControls.swift | 4 +- .../Session/VirtualMachineSessionView.swift | 17 ++++ 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift index 8820aa7c..a156f579 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift @@ -69,3 +69,98 @@ extension URL { return current } } + +// MARK: - Disk Resize Support + +public extension VBVirtualMachine { + + typealias DiskResizeProgressHandler = @MainActor (_ message: String) -> Void + + /// Checks if any disk images need resizing based on configuration vs actual size + func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws { + let config = configuration + + 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 image.canBeResized else { return nil } + return (device, image) + } + + guard !resizableDevices.isEmpty else { + await report("Disk images already match their configured sizes.") + 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.") + 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.") + } else if image.size < actualSize { + let actualDescription = formatter.string(fromByteCount: Int64(actualSize)) + await report("\(deviceName) exceeds the configured size (\(actualDescription)); no changes made.") + } else { + let currentDescription = formatter.string(fromByteCount: Int64(actualSize)) + await report("\(deviceName) already uses \(currentDescription).") + } + } + + 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) + NSLog("Resizing disk image at \(imageURL.path) from current size to \(newSize) bytes") + + try await VBDiskResizer.resizeDiskImage( + at: imageURL, + format: image.format, + newSize: newSize + ) + + NSLog("Successfully resized disk image at \(imageURL.path) to \(newSize) 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/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index fc11f1c3..da7cf5b8 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,27 @@ public final class VMController: ObservableObject { await waitForGuestDiskImageReadyIfNeeded() + // Check and resize disk images if needed + do { + state = .resizingDisk("Preparing disk resize...") + try await virtualMachineModel.checkAndResizeDiskImages { message in + self.state = .resizingDisk(message) + } + state = .starting("Starting virtual machine...") + } catch { + if case let VBDiskResizeError.apfsVolumesLocked(container) = error { + let alert = NSAlert() + 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.addButton(withTitle: "OK") + alert.alertStyle = .informational + alert.runModal() + } + // Log resize errors but don't fail VM start + NSLog("Warning: Failed to resize disk images: \(error)") + state = .starting("Starting virtual machine...") + } + try await updatingState { let newInstance = try createInstance() self.instance = newInstance @@ -437,6 +459,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 +478,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/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/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", From d6567c497c0c2d906ec8e18d66d898f142184e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Fri, 22 May 2026 10:53:10 +0200 Subject: [PATCH 03/17] feat(ui): add managed disk resize controls --- .../Storage/ManagedDiskImageEditor.swift | 107 +++++++++++++++--- .../Storage/StorageConfigurationView.swift | 7 ++ 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index e47cc0dd..e5cc66ee 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -9,11 +9,13 @@ import SwiftUI import VirtualCore struct ManagedDiskImageEditor: View { + @EnvironmentObject var viewModel: VMConfigurationViewModel @State private var image: VBManagedDiskImage 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) { self._image = .init(wrappedValue: image) @@ -22,17 +24,23 @@ struct ManagedDiskImageEditor: View { 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 }() @State private var nameError: String? + @State private var isResizing = false + @State private var showResizeConfirmation = false + @State private var showFileVaultError = false + @State private var newSize: UInt64 = 0 + @State private var sliderTimer: Timer? @Environment(\.dismiss) private var dismiss @@ -51,15 +59,23 @@ 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 { + NumericPropertyControl( + value: $image.size.gbStorageValue, + range: minimumSize.gbStorageValue...maximumSize.gbStorageValue, + hideSlider: isExistingDiskImage && !canResize, + label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", + formatter: NumberFormatter.numericPropertyControlDefault + ) + .disabled((isExistingDiskImage && !canResize) || isResizing) + .foregroundColor(sizeWarning != nil ? .yellow : .primary) + + if isResizing { + ProgressView() + .scaleEffect(0.5) + .frame(width: 16, height: 16) + } + } VStack(alignment: .leading, spacing: 8) { if !isExistingDiskImage, !isBootVolume { @@ -67,6 +83,11 @@ struct ManagedDiskImageEditor: View { .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,7 +110,33 @@ struct ManagedDiskImageEditor: View { .lineLimit(nil) } .onChange(of: image) { _, newValue in - onSave(newValue) + if isExistingDiskImage && canResize && newValue.size != minimumSize { + // Cancel any existing timer + sliderTimer?.invalidate() + + // Set a timer to show confirmation after user stops sliding + sliderTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + newSize = newValue.size + showResizeConfirmation = true + } + } else { + onSave(newValue) + } + } + .alert("Resize Disk Image", isPresented: $showResizeConfirmation) { + Button("Cancel", role: .cancel) { + image.size = minimumSize + } + Button("Resize") { + performResize() + } + } message: { + Text("This will resize the disk image from \(formatter.string(fromByteCount: Int64(minimumSize))) to \(formatter.string(fromByteCount: Int64(newSize))). The resize will run automatically the next time the virtual machine starts and may take some time. This operation cannot be undone.") + } + .alert("FileVault Enabled", isPresented: $showFileVaultError) { + Button("OK", role: .cancel) { } + } message: { + Text("This 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.") } } @@ -99,9 +146,17 @@ 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." + if canResize { + return "Boot disk can be expanded, but not shrunk. Choose your size carefully." + } else { + 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." + if canResize { + return "This disk can be expanded to a larger size, but cannot be shrunk." + } else { + return "It's not possible to change the size of an existing storage device." + } } } @@ -124,6 +179,32 @@ struct ManagedDiskImageEditor: View { return "The volume \(volumeDescription) doesn't have enough free space to fit the full size of the disk image." } + + private func performResize() { + isResizing = true + + Task { + // Check for FileVault before proceeding with resize + let hasFileVault = await viewModel.vm.checkFileVaultForDiskImage(image) + + await MainActor.run { + if hasFileVault { + // Reset size and show FileVault error + image.size = minimumSize + isResizing = false + showFileVaultError = true + } else { + // Proceed with resize + image.size = newSize + onSave(image) + isResizing = false + } + } + + // The actual resize will happen automatically when VM starts or restarts + // due to the size mismatch detection in checkAndResizeDiskImages() + } + } } #if DEBUG diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift index b6fe545f..9b272015 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) + .foregroundColor(.blue) + .help("This disk can be resized") + } + Button { configureDevice() } label: { From 431b72f73d31e66cb1dfd6adde2d6fd297a4053d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:35:24 +0200 Subject: [PATCH 04/17] style(config): modernize disk image switches --- .../Configuration/ConfigurationModels.swift | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index 778a47fb..1836b175 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -114,14 +114,10 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { public var displayName: String { switch self { - case .raw: - return "Raw Image" - case .dmg: - return "Disk Image (DMG)" - case .sparse: - return "Sparse Image" - case .asif: - return "Apple Sparse Image Format (ASIF)" + case .raw: "Raw Image" + case .dmg: "Disk Image (DMG)" + case .sparse: "Sparse Image" + case .asif: "Apple Sparse Image Format (ASIF)" } } } @@ -151,10 +147,8 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { public var canBeResized: Bool { switch format { - case .raw, .sparse: - return true - case .dmg, .asif: - return false + case .raw, .sparse: true + case .dmg, .asif: false } } } From 2af24a561dc199e5b7da338495ab4a84ba56ee98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:35:30 +0200 Subject: [PATCH 05/17] refactor(storage): move disk resize VM helpers --- VirtualBuddy.xcodeproj/project.pbxproj | 4 + .../Models/VBVirtualMachine+DiskResize.swift | 104 ++++++++++++++++++ .../Models/VBVirtualMachine+Metadata.swift | 95 ---------------- 3 files changed, 108 insertions(+), 95 deletions(-) create mode 100644 VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index a1dddb8a..f27ac5b7 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -307,6 +307,7 @@ F4C2374D2888A462001FF286 /* VolumeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374C2888A462001FF286 /* VolumeUtils.swift */; }; F4C237502888AF67001FF286 /* LogStreamer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374F2888AF67001FF286 /* LogStreamer.swift */; }; VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */; }; + VB01DISKRESIZ00004A0104 /* VBVirtualMachine+DiskResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */; }; F4C947BF2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */; }; F4C947D62E0B12D0001ACC91 /* String+AppleOSBuild.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */; }; F4C947DA2E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */; }; @@ -830,6 +831,7 @@ F4C2374C2888A462001FF286 /* VolumeUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeUtils.swift; sourceTree = ""; }; F4C2374F2888AF67001FF286 /* LogStreamer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogStreamer.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 = ""; }; F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+ExtendedAttributes.swift"; sourceTree = ""; }; F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AppleOSBuild.swift"; sourceTree = ""; }; F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SoftwareCatalog+DownloadMatching.swift"; sourceTree = ""; }; @@ -1631,6 +1633,7 @@ F4DE1C102D6F642E00603527 /* VBStorageDeviceContainer.swift */, F46FFBA72804F07400D61023 /* VBNVRAMVariable.swift */, F4D725FD286677B8001818F7 /* VBVirtualMachine+Metadata.swift */, + VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */, F4D0F71428667984004D5782 /* VBVirtualMachine+Screenshot.swift */, ); path = Models; @@ -2704,6 +2707,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 */, diff --git a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift new file mode 100644 index 00000000..fd7d4f46 --- /dev/null +++ b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift @@ -0,0 +1,104 @@ +// +// 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 + func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws { + let config = configuration + + 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 image.canBeResized else { return nil } + return (device, image) + } + + guard !resizableDevices.isEmpty else { + await report("Disk images already match their configured sizes.") + 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.") + 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.") + } else if image.size < actualSize { + let actualDescription = formatter.string(fromByteCount: Int64(actualSize)) + await report("\(deviceName) exceeds the configured size (\(actualDescription)); no changes made.") + } else { + let currentDescription = formatter.string(fromByteCount: Int64(actualSize)) + await report("\(deviceName) already uses \(currentDescription).") + } + } + + 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+Metadata.swift b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift index a156f579..8820aa7c 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift @@ -69,98 +69,3 @@ extension URL { return current } } - -// MARK: - Disk Resize Support - -public extension VBVirtualMachine { - - typealias DiskResizeProgressHandler = @MainActor (_ message: String) -> Void - - /// Checks if any disk images need resizing based on configuration vs actual size - func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws { - let config = configuration - - 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 image.canBeResized else { return nil } - return (device, image) - } - - guard !resizableDevices.isEmpty else { - await report("Disk images already match their configured sizes.") - 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.") - 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.") - } else if image.size < actualSize { - let actualDescription = formatter.string(fromByteCount: Int64(actualSize)) - await report("\(deviceName) exceeds the configured size (\(actualDescription)); no changes made.") - } else { - let currentDescription = formatter.string(fromByteCount: Int64(actualSize)) - await report("\(deviceName) already uses \(currentDescription).") - } - } - - 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) - NSLog("Resizing disk image at \(imageURL.path) from current size to \(newSize) bytes") - - try await VBDiskResizer.resizeDiskImage( - at: imageURL, - format: image.format, - newSize: newSize - ) - - NSLog("Successfully resized disk image at \(imageURL.path) to \(newSize) 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) - } -} From 13c2b1eea55bc7cbbf3d580c8ce1ab6cb1e9feca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:35:36 +0200 Subject: [PATCH 06/17] fix(resize): report startup resize failures --- .../Source/Virtualization/VMController.swift | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index da7cf5b8..82b3a795 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -180,16 +180,8 @@ public final class VMController: ObservableObject { } state = .starting("Starting virtual machine...") } catch { - if case let VBDiskResizeError.apfsVolumesLocked(container) = error { - let alert = NSAlert() - 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.addButton(withTitle: "OK") - alert.alertStyle = .informational - alert.runModal() - } - // Log resize errors but don't fail VM start - NSLog("Warning: Failed to resize disk images: \(error)") + logger.warning("Failed to resize disk images: \(error, privacy: .public)") + presentDiskResizeError(error) state = .starting("Starting virtual machine...") } @@ -223,6 +215,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. /// From 489c3f40be05e711f25ad1e6e1a7908bf99f43f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:35:55 +0200 Subject: [PATCH 07/17] refactor(ui): pass VM into disk image editor --- .../Sections/Storage/ManagedDiskImageEditor.swift | 7 ++++--- .../Sections/Storage/StorageDeviceDetailView.swift | 1 + .../Source/VM Configuration/VMConfigurationView.swift | 7 ++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index e5cc66ee..01c6a5a4 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -9,16 +9,17 @@ import SwiftUI import VirtualCore struct ManagedDiskImageEditor: View { - @EnvironmentObject var viewModel: VMConfigurationViewModel @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 @@ -185,7 +186,7 @@ struct ManagedDiskImageEditor: View { Task { // Check for FileVault before proceeding with resize - let hasFileVault = await viewModel.vm.checkFileVaultForDiskImage(image) + let hasFileVault = await virtualMachine.checkFileVaultForDiskImage(image) await MainActor.run { if hasFileVault { diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift index bf5589c3..bb5fde49 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift @@ -202,6 +202,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/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 { From c72c136980807554cb0f1862268791c150f5b613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:36:21 +0200 Subject: [PATCH 08/17] chore(ui): note resize slider extraction --- .../Sections/Storage/ManagedDiskImageEditor.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index 01c6a5a4..6b34a97f 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -111,6 +111,7 @@ struct ManagedDiskImageEditor: View { .lineLimit(nil) } .onChange(of: image) { _, newValue in + // TODO: Extract the resize slider confirmation flow into a reusable component. if isExistingDiskImage && canResize && newValue.size != minimumSize { // Cancel any existing timer sliderTimer?.invalidate() From 58ed89d44d9b936dc093d7e12636b6d4ef7cdd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:36:26 +0200 Subject: [PATCH 09/17] style(ui): modernize disk editor messages --- .../Storage/ManagedDiskImageEditor.swift | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index 6b34a97f..af95044b 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -147,26 +147,23 @@ struct ManagedDiskImageEditor: View { } private var sizeChangeInfo: String { - if isBootVolume { - if canResize { - return "Boot disk can be expanded, but not shrunk. Choose your size carefully." - } else { - return "Be sure to reserve enough space, since it won't be possible to change the size of the disk later." - } - } else { - if canResize { - return "This disk can be expanded to a larger size, but cannot be shrunk." - } 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." } } From 9973d0754b3d2721f788ade1a65ed95a08114eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:36:33 +0200 Subject: [PATCH 10/17] fix(ui): allow resizable boot disk settings --- .../Sections/Storage/StorageConfigurationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift index 9b272015..efbecd67 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift @@ -134,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) From 3a0f227a86382bccf8a7e71dd09008d384cad25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 22:40:40 +0200 Subject: [PATCH 11/17] chore(xcode): add project file modifications --- VirtualBuddy.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index f27ac5b7..b307211f 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -306,8 +306,6 @@ F4C18A5328491B9D00335EC7 /* VirtualWormhole.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F4C189E02848F59F00335EC7 /* VirtualWormhole.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F4C2374D2888A462001FF286 /* VolumeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374C2888A462001FF286 /* VolumeUtils.swift */; }; F4C237502888AF67001FF286 /* LogStreamer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374F2888AF67001FF286 /* LogStreamer.swift */; }; - VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */; }; - VB01DISKRESIZ00004A0104 /* VBVirtualMachine+DiskResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */; }; F4C947BF2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */; }; F4C947D62E0B12D0001ACC91 /* String+AppleOSBuild.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */; }; F4C947DA2E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */; }; @@ -354,6 +352,8 @@ 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 */; }; + 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 */ @@ -830,8 +830,6 @@ F4C18A4D28491B8500335EC7 /* VirtualBuddyGuest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VirtualBuddyGuest.entitlements; sourceTree = ""; }; F4C2374C2888A462001FF286 /* VolumeUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeUtils.swift; sourceTree = ""; }; F4C2374F2888AF67001FF286 /* LogStreamer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogStreamer.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 = ""; }; F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+ExtendedAttributes.swift"; sourceTree = ""; }; F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AppleOSBuild.swift"; sourceTree = ""; }; F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SoftwareCatalog+DownloadMatching.swift"; sourceTree = ""; }; @@ -877,6 +875,8 @@ 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 = ""; }; + 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 */ From cc05a5ddf7d9b3dc49519132e84bc4eb31299665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Tue, 26 May 2026 19:25:48 +0200 Subject: [PATCH 12/17] style(ui): soften resizable disk indicator --- .../Sections/Storage/StorageConfigurationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift index efbecd67..1dab8c5e 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift @@ -123,7 +123,7 @@ struct StorageDeviceListItem: View { if device.canBeResized { Image(systemName: "arrow.up.right.and.arrow.down.left") .font(.caption) - .foregroundColor(.blue) + .foregroundStyle(.secondary) .help("This disk can be resized") } From c0d52ad0c923ee8a560365c63591cd528cfe7ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Tue, 26 May 2026 21:07:26 +0200 Subject: [PATCH 13/17] feat(resize): support ASIF disk expansion --- VirtualBuddy.xcodeproj/project.pbxproj | 19 ++ .../Configuration/ConfigurationModels.swift | 12 +- .../Source/Utilities/VBDiskResizer.swift | 178 ++++++++++++++++-- .../DiskResizeSupportTests.swift | 82 ++++++++ 4 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 VirtualWormholeTests/DiskResizeSupportTests.swift diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index b307211f..02bcdd8b 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -352,6 +352,8 @@ 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 */ @@ -441,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 */ @@ -875,6 +884,7 @@ 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 */ @@ -954,6 +964,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + VB02ASIFTEST00003A0103 /* VirtualCore.framework in Frameworks */, F4D305A029B8DB700006E748 /* VirtualWormhole.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1958,6 +1969,7 @@ isa = PBXGroup; children = ( F4D305A829B8E70A0006E748 /* Resources */, + VB02ASIFTEST00002A0102 /* DiskResizeSupportTests.swift */, F4D3059E29B8DB700006E748 /* WormholePacketTests.swift */, ); path = VirtualWormholeTests; @@ -2278,6 +2290,7 @@ buildRules = ( ); dependencies = ( + VB02ASIFTEST00005A0105 /* PBXTargetDependency */, F4D305A229B8DB700006E748 /* PBXTargetDependency */, ); name = VirtualWormholeTests; @@ -2809,6 +2822,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + VB02ASIFTEST00001A0101 /* DiskResizeSupportTests.swift in Sources */, F4D3059F29B8DB700006E748 /* WormholePacketTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2880,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 1836b175..91ba3992 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -147,8 +147,16 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { public var canBeResized: Bool { switch format { - case .raw, .sparse: true - case .dmg, .asif: false + case .raw, .sparse: + true + case .asif: + if #available(macOS 26, *) { + true + } else { + false + } + case .dmg: + false } } } diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift index ac16a37b..880960bb 100644 --- a/VirtualCore/Source/Utilities/VBDiskResizer.swift +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -87,6 +87,11 @@ public struct VBDiskResizer { 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)) @@ -98,11 +103,64 @@ public struct VBDiskResizer { switch format { case .raw, .sparse: return true - case .dmg, .asif: + 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: @@ -112,19 +170,12 @@ public struct VBDiskResizer { 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: "/usr/bin/hdiutil") - - switch format { - case .raw: - attachProcess.arguments = ["attach", "-imagekey", "diskimage-class=CRawDiskImage", "-nomount", url.path] - case .sparse: - attachProcess.arguments = ["attach", "-nomount", url.path] - case .dmg, .asif: - return false - } + attachProcess.executableURL = URL(fileURLWithPath: attachCommand.executablePath) + attachProcess.arguments = attachCommand.arguments let attachPipe = Pipe() attachProcess.standardOutput = attachPipe @@ -139,7 +190,7 @@ public struct VBDiskResizer { } guard attachProcess.terminationStatus == 0 else { - NSLog("hdiutil attach failed for FileVault check with exit code \(attachProcess.terminationStatus)") + NSLog("Disk image attach failed for FileVault check with exit code \(attachProcess.terminationStatus)") return false } @@ -151,12 +202,18 @@ public struct VBDiskResizer { } defer { - // Detach the disk image - let detachProcess = Process() - detachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") - detachProcess.arguments = ["detach", deviceNode] - try? detachProcess.run() - detachProcess.waitUntilExit() + 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 @@ -221,11 +278,63 @@ public struct VBDiskResizer { return size - case .dmg, .asif: + 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) @@ -263,11 +372,40 @@ public struct VBDiskResizer { try await expandRawImageInPlace(at: url, newSize: newSize) try adjustGPTLayoutForRawImage(at: url, newSize: newSize) - case .dmg, .asif: + 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() } diff --git a/VirtualWormholeTests/DiskResizeSupportTests.swift b/VirtualWormholeTests/DiskResizeSupportTests.swift new file mode 100644 index 00000000..f138f4c7 --- /dev/null +++ b/VirtualWormholeTests/DiskResizeSupportTests.swift @@ -0,0 +1,82 @@ +// +// DiskResizeSupportTests.swift +// VirtualWormholeTests +// +// Created by VirtualBuddy on 26/05/26. +// + +import XCTest +@testable import VirtualCore + +final class DiskResizeSupportTests: XCTestCase { + + 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"]) + } +} From 36c4c9705041349f562bb222b115ce5617877b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Tue, 26 May 2026 21:20:58 +0200 Subject: [PATCH 14/17] fix(resize): select backing disk on attach --- .../Source/Utilities/VBDiskResizer.swift | 43 +++++++++++++------ .../DiskResizeSupportTests.swift | 14 ++++++ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift index 880960bb..fd0af074 100644 --- a/VirtualCore/Source/Utilities/VBDiskResizer.swift +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -196,7 +196,7 @@ public struct VBDiskResizer { let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - guard let deviceNode = extractDeviceNode(from: attachOutput) else { + guard let deviceNode = deviceNode(fromDiskImageAttachOutput: attachOutput) else { NSLog("Could not extract device node for FileVault check") return false } @@ -460,7 +460,7 @@ public struct VBDiskResizer { let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" // Extract device node (e.g., /dev/disk4) - guard let deviceNode = extractDeviceNode(from: attachOutput) else { + guard let deviceNode = deviceNode(fromDiskImageAttachOutput: attachOutput) else { throw VBDiskResizeError.systemCommandFailed("Could not extract device node", -1) } @@ -496,7 +496,7 @@ public struct VBDiskResizer { let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - guard let deviceNode = extractDeviceNode(from: attachOutput) else { + guard let deviceNode = deviceNode(fromDiskImageAttachOutput: attachOutput) else { throw VBDiskResizeError.systemCommandFailed("Could not extract device node", -1) } @@ -511,18 +511,37 @@ public struct VBDiskResizer { try await resizePartitionOnDevice(deviceNode: deviceNode) } - private static func extractDeviceNode(from hdiutilOutput: String) -> String? { - // hdiutil output format: "/dev/disk4 Apple_partition_scheme" - let lines = hdiutilOutput.components(separatedBy: .newlines) + 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 { - if line.contains("/dev/disk") { - let components = line.components(separatedBy: .whitespaces) - if let deviceNode = components.first, deviceNode.hasPrefix("/dev/disk") { - return deviceNode - } + 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 nil + + 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 { diff --git a/VirtualWormholeTests/DiskResizeSupportTests.swift b/VirtualWormholeTests/DiskResizeSupportTests.swift index f138f4c7..1ef6936f 100644 --- a/VirtualWormholeTests/DiskResizeSupportTests.swift +++ b/VirtualWormholeTests/DiskResizeSupportTests.swift @@ -79,4 +79,18 @@ final class DiskResizeSupportTests: XCTestCase { 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") + } } From 4f2b3964bae2b139de353a3a2d014cfe198b5b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Tue, 26 May 2026 21:25:23 +0200 Subject: [PATCH 15/17] fix(resize): finish partial disk expansion --- .../Models/VBVirtualMachine+DiskResize.swift | 8 ++ .../Source/Utilities/VBDiskResizer.swift | 82 ++++++++++++++++++- .../DiskResizeSupportTests.swift | 26 ++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift index fd7d4f46..4e09be51 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift @@ -73,6 +73,14 @@ public extension VBVirtualMachine { await report("\(deviceName) exceeds the configured size (\(actualDescription)); no changes made.") } 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).") } } diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift index fd0af074..ae8d6fde 100644 --- a/VirtualCore/Source/Utilities/VBDiskResizer.swift +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -114,6 +114,27 @@ public struct VBDiskResizer { } } + 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: @@ -248,6 +269,18 @@ public struct VBDiskResizer { 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: @@ -434,7 +467,14 @@ public struct VBDiskResizer { // For sparse images, we can work with them directly try await expandPartitionsInSparseImage(at: url) - case .dmg, .asif: + 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)") } @@ -511,6 +551,46 @@ public struct VBDiskResizer { 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) diff --git a/VirtualWormholeTests/DiskResizeSupportTests.swift b/VirtualWormholeTests/DiskResizeSupportTests.swift index 1ef6936f..917c9847 100644 --- a/VirtualWormholeTests/DiskResizeSupportTests.swift +++ b/VirtualWormholeTests/DiskResizeSupportTests.swift @@ -93,4 +93,30 @@ final class DiskResizeSupportTests: XCTestCase { 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 + ) + ) + } } From 2258becb7ff45c657612837b97e12357c6c275f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 28 May 2026 17:47:26 +0200 Subject: [PATCH 16/17] fix(resize): harden disk resize flow --- .../Configuration/ConfigurationModels.swift | 26 +++ .../Models/VBVirtualMachine+DiskResize.swift | 13 +- .../Source/Models/VBVirtualMachine.swift | 42 +++++ .../Source/Utilities/VolumeUtils.swift | 11 ++ .../Source/Virtualization/VMController.swift | 23 ++- .../Steps/InstallConfigurationStepView.swift | 2 +- .../VMSessionConfigurationView.swift | 3 +- .../Storage/ManagedDiskImageEditor.swift | 159 ++++++++++-------- .../VMConfigurationSheet.swift | 99 +++++++++-- .../VMConfigurationViewModel.swift | 92 ++++++++++ .../DiskResizeSupportTests.swift | 102 +++++++++++ 11 files changed, 479 insertions(+), 93 deletions(-) diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index 91ba3992..b1c49224 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -159,6 +159,32 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { 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. diff --git a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift index 4e09be51..8a264712 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift @@ -15,9 +15,13 @@ public extension VBVirtualMachine { typealias DiskResizeProgressHandler = @MainActor (_ message: String) -> Void /// Checks if any disk images need resizing based on configuration vs actual size - func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws { + 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 { @@ -27,12 +31,13 @@ public extension VBVirtualMachine { 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 { - await report("Disk images already match their configured sizes.") + metadata.pendingDiskImageResizeIDs.removeAll() return } @@ -56,6 +61,7 @@ public extension VBVirtualMachine { guard FileManager.default.fileExists(atPath: imageURL.path) else { await report("Skipping \(deviceName): disk image not found.") + metadata.clearPendingDiskImageResize(for: image) continue } @@ -68,9 +74,11 @@ public extension VBVirtualMachine { 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( @@ -82,6 +90,7 @@ public extension VBVirtualMachine { try await VBDiskResizer.reconcilePartitions(at: imageURL, format: image.format) } await report("\(deviceName) already uses \(currentDescription).") + metadata.clearPendingDiskImageResize(for: image) } } 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/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 82b3a795..1c17e985 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -172,16 +172,21 @@ public final class VMController: ObservableObject { await waitForGuestDiskImageReadyIfNeeded() - // Check and resize disk images if needed - do { - state = .resizingDisk("Preparing disk resize...") - try await virtualMachineModel.checkAndResizeDiskImages { message in - self.state = .resizingDisk(message) + 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...") } - state = .starting("Starting virtual machine...") - } catch { - logger.warning("Failed to resize disk images: \(error, privacy: .public)") - presentDiskResizeError(error) + } else { state = .starting("Starting virtual machine...") } 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/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/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index af95044b..7a0b65cd 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -37,15 +37,12 @@ struct ManagedDiskImageEditor: View { }() @State private var nameError: String? - @State private var isResizing = false - @State private var showResizeConfirmation = false - @State private var showFileVaultError = false - @State private var newSize: UInt64 = 0 - @State private var sliderTimer: Timer? @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var viewModel: VMConfigurationViewModel + var body: some View { VStack(alignment: .leading) { VStack(alignment: .leading) { @@ -59,26 +56,45 @@ struct ManagedDiskImageEditor: View { } } - let maximumSize = isBootVolume ? VBManagedDiskImage.maximumBootDiskImageSize : VBManagedDiskImage.maximumExtraDiskImageSize - HStack { + HStack(alignment: .top) { NumericPropertyControl( value: $image.size.gbStorageValue, - range: minimumSize.gbStorageValue...maximumSize.gbStorageValue, + range: selectableSizeRangeInGigabytes, + step: 1, hideSlider: isExistingDiskImage && !canResize, label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", formatter: NumberFormatter.numericPropertyControlDefault ) - .disabled((isExistingDiskImage && !canResize) || isResizing) + .disabled(isExistingDiskImage && !canResize) .foregroundColor(sizeWarning != nil ? .yellow : .primary) - if isResizing { - ProgressView() - .scaleEffect(0.5) - .frame(width: 16, height: 16) + 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) @@ -111,37 +127,65 @@ struct ManagedDiskImageEditor: View { .lineLimit(nil) } .onChange(of: image) { _, newValue in - // TODO: Extract the resize slider confirmation flow into a reusable component. - if isExistingDiskImage && canResize && newValue.size != minimumSize { - // Cancel any existing timer - sliderTimer?.invalidate() - - // Set a timer to show confirmation after user stops sliding - sliderTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in - newSize = newValue.size - showResizeConfirmation = true - } - } else { - onSave(newValue) - } - } - .alert("Resize Disk Image", isPresented: $showResizeConfirmation) { - Button("Cancel", role: .cancel) { - image.size = minimumSize - } - Button("Resize") { - performResize() - } - } message: { - Text("This will resize the disk image from \(formatter.string(fromByteCount: Int64(minimumSize))) to \(formatter.string(fromByteCount: Int64(newSize))). The resize will run automatically the next time the virtual machine starts and may take some time. This operation cannot be undone.") + viewModel.updateDiskImageResizeConfirmation( + for: newValue, + originalSize: minimumSize, + deviceName: deviceName, + isExistingDiskImage: isExistingDiskImage, + canResize: canResize + ) + onSave(newValue) } - .alert("FileVault Enabled", isPresented: $showFileVaultError) { - Button("OK", role: .cancel) { } - } message: { - Text("This 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.") + .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? { VBSettingsContainer.current.isLibraryInAPFSVolume ? "The storage space you make available for the disk won't be used immediately, only the space that's used by the virtual machine will be consumed. " : nil } @@ -169,41 +213,18 @@ struct ManagedDiskImageEditor: View { private var sizeWarning: String? { guard !VBSettingsContainer.current.libraryVolumeCanFit(image.size) else { return nil } - let volumeDescription: String - if let volumeName = VBSettingsContainer.current.settings.libraryURL.containingVolumeName { - volumeDescription = "\"\(volumeName)\"" - } else { - volumeDescription = "where your library is stored" - } return "The volume \(volumeDescription) doesn't have enough free space to fit the full size of the disk image." } - private func performResize() { - isResizing = true - - Task { - // Check for FileVault before proceeding with resize - let hasFileVault = await virtualMachine.checkFileVaultForDiskImage(image) - - await MainActor.run { - if hasFileVault { - // Reset size and show FileVault error - image.size = minimumSize - isResizing = false - showFileVaultError = true - } else { - // Proceed with resize - image.size = newSize - onSave(image) - isResizing = false - } - } - - // The actual resize will happen automatically when VM starts or restarts - // due to the size mismatch detection in checkAndResizeDiskImages() + private var volumeDescription: String { + if let volumeName = VBSettingsContainer.current.settings.libraryURL.containingVolumeName { + return "\"\(volumeName)\"" + } else { + return "where your library is stored" } } + } #if DEBUG 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/VMConfigurationViewModel.swift b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift index 415b455c..396811d5 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,85 @@ 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 diskImageResizeConfirmationMessage(formatter: ByteCountFormatter) -> String { + let confirmations = pendingDiskImageResizeConfirmations.values.sorted { $0.deviceName < $1.deviceName } + + 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() async -> String? { + let confirmations = pendingDiskImageResizeConfirmations.values.sorted { $0.deviceName < $1.deviceName } + + for confirmation in confirmations { + if await vm.checkFileVaultForDiskImage(confirmation.image) { + return confirmation.deviceName + } + } + + return nil + } + + func confirmPendingDiskImageResizes() { + for confirmation in pendingDiskImageResizeConfirmations.values { + markDiskImageResizePending(for: confirmation.image) + } + + pendingDiskImageResizeConfirmations.removeAll() + } + + 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 index 917c9847..4a039231 100644 --- a/VirtualWormholeTests/DiskResizeSupportTests.swift +++ b/VirtualWormholeTests/DiskResizeSupportTests.swift @@ -10,6 +10,108 @@ import XCTest 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) From 6b785002eaa80f7f14c77975a1b42d9732ab4b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 28 May 2026 17:53:32 +0200 Subject: [PATCH 17/17] fix(resize): confirm from disk settings --- .../Storage/StorageDeviceDetailView.swift | 67 ++++++++++++++++++- .../VMConfigurationViewModel.swift | 29 +++++--- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift index bb5fde49..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 { diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift index 396811d5..ee6da5dc 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift @@ -134,8 +134,13 @@ public final class VMConfigurationViewModel: ObservableObject { !pendingDiskImageResizeConfirmations.isEmpty } - func diskImageResizeConfirmationMessage(formatter: ByteCountFormatter) -> String { - let confirmations = pendingDiskImageResizeConfirmations.values.sorted { $0.deviceName < $1.deviceName } + 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)) @@ -149,10 +154,8 @@ public final class VMConfigurationViewModel: ObservableObject { 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() async -> String? { - let confirmations = pendingDiskImageResizeConfirmations.values.sorted { $0.deviceName < $1.deviceName } - - for confirmation in confirmations { + func firstFileVaultProtectedPendingResizeName(for image: VBManagedDiskImage? = nil) async -> String? { + for confirmation in sortedPendingDiskImageResizeConfirmations(for: image) { if await vm.checkFileVaultForDiskImage(confirmation.image) { return confirmation.deviceName } @@ -161,12 +164,20 @@ public final class VMConfigurationViewModel: ObservableObject { return nil } - func confirmPendingDiskImageResizes() { - for confirmation in pendingDiskImageResizeConfirmations.values { + 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] } - pendingDiskImageResizeConfirmations.removeAll() + return pendingDiskImageResizeConfirmations.values.sorted { $0.deviceName < $1.deviceName } } func applyPendingDiskImageResizeIDs(to metadata: inout VBVirtualMachine.Metadata) {