Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions VirtualBuddy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@
F4FC98392BB386A000E511C9 /* ContinuousProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC98382BB386A000E511C9 /* ContinuousProgressIndicator.swift */; };
F4FC983B2BB386B500E511C9 /* MaskProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC983A2BB386B500E511C9 /* MaskProgressView.swift */; };
F4FC983D2BB386DD00E511C9 /* VMProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC983C2BB386DD00E511C9 /* VMProgressOverlay.swift */; };
VB02ASIFTEST00001A0101 /* DiskResizeSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB02ASIFTEST00002A0102 /* DiskResizeSupportTests.swift */; };
VB02ASIFTEST00003A0103 /* VirtualCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4BE9C6527FF053A00B648F8 /* VirtualCore.framework */; };
VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */; };
VB01DISKRESIZ00004A0104 /* VBVirtualMachine+DiskResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -439,6 +443,13 @@
remoteGlobalIDString = F4C189DF2848F59F00335EC7;
remoteInfo = VirtualWormhole;
};
VB02ASIFTEST00004A0104 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = F4BE9C4627FF052100B648F8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = F4BE9C6427FF053A00B648F8;
remoteInfo = VirtualCore;
};
/* End PBXContainerItemProxy section */

/* Begin PBXCopyFilesBuildPhase section */
Expand Down Expand Up @@ -873,6 +884,9 @@
F4FC98382BB386A000E511C9 /* ContinuousProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinuousProgressIndicator.swift; sourceTree = "<group>"; };
F4FC983A2BB386B500E511C9 /* MaskProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaskProgressView.swift; sourceTree = "<group>"; };
F4FC983C2BB386DD00E511C9 /* VMProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMProgressOverlay.swift; sourceTree = "<group>"; };
VB02ASIFTEST00002A0102 /* DiskResizeSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskResizeSupportTests.swift; sourceTree = "<group>"; };
VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBDiskResizer.swift; sourceTree = "<group>"; };
VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VBVirtualMachine+DiskResize.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -950,6 +964,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
VB02ASIFTEST00003A0103 /* VirtualCore.framework in Frameworks */,
F4D305A029B8DB700006E748 /* VirtualWormhole.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -1629,6 +1644,7 @@
F4DE1C102D6F642E00603527 /* VBStorageDeviceContainer.swift */,
F46FFBA72804F07400D61023 /* VBNVRAMVariable.swift */,
F4D725FD286677B8001818F7 /* VBVirtualMachine+Metadata.swift */,
VB01DISKRESIZ00003A0103 /* VBVirtualMachine+DiskResize.swift */,
F4D0F71428667984004D5782 /* VBVirtualMachine+Screenshot.swift */,
);
path = Models;
Expand Down Expand Up @@ -1922,6 +1938,7 @@
F485B91E2BB2F4AC004B3C2B /* Bundle+Version.swift */,
F444D1332BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift */,
F453C4BA2DF231B7007EAD5F /* PreventTerminationAssertion.swift */,
VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */,
);
path = Utilities;
sourceTree = "<group>";
Expand Down Expand Up @@ -1952,6 +1969,7 @@
isa = PBXGroup;
children = (
F4D305A829B8E70A0006E748 /* Resources */,
VB02ASIFTEST00002A0102 /* DiskResizeSupportTests.swift */,
F4D3059E29B8DB700006E748 /* WormholePacketTests.swift */,
);
path = VirtualWormholeTests;
Expand Down Expand Up @@ -2272,6 +2290,7 @@
buildRules = (
);
dependencies = (
VB02ASIFTEST00005A0105 /* PBXTargetDependency */,
F4D305A229B8DB700006E748 /* PBXTargetDependency */,
);
name = VirtualWormholeTests;
Expand Down Expand Up @@ -2701,6 +2720,7 @@
F453C4A22DF1D7F6007EAD5F /* SimulatedRestoreBackend.swift in Sources */,
F4DE1C0B2D6F54E700603527 /* VBSavedStateMetadata+Clone.swift in Sources */,
F4D725FE286677B8001818F7 /* VBVirtualMachine+Metadata.swift in Sources */,
VB01DISKRESIZ00004A0104 /* VBVirtualMachine+DiskResize.swift in Sources */,
F4A21BF428033102001072B8 /* VBError.swift in Sources */,
F4D0F71F2867517A004D5782 /* AppUpdateChannel.swift in Sources */,
F49FD8842DFB727B0019D638 /* VMImporter+Helpers.swift in Sources */,
Expand All @@ -2725,6 +2745,7 @@
F485B91D2BB2F0D9004B3C2B /* ProcessInfo+ECID.swift in Sources */,
F444D1342BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift in Sources */,
F4C237502888AF67001FF286 /* LogStreamer.swift in Sources */,
VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */,
F4F9B41A284CE37C00F21737 /* Logging.swift in Sources */,
F4B5C5D728870619005AA632 /* ConfigurationModels+Validation.swift in Sources */,
F4A7FB3B2BB5E79100E4C12A /* DirectoryObserver.swift in Sources */,
Expand Down Expand Up @@ -2801,6 +2822,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
VB02ASIFTEST00001A0101 /* DiskResizeSupportTests.swift in Sources */,
F4D3059F29B8DB700006E748 /* WormholePacketTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -2872,6 +2894,11 @@
target = F4C189DF2848F59F00335EC7 /* VirtualWormhole */;
targetProxy = F4D305A129B8DB700006E748 /* PBXContainerItemProxy */;
};
VB02ASIFTEST00005A0105 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = F4BE9C6427FF053A00B648F8 /* VirtualCore */;
targetProxy = VB02ASIFTEST00004A0104 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */

