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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
39 changes: 33 additions & 6 deletions VirtualUI/Source/Installer/VMInstallData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion VirtualUI/Source/Installer/VMInstallationWizard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -47,7 +63,7 @@ struct VMSessionConfigurationView: View {
VMConfigurationSheet(
configuration: $controller.virtualMachineModel.configuration
)
.environmentObject(VMConfigurationViewModel(vm))
.environmentObject(VMConfigurationViewModel(vm, resolvedRestoreImage: resolvedRestoreImage))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading