From 5e770da6a21efe9454e383d40704505f0e719bd5 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 1/5] 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 66d3eb754194ea4f001ebb743a3db325d4d0e9d3 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 2/5] 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 5e9a160935a9d20ecda3c103fd6414163f1159d5 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 3/5] 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 23033eb67ae4a485f15b7ccdcb45cb39ab15d8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Mon, 25 May 2026 21:52:14 +0200 Subject: [PATCH 4/5] fix: address disk resize review feedback --- VirtualBuddy.xcodeproj/project.pbxproj | 4 + .../Configuration/ConfigurationModels.swift | 18 +-- .../Models/VBVirtualMachine+DiskResize.swift | 104 ++++++++++++++++++ .../Models/VBVirtualMachine+Metadata.swift | 95 ---------------- .../Source/Utilities/VBDiskResizer.swift | 77 ++++++------- .../Source/Virtualization/VMController.swift | 29 +++-- .../Storage/ManagedDiskImageEditor.swift | 33 +++--- .../Storage/StorageConfigurationView.swift | 2 +- .../Storage/StorageDeviceDetailView.swift | 1 + .../VMConfigurationView.swift | 7 +- 10 files changed, 197 insertions(+), 173 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/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 } } } 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) - } -} diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift index ac16a37b..2b04981a 100644 --- a/VirtualCore/Source/Utilities/VBDiskResizer.swift +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import zlib public enum VBDiskResizeError: LocalizedError { @@ -76,6 +77,8 @@ private extension FileHandle { } public struct VBDiskResizer { + private static let logger = Logger(for: VBDiskResizer.self) + private struct APFSContainerInfo { let container: String let physicalStore: String? @@ -134,19 +137,19 @@ public struct VBDiskResizer { try attachProcess.run() attachProcess.waitUntilExit() } catch { - NSLog("Failed to attach disk image for FileVault check: \(error)") + logger.warning("Failed to attach disk image for FileVault check: \(error, privacy: .public)") return false } guard attachProcess.terminationStatus == 0 else { - NSLog("hdiutil attach failed for FileVault check with exit code \(attachProcess.terminationStatus)") + logger.warning("hdiutil attach failed for FileVault check with exit code \(attachProcess.terminationStatus, privacy: .public)") 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") + logger.warning("Could not extract device node for FileVault check") return false } @@ -285,7 +288,7 @@ public struct VBDiskResizer { /// 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)") + logger.debug("Attempting to expand partitions in disk image at \(url.path, privacy: .public)") switch format { case .raw: @@ -298,7 +301,7 @@ public struct VBDiskResizer { case .dmg, .asif: // Unsupported formats — partition expansion is skipped - NSLog("Skipping partition expansion for unsupported format: \(format)") + logger.debug("Skipping partition expansion for unsupported format: \(format.displayName, privacy: .public)") } } @@ -388,7 +391,7 @@ public struct VBDiskResizer { } private static func resizePartitionOnDevice(deviceNode: String) async throws { - NSLog("Attempting to resize partition on device \(deviceNode)") + logger.debug("Attempting to resize partition on device \(deviceNode, privacy: .public)") // First, get partition information let listProcess = Process() @@ -403,12 +406,12 @@ public struct VBDiskResizer { listProcess.waitUntilExit() guard listProcess.terminationStatus == 0 else { - NSLog("Warning: Could not list partitions on \(deviceNode)") + logger.warning("Could not list partitions on \(deviceNode, privacy: .public)") return } let listOutput = String(data: listPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - NSLog("Partition layout for \(deviceNode):\n\(listOutput)") + logger.debug("Partition layout for \(deviceNode, privacy: .public):\n\(listOutput, privacy: .public)") // 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 @@ -418,21 +421,21 @@ public struct VBDiskResizer { throw VBDiskResizeError.apfsVolumesLocked(container: apfsContainerFromList.container) } let targetDescription = apfsContainerFromList.physicalStore ?? apfsContainerFromList.container - NSLog("Found APFS container using 'diskutil apfs list': \(apfsContainerFromList.container) (store: \(targetDescription))") + logger.debug("Found APFS container using 'diskutil apfs list': \(apfsContainerFromList.container, privacy: .public) (store: \(targetDescription, privacy: .public))") 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))") + logger.debug("Found APFS container: \(apfsContainer.container, privacy: .public) (store: \(targetDescription, privacy: .public))") 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") + logger.debug("Disk appears to have APFS partitions, attempting to resize \(deviceNode, privacy: .public) 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)") + logger.warning("Could not find a resizable APFS container on \(deviceNode, privacy: .public)") } } @@ -446,12 +449,12 @@ public struct VBDiskResizer { let primaryResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) if primaryResult.status == 0 { - NSLog("Successfully expanded APFS container target \(resizeTarget)") + logger.debug("Successfully expanded APFS container target \(resizeTarget, privacy: .public)") } 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)") + logger.warning("Initial APFS container resize at \(resizeTarget, privacy: .public) did not apply (will reconcile via nudge): \(primaryResult.output, privacy: .public)") } // When resizing using the physical store, issue a follow-up pass on the logical container to @@ -461,12 +464,12 @@ public struct VBDiskResizer { let containerResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", containerTarget, "0"]) if containerResult.status == 0 { - NSLog("Performed follow-up resize on APFS container \(containerTarget)") + logger.debug("Performed follow-up resize on APFS container \(containerTarget, privacy: .public)") } 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)") + logger.warning("Follow-up resize on container \(containerTarget, privacy: .public) deferred (will reconcile via nudge if needed)") } } @@ -486,12 +489,12 @@ public struct VBDiskResizer { try apfsListProcess.run() apfsListProcess.waitUntilExit() } catch { - NSLog("Failed to run 'diskutil apfs list -plist': \(error)") + logger.warning("Failed to run 'diskutil apfs list -plist': \(error, privacy: .public)") return nil } guard apfsListProcess.terminationStatus == 0 else { - NSLog("'diskutil apfs list -plist' failed with exit code \(apfsListProcess.terminationStatus)") + logger.warning("'diskutil apfs list -plist' failed with exit code \(apfsListProcess.terminationStatus, privacy: .public)") return nil } @@ -500,7 +503,7 @@ public struct VBDiskResizer { 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") + logger.warning("Failed to parse 'diskutil apfs list -plist' output") return nil } @@ -529,7 +532,7 @@ public struct VBDiskResizer { 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)") + logger.debug("APFS candidate: container=\(containerRef, privacy: .public), store=\(storeIdentifier, privacy: .public), size=\(size, privacy: .public), isMain=\(isMainContainer, privacy: .public), hasSystemOrData=\(hasSystemOrData, privacy: .public), hasISCRoles=\(hasISCRoles, privacy: .public), roles=\(String(describing: roles), privacy: .public)") } if containerRef == cleanDeviceNode { @@ -540,7 +543,7 @@ public struct VBDiskResizer { } guard !candidates.isEmpty else { - NSLog("No APFS container found in 'diskutil apfs list' for device \(cleanDeviceNode)") + logger.debug("No APFS container found in 'diskutil apfs list' for device \(cleanDeviceNode, privacy: .public)") return nil } @@ -554,26 +557,26 @@ public struct VBDiskResizer { // 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)") + logger.debug("Selected unlocked main APFS container: \(mainUnlocked.info.container, privacy: .public)") } // 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)") + logger.debug("Selected main APFS container (locked): \(mainAny.info.container, privacy: .public)") } // 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)") + logger.debug("Selected largest unlocked APFS container: \(largestUnlocked.info.container, privacy: .public)") } // Last resort: any container else { selected = candidates.first - NSLog("Selected fallback APFS container: \(selected?.info.container ?? "none")") + logger.debug("Selected fallback APFS container: \(selected?.info.container ?? "none", privacy: .public)") } if let selected = selected { - NSLog("Final APFS container selection: \(selected.info.container) (store: \(selected.info.physicalStore ?? "none"), size: \(selected.size), isMain: \(selected.isMainContainer))") + logger.debug("Final APFS container selection: \(selected.info.container, privacy: .public) (store: \(selected.info.physicalStore ?? "none", privacy: .public), size: \(selected.size, privacy: .public), isMain: \(selected.isMainContainer, privacy: .public))") } return selected?.info @@ -622,21 +625,21 @@ public struct VBDiskResizer { let info = APFSContainerInfo(container: containerIdentifier, physicalStore: partitionDevice, hasLockedVolumes: false) foundContainers.append((info: info, isMain: isMainContainer)) - NSLog("Found APFS partition: \(partitionDevice) -> Container: \(containerIdentifier) (main: \(isMainContainer))") + logger.debug("Found APFS partition: \(partitionDevice, privacy: .public) -> Container: \(containerIdentifier, privacy: .public) (main: \(isMainContainer, privacy: .public))") } } } // Prefer main containers over ISC containers if let mainContainer = foundContainers.first(where: { $0.isMain }) { - NSLog("Using main APFS container: \(mainContainer.info.container)") + logger.debug("Using main APFS container: \(mainContainer.info.container, privacy: .public)") 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)") + logger.debug("Using fallback APFS container: \(anyContainer.info.container, privacy: .public)") return APFSContainerInfo(container: anyContainer.info.container, physicalStore: anyContainer.info.physicalStore, hasLockedVolumes: false) } - NSLog("No APFS container found in diskutil output") + logger.debug("No APFS container found in diskutil output") return nil } @@ -654,11 +657,11 @@ public struct VBDiskResizer { 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") + logger.debug("APFS container \(info.container, privacy: .public) ceiling (\(capacity, privacy: .public)) is below physical store size (\(physicalSize, privacy: .public)); 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))") + logger.debug("Post-nudge container ceiling: \(postDetails.capacityCeiling, privacy: .public) (store: \(postDetails.physicalStoreSize, privacy: .public))") } } } @@ -677,7 +680,7 @@ public struct VBDiskResizer { guard process.terminationStatus == 0 else { let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - NSLog("Failed to query APFS container \(container): \(output)") + logger.warning("Failed to query APFS container \(container, privacy: .public): \(output, privacy: .public)") return nil } @@ -691,7 +694,7 @@ public struct VBDiskResizer { let store = stores.first, let storeSize = store["Size"] as? UInt64 else { - NSLog("Could not parse APFS container details for \(container)") + logger.warning("Could not parse APFS container details for \(container, privacy: .public)") return nil } @@ -712,7 +715,7 @@ public struct VBDiskResizer { let shrinkResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, shrinkArg]) if shrinkResult.status != 0 { - NSLog("APFS shrink nudge for \(resizeTarget) failed: \(shrinkResult.output)") + logger.warning("APFS shrink nudge for \(resizeTarget, privacy: .public) failed: \(shrinkResult.output, privacy: .public)") if shrinkResult.output.localizedCaseInsensitiveContains("locked") { throw VBDiskResizeError.apfsVolumesLocked(container: info.container) } @@ -720,7 +723,7 @@ public struct VBDiskResizer { let growResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) if growResult.status != 0 { - NSLog("APFS grow after nudge for \(resizeTarget) failed: \(growResult.output)") + logger.warning("APFS grow after nudge for \(resizeTarget, privacy: .public) failed: \(growResult.output, privacy: .public)") if growResult.output.localizedCaseInsensitiveContains("locked") { throw VBDiskResizeError.apfsVolumesLocked(container: info.container) } @@ -740,7 +743,7 @@ public struct VBDiskResizer { try process.run() process.waitUntilExit() } catch { - NSLog("Failed to run diskutil \(arguments.joined(separator: " ")): \(error)") + logger.error("Failed to run diskutil \(arguments.joined(separator: " "), privacy: .public): \(error, privacy: .public)") return (-1, "\(error)") } 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. /// diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index e5cc66ee..af95044b 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 @@ -110,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() @@ -145,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." } } @@ -185,7 +184,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/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) 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 5954bf406b84397dc1c02033d24093b874561bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 9 Jul 2026 02:05:56 +0200 Subject: [PATCH 5/5] refactor(resize): claim expanded disk space in guest Co-Authored-By: Claude Fable 5 --- .../Models/VBVirtualMachine+DiskResize.swift | 95 +-- .../Source/Utilities/VBDiskResizer.swift | 726 ++---------------- .../Source/Virtualization/VMController.swift | 33 +- .../Storage/ManagedDiskImageEditor.swift | 75 +- .../Storage/StorageConfigurationView.swift | 7 - .../Storage/StorageDeviceDetailView.swift | 1 - .../VMConfigurationView.swift | 7 +- 7 files changed, 118 insertions(+), 826 deletions(-) diff --git a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift index fd7d4f46..dd9f3266 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift @@ -14,91 +14,40 @@ 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) + /// Expands managed disk images whose configured size is larger than the image on disk. + /// Returns `true` if any disk image was expanded. + @discardableResult + func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws -> Bool { + let resizableImages = configuration.hardware.storageDevices.compactMap { device -> (name: String, image: VBManagedDiskImage)? in + guard case .managedImage(let image) = device.backing, image.canBeResized else { return nil } + return (device.displayName, 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 - }() + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useTB] + formatter.countStyle = .binary - 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))...") + var didResize = false + for (name, image) in resizableImages { let imageURL = diskImageURL(for: image) - guard FileManager.default.fileExists(atPath: imageURL.path) else { - await report("Skipping \(deviceName): disk image not found.") - continue - } + guard FileManager.default.fileExists(atPath: imageURL.path) else { continue } let actualSize = try await VBDiskResizer.currentImageSize(at: imageURL, format: image.format) + guard image.size > actualSize else { continue } - 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).") + let targetDescription = formatter.string(fromByteCount: Int64(image.size)) + if let progressHandler { + await progressHandler("Expanding \(name) to \(targetDescription)...") } - } - - 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("Resizing disk image at \(imageURL.path, privacy: .public) to \(image.size, privacy: .public) bytes") + try await VBDiskResizer.resizeDiskImage(at: imageURL, format: image.format, newSize: image.size) - diskResizeLogger.debug("Successfully resized disk image at \(imageURL.path, privacy: .public) to \(newSize, privacy: .public) bytes") - } + didResize = true + } - /// 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) + return didResize } } diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift index 2b04981a..b2304454 100644 --- a/VirtualCore/Source/Utilities/VBDiskResizer.swift +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -15,8 +15,6 @@ public enum VBDiskResizeError: LocalizedError { 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 { @@ -34,69 +32,19 @@ public enum VBDiskResizeError: LocalizedError { 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() } } } +/// Expands managed disk images in place. +/// +/// This only grows the disk image (and, for raw images, rewrites the GPT so the added +/// space is usable). Expanding the partition/APFS container into the new space is left +/// to the guest OS, where it works regardless of FileVault (e.g. running +/// `diskutil apfs resizeContainer disk0s2 0` in a macOS guest). public struct VBDiskResizer { private static let logger = Logger(for: VBDiskResizer.self) - 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: @@ -106,70 +54,6 @@ public struct VBDiskResizer { } } - /// 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 { - logger.warning("Failed to attach disk image for FileVault check: \(error, privacy: .public)") - return false - } - - guard attachProcess.terminationStatus == 0 else { - logger.warning("hdiutil attach failed for FileVault check with exit code \(attachProcess.terminationStatus, privacy: .public)") - return false - } - - let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - - guard let deviceNode = extractDeviceNode(from: attachOutput) else { - logger.warning("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, @@ -188,551 +72,62 @@ public struct VBDiskResizer { 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 - + let additionalSpaceNeeded = newSize - currentSize + let resourceValues = try url.deletingLastPathComponent().resourceValues(forKeys: [.volumeAvailableCapacityKey]) + let availableSpace = UInt64(resourceValues.volumeAvailableCapacity ?? 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 + let result = run("/usr/bin/hdiutil", ["resize", "-size", "\(newSize / 512)s", url.path]) + guard result.status == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil resize: \(result.outputString)", result.status) + } - try process.run() - process.waitUntilExit() + case .raw: + let fileHandle = try FileHandle(forWritingTo: url) + defer { try? fileHandle.close() } - 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) + guard ftruncate(fileHandle.fileDescriptor, Int64(newSize)) == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", errno) } - case .raw: - try await expandRawImageInPlace(at: url, newSize: newSize) - try adjustGPTLayoutForRawImage(at: url, newSize: newSize) + try GPTLayoutAdjuster(imageURL: url, newSize: newSize).perform() 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 { - logger.debug("Attempting to expand partitions in disk image at \(url.path, privacy: .public)") - + static func currentImageSize(at url: URL, format: VBManagedDiskImage.Format) async throws -> UInt64 { switch format { case .raw: - // For RAW images, we need to mount and resize using diskutil - try await expandPartitionsInRawImage(at: url) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.size] as? UInt64 ?? 0 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 - logger.debug("Skipping partition expansion for unsupported format: \(format.displayName, privacy: .public)") - } - } - - 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 { - logger.debug("Attempting to resize partition on device \(deviceNode, privacy: .public)") - - // 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 { - logger.warning("Could not list partitions on \(deviceNode, privacy: .public)") - return - } - - let listOutput = String(data: listPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - logger.debug("Partition layout for \(deviceNode, privacy: .public):\n\(listOutput, privacy: .public)") - - // 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 - logger.debug("Found APFS container using 'diskutil apfs list': \(apfsContainerFromList.container, privacy: .public) (store: \(targetDescription, privacy: .public))") - try await resizeAPFSContainer(apfsContainerFromList) - } else if let apfsContainer = findAPFSContainer(in: listOutput, deviceNode: deviceNode) { - let targetDescription = apfsContainer.physicalStore ?? apfsContainer.container - logger.debug("Found APFS container: \(apfsContainer.container, privacy: .public) (store: \(targetDescription, privacy: .public))") - 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 - logger.debug("Disk appears to have APFS partitions, attempting to resize \(deviceNode, privacy: .public) as container") - let cleanDevice = sanitizeDeviceIdentifier(deviceNode) - let containerInfo = APFSContainerInfo(container: cleanDevice, physicalStore: nil, hasLockedVolumes: false) - try await resizeAPFSContainer(containerInfo) - } else { - logger.warning("Could not find a resizable APFS container on \(deviceNode, privacy: .public)") - } - } - - 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 { - logger.debug("Successfully expanded APFS container target \(resizeTarget, privacy: .public)") - } else { - if primaryResult.output.localizedCaseInsensitiveContains("locked") { - throw VBDiskResizeError.apfsVolumesLocked(container: info.container) - } - logger.warning("Initial APFS container resize at \(resizeTarget, privacy: .public) did not apply (will reconcile via nudge): \(primaryResult.output, privacy: .public)") - } - - // 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 { - logger.debug("Performed follow-up resize on APFS container \(containerTarget, privacy: .public)") - } else { - if containerResult.output.localizedCaseInsensitiveContains("locked") { - throw VBDiskResizeError.apfsVolumesLocked(container: info.container) - } - logger.warning("Follow-up resize on container \(containerTarget, privacy: .public) 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 { - logger.warning("Failed to run 'diskutil apfs list -plist': \(error, privacy: .public)") - return nil - } - - guard apfsListProcess.terminationStatus == 0 else { - logger.warning("'diskutil apfs list -plist' failed with exit code \(apfsListProcess.terminationStatus, privacy: .public)") - 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 { - logger.warning("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)) - logger.debug("APFS candidate: container=\(containerRef, privacy: .public), store=\(storeIdentifier, privacy: .public), size=\(size, privacy: .public), isMain=\(isMainContainer, privacy: .public), hasSystemOrData=\(hasSystemOrData, privacy: .public), hasISCRoles=\(hasISCRoles, privacy: .public), roles=\(String(describing: roles), privacy: .public)") - } - - 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 { - logger.debug("No APFS container found in 'diskutil apfs list' for device \(cleanDeviceNode, privacy: .public)") - 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 - logger.debug("Selected unlocked main APFS container: \(mainUnlocked.info.container, privacy: .public)") - } - // Second priority: any main container (even if locked) - else if let mainAny = candidates.first(where: { $0.isMainContainer }) { - selected = mainAny - logger.debug("Selected main APFS container (locked): \(mainAny.info.container, privacy: .public)") - } - // Third priority: largest unlocked non-main container - else if let largestUnlocked = candidates.filter({ !$0.info.hasLockedVolumes }).max(by: { $0.size < $1.size }) { - selected = largestUnlocked - logger.debug("Selected largest unlocked APFS container: \(largestUnlocked.info.container, privacy: .public)") - } - // Last resort: any container - else { - selected = candidates.first - logger.debug("Selected fallback APFS container: \(selected?.info.container ?? "none", privacy: .public)") - } - - if let selected = selected { - logger.debug("Final APFS container selection: \(selected.info.container, privacy: .public) (store: \(selected.info.physicalStore ?? "none", privacy: .public), size: \(selected.size, privacy: .public), isMain: \(selected.isMainContainer, privacy: .public))") - } - - 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)) - - logger.debug("Found APFS partition: \(partitionDevice, privacy: .public) -> Container: \(containerIdentifier, privacy: .public) (main: \(isMainContainer, privacy: .public))") - } + let result = run("/usr/bin/hdiutil", ["imageinfo", "-plist", url.path]) + guard result.status == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", result.status) } - } - - // Prefer main containers over ISC containers - if let mainContainer = foundContainers.first(where: { $0.isMain }) { - logger.debug("Using main APFS container: \(mainContainer.info.container, privacy: .public)") - return APFSContainerInfo(container: mainContainer.info.container, physicalStore: mainContainer.info.physicalStore, hasLockedVolumes: false) - } else if let anyContainer = foundContainers.first { - logger.debug("Using fallback APFS container: \(anyContainer.info.container, privacy: .public)") - return APFSContainerInfo(container: anyContainer.info.container, physicalStore: anyContainer.info.physicalStore, hasLockedVolumes: false) - } - - logger.debug("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 { - logger.debug("APFS container \(info.container, privacy: .public) ceiling (\(capacity, privacy: .public)) is below physical store size (\(physicalSize, privacy: .public)); nudging container") - try await nudgeAPFSContainer(info: info, physicalSize: physicalSize) - if let postDetails = try fetchAPFSContainerDetails(container: info.container) { - logger.debug("Post-nudge container ceiling: \(postDetails.capacityCeiling, privacy: .public) (store: \(postDetails.physicalStoreSize, privacy: .public))") + guard let plist = try PropertyListSerialization.propertyList(from: result.output, options: [], format: nil) as? [String: Any], + let size = plist["Total Bytes"] as? UInt64 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", -1) } - } - } - 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) ?? "" - logger.warning("Failed to query APFS container \(container, privacy: .public): \(output, privacy: .public)") - 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 { - logger.warning("Could not parse APFS container details for \(container, privacy: .public)") - 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 { - logger.warning("APFS shrink nudge for \(resizeTarget, privacy: .public) failed: \(shrinkResult.output, privacy: .public)") - if shrinkResult.output.localizedCaseInsensitiveContains("locked") { - throw VBDiskResizeError.apfsVolumesLocked(container: info.container) - } - } + return size - let growResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) - if growResult.status != 0 { - logger.warning("APFS grow after nudge for \(resizeTarget, privacy: .public) failed: \(growResult.output, privacy: .public)") - if growResult.output.localizedCaseInsensitiveContains("locked") { - throw VBDiskResizeError.apfsVolumesLocked(container: info.container) - } + case .dmg, .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) } } - private static func runDiskutilCommand(arguments: [String]) -> (status: Int32, output: String) { + private static func run(_ executablePath: String, _ arguments: [String]) -> (status: Int32, output: Data, outputString: String) { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments let pipe = Pipe() @@ -743,18 +138,20 @@ public struct VBDiskResizer { try process.run() process.waitUntilExit() } catch { - logger.error("Failed to run diskutil \(arguments.joined(separator: " "), privacy: .public): \(error, privacy: .public)") - return (-1, "\(error)") + logger.error("Failed to run \(executablePath, privacy: .public): \(error, privacy: .public)") + return (-1, Data(), "\(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() + let output = pipe.fileHandleForReading.readDataToEndOfFile() + return (process.terminationStatus, output, String(decoding: output, as: UTF8.self)) } + /// Rewrites the GPT of a raw image after the backing file has grown: moves the backup GPT + /// structures to the new end of the disk and relocates the recovery partition so the main + /// APFS container's entry can be extended into the added space. + /// + /// If the image doesn't contain the expected macOS layout (main APFS container followed by + /// a recovery partition), this is a no-op: the guest OS can claim the space by itself. private struct GPTLayoutAdjuster { let imageURL: URL let newSize: UInt64 @@ -772,21 +169,22 @@ public struct VBDiskResizer { defer { try? fileHandle.close() } let headerOffset = sectorSize - try fileHandle.vbSeek(to: headerOffset) + try fileHandle.seek(toOffset: 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) + try fileHandle.seek(toOffset: 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"]) + logger.debug("No macOS APFS + recovery layout in GPT; leaving partition table for the guest to adjust") + return } let mainLast = readUInt64LittleEndian(from: entries, offset: mainIndex * Int(header.entrySize) + 40) @@ -852,26 +250,26 @@ public struct VBDiskResizer { header.lastUsableLBA = newLastUsable header.partitionEntriesCRC32 = crc32(of: entries) - try fileHandle.vbSeek(to: entriesOffset) - try fileHandle.vbWriteAll(entries) + try fileHandle.seek(toOffset: entriesOffset) + try fileHandle.write(contentsOf: entries) let primaryHeaderData = header.serialized(sectorSize: sectorSize, isBackup: false) - try fileHandle.vbSeek(to: headerOffset) - try fileHandle.vbWriteAll(primaryHeaderData) + try fileHandle.seek(toOffset: headerOffset) + try fileHandle.write(contentsOf: primaryHeaderData) let backupEntriesOffset = backupEntriesLBA * sectorSize - try fileHandle.vbSeek(to: backupEntriesOffset) - try fileHandle.vbWriteAll(entries) + try fileHandle.seek(toOffset: backupEntriesOffset) + try fileHandle.write(contentsOf: entries) let backupHeaderData = header.serialized(sectorSize: sectorSize, isBackup: true) - try fileHandle.vbSeek(to: newBackupLBA * sectorSize) - try fileHandle.vbWriteAll(backupHeaderData) + try fileHandle.seek(toOffset: newBackupLBA * sectorSize) + try fileHandle.write(contentsOf: backupHeaderData) - try fileHandle.vbSynchronize() + try fileHandle.synchronize() } private func readExactly(fileHandle: FileHandle, length: Int) throws -> Data { - let data = try fileHandle.vbRead(upToCount: length) ?? Data() + let data = try fileHandle.read(upToCount: length) ?? Data() guard data.count == length else { throw NSError(domain: "VBDiskResizer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read expected GPT data"]) } @@ -913,11 +311,11 @@ public struct VBDiskResizer { while remaining > 0 { let chunk = Int(min(bufferSize, remaining)) - try fileHandle.vbSeek(to: readOffset) + try fileHandle.seek(toOffset: readOffset) let data = try readExactly(fileHandle: fileHandle, length: chunk) - try fileHandle.vbSeek(to: writeOffset) - try fileHandle.vbWriteAll(data) + try fileHandle.seek(toOffset: writeOffset) + try fileHandle.write(contentsOf: data) remaining -= UInt64(chunk) readOffset += UInt64(chunk) @@ -933,8 +331,8 @@ public struct VBDiskResizer { while remaining > 0 { let chunk = Int(min(UInt64(zeroChunk.count), remaining)) - try fileHandle.vbSeek(to: offset) - try fileHandle.vbWriteAll(zeroChunk.prefix(chunk)) + try fileHandle.seek(toOffset: offset) + try fileHandle.write(contentsOf: zeroChunk.prefix(chunk)) remaining -= UInt64(chunk) offset += UInt64(chunk) diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index 82b3a795..6b5a8d66 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -174,16 +174,18 @@ public final class VMController: ObservableObject { // Check and resize disk images if needed do { - state = .resizingDisk("Preparing disk resize...") - try await virtualMachineModel.checkAndResizeDiskImages { message in + state = .resizingDisk("Checking disk images...") + let didResize = try await virtualMachineModel.checkAndResizeDiskImages { message in self.state = .resizingDisk(message) } - state = .starting("Starting virtual machine...") + if didResize { + presentDiskResizeCompletedAlert() + } } catch { logger.warning("Failed to resize disk images: \(error, privacy: .public)") presentDiskResizeError(error) - state = .starting("Starting virtual machine...") } + state = .starting("Starting virtual machine...") try await updatingState { let newInstance = try createInstance() @@ -215,19 +217,20 @@ public final class VMController: ObservableObject { } } - private func presentDiskResizeError(_ error: Error) { + private func presentDiskResizeCompletedAlert() { let alert = NSAlert() + alert.messageText = "Disk Image Expanded" + alert.informativeText = "The disk image now has more space, but the guest operating system still needs to claim it. In a macOS guest, run 'diskutil apfs resizeContainer disk0s2 0' in Terminal after starting up. In other guests, use the system's partitioning tools." + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } - 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 - } - + private func presentDiskResizeError(_ error: Error) { + let alert = NSAlert() + 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() } diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index af95044b..c411d198 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -10,16 +10,14 @@ import VirtualCore struct ManagedDiskImageEditor: View { @State private var image: VBManagedDiskImage - let virtualMachine: VBVirtualMachine var minimumSize: UInt64 var isExistingDiskImage: Bool var onSave: (VBManagedDiskImage) -> Void var isBootVolume: Bool var canResize: Bool - init(image: VBManagedDiskImage, virtualMachine: VBVirtualMachine, isExistingDiskImage: Bool, isForBootVolume: Bool, onSave: @escaping (VBManagedDiskImage) -> Void) { + init(image: VBManagedDiskImage, 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 @@ -37,9 +35,7 @@ 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? @@ -60,23 +56,15 @@ struct ManagedDiskImageEditor: View { } let maximumSize = isBootVolume ? VBManagedDiskImage.maximumBootDiskImageSize : VBManagedDiskImage.maximumExtraDiskImageSize - 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) - } - } + 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) + .foregroundColor(sizeWarning != nil ? .yellow : .primary) VStack(alignment: .leading, spacing: 8) { if !isExistingDiskImage, !isBootVolume { @@ -85,7 +73,7 @@ struct ManagedDiskImageEditor: View { } 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.") + Text("This \(image.format.displayName) can be expanded. After resizing, the added space must be claimed inside the guest operating system.") .foregroundColor(.blue) } @@ -111,12 +99,9 @@ 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 + // Debounce so the confirmation only shows after the user stops sliding. 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 @@ -130,15 +115,11 @@ struct ManagedDiskImageEditor: View { image.size = minimumSize } Button("Resize") { - performResize() + image.size = newSize + onSave(image) } } 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.") + 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.") } } @@ -178,32 +159,6 @@ 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 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() - } - } } #if DEBUG diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift index efbecd67..b1425f51 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift @@ -120,13 +120,6 @@ 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: { diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift index bb5fde49..bf5589c3 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageDeviceDetailView.swift @@ -202,7 +202,6 @@ 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 e2ecdf20..c3e8db52 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift @@ -161,12 +161,7 @@ struct VMConfigurationView: View { private var bootDisk: some View { ConfigurationSection(.constant(false), collapsingDisabled: true) { if let image = (try? viewModel.vm.bootDevice)?.managedImage { - ManagedDiskImageEditor( - image: image, - virtualMachine: viewModel.vm, - isExistingDiskImage: false, - isForBootVolume: true - ) { image in + ManagedDiskImageEditor(image: image, isExistingDiskImage: false, isForBootVolume: true) { image in viewModel.updateBootStorageDevice(with: image) } } else {