/* Begin PBXVariantGroup section */
Expand Down
55 changes: 55 additions & 0 deletions VirtualCore/Source/Models/Configuration/ConfigurationModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
}
Comment thread
balcsida marked this conversation as resolved.

public var id: String = UUID().uuidString
Expand All @@ -135,6 +144,47 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable {
format: .raw
)
}

public var canBeResized: Bool {
switch format {
case .raw, .sparse:
true
case .asif:
if #available(macOS 26, *) {
true
} else {
false
}
case .dmg:
false
}
}

public static func maximumSelectableSize(
configuredMaximum: UInt64,
minimumSize: UInt64,
existingImageSize: UInt64?,
availableSpace: UInt64?,
volumeCapacity: UInt64?
) -> UInt64 {
let availableLimit = availableSpace.map { available in
(existingImageSize ?? 0) + available
} ?? configuredMaximum

let capacityLimit = volumeCapacity ?? configuredMaximum
let storageLimit = min(availableLimit, capacityLimit)

return max(minimumSize, min(configuredMaximum, storageLimit))
}

public static func requiresResizeConfirmation(
isExistingDiskImage: Bool,
canResize: Bool,
originalSize: UInt64,
proposedSize: UInt64
) -> Bool {
isExistingDiskImage && canResize && proposedSize > originalSize
}
}
Comment thread
balcsida marked this conversation as resolved.

/// Configures a storage device.
Expand Down Expand Up @@ -202,6 +252,11 @@ public struct VBStorageDevice: Identifiable, Hashable, Codable {
)
}

public var canBeResized: Bool {
guard case .managedImage(let image) = backing else { return false }
return image.canBeResized
}

public var displayName: String {
guard !isBootVolume else { return "Boot" }

Expand Down
121 changes: 121 additions & 0 deletions VirtualCore/Source/Models/VBVirtualMachine+DiskResize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// VBVirtualMachine+DiskResize.swift
// VirtualCore
//
// Created by VirtualBuddy on 25/05/26.
//

import Foundation
import OSLog

private let diskResizeLogger = Logger(for: VBVirtualMachine.self, label: "DiskResize")

public extension VBVirtualMachine {

typealias DiskResizeProgressHandler = @MainActor (_ message: String) -> Void

/// Checks if any disk images need resizing based on configuration vs actual size
mutating func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws {
let config = configuration

guard metadata.hasPendingDiskImageResizes else { return }

let pendingImageIDs = metadata.pendingDiskImageResizeIDs

func report(_ message: String) async {
guard let progressHandler else { return }
await MainActor.run {
progressHandler(message)
}
}

let resizableDevices = config.hardware.storageDevices.compactMap { device -> (VBStorageDevice, VBManagedDiskImage)? in
guard case .managedImage(let image) = device.backing else { return nil }
guard pendingImageIDs.contains(image.id) else { return nil }
guard image.canBeResized else { return nil }
return (device, image)
}

guard !resizableDevices.isEmpty else {
metadata.pendingDiskImageResizeIDs.removeAll()
return
}

let formatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useGB, .useMB, .useTB]
formatter.countStyle = .binary
formatter.includesUnit = true
return formatter
}()

for (index, entry) in resizableDevices.enumerated() {
let (device, image) = entry
let position = index + 1
let total = resizableDevices.count
let deviceName = device.displayName

await report("Checking \(deviceName) (\(position)/\(total))...")

let imageURL = diskImageURL(for: image)

guard FileManager.default.fileExists(atPath: imageURL.path) else {
await report("Skipping \(deviceName): disk image not found.")
metadata.clearPendingDiskImageResize(for: image)
continue
}

let actualSize = try await VBDiskResizer.currentImageSize(at: imageURL, format: image.format)

if image.size > actualSize {
let targetDescription = formatter.string(fromByteCount: Int64(image.size))
await report("Expanding \(deviceName) to \(targetDescription) (\(position)/\(total))...")

try await resizeDiskImage(image, to: image.size)

await report("\(deviceName) expanded successfully.")
metadata.clearPendingDiskImageResize(for: image)
} else if image.size < actualSize {
let actualDescription = formatter.string(fromByteCount: Int64(actualSize))
await report("\(deviceName) exceeds the configured size (\(actualDescription)); no changes made.")
metadata.clearPendingDiskImageResize(for: image)
} else {
let currentDescription = formatter.string(fromByteCount: Int64(actualSize))
if VBDiskResizer.shouldReconcilePartitions(
configuredSize: image.size,
actualSize: actualSize,
format: image.format
) {
await report("Verifying \(deviceName) partition layout (\(position)/\(total))...")
try await VBDiskResizer.reconcilePartitions(at: imageURL, format: image.format)
}
await report("\(deviceName) already uses \(currentDescription).")
metadata.clearPendingDiskImageResize(for: image)
}
}

await report("Disk image checks complete.")
}

