diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index dd627c22..f27ac5b7 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -306,6 +306,8 @@ F4C18A5328491B9D00335EC7 /* VirtualWormhole.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F4C189E02848F59F00335EC7 /* VirtualWormhole.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F4C2374D2888A462001FF286 /* VolumeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374C2888A462001FF286 /* VolumeUtils.swift */; }; F4C237502888AF67001FF286 /* LogStreamer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C2374F2888AF67001FF286 /* LogStreamer.swift */; }; + VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */; }; + VB01DISKRESIZ00004A0104 /* VBVirtualMachine+DiskResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */; }; F4C947BF2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */; }; F4C947D62E0B12D0001ACC91 /* String+AppleOSBuild.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */; }; F4C947DA2E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */; }; @@ -828,6 +830,8 @@ F4C18A4D28491B8500335EC7 /* VirtualBuddyGuest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VirtualBuddyGuest.entitlements; sourceTree = ""; }; F4C2374C2888A462001FF286 /* VolumeUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeUtils.swift; sourceTree = ""; }; F4C2374F2888AF67001FF286 /* LogStreamer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogStreamer.swift; sourceTree = ""; }; + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBDiskResizer.swift; sourceTree = ""; }; + VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VBVirtualMachine+DiskResize.swift"; sourceTree = ""; }; F4C947BE2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+ExtendedAttributes.swift"; sourceTree = ""; }; F4C947D52E0B12D0001ACC91 /* String+AppleOSBuild.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AppleOSBuild.swift"; sourceTree = ""; }; F4C947D92E0B1E5D001ACC91 /* SoftwareCatalog+DownloadMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SoftwareCatalog+DownloadMatching.swift"; sourceTree = ""; }; @@ -1629,6 +1633,7 @@ F4DE1C102D6F642E00603527 /* VBStorageDeviceContainer.swift */, F46FFBA72804F07400D61023 /* VBNVRAMVariable.swift */, F4D725FD286677B8001818F7 /* VBVirtualMachine+Metadata.swift */, + VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */, F4D0F71428667984004D5782 /* VBVirtualMachine+Screenshot.swift */, ); path = Models; @@ -1922,6 +1927,7 @@ F485B91E2BB2F4AC004B3C2B /* Bundle+Version.swift */, F444D1332BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift */, F453C4BA2DF231B7007EAD5F /* PreventTerminationAssertion.swift */, + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */, ); path = Utilities; sourceTree = ""; @@ -2701,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 */, @@ -2725,6 +2732,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..1836b175 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -111,6 +111,15 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { } } } + + public var displayName: String { + switch self { + case .raw: "Raw Image" + case .dmg: "Disk Image (DMG)" + case .sparse: "Sparse Image" + case .asif: "Apple Sparse Image Format (ASIF)" + } + } } public var id: String = UUID().uuidString @@ -135,6 +144,13 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { format: .raw ) } + + public var canBeResized: Bool { + switch format { + case .raw, .sparse: true + case .dmg, .asif: false + } + } } /// Configures a storage device. @@ -202,6 +218,11 @@ public struct VBStorageDevice: Identifiable, Hashable, Codable { ) } + public var canBeResized: Bool { + guard case .managedImage(let image) = backing else { return false } + return image.canBeResized + } + public var displayName: String { guard !isBootVolume else { return "Boot" } diff --git a/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift new file mode 100644 index 00000000..dd9f3266 --- /dev/null +++ b/VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift @@ -0,0 +1,53 @@ +// +// 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 + + /// 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) + } + + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useTB] + formatter.countStyle = .binary + + var didResize = false + + for (name, image) in resizableImages { + let imageURL = diskImageURL(for: image) + + 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 } + + let targetDescription = formatter.string(fromByteCount: Int64(image.size)) + if let progressHandler { + await progressHandler("Expanding \(name) to \(targetDescription)...") + } + + 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) + + didResize = true + } + + return didResize + } +} diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift new file mode 100644 index 00000000..b2304454 --- /dev/null +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -0,0 +1,461 @@ +// +// VBDiskResizer.swift +// VirtualCore +// +// Created by VirtualBuddy on 22/08/25. +// + +import Foundation +import OSLog +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) + + 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)" + } + } +} + +/// 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) + + public static func canResizeFormat(_ format: VBManagedDiskImage.Format) -> Bool { + switch format { + case .raw, .sparse: + return true + case .dmg, .asif: + 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 + } + + 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 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) + } + + case .raw: + let fileHandle = try FileHandle(forWritingTo: url) + defer { try? fileHandle.close() } + + guard ftruncate(fileHandle.fileDescriptor, Int64(newSize)) == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", errno) + } + + try GPTLayoutAdjuster(imageURL: url, newSize: newSize).perform() + + case .dmg, .asif: + throw VBDiskResizeError.unsupportedImageFormat(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 result = run("/usr/bin/hdiutil", ["imageinfo", "-plist", url.path]) + guard result.status == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", result.status) + } + + 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) + } + + return size + + case .dmg, .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + private static func run(_ executablePath: String, _ arguments: [String]) -> (status: Int32, output: Data, outputString: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + } catch { + logger.error("Failed to run \(executablePath, privacy: .public): \(error, privacy: .public)") + return (-1, Data(), "\(error)") + } + + 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 + + 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.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.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 { + 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) + 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.seek(toOffset: entriesOffset) + try fileHandle.write(contentsOf: entries) + + let primaryHeaderData = header.serialized(sectorSize: sectorSize, isBackup: false) + try fileHandle.seek(toOffset: headerOffset) + try fileHandle.write(contentsOf: primaryHeaderData) + + let backupEntriesOffset = backupEntriesLBA * sectorSize + try fileHandle.seek(toOffset: backupEntriesOffset) + try fileHandle.write(contentsOf: entries) + + let backupHeaderData = header.serialized(sectorSize: sectorSize, isBackup: true) + try fileHandle.seek(toOffset: newBackupLBA * sectorSize) + try fileHandle.write(contentsOf: backupHeaderData) + + try fileHandle.synchronize() + } + + private func readExactly(fileHandle: FileHandle, length: Int) throws -> 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"]) + } + 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.seek(toOffset: readOffset) + let data = try readExactly(fileHandle: fileHandle, length: chunk) + + try fileHandle.seek(toOffset: writeOffset) + try fileHandle.write(contentsOf: 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.seek(toOffset: offset) + try fileHandle.write(contentsOf: zeroChunk.prefix(chunk)) + + remaining -= UInt64(chunk) + offset += UInt64(chunk) + } + } + } + + private struct GPTHeader { + var signature: UInt64 + var revision: UInt32 + var headerSize: UInt32 + var headerCRC32: UInt32 + var reserved: UInt32 + var currentLBA: UInt64 + var backupLBA: UInt64 + var firstUsableLBA: UInt64 + var lastUsableLBA: UInt64 + var diskGUID: Data + var partitionEntriesLBA: UInt64 + var numberOfEntries: UInt32 + var entrySize: UInt32 + var partitionEntriesCRC32: UInt32 + + init(data: Data) { + signature = readUInt64LittleEndian(from: data, offset: 0) + revision = readUInt32LittleEndian(from: data, offset: 8) + headerSize = readUInt32LittleEndian(from: data, offset: 12) + headerCRC32 = readUInt32LittleEndian(from: data, offset: 16) + reserved = readUInt32LittleEndian(from: data, offset: 20) + currentLBA = readUInt64LittleEndian(from: data, offset: 24) + backupLBA = readUInt64LittleEndian(from: data, offset: 32) + firstUsableLBA = readUInt64LittleEndian(from: data, offset: 40) + lastUsableLBA = readUInt64LittleEndian(from: data, offset: 48) + diskGUID = data.subdata(in: 56..<72) + partitionEntriesLBA = readUInt64LittleEndian(from: data, offset: 72) + numberOfEntries = readUInt32LittleEndian(from: data, offset: 80) + entrySize = readUInt32LittleEndian(from: data, offset: 84) + partitionEntriesCRC32 = readUInt32LittleEndian(from: data, offset: 88) + } + + func serialized(sectorSize: UInt64, isBackup: Bool) -> Data { + var data = Data(count: Int(sectorSize)) + writeUInt64LittleEndian(&data, offset: 0, value: signature) + writeUInt32LittleEndian(&data, offset: 8, value: revision) + writeUInt32LittleEndian(&data, offset: 12, value: headerSize) + writeUInt32LittleEndian(&data, offset: 16, value: 0) // placeholder for CRC + writeUInt32LittleEndian(&data, offset: 20, value: reserved) + let current = isBackup ? backupLBA : currentLBA + let backup = isBackup ? currentLBA : backupLBA + writeUInt64LittleEndian(&data, offset: 24, value: current) + writeUInt64LittleEndian(&data, offset: 32, value: backup) + writeUInt64LittleEndian(&data, offset: 40, value: firstUsableLBA) + writeUInt64LittleEndian(&data, offset: 48, value: lastUsableLBA) + data.replaceSubrange(56..<72, with: diskGUID) + let entriesLBA = isBackup ? (backupLBA - 32) : partitionEntriesLBA + writeUInt64LittleEndian(&data, offset: 72, value: entriesLBA) + writeUInt32LittleEndian(&data, offset: 80, value: numberOfEntries) + writeUInt32LittleEndian(&data, offset: 84, value: entrySize) + writeUInt32LittleEndian(&data, offset: 88, value: partitionEntriesCRC32) + + let crc = crc32(of: data.prefix(Int(headerSize))) + writeUInt32LittleEndian(&data, offset: 16, value: crc) + return data + } + } + + private static func crc32(of data: Data) -> UInt32 { + data.withUnsafeBytes { buffer -> UInt32 in + guard let base = buffer.bindMemory(to: UInt8.self).baseAddress else { return 0 } + return UInt32(zlib.crc32(0, base, uInt(buffer.count))) + } + } + + private static func uuidFromGPTBytes(_ data: Data) -> UUID? { + guard data.count == 16 else { return nil } + let a = readUInt32LittleEndian(from: data, offset: 0) + let b = readUInt16LittleEndian(from: data, offset: 4) + let c = readUInt16LittleEndian(from: data, offset: 6) + let tail = Array(data[8..<16]) + let uuidString = String( + format: "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", + a, b, c, + tail[0], tail[1], + tail[2], tail[3], + tail[4], tail[5], tail[6], tail[7] + ) + return UUID(uuidString: uuidString) + } + + private static func readUInt64LittleEndian(from data: Data, offset: Int) -> UInt64 { + let range = offset..<(offset + 8) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt64.self) }.littleEndian + } + + private static func readUInt32LittleEndian(from data: Data, offset: Int) -> UInt32 { + let range = offset..<(offset + 4) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt32.self) }.littleEndian + } + + private static func readUInt16LittleEndian(from data: Data, offset: Int) -> UInt16 { + let range = offset..<(offset + 2) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt16.self) }.littleEndian + } + + private static func writeUInt64LittleEndian(_ data: inout Data, offset: Int, value: UInt64) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 8), with: bytes) + } + } + + private static func writeUInt32LittleEndian(_ data: inout Data, offset: Int, value: UInt32) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 4), with: bytes) + } + } + + private static func writeUInt16LittleEndian(_ data: inout Data, offset: Int, value: UInt16) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 2), with: bytes) + } + } + +} diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index fc11f1c3..6b5a8d66 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,21 @@ public final class VMController: ObservableObject { await waitForGuestDiskImageReadyIfNeeded() + // Check and resize disk images if needed + do { + state = .resizingDisk("Checking disk images...") + let didResize = try await virtualMachineModel.checkAndResizeDiskImages { message in + self.state = .resizingDisk(message) + } + if didResize { + presentDiskResizeCompletedAlert() + } + } catch { + logger.warning("Failed to resize disk images: \(error, privacy: .public)") + presentDiskResizeError(error) + } + state = .starting("Starting virtual machine...") + try await updatingState { let newInstance = try createInstance() self.instance = newInstance @@ -201,6 +217,24 @@ public final class VMController: ObservableObject { } } + 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() + } + + 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() + } + /// Checks whether this virtual machine's network devices share a MAC address with any running virtual machine, /// asking the conflict handler how to proceed if so. /// @@ -437,6 +471,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 +490,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", diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index e47cc0dd..c411d198 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -14,6 +14,7 @@ struct ManagedDiskImageEditor: View { 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 +23,21 @@ 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 showResizeConfirmation = false + @State private var newSize: UInt64 = 0 + @State private var sliderTimer: Timer? @Environment(\.dismiss) private var dismiss @@ -54,11 +59,11 @@ struct ManagedDiskImageEditor: View { NumericPropertyControl( value: $image.size.gbStorageValue, range: minimumSize.gbStorageValue...maximumSize.gbStorageValue, - hideSlider: isExistingDiskImage, + hideSlider: isExistingDiskImage && !canResize, label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", formatter: NumberFormatter.numericPropertyControlDefault ) - .disabled(isExistingDiskImage) + .disabled(isExistingDiskImage && !canResize) .foregroundColor(sizeWarning != nil ? .yellow : .primary) VStack(alignment: .leading, spacing: 8) { @@ -67,6 +72,11 @@ struct ManagedDiskImageEditor: View { .foregroundColor(.yellow) } + if isExistingDiskImage && canResize { + Text("This \(image.format.displayName) can be expanded. After resizing, the added space must be claimed inside the guest operating system.") + .foregroundColor(.blue) + } + if let sizeWarning { Text(sizeWarning) .foregroundColor(.yellow) @@ -89,7 +99,27 @@ struct ManagedDiskImageEditor: View { .lineLimit(nil) } .onChange(of: image) { _, newValue in - onSave(newValue) + if isExistingDiskImage && canResize && newValue.size != minimumSize { + // Debounce so the confirmation only shows after the user stops sliding. + sliderTimer?.invalidate() + 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") { + 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.") } } @@ -98,18 +128,23 @@ struct ManagedDiskImageEditor: View { } private var sizeChangeInfo: String { - if isBootVolume { - return "Be sure to reserve enough space, since it won't be possible to change the size of the disk later." - } else { - return "It's not possible to change the size of an existing storage device." + switch (isBootVolume, canResize) { + case (true, true): + "Boot disk can be expanded, but not shrunk. Choose your size carefully." + case (true, false): + "Be sure to reserve enough space, since it won't be possible to change the size of the disk later." + case (false, true): + "This disk can be expanded to a larger size, but cannot be shrunk." + case (false, false): + "It's not possible to change the size of an existing storage device." } } private var sizeMessage: String { if isExistingDiskImage { - return sizeChangeInfo + sizeChangeInfo } else { - return "\(sizeMessagePrefix ?? "")After adding the storage device, it won't be possible to change the size of its disk image with VirtualBuddy." + "\(sizeMessagePrefix ?? "")After adding the storage device, it won't be possible to change the size of its disk image with VirtualBuddy." } } diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift index b6fe545f..b1425f51 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift @@ -127,7 +127,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)