diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift index b45cb5f7..cbf4fb41 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift @@ -133,8 +133,6 @@ public extension VBNetworkDevice { } public extension VBDisplayDevice { - static let automaticallyReconfiguresDisplayWarningMessage = "Automatic display configuration is only recognized by VMs running macOS 14 and later." - static var automaticallyReconfiguresDisplaySupportedByHost: Bool { if #available(macOS 14.0, *) { return true diff --git a/VirtualCore/Source/VirtualCatalog/SoftwareCatalog+DownloadMatching.swift b/VirtualCore/Source/VirtualCatalog/SoftwareCatalog+DownloadMatching.swift index a881b005..f57aee0b 100644 --- a/VirtualCore/Source/VirtualCatalog/SoftwareCatalog+DownloadMatching.swift +++ b/VirtualCore/Source/VirtualCatalog/SoftwareCatalog+DownloadMatching.swift @@ -90,6 +90,23 @@ extension URL { /// Container for properties of a restore image that can be inferred from a local file by reading from extended attributes or parsing from the file name. var vb_restoreImageStub: RestoreImageStub { RestoreImageStub(url: self) } + + /// Attempts to infer the OS version represented by a restore image file or URL. + var vb_restoreImageVersion: SoftwareVersion? { + let candidates = [ + vb_softwareCatalogData?.filename, + lastPathComponent, + vb_whereFromsSpotlightMetadata.first?.lastPathComponent + ].compactMap { $0 } + + for candidate in candidates { + if let version = candidate.matchAppleOSVersion() { + return version + } + } + + return nil + } } public extension SoftwareCatalog { @@ -130,3 +147,94 @@ extension Array where Element: DownloadableCatalogContent { } } +// MARK: - Best-effort Resolution + +public extension SoftwareCatalog { + /// Attempts to resolve a restore image using catalog matching. If no catalog entry matches, + /// creates a best-effort inferred restore image using metadata from the URL. + func resolvedRestoreImage(matching url: URL, guestType: VBGuestType) -> ResolvedRestoreImage? { + if let match = restoreImageMatchingDownloadableCatalogContent(at: url) { + return try? ResolvedRestoreImage( + environment: .current.guestType(guestType), + catalog: self, + image: match + ) + } + + guard let inferredImage = inferredRestoreImage(for: url, guestType: guestType) else { + return nil + } + + return try? ResolvedRestoreImage( + environment: .current.guestType(guestType), + catalog: self, + image: inferredImage + ) + } +} + +private extension SoftwareCatalog { + func inferredRestoreImage(for url: URL, guestType: VBGuestType) -> RestoreImage? { + guard let version = url.vb_restoreImageVersion else { return nil } + + let build = url.vb_restoreImageStub.build + let resolvedBuild = build.isEmpty ? url.deletingPathExtension().lastPathComponent : build + let groupID = groupID(for: version) ?? "custom" + let channelID = channels.first?.id ?? "custom" + let requirementID = bestRequirementSetID(for: version) ?? "custom" + let name = inferredName(for: version, guestType: guestType) + let mobileDeviceMinVersion = inferredMobileDeviceMinVersion(for: version) + + return RestoreImage( + id: resolvedBuild, + group: groupID, + channel: channelID, + requirements: requirementID, + name: name, + build: resolvedBuild, + version: version, + mobileDeviceMinVersion: mobileDeviceMinVersion, + url: url, + downloadSize: nil + ) + } + + func inferredName(for version: SoftwareVersion, guestType: VBGuestType) -> String { + switch guestType { + case .mac: + return "macOS \(version.shortDescription)" + case .linux: + return "Linux \(version.shortDescription)" + } + } + + func groupID(for version: SoftwareVersion) -> CatalogGroup.ID? { + groups.first(where: { $0.majorVersion.major == version.major })?.id + } + + func inferredMobileDeviceMinVersion(for version: SoftwareVersion) -> SoftwareVersion { + if let deviceSupportVersion = deviceSupportVersions.first(where: { + ($0.osVersion.major == version.major && $0.osVersion.minor == version.minor) + || $0.osVersion.major == version.major + }) { + return deviceSupportVersion.mobileDeviceMinVersion + } + + return .empty + } + + func bestRequirementSetID(for version: SoftwareVersion) -> RequirementSet.ID? { + guard !requirementSets.isEmpty else { return nil } + + if let minHost13 = requirementSets.first(where: { $0.id == "min_host_13" }), + let minHost12 = requirementSets.first(where: { $0.id == "min_host_12" }), + let threshold = SoftwareVersion(string: "13.3") + { + return version >= threshold ? minHost13.id : minHost12.id + } + + return requirementSets + .max(by: { $0.minVersionHost < $1.minVersionHost })? + .id + } +} diff --git a/VirtualCore/Source/VirtualCatalog/Utilities/String+AppleOSBuild.swift b/VirtualCore/Source/VirtualCatalog/Utilities/String+AppleOSBuild.swift index e93036a1..be254cbf 100644 --- a/VirtualCore/Source/VirtualCatalog/Utilities/String+AppleOSBuild.swift +++ b/VirtualCore/Source/VirtualCatalog/Utilities/String+AppleOSBuild.swift @@ -1,10 +1,18 @@ import Foundation +import BuddyFoundation extension String { static let appleOSBuildRegex = /[0-9]{2}[A-Z][0-9]{2,}[a-z]?/ + static let appleOSVersionRegex = /[0-9]+(?:\.[0-9]+){1,2}/ /// Returns the first regex match for an Apple OS build number (ex: `23A5276f`). func matchAppleOSBuild() -> String? { (try? Self.appleOSBuildRegex.firstMatch(in: self)?.output).flatMap { String($0) } } + + /// Returns the first regex match for an Apple OS version (ex: `15.5` or `15.5.1`). + func matchAppleOSVersion() -> SoftwareVersion? { + (try? Self.appleOSVersionRegex.firstMatch(in: self)?.output) + .flatMap { SoftwareVersion(string: String($0)) } + } } diff --git a/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift b/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift index af0b4fd0..2a3321fd 100644 --- a/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift +++ b/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift @@ -13,9 +13,9 @@ struct InstallConfigurationStepView: View { @State private var vm: VBVirtualMachine var onSave: (VBVirtualMachine) -> Void - init(vm: VBVirtualMachine, onSave: @escaping (VBVirtualMachine) -> Void) { + init(vm: VBVirtualMachine, resolvedRestoreImage: ResolvedRestoreImage? = nil, onSave: @escaping (VBVirtualMachine) -> Void) { self._vm = .init(wrappedValue: vm) - self._viewModel = .init(wrappedValue: VMConfigurationViewModel(vm, context: .preInstall)) + self._viewModel = .init(wrappedValue: VMConfigurationViewModel(vm, context: .preInstall, resolvedRestoreImage: resolvedRestoreImage)) self.onSave = onSave } diff --git a/VirtualUI/Source/Installer/VMInstallData.swift b/VirtualUI/Source/Installer/VMInstallData.swift index 36182b9b..a59c2fc9 100644 --- a/VirtualUI/Source/Installer/VMInstallData.swift +++ b/VirtualUI/Source/Installer/VMInstallData.swift @@ -131,8 +131,8 @@ extension VMInstallData { let customURL = try URL(string: customInstallImageRemoteURL).require("Invalid URL: \(customInstallImageRemoteURL.quoted).") installMethodSelection = .remoteManual(customURL) - /// Attempt to match custom URL with known catalog content. - restoreImage = catalog.restoreImageMatchingDownloadableCatalogContent(at: customURL) + /// Attempt to resolve a catalog image for custom URL. + resolveCatalogImage(for: customURL) } @MainActor @@ -143,15 +143,24 @@ extension VMInstallData { installMethodSelection = .localFile(fileURL) commitLocalRestoreImageURL(fileURL) - /// Attempt to match custom local file with known catalog content. - restoreImage = catalog.restoreImageMatchingDownloadableCatalogContent(at: fileURL) + /// Attempt to resolve a catalog image for custom local file. + resolveCatalogImage(for: fileURL, localFileURL: fileURL) } @MainActor mutating func resolveCatalogImageIfNeeded(with model: VBVirtualMachine) throws { - guard case .remoteOptions(let restoreImage) = installMethodSelection else { return } + guard resolvedRestoreImage == nil else { return } - resolvedRestoreImage = try model.resolveCatalogImage(restoreImage) + switch installMethodSelection { + case .remoteOptions(let restoreImage): + resolvedRestoreImage = try model.resolveCatalogImage(restoreImage) + case .remoteManual(let url): + resolveCatalogImage(for: url) + case .localFile(let url): + resolveCatalogImage(for: url, localFileURL: url) + case .none: + break + } } mutating func commitLocalRestoreImageURL(_ url: URL) { @@ -201,6 +210,24 @@ extension VMInstallData { } } +// MARK: - Catalog Resolution + +private extension VMInstallData { + @MainActor + mutating func resolveCatalogImage(for url: URL, localFileURL: URL? = nil) { + guard var resolved = catalog.resolvedRestoreImage(matching: url, guestType: systemType) else { + restoreImage = catalog.restoreImageMatchingDownloadableCatalogContent(at: url) + return + } + + if let localFileURL { + resolved.localFileURL = localFileURL + } + + resolvedRestoreImage = resolved + } +} + extension VBVirtualMachine.Metadata { mutating func updateRestoreImageURLs(with data: VMInstallData) { /// Always save whatever URL the restore image was downloaded from and the local file URL, regardless of the install method. diff --git a/VirtualUI/Source/Installer/VMInstallationWizard.swift b/VirtualUI/Source/Installer/VMInstallationWizard.swift index c1f34cad..1acd9e1e 100644 --- a/VirtualUI/Source/Installer/VMInstallationWizard.swift +++ b/VirtualUI/Source/Installer/VMInstallationWizard.swift @@ -242,7 +242,7 @@ public struct VMInstallationWizard: View { @ViewBuilder private var configureVM: some View { if let machine = viewModel.machine { - InstallConfigurationStepView(vm: machine) { configuredModel in + InstallConfigurationStepView(vm: machine, resolvedRestoreImage: viewModel.data.resolvedRestoreImage) { configuredModel in viewModel.machine = configuredModel try? viewModel.machine?.saveMetadata() diff --git a/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift b/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift index 33106e37..e44b0555 100644 --- a/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift +++ b/VirtualUI/Source/Session/Configuration/VMSessionConfigurationView.swift @@ -15,6 +15,22 @@ struct VMSessionConfigurationView: View { private var vm: VBVirtualMachine { controller.virtualMachineModel } + private var resolvedRestoreImage: ResolvedRestoreImage? { + let catalog = SoftwareCatalog.current(for: vm.configuration.systemType) + + if let remoteURL = vm.metadata.remoteInstallImageURL, + let resolved = catalog.resolvedRestoreImage(matching: remoteURL, guestType: vm.configuration.systemType) { + return resolved + } + + if let localURL = vm.metadata.installImageURL, + let resolved = catalog.resolvedRestoreImage(matching: localURL, guestType: vm.configuration.systemType) { + return resolved + } + + return nil + } + var body: some View { SelfSizingGroupedForm(minHeight: 100) { if showSavedStatePicker { @@ -47,7 +63,7 @@ struct VMSessionConfigurationView: View { VMConfigurationSheet( configuration: $controller.virtualMachineModel.configuration ) - .environmentObject(VMConfigurationViewModel(vm)) + .environmentObject(VMConfigurationViewModel(vm, resolvedRestoreImage: resolvedRestoreImage)) } } diff --git a/VirtualUI/Source/VM Configuration/Sections/DisplayConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/DisplayConfigurationView.swift index 929fb054..1289f7aa 100644 --- a/VirtualUI/Source/VM Configuration/Sections/DisplayConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/DisplayConfigurationView.swift @@ -13,6 +13,15 @@ struct DisplayConfigurationView: View { @Binding var device: VBDisplayDevice @Binding var selectedPreset: VBDisplayPreset? var canChangePPI: Bool + + @Environment(\.resolvedRestoreImage) + private var resolvedRestoreImage + + private var displayResizeStatus: ResolvedFeatureStatus? { + resolvedRestoreImage?.feature(id: CatalogFeatureID.displayResize)?.status + } + + private var displayResizeUnsupported: Bool { displayResizeStatus?.isUnsupported == true } var body: some View { if let warning = selectedPreset?.warning { @@ -48,11 +57,25 @@ struct DisplayConfigurationView: View { } if VBDisplayDevice.automaticallyReconfiguresDisplaySupportedByHost { - Toggle("Automatically Configure Display", isOn: $device.automaticallyReconfiguresDisplay) - - if (device.automaticallyReconfiguresDisplay) { - Text(VBDisplayDevice.automaticallyReconfiguresDisplayWarningMessage) - .foregroundColor(.yellow) + Group { + if displayResizeUnsupported { + let helpMessage = displayResizeStatus?.supportMessage ?? "Not supported." + Toggle("Automatically Configure Display", isOn: $device.automaticallyReconfiguresDisplay) + .disabled(true) + .help(helpMessage) + } else { + Toggle("Automatically Configure Display", isOn: $device.automaticallyReconfiguresDisplay) + } + } + .onChange(of: displayResizeUnsupported) { isUnsupported in + if isUnsupported { + device.automaticallyReconfiguresDisplay = false + } + } + .onAppear { + if displayResizeUnsupported { + device.automaticallyReconfiguresDisplay = false + } } } } diff --git a/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift index c30bf280..4c1a9359 100644 --- a/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift @@ -11,9 +11,39 @@ import VirtualCore struct GuestAppConfigurationView: View { @Binding var configuration: VBMacConfiguration + @Environment(\.resolvedRestoreImage) + private var resolvedRestoreImage + + private var guestAppStatus: ResolvedFeatureStatus? { + resolvedRestoreImage?.feature(id: CatalogFeatureID.guestApp)?.status + } + + private var guestAppUnsupported: Bool { guestAppStatus?.isUnsupported == true } + private var guestAppHelp: String? { + guestAppUnsupported ? (guestAppStatus?.supportMessage ?? "Not supported.") : nil + } + var body: some View { VStack(alignment: .leading, spacing: 16) { - Toggle("Enable VirtualBuddy Guest App", isOn: $configuration.guestAdditionsEnabled) + Group { + if let guestAppHelp { + Toggle("Enable VirtualBuddy Guest App", isOn: $configuration.guestAdditionsEnabled) + .disabled(true) + .help(guestAppHelp) + } else { + Toggle("Enable VirtualBuddy Guest App", isOn: $configuration.guestAdditionsEnabled) + } + } + .onChange(of: guestAppUnsupported) { isUnsupported in + if isUnsupported { + configuration.guestAdditionsEnabled = false + } + } + .onAppear { + if guestAppUnsupported { + configuration.guestAdditionsEnabled = false + } + } Text(""" The guest app mounts shared directories and shares the clipboard between your Mac and virtual machines. diff --git a/VirtualUI/Source/VM Configuration/Sections/KeyboardDeviceConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/KeyboardDeviceConfigurationView.swift index ffc8c31e..c28aad04 100644 --- a/VirtualUI/Source/VM Configuration/Sections/KeyboardDeviceConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/KeyboardDeviceConfigurationView.swift @@ -11,22 +11,41 @@ import VirtualCore struct KeyboardDeviceConfigurationView: View { @Binding var hardware: VBMacDevice + @Environment(\.resolvedRestoreImage) + private var resolvedRestoreImage + + private var macKeyboardFeature: ResolvedVirtualizationFeature? { + resolvedRestoreImage?.feature(id: CatalogFeatureID.macKeyboard) + } + + private var macKeyboardStatus: ResolvedFeatureStatus? { macKeyboardFeature?.status } + + private var macKeyboardUnsupported: Bool { macKeyboardStatus?.isUnsupported == true } + + private var availableKinds: [VBKeyboardDevice.Kind] { + VBKeyboardDevice.Kind.allCases.filter { kind in + kind != .mac || !macKeyboardUnsupported + } + } + var body: some View { PropertyControl("Device Type", spacing: 8) { VStack(alignment: .leading) { Picker("Device Type", selection: $hardware.keyboardDevice.kind) { - ForEach(VBKeyboardDevice.Kind.allCases) { kind in + ForEach(availableKinds) { kind in Text(kind.name) .tag(kind) } } - - if let error = hardware.keyboardDevice.kind.error { - Text(error) - .foregroundColor(.red) - } else if let warning = hardware.keyboardDevice.kind.warning { - Text(warning) - .foregroundColor(.yellow) + .onChange(of: macKeyboardUnsupported) { isUnsupported in + if isUnsupported, hardware.keyboardDevice.kind == .mac { + hardware.keyboardDevice.kind = .generic + } + } + .onAppear { + if macKeyboardUnsupported, hardware.keyboardDevice.kind == .mac { + hardware.keyboardDevice.kind = .generic + } } } } diff --git a/VirtualUI/Source/VM Configuration/Sections/PointingDeviceConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/PointingDeviceConfigurationView.swift index 3f3e6f55..35987a0c 100644 --- a/VirtualUI/Source/VM Configuration/Sections/PointingDeviceConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/PointingDeviceConfigurationView.swift @@ -10,23 +10,42 @@ import VirtualCore struct PointingDeviceConfigurationView: View { @Binding var hardware: VBMacDevice + + @Environment(\.resolvedRestoreImage) + private var resolvedRestoreImage + + private var trackpadFeature: ResolvedVirtualizationFeature? { + resolvedRestoreImage?.feature(id: CatalogFeatureID.trackpad) + } + + private var trackpadStatus: ResolvedFeatureStatus? { trackpadFeature?.status } + + private var trackpadUnsupported: Bool { trackpadStatus?.isUnsupported == true } + + private var availableKinds: [VBPointingDevice.Kind] { + VBPointingDevice.Kind.allCases.filter { kind in + kind != .trackpad || !trackpadUnsupported + } + } var body: some View { PropertyControl("Device Type", spacing: 8) { VStack(alignment: .leading) { Picker("Device Type", selection: $hardware.pointingDevice.kind) { - ForEach(VBPointingDevice.Kind.allCases) { kind in + ForEach(availableKinds) { kind in Text(kind.name) .tag(kind) } } - - if let error = hardware.pointingDevice.kind.error { - Text(error) - .foregroundColor(.red) - } else if let warning = hardware.pointingDevice.kind.warning { - Text(warning) - .foregroundColor(.yellow) + .onChange(of: trackpadUnsupported) { isUnsupported in + if isUnsupported, hardware.pointingDevice.kind == .trackpad { + hardware.pointingDevice.kind = .mouse + } + } + .onAppear { + if trackpadUnsupported, hardware.pointingDevice.kind == .trackpad { + hardware.pointingDevice.kind = .mouse + } } } } diff --git a/VirtualUI/Source/VM Configuration/Sections/Sharing/SharedFoldersManagementView.swift b/VirtualUI/Source/VM Configuration/Sections/Sharing/SharedFoldersManagementView.swift index 06e3bb25..29c194f7 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Sharing/SharedFoldersManagementView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Sharing/SharedFoldersManagementView.swift @@ -11,6 +11,9 @@ import VirtualCore struct SharedFoldersManagementView: View { @Binding var configuration: VBMacConfiguration + + @Environment(\.resolvedRestoreImage) + private var resolvedRestoreImage @StateObject private var availabilityProvider: SharedFoldersAvailabilityProvider @@ -26,54 +29,108 @@ struct SharedFoldersManagementView: View { @State private var selectionBeingRemoved: Set? @State private var isShowingRemovalConfirmation = false @State private var isShowingHelpPopover = false + + private var fileSharingStatus: ResolvedFeatureStatus? { + guard configuration.systemType == .mac else { return nil } + return resolvedRestoreImage?.feature(id: CatalogFeatureID.fileSharing)?.status + } + + private var fileSharingUnsupported: Bool { fileSharingStatus?.isUnsupported == true } + private var fileSharingHelp: String? { + fileSharingUnsupported ? (fileSharingStatus?.supportMessage ?? "Not supported.") : nil + } + + private var rosettaStatus: ResolvedFeatureStatus? { + guard configuration.systemType == .linux else { return nil } + return resolvedRestoreImage?.feature(id: CatalogFeatureID.rosettaSharing)?.status + } + + private var rosettaUnsupported: Bool { rosettaStatus?.isUnsupported == true } + private var rosettaHelp: String? { + rosettaUnsupported ? (rosettaStatus?.supportMessage ?? "Not supported.") : nil + } + + @ViewBuilder + private var sharedFoldersList: some View { + GroupedList { + List(selection: $selection) { + ForEach($configuration.sharedFolders) { $folder in + SharedFolderListItem(folder: $folder) + .contextMenu { folderMenu(for: $folder) } + .tag(folder.id) + } + } + } headerAccessory: { + headerAccessory + } footerAccessory: { + EmptyView() + } emptyOverlay: { + emptyOverlay + } addButton: { label in + Button { + addFolder() + } label: { + label + } + .help("Add shared folder") + } removeButton: { label in + Button { + confirmRemoval() + } label: { + label + } + .help("Remove selection from shared folders") + .disabled(selection.isEmpty) + } + } var body: some View { VStack(alignment: .leading, spacing: 16) { - GroupedList { - List(selection: $selection) { - ForEach($configuration.sharedFolders) { $folder in - SharedFolderListItem(folder: $folder) - .contextMenu { folderMenu(for: $folder) } - .tag(folder.id) - } - } - } headerAccessory: { - headerAccessory - } footerAccessory: { - EmptyView() - } emptyOverlay: { - emptyOverlay - } addButton: { label in - Button { - addFolder() - } label: { - label - } - .help("Add shared folder") - } removeButton: { label in - Button { - confirmRemoval() - } label: { - label + Group { + if let fileSharingHelp { + sharedFoldersList + .help(fileSharingHelp) + } else { + sharedFoldersList } - .help("Remove selection from shared folders") - .disabled(selection.isEmpty) } + .disabled(fileSharingUnsupported) - Text(VBMacConfiguration.fileSharingNotice) - .font(.caption) - .foregroundColor(.yellow) + if configuration.systemType == .mac, fileSharingUnsupported { + Text(VBMacConfiguration.fileSharingNotice) + .font(.caption) + .foregroundColor(.yellow) + } if configuration.systemType == .linux { - let rosettaToggleBind = if VBMacConfiguration.rosettaSupported { - $configuration.rosettaSharingEnabled - } else { - Binding.constant(false) + Group { + let rosettaToggleBind = if VBMacConfiguration.rosettaSupported && !rosettaUnsupported { + $configuration.rosettaSharingEnabled + } else { + Binding.constant(false) + } + + if let rosettaHelp { + Toggle("Share Rosetta for Linux", isOn: rosettaToggleBind) + .disabled(true) + .help(rosettaHelp) + } else { + Toggle("Share Rosetta for Linux", isOn: rosettaToggleBind) + .disabled(!VBMacConfiguration.rosettaSupported || rosettaUnsupported) + } + } + .onChange(of: rosettaUnsupported) { isUnsupported in + if isUnsupported { + configuration.rosettaSharingEnabled = false + } + } + .onAppear { + if rosettaUnsupported { + configuration.rosettaSharingEnabled = false + } } - Toggle("Share Rosetta for Linux", isOn: rosettaToggleBind) - .disabled(!VBMacConfiguration.rosettaSupported) - if let rosettaSharingNotice = VBMacConfiguration.rosettaSharingNotice() { + if !rosettaUnsupported, let rosettaSharingNotice = VBMacConfiguration.rosettaSharingNotice() { Text(try! AttributedString(markdown: rosettaSharingNotice)) .font(.caption) .foregroundColor(.yellow) diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift index 57c982b8..c3e8db52 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift @@ -13,6 +13,55 @@ extension EnvironmentValues { @Entry fileprivate(set) var configurationGuestType: VBGuestType = .mac } +private struct ResolvedRestoreImageKey: EnvironmentKey { + static let defaultValue: ResolvedRestoreImage? = nil +} + +extension EnvironmentValues { + var resolvedRestoreImage: ResolvedRestoreImage? { + get { self[ResolvedRestoreImageKey.self] } + set { self[ResolvedRestoreImageKey.self] = newValue } + } +} + +enum CatalogFeatureID { + static let fileSharing = "file_sharing" + static let guestApp = "guest_app" + static let trackpad = "trackpad" + static let macKeyboard = "mac_keyboard" + static let stateRestoration = "state_restoration" + static let displayResize = "display_resize" + static let rosettaSharing = "rosetta_sharing" +} + +extension ResolvedRestoreImage { + func feature(id: String) -> ResolvedVirtualizationFeature? { + features.first { $0.id == id } + } +} + +extension ResolvedFeatureStatus { + var supportMessage: String? { + switch self { + case .supported: + return nil + case .warning(let title, let message), .unsupported(let title, let message): + return title ?? message + } + } + + var supportMessageColor: Color { + switch self { + case .supported: + return .secondary + case .warning: + return .yellow + case .unsupported: + return .red + } + } +} + struct VMConfigurationView: View { @EnvironmentObject private var viewModel: VMConfigurationViewModel @@ -92,6 +141,7 @@ struct VMConfigurationView: View { } .font(.system(size: 12)) .environment(\.configurationGuestType, viewModel.config.systemType) + .environment(\.resolvedRestoreImage, viewModel.resolvedRestoreImage) } @ViewBuilder diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift index f3d949d4..415b455c 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift @@ -28,6 +28,12 @@ public final class VMConfigurationViewModel: ObservableObject { } @Published public internal(set) var supportState: VBMacConfiguration.SupportState = .supported + + @Published public internal(set) var resolvedRestoreImage: ResolvedRestoreImage? { + didSet { + applyResolvedFeatureDefaultsIfNeeded() + } + } @Published var selectedDisplayPreset: VBDisplayPreset? @@ -35,11 +41,14 @@ public final class VMConfigurationViewModel: ObservableObject { public let context: VMConfigurationContext - public init(_ vm: VBVirtualMachine, context: VMConfigurationContext = .postInstall) { + public init(_ vm: VBVirtualMachine, context: VMConfigurationContext = .postInstall, resolvedRestoreImage: ResolvedRestoreImage? = nil) { self.config = vm.configuration self.vm = vm self.context = context + self.resolvedRestoreImage = resolvedRestoreImage + applyResolvedFeatureDefaultsIfNeeded() + Task { await validate() } } @@ -75,3 +84,46 @@ public final class VMConfigurationViewModel: ObservableObject { } } + +// MARK: - Feature Defaults + +private extension VMConfigurationViewModel { + func applyResolvedFeatureDefaultsIfNeeded() { + guard context == .preInstall else { return } + guard let resolvedRestoreImage else { return } + + var updated = config + + if resolvedRestoreImage.feature(id: CatalogFeatureID.guestApp)?.status.isUnsupported == true { + updated.guestAdditionsEnabled = false + } + + if resolvedRestoreImage.feature(id: CatalogFeatureID.trackpad)?.status.isUnsupported == true, + updated.hardware.pointingDevice.kind == .trackpad + { + updated.hardware.pointingDevice.kind = .mouse + } + + if resolvedRestoreImage.feature(id: CatalogFeatureID.macKeyboard)?.status.isUnsupported == true, + updated.hardware.keyboardDevice.kind == .mac + { + updated.hardware.keyboardDevice.kind = .generic + } + + if resolvedRestoreImage.feature(id: CatalogFeatureID.displayResize)?.status.isUnsupported == true { + updated.hardware.displayDevices = updated.hardware.displayDevices.map { device in + var updatedDevice = device + updatedDevice.automaticallyReconfiguresDisplay = false + return updatedDevice + } + } + + if resolvedRestoreImage.feature(id: CatalogFeatureID.rosettaSharing)?.status.isUnsupported == true { + updated.rosettaSharingEnabled = false + } + + if updated != config { + config = updated + } + } +}