diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index 3dd511d9..c06ed2c2 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ /* Begin PBXBuildFile section */ 0196B45329292B2A00614EF1 /* LinuxVirtualMachineConfigurationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0196B45229292B2A00614EF1 /* LinuxVirtualMachineConfigurationHelper.swift */; }; 4BA6BE7D293D22E500F396AE /* VirtualMachineConfigurationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA6BE7C293D22E500F396AE /* VirtualMachineConfigurationHelper.swift */; }; + E81981382E58FD210082D76A /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81981372E58FD210082D76A /* VBDiskResizer.swift */; }; F40A1E9D2C1873CA0033E47D /* VBBuildType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F40A1E9C2C1873C60033E47D /* VBBuildType.swift */; }; F413696229916F6E002CE8D3 /* StatusItemButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F413695129916F6E002CE8D3 /* StatusItemButton.swift */; }; F413696329916F6E002CE8D3 /* StatusBarPanelChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = F413695229916F6E002CE8D3 /* StatusBarPanelChrome.swift */; }; @@ -545,6 +546,7 @@ /* Begin PBXFileReference section */ 0196B45229292B2A00614EF1 /* LinuxVirtualMachineConfigurationHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinuxVirtualMachineConfigurationHelper.swift; sourceTree = ""; }; 4BA6BE7C293D22E500F396AE /* VirtualMachineConfigurationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMachineConfigurationHelper.swift; sourceTree = ""; }; + E81981372E58FD210082D76A /* VBDiskResizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBDiskResizer.swift; sourceTree = ""; }; F40A1E9C2C1873C60033E47D /* VBBuildType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBBuildType.swift; sourceTree = ""; }; F413695129916F6E002CE8D3 /* StatusItemButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItemButton.swift; sourceTree = ""; }; F413695229916F6E002CE8D3 /* StatusBarPanelChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarPanelChrome.swift; sourceTree = ""; }; @@ -1877,6 +1879,7 @@ F4C2374E2888AF5B001FF286 /* Utilities */ = { isa = PBXGroup; children = ( + E81981372E58FD210082D76A /* VBDiskResizer.swift */, F4C2374F2888AF67001FF286 /* LogStreamer.swift */, F4C2374C2888A462001FF286 /* VolumeUtils.swift */, F4510A772AE2A16F00E24DD9 /* WeakReference.swift */, @@ -2697,6 +2700,7 @@ F46FFBAC28059FF600D61023 /* VMInstance.swift in Sources */, F4E7DF952BB336F600C459FC /* VBSavedStatePackage.swift in Sources */, F40A1E9D2C1873CA0033E47D /* VBBuildType.swift in Sources */, + E81981382E58FD210082D76A /* VBDiskResizer.swift in Sources */, F465C3B0284F9660006E9ED4 /* VBRestoreImagesResponse.swift in Sources */, F453C41D2DF0B43D007EAD5F /* ResolvedCatalog.swift in Sources */, F453C41E2DF0B43D007EAD5F /* LegacyCatalog.swift in Sources */, @@ -2907,11 +2911,11 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddy/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -2922,6 +2926,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(VB_APP_RUNPATH_SEARCH_PATHS)"; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(VB_BUNDLE_ID_PREFIX)codes.rambo.VirtualBuddy"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -2935,10 +2940,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuest/VirtualBuddyGuest.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddyGuest/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -2967,9 +2973,10 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuestHelper/VirtualBuddyGuestHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -2997,7 +3004,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -3180,11 +3187,11 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddy/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -3195,6 +3202,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(VB_APP_RUNPATH_SEARCH_PATHS)"; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(VB_BUNDLE_ID_PREFIX)codes.rambo.VirtualBuddy"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -3207,10 +3215,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuest/VirtualBuddyGuest.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddyGuest/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -3238,9 +3247,10 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuestHelper/VirtualBuddyGuestHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -3267,7 +3277,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -3392,9 +3402,10 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuestHelper/VirtualBuddyGuestHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -3421,9 +3432,10 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuestHelper/VirtualBuddyGuestHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -3450,9 +3462,10 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuestHelper/VirtualBuddyGuestHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -3478,9 +3491,10 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuestHelper/VirtualBuddyGuestHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -3507,7 +3521,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -3542,7 +3556,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -3577,7 +3591,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -3611,7 +3625,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -3814,11 +3828,11 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddy/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -3829,6 +3843,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(VB_APP_RUNPATH_SEARCH_PATHS)"; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(VB_BUNDLE_ID_PREFIX)codes.rambo.VirtualBuddy"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -3841,10 +3856,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuest/VirtualBuddyGuest.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddyGuest/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -3872,9 +3888,10 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuestHelper/VirtualBuddyGuestHelper.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -3901,7 +3918,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -4087,11 +4104,11 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddy/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4102,6 +4119,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(VB_APP_RUNPATH_SEARCH_PATHS)"; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(VB_BUNDLE_ID_PREFIX)codes.rambo.VirtualBuddy"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -4115,10 +4133,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuest/VirtualBuddyGuest.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddyGuest/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4280,11 +4299,11 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddy/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4295,6 +4314,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(VB_APP_RUNPATH_SEARCH_PATHS)"; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(VB_BUNDLE_ID_PREFIX)codes.rambo.VirtualBuddy"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -4307,10 +4327,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuest/VirtualBuddyGuest.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddyGuest/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4531,9 +4552,10 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddy/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4556,9 +4578,10 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddy/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4683,10 +4706,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuest/VirtualBuddyGuest.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddyGuest/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4714,10 +4738,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = VirtualBuddyGuest/VirtualBuddyGuest.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"VirtualBuddyGuest/Preview Content\""; - DEVELOPMENT_TEAM = 8C7439RJLG; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 530c32d4..58ba0ccd 100644 --- a/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/insidegui/BuddyKit", "state" : { - "revision" : "a68a8aef7eadbd62ec61afcf97e4b2cd0a31f05d", - "version" : "1.7.4" + "revision" : "21d183e3c9c55ad23eb795739ffd73a1e354bca5", + "version" : "1.8.0" } }, { diff --git a/VirtualBuddy/Config/Signing.xcconfig b/VirtualBuddy/Config/Signing.xcconfig index f733701e..591e1a59 100644 --- a/VirtualBuddy/Config/Signing.xcconfig +++ b/VirtualBuddy/Config/Signing.xcconfig @@ -1,5 +1,5 @@ CODE_SIGN_IDENTITY = Apple Development -VB_BUNDLE_ID_PREFIX = +VB_BUNDLE_ID_PREFIX = com.yourname. GUEST_LAUNCH_AT_LOGIN_HELPER_BUNDLE_ID = $(VB_BUNDLE_ID_PREFIX)codes.rambo.VirtualBuddyGuestHelper GUEST_LAUNCH_AT_LOGIN_HELPER_BUNDLE_ID_STR=@"$(GUEST_LAUNCH_AT_LOGIN_HELPER_BUNDLE_ID)" diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index 552b87a1..7e65816d 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 Silicon Image" + } + } } 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, .dmg, .sparse: + return true + case .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/Models/Configuration/VBManagedDiskImage+Resize.swift b/VirtualCore/Source/Models/Configuration/VBManagedDiskImage+Resize.swift new file mode 100644 index 00000000..aa5ba7ac --- /dev/null +++ b/VirtualCore/Source/Models/Configuration/VBManagedDiskImage+Resize.swift @@ -0,0 +1,92 @@ +// +// VBManagedDiskImage+Resize.swift +// VirtualCore +// +// Created by VirtualBuddy on 22/08/25. +// + +import Foundation + +extension VBManagedDiskImage { + + public var canBeResized: Bool { + VBDiskResizer.canResizeFormat(format) + } + + public var displayName: String { + format.displayName + } + + public func resized(to newSize: UInt64) -> VBManagedDiskImage { + var copy = self + copy.size = newSize + return copy + } + + public mutating func resize(to newSize: UInt64, at container: any VBStorageDeviceContainer) async throws { + guard canBeResized else { + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + guard newSize > size else { + throw VBDiskResizeError.cannotShrinkDisk + } + + guard newSize <= Self.maximumExtraDiskImageSize else { + throw VBDiskResizeError.invalidSize(newSize) + } + + let imageURL = container.diskImageURL(for: self) + + try await VBDiskResizer.resizeDiskImage( + at: imageURL, + format: format, + newSize: newSize + ) + + self.size = newSize + } + +} + +extension VBManagedDiskImage.Format { + + 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 Silicon Image" + } + } + + public var supportsResize: Bool { + VBDiskResizer.canResizeFormat(self) + } + +} + +extension VBStorageDevice { + + public func canBeResized(in container: any VBStorageDeviceContainer) -> Bool { + guard let managedImage = managedImage else { return false } + guard managedImage.canBeResized else { return false } + + let imageURL = container.diskImageURL(for: self) + return FileManager.default.fileExists(atPath: imageURL.path) + } + + public func resizeDisk(to newSize: UInt64, in container: any VBStorageDeviceContainer) async throws { + guard var managedImage = managedImage else { + throw VBDiskResizeError.unsupportedImageFormat(.raw) + } + + try await managedImage.resize(to: newSize, at: container) + backing = .managedImage(managedImage) + } + +} \ No newline at end of file diff --git a/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift index 8820aa7c..a8815897 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift @@ -69,3 +69,110 @@ 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 attributes = try FileManager.default.attributesOfItem(atPath: imageURL.path) + let actualSize = attributes[.size] as? UInt64 ?? 0 + + 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") + } + + /// Validates that all disk images can be resized if needed + func validateDiskResizeCapability() -> [(device: VBStorageDevice, canResize: Bool)] { + let config = configuration + + return config.hardware.storageDevices.compactMap { device in + guard case .managedImage(let image) = device.backing else { return nil } + + let imageURL = diskImageURL(for: image) + let exists = FileManager.default.fileExists(atPath: imageURL.path) + + if !exists { + // New image, no resize needed + return nil + } + + return (device: device, canResize: image.canBeResized) + } + } +} diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift new file mode 100644 index 00000000..84e0a320 --- /dev/null +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -0,0 +1,1425 @@ +// +// 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 { + + public enum ResizeStrategy { + case createLargerImage + case expandInPlace + } + + 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, .dmg, .sparse: + return true + case .asif: + return false + } + } + + public static func recommendedStrategy(for format: VBManagedDiskImage.Format) -> ResizeStrategy { + switch format { + case .raw: + return .expandInPlace // Use in-place expansion to save disk space + case .dmg, .sparse: + return .expandInPlace + case .asif: + return .createLargerImage + } + } + + public static func resizeDiskImage( + at url: URL, + format: VBManagedDiskImage.Format, + newSize: UInt64, + strategy: ResizeStrategy? = nil + ) 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 getCurrentImageSize(at: url, format: format) + guard newSize > currentSize else { + throw VBDiskResizeError.cannotShrinkDisk + } + + let finalStrategy = strategy ?? recommendedStrategy(for: format) + + switch finalStrategy { + case .createLargerImage: + try await createLargerImage(at: url, format: format, newSize: newSize, currentSize: currentSize) + case .expandInPlace: + 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) + } + + private static func getCurrentImageSize(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 .dmg, .sparse: + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + process.arguments = ["imageinfo", "-plist", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", process.terminationStatus) + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let size = plist["Total Bytes"] as? UInt64 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", -1) + } + + return size + + case .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + private static func createLargerImage( + at url: URL, + format: VBManagedDiskImage.Format, + newSize: UInt64, + currentSize: UInt64 + ) async throws { + let backupURL = url.appendingPathExtension("backup") + let tempURL = url.appendingPathExtension("resizing") + + let parentDir = url.deletingLastPathComponent() + let availableSpace = try await getAvailableSpace(at: parentDir) + + let requiredSpace = newSize + currentSize + guard availableSpace >= requiredSpace else { + throw VBDiskResizeError.insufficientSpace(required: requiredSpace, available: availableSpace) + } + + do { + try FileManager.default.moveItem(at: url, to: backupURL) + + switch format { + case .raw: + // Create empty file of new size + FileManager.default.createFile(atPath: tempURL.path, contents: nil, attributes: nil) + let fileHandle = try FileHandle(forWritingTo: tempURL) + defer { fileHandle.closeFile() } + + let result = ftruncate(fileHandle.fileDescriptor, Int64(newSize)) + guard result == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", result) + } + + // Copy original data to the beginning of the new larger file + let sourceFile = try FileHandle(forReadingFrom: backupURL) + fileHandle.seek(toFileOffset: 0) + defer { sourceFile.closeFile() } + + let bufferSize = 1024 * 1024 + while true { + let data = sourceFile.readData(ofLength: bufferSize) + if data.isEmpty { break } + fileHandle.write(data) + } + + case .dmg, .sparse: + try await createExpandedDMGImage(from: backupURL, to: tempURL, newSize: newSize, format: format) + + case .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + try FileManager.default.moveItem(at: tempURL, to: url) + try FileManager.default.removeItem(at: backupURL) + + } catch { + if FileManager.default.fileExists(atPath: tempURL.path) { + try? FileManager.default.removeItem(at: tempURL) + } + + if FileManager.default.fileExists(atPath: backupURL.path) { + try? FileManager.default.moveItem(at: backupURL, to: url) + } + + throw error + } + } + + 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 getCurrentImageSize(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 .dmg, .sparse: + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + + let sizeInSectors = newSize / 512 + process.arguments = ["resize", "-size", "\(sizeInSectors)s", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errorData = pipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw VBDiskResizeError.systemCommandFailed("hdiutil resize: \(errorString)", process.terminationStatus) + } + + case .raw: + try await expandRawImageInPlace(at: url, newSize: newSize) + try adjustGPTLayoutForRawImage(at: url, newSize: newSize) + + case .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + private static func createRawImage(at url: URL, size: UInt64) async throws { + let tempURL = url.appendingPathExtension("tmp") + + // Create the temporary file first + FileManager.default.createFile(atPath: tempURL.path, contents: nil, attributes: nil) + + let fileHandle = try FileHandle(forWritingTo: tempURL) + defer { fileHandle.closeFile() } + + let result = ftruncate(fileHandle.fileDescriptor, Int64(size)) + guard result == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", result) + } + + try FileManager.default.moveItem(at: tempURL, to: url) + } + + private static func createExpandedDMGImage(from sourceURL: URL, to destURL: URL, newSize: UInt64, format: VBManagedDiskImage.Format) async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + + let formatArg: String + switch format { + case .dmg: + formatArg = "UDRW" + case .sparse: + formatArg = "SPARSE" + default: + formatArg = "UDRW" + } + + let sizeInSectors = newSize / 512 + process.arguments = [ + "convert", sourceURL.path, + "-format", formatArg, + "-o", destURL.path, + "-size", "\(sizeInSectors)s" + ] + + 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 convert: \(errorString)", process.terminationStatus) + } + } + + private static func expandRawImageInPlace(at url: URL, newSize: UInt64) async throws { + let fileHandle = try FileHandle(forWritingTo: url) + defer { fileHandle.closeFile() } + + let result = ftruncate(fileHandle.fileDescriptor, Int64(newSize)) + guard result == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", result) + } + } + + private static func getAvailableSpace(at url: URL) async throws -> UInt64 { + let resourceValues = try url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) + return UInt64(resourceValues.volumeAvailableCapacity ?? 0) + } + + /// Expands partitions within a disk image to use the newly available space + private static func expandPartitionsInDiskImage(at url: URL, format: VBManagedDiskImage.Format) async throws { + NSLog("Attempting to expand partitions in disk image at \(url.path)") + + switch format { + case .raw: + // For RAW images, we need to mount and resize using diskutil + try await expandPartitionsInRawImage(at: url) + + case .dmg, .sparse: + // For DMG/Sparse images, we can work with them directly + try await expandPartitionsInDMGImage(at: url) + + case .asif: + // ASIF format doesn't support resizing + NSLog("Skipping partition expansion for ASIF 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 expandPartitionsInDMGImage(at url: URL) async throws { + // Mount the DMG 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 listOutput.contains("Apple_APFS_Recovery") { + // Check if there's an Apple_APFS_Recovery partition blocking expansion + NSLog("Detected Apple_APFS_Recovery partition - attempting recovery partition resize strategy") + try await resizeWithRecoveryPartition(deviceNode: deviceNode, listOutput: listOutput) + } 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 if let hfsPartition = findHFSPartition(in: listOutput, deviceNode: deviceNode) { + NSLog("Found HFS+ partition: \(hfsPartition)") + try await resizeHFSPartition(hfsPartition) + } else { + // Fallback: try the original method + if let partitionIdentifier = findResizablePartition(in: listOutput, deviceNode: deviceNode) { + NSLog("Using fallback resize for partition: \(partitionIdentifier)") + try await resizeGenericPartition(partitionIdentifier) + } else { + NSLog("Warning: Could not find any resizable partition 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 { + NSLog("Warning: Failed to resize APFS container target \(resizeTarget): \(primaryResult.output)") + if primaryResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + + // 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 { + NSLog("Follow-up resize on container \(containerTarget) failed (ignored): \(containerResult.output)") + if containerResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + } + + try await ensureAPFSContainerMaximized(info: info) + } + + private static func resizeHFSPartition(_ partitionIdentifier: String) async throws { + try await resizeGenericPartition(partitionIdentifier) + } + + private static func resizeGenericPartition(_ partitionIdentifier: String) async throws { + let resizeProcess = Process() + resizeProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + resizeProcess.arguments = ["resizeVolume", partitionIdentifier, "R"] + + let resizePipe = Pipe() + resizeProcess.standardOutput = resizePipe + resizeProcess.standardError = resizePipe + + try resizeProcess.run() + resizeProcess.waitUntilExit() + + let resizeOutput = String(data: resizePipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + if resizeProcess.terminationStatus == 0 { + NSLog("Successfully expanded partition \(partitionIdentifier)") + } else { + // Check if this is an APFS volume that needs container resizing + if resizeOutput.contains("is an APFS Volume") && resizeOutput.contains("diskutil apfs resizeContainer") { + NSLog("Partition \(partitionIdentifier) is an APFS Volume, attempting to find and resize its container") + + // Extract the base device (e.g., /dev/disk10 from /dev/disk10s2) + // We need to find the last 's' followed by a number to properly extract the base device + let baseDevice: String + if let lastSIndex = partitionIdentifier.lastIndex(of: "s"), + partitionIdentifier.index(after: lastSIndex) < partitionIdentifier.endIndex, + partitionIdentifier[partitionIdentifier.index(after: lastSIndex)].isNumber { + baseDevice = String(partitionIdentifier[.. String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + // Look for APFS or HFS+ partitions (typically the main data partition) + 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 APFS Container or HFS+ partition + if (trimmed.contains("APFS") || trimmed.contains("Apple_HFS")) && + (trimmed.contains("Container") || trimmed.contains("Macintosh HD") || trimmed.contains("disk")) { + + // Extract partition number (e.g., "1:" -> "disk4s1") + let components = trimmed.components(separatedBy: .whitespaces) + for component in components { + if component.hasSuffix(":") { + let partitionNum = component.dropLast() // Remove ":" + return "\(deviceNode)s\(partitionNum)" + } + } + } + } + + // Fallback: try s2 which is commonly the main partition + return "\(deviceNode)s2" + } + + 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, isLikelyISC: 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 hasSystemOrData = roles.contains(where: { $0 == "System" }) || roles.contains(where: { $0 == "Data" }) + let hasLockedVolumes = volumes.contains { ($0["Locked"] as? Bool) == true } + + 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, isLikelyISC: !hasSystemOrData)) + } + + 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, isLikelyISC: !hasSystemOrData)) + } + } + + guard !candidates.isEmpty else { + NSLog("No APFS container found in 'diskutil apfs list' for device \(cleanDeviceNode)") + return nil + } + + let preferred = candidates.sorted { lhs, rhs in + if lhs.info.hasLockedVolumes != rhs.info.hasLockedVolumes { + return lhs.info.hasLockedVolumes == false + } + if lhs.isLikelyISC != rhs.isLikelyISC { + return lhs.isLikelyISC == false + } + return lhs.size > rhs.size + }.first + + return preferred?.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 findHFSPartition(in diskutilOutput: String, deviceNode: String) -> String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + // Look for HFS+ partitions + 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_HFS partition (Mac OS Extended) + if trimmed.contains("Apple_HFS") && !trimmed.contains("Container") { + // Extract partition number (e.g., "2:" -> "disk4s2") + let components = trimmed.components(separatedBy: .whitespaces) + for component in components { + if component.hasSuffix(":") { + let partitionNum = component.dropLast() // Remove ":" + let hfsDevice = "\(deviceNode)s\(partitionNum)" + NSLog("Found HFS+ partition: \(hfsDevice)") + return hfsDevice + } + } + } + } + + NSLog("No HFS+ partition found in diskutil output") + return nil + } + + private static func resizeWithRecoveryPartition(deviceNode: String, listOutput: String) async throws { + NSLog("Handling partition layout with Apple_APFS_Recovery partition") + + guard let mainContainer = findAPFSContainer(in: listOutput, deviceNode: deviceNode) else { + NSLog("Could not find main APFS container for recovery partition resize") + return + } + + let mainContainerTarget = mainContainer.physicalStore ?? mainContainer.container + NSLog("Primary APFS container for recovery handling: \(mainContainer.container) (store: \(mainContainerTarget))") + + // Check if recovery partition is blocking expansion + let recoveryPartition = findRecoveryPartition(in: listOutput, deviceNode: deviceNode) + + if let recovery = recoveryPartition { + NSLog("Found recovery partition: \(recovery)") + NSLog("Recovery partition detected - attempting advanced resize strategies") + + // Strategy 1: Try to delete the recovery partition to allow expansion + NSLog("Attempting to temporarily remove recovery partition for expansion") + + // First, we need to find the actual container reference for the recovery partition + // The recovery partition is typically a synthesized disk, so we need to find its container + let recoveryContainer = findRecoveryContainer(in: listOutput) + + if let containerToDelete = recoveryContainer { + NSLog("Found recovery container reference: \(containerToDelete)") + + let deleteProcess = Process() + deleteProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + deleteProcess.arguments = ["apfs", "deleteContainer", containerToDelete, "-force"] + + let deletePipe = Pipe() + deleteProcess.standardOutput = deletePipe + deleteProcess.standardError = deletePipe + + try deleteProcess.run() + deleteProcess.waitUntilExit() + + let deleteOutput = String(data: deletePipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + if deleteProcess.terminationStatus == 0 { + NSLog("Successfully removed recovery partition, attempting main container resize") + + // Now try to resize the main container + try await resizeAPFSContainer(mainContainer) + + NSLog("Main container resized successfully") + // Note: The recovery partition will be recreated by macOS when needed + + return // Exit early on success + } else { + NSLog("Could not remove recovery container: \(deleteOutput)") + + // Check if it's protected by SIP + if deleteOutput.contains("csrutil disable") || deleteOutput.contains("Recovery Container") { + NSLog("Recovery partition is protected by System Integrity Protection (SIP)") + NSLog("The disk image has been successfully resized to provide more total space") + NSLog("To fully utilize the space, you can:") + NSLog("1. Boot the VM into Recovery Mode (Command+R during startup)") + NSLog("2. Use Disk Utility to manually adjust partitions") + NSLog("3. Or disable SIP temporarily if needed (not recommended)") + return // This is actually successful, just with limitations + } + } + } else { + NSLog("Could not identify recovery container reference") + + // Strategy 2: Try using the limit parameter to resize up to the recovery partition + NSLog("Attempting to resize main container up to recovery partition boundary") + + // Get total disk size (might be useful for debugging) + let diskInfoProcess = Process() + diskInfoProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + diskInfoProcess.arguments = ["info", deviceNode] + + let diskInfoPipe = Pipe() + diskInfoProcess.standardOutput = diskInfoPipe + diskInfoProcess.standardError = Pipe() + + try diskInfoProcess.run() + diskInfoProcess.waitUntilExit() + + _ = diskInfoPipe.fileHandleForReading.readDataToEndOfFile() + + // Try to resize leaving space for recovery + let resizeProcess = Process() + resizeProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + let recoveryResizeTarget = mainContainer.physicalStore ?? mainContainer.container + resizeProcess.arguments = ["apfs", "resizeContainer", recoveryResizeTarget, "0"] + + let resizePipe = Pipe() + resizeProcess.standardOutput = resizePipe + resizeProcess.standardError = resizePipe + + try resizeProcess.run() + resizeProcess.waitUntilExit() + + let resizeOutput = String(data: resizePipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + if resizeProcess.terminationStatus == 0 { + NSLog("Successfully resized APFS container") + } else { + NSLog("Container resize failed: \(resizeOutput)") + NSLog("The disk image has been enlarged successfully") + NSLog("Note: The available space may be used by macOS dynamically") + } + } + } else { + NSLog("No recovery partition found, proceeding with standard resize") + try await resizeAPFSContainer(mainContainer) + } + } + + private static func parsePartitionLayout(_ listOutput: String, deviceNode: String) -> [(number: Int, type: String, name: String, size: String)] { + let lines = listOutput.components(separatedBy: .newlines) + var partitions: [(number: Int, type: String, name: String, size: String)] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") && trimmed.contains(":") else { continue } + + let components = trimmed.components(separatedBy: .whitespaces) + if let first = components.first, first.hasSuffix(":") { + let partitionNum = String(first.dropLast()) + if let num = Int(partitionNum), components.count >= 4 { + let type = components[1] + let name = components.count > 2 ? components[2] : "" + let size = components.count > 3 ? components[3] : "" + partitions.append((number: num, type: type, name: name, size: size)) + } + } + } + + return partitions + } + + private static func findRecoveryPartition(in diskutilOutput: String, deviceNode: String) -> String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + if trimmed.contains("Apple_APFS_Recovery") || (trimmed.contains("Recovery") && trimmed.contains("Container")) { + let components = trimmed.components(separatedBy: .whitespaces) + for component in components { + if component.hasSuffix(":") { + let partitionNum = component.dropLast() + let recoveryDevice = "\(deviceNode)s\(partitionNum)" + NSLog("Found recovery partition: \(recoveryDevice)") + return recoveryDevice + } + } + } + } + + return nil + } + + private static func findRecoveryContainer(in diskutilOutput: String) -> String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + // Look for the recovery container - it's typically shown as "Container disk6" in the output + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + if trimmed.contains("Apple_APFS_Recovery") && trimmed.contains("Container") { + // Extract the container disk reference (e.g., "disk6" from "Container disk6") + let components = trimmed.components(separatedBy: .whitespaces) + + // Look for "Container" followed by "diskX" + for (index, component) in components.enumerated() { + if component == "Container" && index + 1 < components.count { + let nextComponent = components[index + 1] + if nextComponent.hasPrefix("disk") { + NSLog("Found recovery container: \(nextComponent)") + return nextComponent + } + } + } + } + } + + NSLog("Could not find recovery container in diskutil output") + return nil + } + + private static func ensureAPFSContainerMaximized(info: APFSContainerInfo) async throws { + if info.hasLockedVolumes { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + + guard let details = try fetchAPFSContainerDetails(container: info.container) else { + return + } + + let physicalSize = details.physicalStoreSize + let capacity = details.capacityCeiling + let tolerance: UInt64 = 1 * 1024 * 1024 // 1 MB tolerance to account for rounding + + if physicalSize > capacity + tolerance { + NSLog("APFS container \(info.container) ceiling (\(capacity)) is below physical store size (\(physicalSize)); nudging container") + try await nudgeAPFSContainer(info: info, physicalSize: physicalSize) + + if let postDetails = try fetchAPFSContainerDetails(container: info.container) { + NSLog("Post-nudge container ceiling: \(postDetails.capacityCeiling) (store: \(postDetails.physicalStoreSize))") + } + } + } + + private static func fetchAPFSContainerDetails(container: String) throws -> APFSContainerDetails? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["apfs", "list", "-plist", container] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + NSLog("Failed to query APFS container \(container): \(output)") + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let containers = plist["Containers"] as? [[String: Any]], + let first = containers.first, + let capacity = first["CapacityCeiling"] as? UInt64, + let stores = first["PhysicalStores"] as? [[String: Any]], + let store = stores.first, + let storeSize = store["Size"] as? UInt64 + else { + NSLog("Could not parse APFS container details for \(container)") + return nil + } + + return APFSContainerDetails(capacityCeiling: capacity, physicalStoreSize: storeSize) + } + + private static func nudgeAPFSContainer(info: APFSContainerInfo, physicalSize: UInt64) async throws { + let alignment: UInt64 = 4096 + let shrinkDelta: UInt64 = 32 * 1024 * 1024 // 32 MB nudge to ensure actual size change + let resizeTarget = info.physicalStore ?? info.container + + guard physicalSize > alignment else { return } + + let tentativeShrink = physicalSize > shrinkDelta ? physicalSize - shrinkDelta : physicalSize - alignment + let alignedShrink = max((tentativeShrink / alignment) * alignment, alignment) + + let shrinkArg = "\(alignedShrink)B" + let shrinkResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, shrinkArg]) + + if shrinkResult.status != 0 { + NSLog("APFS shrink nudge for \(resizeTarget) failed: \(shrinkResult.output)") + if shrinkResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + + let growResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) + if growResult.status != 0 { + NSLog("APFS grow after nudge for \(resizeTarget) failed: \(growResult.output)") + if growResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + } + + private static func runDiskutilCommand(arguments: [String]) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + } catch { + NSLog("Failed to run diskutil \(arguments.joined(separator: " ")): \(error)") + return (-1, "\(error)") + } + + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, output) + } + + private static func adjustGPTLayoutForRawImage(at url: URL, newSize: UInt64) throws { + try GPTLayoutAdjuster(imageURL: url, newSize: newSize).perform() + } + + private struct GPTLayoutAdjuster { + let imageURL: URL + let newSize: UInt64 + + private let sectorSize: UInt64 = 512 + private let mainContainerGUID = UUID(uuidString: "7C3457EF-0000-11AA-AA11-00306543ECAC")! + private let recoveryGUID = UUID(uuidString: "52637672-7900-11AA-AA11-00306543ECAC")! + + func perform() throws { + guard newSize % sectorSize == 0 else { + throw VBDiskResizeError.systemCommandFailed("New disk size must be 512-byte aligned", -1) + } + + let fileHandle = try FileHandle(forUpdating: imageURL) + defer { try? fileHandle.close() } + + let headerOffset = sectorSize + try fileHandle.vbSeek(to: headerOffset) + let headerData = try readExactly(fileHandle: fileHandle, length: Int(sectorSize)) + + var header = GPTHeader(data: headerData) + let entriesOffset = UInt64(header.partitionEntriesLBA) * sectorSize + let entriesLength = Int(header.numberOfEntries) * Int(header.entrySize) + + try fileHandle.vbSeek(to: entriesOffset) + var entries = try readExactly(fileHandle: fileHandle, length: entriesLength) + + guard + let mainIndex = findPartitionIndex(in: entries, guid: mainContainerGUID, entrySize: Int(header.entrySize), preferLargest: true), + let recoveryIndex = findPartitionIndex(in: entries, guid: recoveryGUID, entrySize: Int(header.entrySize), preferLargest: false) + else { + throw NSError(domain: "VBDiskResizer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not locate APFS partitions in GPT"]) + } + + let mainLast = readUInt64LittleEndian(from: entries, offset: mainIndex * Int(header.entrySize) + 40) + let recoveryFirst = readUInt64LittleEndian(from: entries, offset: recoveryIndex * Int(header.entrySize) + 32) + let recoveryLast = readUInt64LittleEndian(from: entries, offset: recoveryIndex * Int(header.entrySize) + 40) + + let recoveryLength = recoveryLast - recoveryFirst + 1 + + let totalSectors = newSize / sectorSize + let newBackupLBA = totalSectors - 1 + let backupEntriesLBA = newBackupLBA - 32 + var newLastUsable = backupEntriesLBA - 8 + var newRecoveryFirst = newLastUsable - (recoveryLength - 1) + + let alignment: UInt64 = 8 + let remainder = newRecoveryFirst % alignment + if remainder != 0 { + newRecoveryFirst -= remainder + newLastUsable = newRecoveryFirst + recoveryLength - 1 + } + + let newMainLast = newRecoveryFirst - 1 + + guard newMainLast > mainLast else { + // Nothing to do if the main container already occupies the space + return + } + + try copySectors( + fileHandle: fileHandle, + from: recoveryFirst, + to: newRecoveryFirst, + count: recoveryLength, + sectorSize: sectorSize + ) + + try zeroSectors( + fileHandle: fileHandle, + start: recoveryFirst, + count: recoveryLength, + sectorSize: sectorSize + ) + + writeUInt64LittleEndian( + &entries, + offset: mainIndex * Int(header.entrySize) + 40, + value: newMainLast + ) + + writeUInt64LittleEndian( + &entries, + offset: recoveryIndex * Int(header.entrySize) + 32, + value: newRecoveryFirst + ) + + writeUInt64LittleEndian( + &entries, + offset: recoveryIndex * Int(header.entrySize) + 40, + value: newLastUsable + ) + + header.backupLBA = newBackupLBA + header.lastUsableLBA = newLastUsable + header.partitionEntriesCRC32 = crc32(of: entries) + + try fileHandle.vbSeek(to: entriesOffset) + try fileHandle.vbWriteAll(entries) + + let primaryHeaderData = header.serialized(sectorSize: sectorSize, isBackup: false) + try fileHandle.vbSeek(to: headerOffset) + try fileHandle.vbWriteAll(primaryHeaderData) + + let backupEntriesOffset = backupEntriesLBA * sectorSize + try fileHandle.vbSeek(to: backupEntriesOffset) + try fileHandle.vbWriteAll(entries) + + let backupHeaderData = header.serialized(sectorSize: sectorSize, isBackup: true) + try fileHandle.vbSeek(to: newBackupLBA * sectorSize) + try fileHandle.vbWriteAll(backupHeaderData) + + try fileHandle.vbSynchronize() + } + + private func readExactly(fileHandle: FileHandle, length: Int) throws -> Data { + let data = try fileHandle.vbRead(upToCount: length) ?? Data() + guard data.count == length else { + throw NSError(domain: "VBDiskResizer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read expected GPT data"]) + } + return data + } + + private func findPartitionIndex(in entries: Data, guid: UUID, entrySize: Int, preferLargest: Bool) -> Int? { + var bestIndex: Int? + var bestLength: UInt64 = 0 + + for index in 0..<(entries.count / entrySize) { + let base = index * entrySize + let typeData = entries.subdata(in: base..<(base + 16)) + guard let entryGUID = uuidFromGPTBytes(typeData), entryGUID == guid else { + continue + } + + if !preferLargest { + return index + } + + let first = readUInt64LittleEndian(from: entries, offset: base + 32) + let last = readUInt64LittleEndian(from: entries, offset: base + 40) + let length = last >= first ? last - first : 0 + if length > bestLength { + bestLength = length + bestIndex = index + } + } + + return preferLargest ? bestIndex : nil + } + + private func copySectors(fileHandle: FileHandle, from: UInt64, to: UInt64, count: UInt64, sectorSize: UInt64) throws { + let bufferSize: UInt64 = 4 * 1024 * 1024 + var remaining = count * sectorSize + var readOffset = from * sectorSize + var writeOffset = to * sectorSize + + while remaining > 0 { + let chunk = Int(min(bufferSize, remaining)) + try fileHandle.vbSeek(to: readOffset) + let data = try readExactly(fileHandle: fileHandle, length: chunk) + + try fileHandle.vbSeek(to: writeOffset) + try fileHandle.vbWriteAll(data) + + remaining -= UInt64(chunk) + readOffset += UInt64(chunk) + writeOffset += UInt64(chunk) + } + } + + private func zeroSectors(fileHandle: FileHandle, start: UInt64, count: UInt64, sectorSize: UInt64) throws { + let bufferSize: UInt64 = 4 * 1024 * 1024 + var remaining = count * sectorSize + var offset = start * sectorSize + let zeroChunk = Data(count: Int(min(bufferSize, remaining))) + + while remaining > 0 { + let chunk = Int(min(UInt64(zeroChunk.count), remaining)) + try fileHandle.vbSeek(to: offset) + try fileHandle.vbWriteAll(zeroChunk.prefix(chunk)) + + remaining -= UInt64(chunk) + offset += UInt64(chunk) + } + } + } + + private struct GPTHeader { + var signature: UInt64 + var revision: UInt32 + var headerSize: UInt32 + var headerCRC32: UInt32 + var reserved: UInt32 + var currentLBA: UInt64 + var backupLBA: UInt64 + var firstUsableLBA: UInt64 + var lastUsableLBA: UInt64 + var diskGUID: Data + var partitionEntriesLBA: UInt64 + var numberOfEntries: UInt32 + var entrySize: UInt32 + var partitionEntriesCRC32: UInt32 + + init(data: Data) { + signature = readUInt64LittleEndian(from: data, offset: 0) + revision = readUInt32LittleEndian(from: data, offset: 8) + headerSize = readUInt32LittleEndian(from: data, offset: 12) + headerCRC32 = readUInt32LittleEndian(from: data, offset: 16) + reserved = readUInt32LittleEndian(from: data, offset: 20) + currentLBA = readUInt64LittleEndian(from: data, offset: 24) + backupLBA = readUInt64LittleEndian(from: data, offset: 32) + firstUsableLBA = readUInt64LittleEndian(from: data, offset: 40) + lastUsableLBA = readUInt64LittleEndian(from: data, offset: 48) + diskGUID = data.subdata(in: 56..<72) + partitionEntriesLBA = readUInt64LittleEndian(from: data, offset: 72) + numberOfEntries = readUInt32LittleEndian(from: data, offset: 80) + entrySize = readUInt32LittleEndian(from: data, offset: 84) + partitionEntriesCRC32 = readUInt32LittleEndian(from: data, offset: 88) + } + + func serialized(sectorSize: UInt64, isBackup: Bool) -> Data { + var data = Data(count: Int(sectorSize)) + writeUInt64LittleEndian(&data, offset: 0, value: signature) + writeUInt32LittleEndian(&data, offset: 8, value: revision) + writeUInt32LittleEndian(&data, offset: 12, value: headerSize) + writeUInt32LittleEndian(&data, offset: 16, value: 0) // placeholder for CRC + writeUInt32LittleEndian(&data, offset: 20, value: reserved) + let current = isBackup ? backupLBA : currentLBA + let backup = isBackup ? currentLBA : backupLBA + writeUInt64LittleEndian(&data, offset: 24, value: current) + writeUInt64LittleEndian(&data, offset: 32, value: backup) + writeUInt64LittleEndian(&data, offset: 40, value: firstUsableLBA) + writeUInt64LittleEndian(&data, offset: 48, value: lastUsableLBA) + data.replaceSubrange(56..<72, with: diskGUID) + let entriesLBA = isBackup ? (backupLBA - 32) : partitionEntriesLBA + writeUInt64LittleEndian(&data, offset: 72, value: entriesLBA) + writeUInt32LittleEndian(&data, offset: 80, value: numberOfEntries) + writeUInt32LittleEndian(&data, offset: 84, value: entrySize) + writeUInt32LittleEndian(&data, offset: 88, value: partitionEntriesCRC32) + + let crc = crc32(of: data.prefix(Int(headerSize))) + writeUInt32LittleEndian(&data, offset: 16, value: crc) + return data + } + } + + private static func crc32(of data: Data) -> UInt32 { + data.withUnsafeBytes { buffer -> UInt32 in + guard let base = buffer.bindMemory(to: UInt8.self).baseAddress else { return 0 } + return UInt32(zlib.crc32(0, base, uInt(buffer.count))) + } + } + + private static func uuidFromGPTBytes(_ data: Data) -> UUID? { + guard data.count == 16 else { return nil } + let a = readUInt32LittleEndian(from: data, offset: 0) + let b = readUInt16LittleEndian(from: data, offset: 4) + let c = readUInt16LittleEndian(from: data, offset: 6) + let tail = Array(data[8..<16]) + let uuidString = String( + format: "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", + a, b, c, + tail[0], tail[1], + tail[2], tail[3], + tail[4], tail[5], tail[6], tail[7] + ) + return UUID(uuidString: uuidString) + } + + private static func readUInt64LittleEndian(from data: Data, offset: Int) -> UInt64 { + let range = offset..<(offset + 8) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt64.self) }.littleEndian + } + + private static func readUInt32LittleEndian(from data: Data, offset: Int) -> UInt32 { + let range = offset..<(offset + 4) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt32.self) }.littleEndian + } + + private static func readUInt16LittleEndian(from data: Data, offset: Int) -> UInt16 { + let range = offset..<(offset + 2) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt16.self) }.littleEndian + } + + private static func writeUInt64LittleEndian(_ data: inout Data, offset: Int, value: UInt64) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 8), with: bytes) + } + } + + private static func writeUInt32LittleEndian(_ data: inout Data, offset: Int, value: UInt32) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 4), with: bytes) + } + } + + private static func writeUInt16LittleEndian(_ data: inout Data, offset: Int, value: UInt16) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 2), with: bytes) + } + } + +} diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index 607da105..1e86f1ab 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) @@ -158,6 +159,27 @@ public final class VMController: ObservableObject { state = .starting(nil) 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() @@ -402,6 +424,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 @@ -420,6 +443,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 } @@ -512,3 +539,4 @@ public extension VBMacConfiguration { #endif } } + diff --git a/VirtualUI/Source/Session/Components/VirtualMachineControls.swift b/VirtualUI/Source/Session/Components/VirtualMachineControls.swift index 898a2c2b..dd912bbf 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 { diff --git a/VirtualUI/Source/Session/VirtualMachineSessionView.swift b/VirtualUI/Source/Session/VirtualMachineSessionView.swift index 8a092368..c7ed5bce 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 f013d449..547fd835 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,22 @@ 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 newSize: UInt64 = 0 + @State private var sliderTimer: Timer? @Environment(\.dismiss) private var dismiss @@ -50,21 +57,34 @@ struct ManagedDiskImageEditor: View { } } - NumericPropertyControl( - value: $image.size.gbStorageValue, - range: minimumSize.gbStorageValue...VBManagedDiskImage.maximumExtraDiskImageSize.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...VBManagedDiskImage.maximumExtraDiskImageSize.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 { Text("You'll have to use Disk Utility in the guest operating system to initialize the disk image. If you see an error after it boots up, choose the \"Initialize\" option.") .foregroundColor(.yellow) } + + if isExistingDiskImage && canResize { + Text("This \(image.format.displayName) can be expanded. After resizing, you may need to expand the partition using Disk Utility in the guest operating system.") + .foregroundColor(.blue) + } if let sizeWarning { Text(sizeWarning) @@ -88,7 +108,28 @@ 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.") } } @@ -98,9 +139,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." + } } } @@ -123,6 +172,21 @@ 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 { + await MainActor.run { + 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 188c2de7..aa33d247 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift @@ -36,6 +36,21 @@ struct StorageConfigurationView: View { configure(device) } .tag(device.id) + .contextMenu { + if device.canBeResized { + Button("Resize Disk…") { + configure(device) + } + } + + if !device.isBootVolume { + Button("Remove Device", role: .destructive) { + if let idx = hardware.storageDevices.firstIndex(where: { $0.id == device.id }) { + hardware.storageDevices.remove(at: idx) + } + } + } + } } } } emptyOverlay: { @@ -94,6 +109,7 @@ struct StorageConfigurationView: View { } struct StorageDeviceListItem: View { + @EnvironmentObject var viewModel: VMConfigurationViewModel @Binding var device: VBStorageDevice var configureDevice: () -> Void @@ -119,6 +135,13 @@ struct StorageDeviceListItem: View { Text(device.displayName) 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()