/// Resizes a managed disk image to the specified size
private func resizeDiskImage(_ image: VBManagedDiskImage, to newSize: UInt64) async throws {
let imageURL = diskImageURL(for: image)
diskResizeLogger.debug("Resizing disk image at \(imageURL.path, privacy: .public) to \(newSize, privacy: .public) bytes")

try await VBDiskResizer.resizeDiskImage(
at: imageURL,
format: image.format,
newSize: newSize
)

diskResizeLogger.debug("Successfully resized disk image at \(imageURL.path, privacy: .public) to \(newSize, privacy: .public) bytes")
}

/// Checks if a managed disk image has FileVault (locked volumes) enabled.
/// - Parameter image: The managed disk image to check.
/// - Returns: `true` if the disk image has FileVault-protected (locked) volumes, `false` otherwise.
func checkFileVaultForDiskImage(_ image: VBManagedDiskImage) async -> Bool {
let imageURL = diskImageURL(for: image)
return await VBDiskResizer.checkFileVaultStatus(at: imageURL, format: image.format)
}
}
42 changes: 42 additions & 0 deletions VirtualCore/Source/Models/VBVirtualMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer {
public var lastBootDate: Date? = nil
@DecodableDefault.EmptyPlaceholder
public var backgroundHash: BlurHashToken = .virtualBuddyBackground
@DecodableDefault.EmptyList
public var pendingDiskImageResizeIDs = Set<String>()
/// If this VM was imported from some other app, contains the name of the ``VMImporter`` that was used.
public var importedFromAppName: String? = nil

Expand All @@ -26,6 +28,30 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer {
/// The original local file URL that was specified (or set after a successful download from ``remoteInstallImageURL``).
public private(set) var installImageURL: URL? = nil

public init(
uuid: UUID = UUID(),
version: Int = Self.currentVersion,
installFinished: Bool = false,
firstBootDate: Date? = nil,
lastBootDate: Date? = nil,
backgroundHash: BlurHashToken = .virtualBuddyBackground,
pendingDiskImageResizeIDs: Set<String> = [],
importedFromAppName: String? = nil,
remoteInstallImageURL: URL? = nil,
installImageURL: URL? = nil
) {
self.uuid = uuid
self.version = version
self.installFinished = installFinished
self.firstBootDate = firstBootDate
self.lastBootDate = lastBootDate
self.backgroundHash = backgroundHash
self.pendingDiskImageResizeIDs = pendingDiskImageResizeIDs
self.importedFromAppName = importedFromAppName
self.remoteInstallImageURL = remoteInstallImageURL
self.installImageURL = installImageURL
}

/**
Usage of the same property for both local and remote restore image URLs has been the source of recurring bugs in the past.
Example: https://github.com/insidegui/VirtualBuddy/pull/395
Expand All @@ -46,6 +72,18 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer {
guard backgroundHash == .virtualBuddyBackground else { return }
backgroundHash = .virtualBuddyBackgroundLinux
}

public var hasPendingDiskImageResizes: Bool {
!pendingDiskImageResizeIDs.isEmpty
}

public mutating func markDiskImageResizePending(for image: VBManagedDiskImage) {
pendingDiskImageResizeIDs.insert(image.id)
}

public mutating func clearPendingDiskImageResize(for image: VBManagedDiskImage) {
pendingDiskImageResizeIDs.remove(image.id)
}
}

public var id: String { bundleURL.absoluteString }
Expand Down Expand Up @@ -78,6 +116,10 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer {
get { _installRestoreData }
set { _installRestoreData = newValue }
}

public var hasPendingDiskImageResizes: Bool {
metadata.hasPendingDiskImageResizes
}

}

Expand Down
Loading