diff --git a/Scripts/check_guest_app_deployment_target.sh b/Scripts/check_guest_app_deployment_target.sh new file mode 100755 index 00000000..ac689e1e --- /dev/null +++ b/Scripts/check_guest_app_deployment_target.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env zsh + +# This script prevents raising the deployment target for VirtualBuddyGuest without placing the corresponding legacy archive in data/LegacyGuest, +# ensuring that older releases are always made available to users running legacy guest OSes + +set -e + +source "Scripts/legacy_guest_env.zsh" + +# This only runs for builds using Managed configurations (Beta configurations are also managed) +if [[ "$CONFIGURATION" == *"Managed"* || "$CONFIGURATION" == *"Beta"* ]]; then + EXPECTED_GUEST_ARCHIVE="${SRCROOT}/${LEGACY_GUEST_ARCHIVE_DIR}/VirtualBuddyGuest_minOS_${LATEST_LEGACY_GUEST_APP_ARCHIVE_DEPLOYMENT_TARGET}.dmg" + + if [ ! -f "${EXPECTED_GUEST_ARCHIVE}" ]; then + echo "error: The deployment target for VirtualBuddyGuest has been raised to ${MACOSX_DEPLOYMENT_TARGET} without a legacy archive being created for the latest version that supported ${LATEST_LEGACY_GUEST_APP_ARCHIVE_DEPLOYMENT_TARGET}. Please use dmgdist against a notarized build of VirtualBuddyGuest to generate an archive and register it with vctool before proceeding with this release." + exit 1 + fi +fi diff --git a/Scripts/legacy_guest_env.zsh b/Scripts/legacy_guest_env.zsh new file mode 100755 index 00000000..8d46fa4d --- /dev/null +++ b/Scripts/legacy_guest_env.zsh @@ -0,0 +1,9 @@ +#!/usr/bin/env zsh + +# Path to legacy guest app archive directory relative to VirtualBuddy repo root +export LEGACY_GUEST_ARCHIVE_DIR=data/LegacyGuestApp + +# The deployment target for the latest VirtualBuddyGuest app archive +# If a managed build is attempted with a different value without an archive +# existing with this version number suffix, the build fails. +export LATEST_LEGACY_GUEST_APP_ARCHIVE_DEPLOYMENT_TARGET="14.0" diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index 088db35e..2a277b42 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ F43B01472AD85A7D00164CD1 /* URLQueryItemCoder in Frameworks */ = {isa = PBXBuildFile; productRef = F43B01462AD85A7D00164CD1 /* URLQueryItemCoder */; }; F43B014B2AD85ABB00164CD1 /* DeepLinkAuthDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43B01492AD85ABB00164CD1 /* DeepLinkAuthDialog.swift */; }; F43B014E2AD86BFA00164CD1 /* DeepLinkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43B014D2AD86BFA00164CD1 /* DeepLinkHandler.swift */; }; + F43ED2BE2FDD9631001D143B /* LegacyGuestAppCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43ED2BD2FDD9631001D143B /* LegacyGuestAppCommand.swift */; }; F443620A29B7947A00745B43 /* GuestAdditionsDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F443620929B7947A00745B43 /* GuestAdditionsDiskImage.swift */; }; F443620C29B79A6800745B43 /* VirtualBuddyGuest.app in Embed Guest App */ = {isa = PBXBuildFile; fileRef = F4C18A4228491B8500335EC7 /* VirtualBuddyGuest.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F443620F29B7A0C600745B43 /* CreateGuestImage.sh in Resources */ = {isa = PBXBuildFile; fileRef = F443620E29B7A0C600745B43 /* CreateGuestImage.sh */; }; @@ -701,6 +702,7 @@ F43B01432AD85A6500164CD1 /* VirtualBuddyDeepLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualBuddyDeepLinks.swift; sourceTree = ""; }; F43B01492AD85ABB00164CD1 /* DeepLinkAuthDialog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeepLinkAuthDialog.swift; sourceTree = ""; }; F43B014D2AD86BFA00164CD1 /* DeepLinkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkHandler.swift; sourceTree = ""; }; + F43ED2BD2FDD9631001D143B /* LegacyGuestAppCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGuestAppCommand.swift; sourceTree = ""; }; F443620929B7947A00745B43 /* GuestAdditionsDiskImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestAdditionsDiskImage.swift; sourceTree = ""; }; F443620E29B7A0C600745B43 /* CreateGuestImage.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = CreateGuestImage.sh; sourceTree = ""; }; F444D0C92DF321CD0086537A /* CatalogGroupPlaceholder.heic */ = {isa = PBXFileReference; lastKnownFileType = file; path = CatalogGroupPlaceholder.heic; sourceTree = ""; }; @@ -965,6 +967,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + F43EC1962FDD8233001D143B /* Scripts */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Scripts; sourceTree = ""; }; F4EFB5642FDB2A0D00AF2A63 /* VirtualInstallation */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F4EFB5AD2FDB2A7600AF2A63 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = VirtualInstallation; sourceTree = ""; }; F4EFB5B52FDB2BD400AF2A63 /* VirtualInstallationService */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F4EFB5C02FDB2BD400AF2A63 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = VirtualInstallationService; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -1467,6 +1470,7 @@ F453C4312DF0B7A5007EAD5F /* Core */, F453C4322DF0B7A5007EAD5F /* CatalogCommand.swift */, F453C4332DF0B7A5007EAD5F /* GroupCommand.swift */, + F43ED2BD2FDD9631001D143B /* LegacyGuestAppCommand.swift */, F453C4342DF0B7A5007EAD5F /* ImageCommand.swift */, F453C4352DF0B7A5007EAD5F /* IPSWCommand.swift */, F453C4362DF0B7A5007EAD5F /* MigrateCommand.swift */, @@ -1834,6 +1838,7 @@ F4BE9C4527FF052100B648F8 = { isa = PBXGroup; children = ( + F43EC1962FDD8233001D143B /* Scripts */, F4BE9C5027FF052100B648F8 /* VirtualBuddy */, F4BE9C6627FF053A00B648F8 /* VirtualCore */, F498ACFF2884BF13006F1C00 /* VirtualUI */, @@ -2379,6 +2384,7 @@ F4C18A4028491B8500335EC7 /* Resources */, F4C18A5628491B9D00335EC7 /* Embed Frameworks */, F41369BE2991861C002CE8D3 /* Embed Login Item */, + F43EC19B2FDD8920001D143B /* ShellScript */, ); buildRules = ( ); @@ -2426,6 +2432,7 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + F43EC1962FDD8233001D143B /* Scripts */, F4EFB5642FDB2A0D00AF2A63 /* VirtualInstallation */, ); name = VirtualInstallation; @@ -2634,6 +2641,24 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + F43EC19B2FDD8920001D143B /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "./Scripts/check_guest_app_deployment_target.sh\n"; + }; F453C4282DF0B65B007EAD5F /* Create Command-Line Tool Symlinks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2871,6 +2896,7 @@ F453C4422DF0B7A5007EAD5F /* MobileDeviceCommand.swift in Sources */, F453C4432DF0B7A5007EAD5F /* BuildManifest+Fetch.swift in Sources */, F453C4682DF10181007EAD5F /* BlurHashCommand.swift in Sources */, + F43ED2BE2FDD9631001D143B /* LegacyGuestAppCommand.swift in Sources */, F453C44E2DF0B870007EAD5F /* VirtualBuddyCLI.swift in Sources */, F453C4442DF0B7A5007EAD5F /* TreeStringConvertible.swift in Sources */, F453C4452DF0B7A5007EAD5F /* MigrateCommand.swift in Sources */, diff --git a/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b3b6e5a..88338432 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" : "21d183e3c9c55ad23eb795739ffd73a1e354bca5", - "version" : "1.8.0" + "revision" : "0d8c630c1646b41d4e5164777a3ede53b7329b40", + "version" : "1.9.4" } }, { diff --git a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme index 0b7f0abe..5a9505ea 100644 --- a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme +++ b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme @@ -57,7 +57,7 @@ + isEnabled = "NO"> + isEnabled = "NO"> + + + + () func applicationDidFinishLaunching(_ notification: Notification) { - GuestAdditionsDiskImage.current.$state.sink { state in + GuestAdditionsDiskImage.default.$state.sink { state in switch state { case .ready: - self.logger.debug("Guest disk image ready") + self.logger.debug("Default guest disk image ready") + case .downloading: + self.logger.debug("Default guest disk image downloading") case .installing: - self.logger.debug("Guest disk image installing") + self.logger.debug("Default guest disk image installing") case .installFailed(let error): - self.logger.debug("Guest disk image installation failed - \(error, privacy: .public)") + self.logger.debug("Default guest disk image installation failed - \(error, privacy: .public)") } } .store(in: &cancellables) Task { - try? await GuestAdditionsDiskImage.current.installIfNeeded() + try? await GuestAdditionsDiskImage.default.installIfNeeded() } #if DEBUG diff --git a/VirtualBuddy/CommandLine/vctool/CatalogCommand.swift b/VirtualBuddy/CommandLine/vctool/CatalogCommand.swift index 31f3da98..3e96e5a8 100644 --- a/VirtualBuddy/CommandLine/vctool/CatalogCommand.swift +++ b/VirtualBuddy/CommandLine/vctool/CatalogCommand.swift @@ -9,7 +9,8 @@ struct CatalogCommand: AsyncParsableCommand { subcommands: [ ImageCommand.self, GroupCommand.self, - MigrateCommand.self + MigrateCommand.self, + LegacyGuestAppCommand.self ] ) } diff --git a/VirtualBuddy/CommandLine/vctool/LegacyGuestAppCommand.swift b/VirtualBuddy/CommandLine/vctool/LegacyGuestAppCommand.swift new file mode 100644 index 00000000..6a564ef5 --- /dev/null +++ b/VirtualBuddy/CommandLine/vctool/LegacyGuestAppCommand.swift @@ -0,0 +1,133 @@ +import Foundation +import ArgumentParser +import AppKit +import BuddyFoundation +import VirtualUI +import CryptoKit + +extension CatalogCommand { + struct LegacyGuestAppCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "legacyguestapp", + abstract: "Modify legacy guest app disk images.", + subcommands: [ + AddCommand.self, + ] + ) + + struct AddCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "add", + abstract: "Adds a legacy guest app disk image to the catalog.", + discussion: """ + This command is used to add legacy builds of the VirtualBuddyGuest app to the catalog so that users running legacy guest OSes + can still have a version of the guest app that supports them. + + If run with a disk image matching the file name of a disk image already in the catalog, the corresponding entry in the catalog is updated. + """ + ) + + @Option(name: [.short, .long], help: "Path to an existing catalog JSON file that will be updated with the new group.") + var output: String + + @Option(help: "Remote base URL where legacy guest app disk images will be served from.") + var baseURL: String = "https://raw.githubusercontent.com/insidegui/VirtualBuddy/refs/heads/main/data/LegacyGuestApp" + + @Argument(help: "Path to VirtualBuddyGuest app DMG created with dmgdist.", completion: .file(extensions: ["dmg"])) + var input: String + + func run() async throws { + let catalogURL = try output.resolvedURL.ensureExistingFile() + var catalog = try SoftwareCatalog(contentsOf: catalogURL) + + let imageURL = URL(filePath: input) + let imageData = try Data(contentsOf: imageURL, options: .mappedIfSafe) + let baseURL = try URL(string: self.baseURL).require("Invalid base URL \(self.baseURL.quoted)") + let remoteURL = baseURL.appending(path: imageURL.lastPathComponent) + + let mountURL = FileManager.default.temporaryDirectory + .appending(path: "VirtualBuddyGuest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: mountURL, withIntermediateDirectories: true) + var isMounted = false + defer { + if isMounted { + let hdiutilDetach = Process() + hdiutilDetach.executableURL = URL(filePath: "/usr/bin/hdiutil") + hdiutilDetach.arguments = [ + "detach", + mountURL.path(percentEncoded: false), + "-quiet", + ] + + if (try? hdiutilDetach.run()) != nil { + hdiutilDetach.waitUntilExit() + } + } + + try? FileManager.default.removeItem(at: mountURL) + } + + let hdiutilAttach = Process() + hdiutilAttach.executableURL = URL(filePath: "/usr/bin/hdiutil") + hdiutilAttach.arguments = [ + "attach", + input, + "-readonly", + "-nobrowse", + "-mountpoint", + mountURL.path(percentEncoded: false), + ] + try hdiutilAttach.run() + hdiutilAttach.waitUntilExit() + + try (hdiutilAttach.terminationStatus == 0).require("Error mounting disk image.") + isMounted = true + + let bundleURL = mountURL.appending(path: "VirtualBuddyGuest.app") + + try bundleURL.requireExistingDirectory() + + let bundle = try Bundle(url: bundleURL).require("Error constructing bundle at \(bundleURL.path(percentEncoded: false).quoted).") + + let infoDict = try bundle.infoDictionary.require("Bundle has no info dictionary!") + + let appVersionString = try cast(infoDict["CFBundleShortVersionString"], as: String.self, "CFBundleShortVersionString not a string.") + let minOSVersionString = try cast(infoDict["LSMinimumSystemVersion"], as: String.self, "LSMinimumSystemVersion not a string.") + + let appVersion = try SoftwareVersion(string: appVersionString).require("Invalid app version string \(appVersionString.quoted).") + let minOSVersion = try SoftwareVersion(string: minOSVersionString).require("Invalid min OS version string \(minOSVersionString.quoted).") + let maxOSVersion = SoftwareVersion(major: minOSVersion.major, minor: 99, patch: 99) + + let sha384 = SHA384.hash(data: imageData) + .map { String(format: "%02x", $0) } + .joined() + + let entry = CatalogLegacyGuestAppVersion( + id: imageURL.deletingPathExtension().lastPathComponent, + url: remoteURL, + sha384: sha384, + guestAppVersion: appVersion, + minGuestVersion: minOSVersion, + maxGuestVersion: maxOSVersion + ) + + let isUpdate: Bool + if let index = catalog.legacyGuestAppVersions.firstIndex(where: { $0.id == entry.id }) { + catalog.legacyGuestAppVersions[index] = entry + isUpdate = true + } else { + catalog.legacyGuestAppVersions.append(entry) + isUpdate = false + } + + try catalog.write(to: catalogURL) + + if isUpdate { + print("✅ Updated \(entry.id)") + } else { + print("✅ Added \(entry.id)") + } + } + } + } +} diff --git a/VirtualBuddy/CommandLine/vctool/MigrateCommand.swift b/VirtualBuddy/CommandLine/vctool/MigrateCommand.swift index 147e16c6..b90609f1 100644 --- a/VirtualBuddy/CommandLine/vctool/MigrateCommand.swift +++ b/VirtualBuddy/CommandLine/vctool/MigrateCommand.swift @@ -52,7 +52,7 @@ extension CatalogCommand { } else { fputs("Creating empty version 2 catalog for migration\n", stderr) - catalog = SoftwareCatalog(apiVersion: 2, minAppVersion: .init(string: "2.0.0")!, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [], deviceSupportVersions: []) + catalog = SoftwareCatalog(apiVersion: 2, minAppVersion: .init(string: "2.0.0")!, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [], deviceSupportVersions: [], legacyGuestAppVersions: []) } for legacyChannel in legacyCatalog.channels { diff --git a/VirtualCore/Source/Definitions/PreviewSupport.swift b/VirtualCore/Source/Definitions/PreviewSupport.swift index 6bc776f4..ea2b5e09 100644 --- a/VirtualCore/Source/Definitions/PreviewSupport.swift +++ b/VirtualCore/Source/Definitions/PreviewSupport.swift @@ -139,5 +139,8 @@ public extension ResolvedCatalogGroup { public extension ResolvedRestoreImage { static let previewMac = ResolvedCatalog.previewMac.groups[0].restoreImages[0] static let previewLinux = ResolvedCatalog.previewLinux.groups[0].restoreImages[0] + + static let previewMacLegacyVentura = ResolvedCatalog.previewMac.groups.first(where: { $0.id == "ventura" })!.restoreImages[0] + static let previewMacLegacyMonterey = ResolvedCatalog.previewMac.groups.first(where: { $0.id == "monterey" })!.restoreImages[0] } #endif diff --git a/VirtualCore/Source/GuestSupport/GuestAdditionsDiskImage.swift b/VirtualCore/Source/GuestSupport/GuestAdditionsDiskImage.swift index 22c0a879..d3fa93d4 100644 --- a/VirtualCore/Source/GuestSupport/GuestAdditionsDiskImage.swift +++ b/VirtualCore/Source/GuestSupport/GuestAdditionsDiskImage.swift @@ -11,35 +11,129 @@ import CryptoKit import UniformTypeIdentifiers import OSLog import Combine +import BuddyFoundation public final class GuestAdditionsDiskImage: ObservableObject { - private lazy var logger = Logger(subsystem: VirtualCoreConstants.subsystemName, category: String(describing: Self.self)) + private let logger: Logger - public static let current = GuestAdditionsDiskImage() + public static let `default` = GuestAdditionsDiskImage(source: .embedded) public enum State: CustomStringConvertible { case ready + case downloading case installing case installFailed(Error) public var description: String { switch self { case .ready: "Ready" + case .downloading: "Downloading" case .installing: "Installing" case .installFailed(let error): "Failed: \(error)" } } } - @MainActor - @Published public private(set) var state = State.ready + public enum Source { + case embedded + case catalog(_ id: CatalogLegacyGuestAppVersion.ID) + + var imageBaseName: String { + switch self { + case .embedded: "VirtualBuddyGuest" + case .catalog(let id): id + } + } + + var loggerName: String { + switch self { + case .embedded: "Embedded" + case .catalog(let id): id + } + } + + var initialState: State { + switch self { + case .embedded: .ready + case .catalog: .downloading + } + } + } + + private let source: Source + private var imageBaseName: String { source.imageBaseName } + + @Published public private(set) var state: State + + public init(source: Source) { + self.source = source + self.logger = Logger(subsystem: VirtualCoreConstants.subsystemName, category: "\(Self.self)\(source.loggerName)") + self.state = source.initialState + } public func installIfNeeded() async throws { #if DEBUG if await simulateInstall() { return } #endif + switch source { + case .embedded: try await generateAndInstallEmbeddedGuestDiskImage() + case .catalog(let id): do { + try await installCatalogDiskImage(id) + await MainActor.run { self.state = .ready } + } catch { + await MainActor.run { self.state = .installFailed(error) } + } + } + } + + private func installCatalogDiskImage(_ id: CatalogLegacyGuestAppVersion.ID) async throws { + let app = try await SoftwareCatalog.currentMacCatalog.legacyGuestAppVersions + .first(where: { $0.id == id }) + .require("Guest app image not found: \(id.quoted).") + + let imagePath = FilePath(installedImageURL) + + if imagePath.exists { + logger.debug("Catalog disk image already installed at \(imagePath)") + + if let digest = try? imagePath.sha384Digest { + guard digest.hexString.caseInsensitiveCompare(app.sha384) != .orderedSame else { + await MainActor.run { self.state = .ready } + return + } + + logger.debug("Local disk image digest doesn't match catalog, will redownload") + + do { + try imagePath.delete() + } catch { + logger.error("Error removing cached local disk image: \(error, privacy: .public)") + } + } + } + + logger.debug("Downloading image from \(app.url, privacy: .public)") + + await MainActor.run { self.state = .downloading } + + let request = URLRequest(url: app.url) + let (fileURL, response) = try await URLSession.shared.download(for: request) + + let status = (response as! HTTPURLResponse).statusCode + try (status == 200).require("HTTP \(status).") + + await MainActor.run { self.state = .installing } + + logger.debug("Copying image to \(imagePath)") + + try FilePath(fileURL).copy(imagePath) + + logger.notice("Image installed for \(id, privacy: .public): \(imagePath, privacy: .public)") + } + + private func generateAndInstallEmbeddedGuestDiskImage() async throws { do { logger.debug(#function) @@ -51,12 +145,12 @@ public final class GuestAdditionsDiskImage: ObservableObject { await MainActor.run { state = .ready } } - let embeddedDigest = try computeEmbeddedGuestDigest() + let digest = try computeGuestDigest() if let currentlyInstalledGuestImageDigest { - logger.debug("Embedded guest app digest: \(embeddedDigest, privacy: .public) / Library guest app digest: \(currentlyInstalledGuestImageDigest, privacy: .public)") + logger.debug("Guest app digest: \(digest, privacy: .public) / Library guest app digest: \(currentlyInstalledGuestImageDigest, privacy: .public)") - guard embeddedDigest != currentlyInstalledGuestImageDigest else { + guard digest != currentlyInstalledGuestImageDigest else { logger.debug("Guest digests match, skipping guest image generation") await MainActor.run { state = .ready } @@ -64,13 +158,13 @@ public final class GuestAdditionsDiskImage: ObservableObject { return } - logger.debug("Guest digests don't match, generating new guest image with embedded guest") + logger.debug("Guest digests don't match, generating new guest image") - try await performInstall(with: embeddedDigest) + try await performInstall(with: digest) } else { - logger.debug("No digest for currently installed image, assuming not installed. Embedded guest app digest: \(embeddedDigest, privacy: .public)") + logger.debug("No digest for currently installed image, assuming not installed. Guest app digest: \(digest, privacy: .public)") - try await performInstall(with: embeddedDigest) + try await performInstall(with: digest) } } catch { logger.error("Guest disk image installation failed. \(error, privacy: .public)") @@ -83,20 +177,6 @@ public final class GuestAdditionsDiskImage: ObservableObject { // MARK: File Paths - private var embeddedGuestAppURL: URL { - get throws { - guard let url = Bundle.main.sharedSupportURL?.appendingPathComponent("VirtualBuddyGuest.app") else { - throw Failure("Couldn't get VirtualBuddyGuest.app URL within main app bundle") - } - - guard FileManager.default.fileExists(atPath: url.path) else { - throw Failure("VirtualBuddyGuest.app doesn't exist at \(url.path)") - } - - return url - } - } - private var generatorScriptURL: URL { get throws { guard let url = Bundle.virtualCore.url(forResource: "CreateGuestImage", withExtension: "sh") else { @@ -111,13 +191,11 @@ public final class GuestAdditionsDiskImage: ObservableObject { } } - private var _imageBaseName: String { "VirtualBuddyGuest" } - private var imageName: String { if let suffix = VBBuildType.current.guestAdditionsImageSuffix { - _imageBaseName + suffix + imageBaseName + suffix } else { - _imageBaseName + imageBaseName } } @@ -132,9 +210,16 @@ public final class GuestAdditionsDiskImage: ObservableObject { } public var installedImageURL: URL { - imagesRootURL - .appendingPathComponent(imageName) - .appendingPathExtension("dmg") + switch source { + case .embedded: + imagesRootURL + .appendingPathComponent(imageName) + .appendingPathExtension("dmg") + case .catalog(let id): + imagesRootURL + .appendingPathComponent(id) + .appendingPathExtension("dmg") + } } // MARK: Digest @@ -153,9 +238,8 @@ public final class GuestAdditionsDiskImage: ObservableObject { } } - private func computeEmbeddedGuestDigest() throws -> String { - let url = try embeddedGuestAppURL - guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.contentTypeKey]) else { + private func computeGuestDigest() throws -> String { + guard let enumerator = FileManager.default.enumerator(at: Bundle.embeddedGuestApp.bundleURL, includingPropertiesForKeys: [.contentTypeKey]) else { throw Failure("Couldn't instantiate file enumerator for computing guest app bundle digest") } @@ -193,9 +277,8 @@ public final class GuestAdditionsDiskImage: ObservableObject { private func writeGuestImage(with digest: String) async throws { let scriptPath = try generatorScriptURL.path - let guestURL = try embeddedGuestAppURL - let guestPath = guestURL.path - let size = computeImageSizeInMB(guestAppURL: guestURL) + let guestPath = Bundle.embeddedGuestApp.bundlePath + let size = computeImageSizeInMB(guestAppURL: Bundle.embeddedGuestApp.bundleURL) var args: [String] = [ scriptPath, @@ -246,20 +329,57 @@ public final class GuestAdditionsDiskImage: ObservableObject { } +public extension Bundle { + /// Bundle of the VirtualBuddyGuest app embedded in the app's main bundle. + static let embeddedGuestApp: Bundle = { + #if DEBUG + /// Allow using SwiftUI previews with VirtualUI target selected without having to embed VirtualBuddyGuest.app inside VirtualUI. + guard !ProcessInfo.isSwiftUIPreview else { return Bundle.main } + #endif + do { + guard let url = Bundle.main.sharedSupportURL?.appendingPathComponent("VirtualBuddyGuest.app") else { + throw Failure("Couldn't get VirtualBuddyGuest.app URL within main app bundle") + } + + guard FileManager.default.fileExists(atPath: url.path) else { + throw Failure("VirtualBuddyGuest.app doesn't exist at \(url.path)") + } + + guard let bundle = Bundle(url: url) else { + throw Failure("Failed to construct bundle for embedded guest app at \(url.path(percentEncoded: false)).") + } + + return bundle + } catch { + preconditionFailure("\(error)") + } + }() + + var minimumSystemVersion: SoftwareVersion { + guard let versionString: String = self.infoPlistValue(for: "LSMinimumSystemVersion") else { return .empty } + return SoftwareVersion(string: versionString) ?? .empty + } +} + +public extension SoftwareVersion { + /// Version of the VirtualBuddyGuest app embedded in the app's main bundle. + static let embeddedGuestApp = Bundle.embeddedGuestApp.softwareVersion +} + // MARK: - Virtualization Extensions extension VZVirtioBlockDeviceConfiguration { - static var guestAdditionsDisk: VZVirtioBlockDeviceConfiguration? { - get throws { - let guestImageURL = GuestAdditionsDiskImage.current.installedImageURL + static func guestAdditionsDisk(for configuration: VBMacConfiguration) async throws -> VZVirtioBlockDeviceConfiguration? { + let image = GuestAdditionsDiskImage(source: configuration.guestAppDiskImageSource) - guard FileManager.default.fileExists(atPath: guestImageURL.path) else { return nil } + let guestImagePath = FilePath(image.installedImageURL) - let guestAttachment = try VZDiskImageStorageDeviceAttachment(url: guestImageURL, readOnly: true) + guard guestImagePath.exists else { return nil } - return VZVirtioBlockDeviceConfiguration(attachment: guestAttachment) - } + let guestAttachment = try VZDiskImageStorageDeviceAttachment(url: guestImagePath.url, readOnly: true) + + return VZVirtioBlockDeviceConfiguration(attachment: guestAttachment) } } @@ -333,6 +453,16 @@ extension VBBuildType { } } +extension FilePath { + var sha384Digest: Data { + get throws { try Data(contentsOf: url, options: .mappedIfSafe).sha384Digest } + } +} + +extension Data { + var sha384Digest: Data { Data(SHA384.hash(data: self)) } +} + // MARK: - Debug Simulation #if DEBUG diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index d325826a..79596355 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -59,6 +59,11 @@ public struct VBMacConfiguration: Hashable, Codable { public var hasSharedFolders: Bool { !sharedFolders.filter(\.isEnabled).isEmpty } + /// Manual override for which guest app version should be mounted for this virtual machine. + /// + /// `nil` means use the copy of VirtualBuddyGuest that's embedded in the current build of VirtualBuddy. + public var guestAppVersion: CatalogLegacyGuestAppVersion.ID? = nil + } // MARK: - Hardware Configuration diff --git a/VirtualCore/Source/VirtualCatalog/ResolvedCatalog.swift b/VirtualCore/Source/VirtualCatalog/ResolvedCatalog.swift index ceae7f4e..15487ad7 100644 --- a/VirtualCore/Source/VirtualCatalog/ResolvedCatalog.swift +++ b/VirtualCore/Source/VirtualCatalog/ResolvedCatalog.swift @@ -38,6 +38,7 @@ public struct ResolvedRestoreImage: ResolvedCatalogModel, DownloadableCatalogCon public var deviceSupportVersion: CatalogDeviceSupportVersion? public var status: ResolvedFeatureStatus public var localFileURL: URL? + public var legacyGuestAppVersion: CatalogLegacyGuestAppVersion? public var name: String { image.name } public var build: String { image.build } @@ -47,7 +48,7 @@ public struct ResolvedRestoreImage: ResolvedCatalogModel, DownloadableCatalogCon public var downloadSize: Int64 { Int64(image.downloadSize ?? 0) } public var isDownloaded: Bool { localFileURL != nil } - public init(image: RestoreImage, channel: CatalogChannel, features: [ResolvedVirtualizationFeature], requirements: ResolvedRequirementSet, status: ResolvedFeatureStatus, localFileURL: URL?, deviceSupportVersion: CatalogDeviceSupportVersion?) { + public init(image: RestoreImage, channel: CatalogChannel, features: [ResolvedVirtualizationFeature], requirements: ResolvedRequirementSet, status: ResolvedFeatureStatus, localFileURL: URL?, deviceSupportVersion: CatalogDeviceSupportVersion?, legacyGuestAppVersion: CatalogLegacyGuestAppVersion?) { self.image = image self.channel = channel self.features = features @@ -55,6 +56,7 @@ public struct ResolvedRestoreImage: ResolvedCatalogModel, DownloadableCatalogCon self.status = status self.localFileURL = localFileURL self.deviceSupportVersion = deviceSupportVersion + self.legacyGuestAppVersion = legacyGuestAppVersion } } @@ -218,7 +220,8 @@ public extension ResolvedRestoreImage { requirements: ResolvedRequirementSet(requirements: catalog.requirementSet(with: image.requirements), status: .supported), status: .supported, localFileURL: environment.downloadsProvider?.localFileURL(for: image), - deviceSupportVersion: catalog.deviceSupportVersion(for: image) + deviceSupportVersion: catalog.deviceSupportVersion(for: image), + legacyGuestAppVersion: catalog.legacyGuestAppVersion(for: image) ) update(with: environment) @@ -256,6 +259,40 @@ extension SoftwareCatalog { || $0.osVersion.major == image.version.major }) } + + func legacyGuestAppVersion(for image: RestoreImage) -> CatalogLegacyGuestAppVersion? { + legacyGuestAppVersions.first(where: { + $0.isCompatibleWithCurrentVirtualBuddyVersion + && $0.minGuestVersion >= image.version + && $0.maxGuestVersion < image.version + }) + } +} + +extension CatalogLegacyGuestAppVersion { + var isCompatibleWithCurrentVirtualBuddyVersion: Bool { + if let minAppVersion, let maxAppVersion { + minAppVersion >= SoftwareVersion.currentApp + && maxAppVersion < SoftwareVersion.currentApp + } else if let minAppVersion { + minAppVersion >= SoftwareVersion.currentApp + } else if let maxAppVersion { + maxAppVersion < SoftwareVersion.currentApp + } else { + true + } + } +} + +public extension CatalogLegacyGuestAppVersion { + /// Run `defaults write codes.rambo.VirtualBuddy VBAllowAllGuestAppVersions -bool YES` to force-enable all guest app versions in the override UI. + private static var allowAllGuestAppVersions: Bool { UserDefaults.standard.bool(forKey: "VBAllowAllGuestAppVersions") } + + func supports(_ image: ResolvedRestoreImage?) -> Bool { + guard let image, !Self.allowAllGuestAppVersions else { return true } + return image.version >= minGuestVersion + && image.version < maxGuestVersion + } } public extension ResolvedVirtualizationFeature { diff --git a/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift b/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift index 077876aa..3d5e5ecd 100644 --- a/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift +++ b/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift @@ -143,6 +143,44 @@ public struct CatalogDeviceSupportVersion: CatalogModel { public var instructions: String } +/// Describes an archive of the VirtualBuddyGuest app that supports an older OS version no longer supported by the built-in guest app. +/// +/// Individual releases in the catalog do not reference this directly. VirtualBuddy determines the need to use a legacy version of the guest app +/// by analyzing the version of the guest operating system being booted and the deployment target of the bundled guest app. +/// +/// If the bundled guest app has a deployment target that's higher than the version of the guest OS being installed, +/// the app looks up a legacy version of the guest app in the catalog that can support the legacy guest OS. +public struct CatalogLegacyGuestAppVersion: CatalogModel { + public var id: String + /// URL to downloadable Apple Archive. + public var url: URL + /// SHA384 digest of the archive. + public var sha384: String + /// The version of the guest app itself as declared in its Info.plist. + public var guestAppVersion: SoftwareVersion + /// The minimum guest OS version supported by this legacy guest app build. + public var minGuestVersion: SoftwareVersion + /// The maximum guest OS version supported by this legacy guest app build. + public var maxGuestVersion: SoftwareVersion + /// The minimum host VirtualBuddy app version supported by this legacy guest app build. + /// `nil` means all VirtualBuddy versions are supported (gated only by guest OS version). + public var minAppVersion: SoftwareVersion? + /// The maximum host VirtualBuddy app version supported by this legacy guest app build. + /// `nil` means all VirtualBuddy versions are supported (gated only by guest OS version). + public var maxAppVersion: SoftwareVersion? + + public init(id: String, url: URL, sha384: String, guestAppVersion: SoftwareVersion, minGuestVersion: SoftwareVersion, maxGuestVersion: SoftwareVersion, minAppVersion: SoftwareVersion? = nil, maxAppVersion: SoftwareVersion? = nil) { + self.id = id + self.url = url + self.sha384 = sha384 + self.guestAppVersion = guestAppVersion + self.minGuestVersion = minGuestVersion + self.maxGuestVersion = maxGuestVersion + self.minAppVersion = minAppVersion + self.maxAppVersion = maxAppVersion + } +} + /// Adopted by both ``RestoreImage`` and ``ResolvedRestoreImage`` to make download lookup more convenient to implement. public protocol DownloadableCatalogContent: Identifiable, Hashable, Sendable { var build: String { get } @@ -206,8 +244,10 @@ public struct SoftwareCatalog: Codable, Sendable { public var requirementSets: [RequirementSet] /// Device support files definitions. public var deviceSupportVersions: [CatalogDeviceSupportVersion] + /// Legacy VirtualBuddyGuest app archive definitions. + public var legacyGuestAppVersions: [CatalogLegacyGuestAppVersion] - public init(apiVersion: Int, minAppVersion: SoftwareVersion, channels: [CatalogChannel], groups: [CatalogGroup], restoreImages: [RestoreImage], features: [VirtualizationFeature], requirementSets: [RequirementSet], deviceSupportVersions: [CatalogDeviceSupportVersion]) { + public init(apiVersion: Int, minAppVersion: SoftwareVersion, channels: [CatalogChannel], groups: [CatalogGroup], restoreImages: [RestoreImage], features: [VirtualizationFeature], requirementSets: [RequirementSet], deviceSupportVersions: [CatalogDeviceSupportVersion], legacyGuestAppVersions: [CatalogLegacyGuestAppVersion]) { self.apiVersion = apiVersion self.minAppVersion = minAppVersion self.channels = channels @@ -216,9 +256,10 @@ public struct SoftwareCatalog: Codable, Sendable { self.features = features self.requirementSets = requirementSets self.deviceSupportVersions = deviceSupportVersions + self.legacyGuestAppVersions = legacyGuestAppVersions } - public static let empty = SoftwareCatalog(apiVersion: 0, minAppVersion: .empty, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [], deviceSupportVersions: []) + public static let empty = SoftwareCatalog(apiVersion: 0, minAppVersion: .empty, channels: [], groups: [], restoreImages: [], features: [], requirementSets: [], deviceSupportVersions: [], legacyGuestAppVersions: []) } public extension SoftwareCatalog { diff --git a/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift b/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift index 08b91fef..f43defd6 100644 --- a/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift +++ b/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift @@ -31,8 +31,14 @@ struct MacOSVirtualMachineConfigurationHelper: VirtualMachineConfigurationHelper func createAdditionalBlockDevices() async throws -> [VZVirtioBlockDeviceConfiguration] { var devices = try storageDeviceContainer.additionalBlockDevices(guestType: vm.configuration.systemType) - if vm.configuration.guestAdditionsEnabled, let disk = try? VZVirtioBlockDeviceConfiguration.guestAdditionsDisk { - devices.append(disk) + if vm.configuration.guestAdditionsEnabled { + do { + if let disk = try await VZVirtioBlockDeviceConfiguration.guestAdditionsDisk(for: vm.configuration) { + devices.append(disk) + } + } catch { + assertionFailure("VZVirtioBlockDeviceConfiguration initialization failed for guest additions disk: \(error)") + } } return devices diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index c9e0b63b..6f61f0ab 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -108,15 +108,18 @@ public final class VMController: ObservableObject { public private(set) var savedStatesController: VMSavedStatesController - private lazy var cancellables = Set() - + private var cancellables = Set() + + private let guestAppDiskImage: GuestAdditionsDiskImage + public init(with vm: VBVirtualMachine, library: VMLibraryController, options: VMSessionOptions? = nil) { self.id = vm.id self.name = vm.name self.virtualMachineModel = vm self.library = library self.savedStatesController = VMSavedStatesController(library: library, virtualMachine: vm) - + self.guestAppDiskImage = GuestAdditionsDiskImage(source: vm.configuration.guestAppDiskImageSource) + #if DEBUG if ProcessInfo.isSwiftUIPreview { self.savedStatesController = .preview } #endif @@ -246,14 +249,19 @@ public final class VMController: ObservableObject { virtualMachineModel.configuration.systemType.supportsGuestApp else { return } - let guestDiskState = GuestAdditionsDiskImage.current.state + /// Kick off legacy guest app download if needed. + if virtualMachineModel.configuration.guestAppVersion != nil { + Task { try? await guestAppDiskImage.installIfNeeded() } + } + + let guestDiskState = guestAppDiskImage.state logger.info("Guest disk image state is \(guestDiskState, privacy: .public)") switch guestDiskState { case .ready: break - case .installing: + case .downloading, .installing: await waitForGuestDiskImageReady() case .installFailed(let error): runGuestDiskImageErrorAlert(error: error) @@ -263,7 +271,7 @@ public final class VMController: ObservableObject { private func waitForGuestDiskImageReady() async { state = .starting("Preparing guest app disk image") - for await state in GuestAdditionsDiskImage.current.$state.values { + for await state in guestAppDiskImage.$state.values { switch state { case .ready: logger.debug("Guest disk image is ready 🚀") @@ -271,6 +279,8 @@ public final class VMController: ObservableObject { case .installFailed(let error): logger.error("Guest disk image install failed - \(error, privacy: .public)") return runGuestDiskImageErrorAlert(error: error) + case .downloading: + logger.debug("Guest disk image is downloading...") case .installing: logger.debug("Guest disk image is installing...") } @@ -557,3 +567,13 @@ public extension VBMacConfiguration { #endif } } + +extension VBMacConfiguration { + var guestAppDiskImageSource: GuestAdditionsDiskImage.Source { + if let guestAppVersion { + GuestAdditionsDiskImage.Source.catalog(guestAppVersion) + } else { + GuestAdditionsDiskImage.Source.embedded + } + } +} diff --git a/VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/SoftwareCatalog+Placeholder.swift b/VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/SoftwareCatalog+Placeholder.swift index 1a9e3590..70e1cad8 100644 --- a/VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/SoftwareCatalog+Placeholder.swift +++ b/VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/SoftwareCatalog+Placeholder.swift @@ -68,9 +68,9 @@ extension ResolvedRequirementSet { } extension SoftwareCatalog { - static let placeholder = SoftwareCatalog(apiVersion: 1, minAppVersion: "1.0", channels: [.placeholder], groups: [.placeholder], restoreImages: [.placeholder], features: [], requirementSets: [.placeholder], deviceSupportVersions: []) + static let placeholder = SoftwareCatalog(apiVersion: 1, minAppVersion: "1.0", channels: [.placeholder], groups: [.placeholder], restoreImages: [.placeholder], features: [], requirementSets: [.placeholder], deviceSupportVersions: [], legacyGuestAppVersions: []) } extension ResolvedRestoreImage { - static let placeholder = ResolvedRestoreImage(image: .placeholder, channel: .placeholder, features: [], requirements: .placeholder, status: .supported, localFileURL: nil, deviceSupportVersion: nil) + static let placeholder = ResolvedRestoreImage(image: .placeholder, channel: .placeholder, features: [], requirements: .placeholder, status: .supported, localFileURL: nil, deviceSupportVersion: nil, legacyGuestAppVersion: nil) } diff --git a/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift index f86244fc..dc6260b1 100644 --- a/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift @@ -21,8 +21,16 @@ struct GuestAppConfigurationView: View { private var guestAppUnsupported: Bool { guestAppStatus?.isUnsupported == true } private var guestAppHelp: String? { guestAppUnsupported ? (guestAppStatus?.supportMessage ?? "Not supported.") : nil + } + + private var availableGuestAppVersions: [CatalogLegacyGuestAppVersion] { + SoftwareCatalog.currentMacCatalog.legacyGuestAppVersions + .filter { $0.supports(resolvedRestoreImage) } + .sorted(by: { $0.minGuestVersion > $1.minGuestVersion }) } + private var disableVersionPicker: Bool { availableGuestAppVersions.count <= 1 } + var body: some View { VStack(alignment: .leading, spacing: 16) { Group { @@ -45,6 +53,32 @@ struct GuestAppConfigurationView: View { } } + /** + The ability to pick a custom VirtualBuddyGuest app version exists to allow users running legacy OSes that don't have restore image + metadata to manually override the version of the guest app that's used when starting the guest. + */ + Picker("Override Guest App Version", selection: $configuration.guestAppVersion) { + if CatalogLegacyGuestAppVersion.default.supports(resolvedRestoreImage) { + Text(CatalogLegacyGuestAppVersion.default.title) + .tag(Optional.none) + + Divider() + } + + ForEach(availableGuestAppVersions) { option in + Text(option.title) + .tag(Optional.some(option.id)) + } + } + .task { + if !CatalogLegacyGuestAppVersion.default.supports(resolvedRestoreImage), configuration.guestAppVersion == nil { + configuration.guestAppVersion = availableGuestAppVersions.first(where: { $0.supports(resolvedRestoreImage) })?.id + } + } + /// No point in enabling picker if there are no alternate versions available. + .disabled(disableVersionPicker) + .help(disableVersionPicker ? "This option is only available for guests running older versions of macOS that don’t support the latest VirtualBuddyGuest app." : "If you’re running an older version of macOS, you can choose a version of the VirtualBuddyGuest app that works with the version of macOS you’re using on the guest.") + Text(""" The guest app mounts shared directories and shares the clipboard between your Mac and virtual machines. @@ -58,8 +92,29 @@ struct GuestAppConfigurationView: View { } } +extension CatalogLegacyGuestAppVersion { + /// A placeholder that represents the version that ships with this build of VirtualBuddy. + /// + /// - note: Use for UI purposes only, do not use as a source of truth. + static let `default` = CatalogLegacyGuestAppVersion( + id: "__DEFAULT__", + url: Bundle.embeddedGuestApp.bundleURL, + sha384: "", + guestAppVersion: .embeddedGuestApp, + minGuestVersion: Bundle.embeddedGuestApp.minimumSystemVersion, + maxGuestVersion: SoftwareVersion(major: 99, minor: 99, patch: 99), + minAppVersion: nil, + maxAppVersion: nil + ) + + var isDefault: Bool { guestAppVersion == SoftwareVersion.embeddedGuestApp } + + var title: String { "\(isDefault ? "Latest" : guestAppVersion.shortDescription) (macOS \(minGuestVersion.shortDescription) or later)" } +} + #if DEBUG #Preview { _ConfigurationSectionPreview { GuestAppConfigurationView(configuration: $0) } +// .environment(\.resolvedRestoreImage, ResolvedRestoreImage.previewMac) } #endif diff --git a/data/LegacyGuestApp/README.md b/data/LegacyGuestApp/README.md new file mode 100644 index 00000000..b81bad49 --- /dev/null +++ b/data/LegacyGuestApp/README.md @@ -0,0 +1,5 @@ +# Legacy Guest App Versions + +This directory contains versions of the VirtualBuddyGuest app compatible with legacy guests that are no longer supported by the latest version. + +The VirtualBuddy catalog references these so that the app can provide the guest app to guests running older versions of macOS. \ No newline at end of file diff --git a/data/LegacyGuestApp/VirtualBuddyGuest_minOS_12.3.dmg b/data/LegacyGuestApp/VirtualBuddyGuest_minOS_12.3.dmg new file mode 100644 index 00000000..348e2a63 Binary files /dev/null and b/data/LegacyGuestApp/VirtualBuddyGuest_minOS_12.3.dmg differ diff --git a/data/LegacyGuestApp/VirtualBuddyGuest_minOS_13.0.dmg b/data/LegacyGuestApp/VirtualBuddyGuest_minOS_13.0.dmg new file mode 100644 index 00000000..a83cbe7e Binary files /dev/null and b/data/LegacyGuestApp/VirtualBuddyGuest_minOS_13.0.dmg differ diff --git a/data/LegacyGuestApp/VirtualBuddyGuest_minOS_14.0.dmg b/data/LegacyGuestApp/VirtualBuddyGuest_minOS_14.0.dmg new file mode 100644 index 00000000..a83cbe7e Binary files /dev/null and b/data/LegacyGuestApp/VirtualBuddyGuest_minOS_14.0.dmg differ diff --git a/data/ipsws_v2.json b/data/ipsws_v2.json index 619e7cb8..37e76d45 100644 --- a/data/ipsws_v2.json +++ b/data/ipsws_v2.json @@ -34,7 +34,7 @@ }, { "id" : "guest_app", - "minVersionGuest" : "13.0.0", + "minVersionGuest" : "12.3.0", "minVersionHost" : "13.0.0", "name" : "VirtualBuddyGuest app", "unsupportedPlatform" : false @@ -74,13 +74,13 @@ "name" : "Rosetta Sharing", "unsupportedPlatform" : true }, - { - "id": "provisioning", - "minVersionGuest": "27.0.0", - "minVersionHost": "27.0.0", - "name": "Skip Setup Assistant", - "unsupportedPlatform": false - } + { + "id" : "provisioning", + "minVersionGuest" : "27.0.0", + "minVersionHost" : "27.0.0", + "name" : "Skip Setup Assistant", + "unsupportedPlatform" : false + } ], "groups" : [ { @@ -234,39 +234,69 @@ "name" : "macOS Monterey" } ], + "legacyGuestAppVersions" : [ + { + "guestAppVersion" : "1.4.0", + "id" : "VirtualBuddyGuest_minOS_12.3", + "maxGuestVersion" : "12.99.99", + "minGuestVersion" : "12.3.0", + "sha384" : "8b4797ad33b40fc939a532c28d121c63bebcd7bb36c0fdd0de1db54174652c18fb3da4a2cea298a2efd1984ead37ac77", + "url" : "https://raw.githubusercontent.com/insidegui/VirtualBuddy/refs/heads/legacy-guest-support/data/LegacyGuestApp/VirtualBuddyGuest_minOS_12.3.dmg" + }, + { + "guestAppVersion" : "2.1.0", + "id" : "VirtualBuddyGuest_minOS_13.0", + "maxGuestVersion" : "13.99.99", + "minGuestVersion" : "13.0.0", + "sha384" : "ccb6c968eff880dd2e2a1b91d9d1be373b95cfb3bb2072042c0af3be0fc0962fb22d56516d2ca2b05a51935ad5a63f8c", + "url" : "https://raw.githubusercontent.com/insidegui/VirtualBuddy/refs/heads/legacy-guest-support/data/LegacyGuestApp/VirtualBuddyGuest_minOS_13.0.dmg" + }, + { + "guestAppVersion" : "2.2.0", + "id" : "VirtualBuddyGuest_minOS_14.0", + "maxGuestVersion" : "14.99.99", + "minGuestVersion" : "14.0.0", + "sha384" : "ccb6c968eff880dd2e2a1b91d9d1be373b95cfb3bb2072042c0af3be0fc0962fb22d56516d2ca2b05a51935ad5a63f8c", + "url" : "https://raw.githubusercontent.com/insidegui/VirtualBuddy/refs/heads/legacy-guest-support/data/LegacyGuestApp/VirtualBuddyGuest_minOS_14.0.dmg" + } + ], "minAppVersion" : "2.0.0", "requirementSets" : [ { "id" : "min_host_12", "minCPUCount" : 2, "minMemorySizeMB" : 4096, - "minVersionHost" : "12.0.0" + "minVersionHost" : "12.0.0", + "virtualInstallationBackend" : false }, { "id" : "min_host_13", "minCPUCount" : 2, "minMemorySizeMB" : 4096, - "minVersionHost" : "13.0.0" + "minVersionHost" : "13.0.0", + "virtualInstallationBackend" : false }, - { - "id": "min_host_26", - "minCPUCount": 2, - "minMemorySizeMB": 4096, - "minVersionHost": "26.0.0" - }, - { - "id": "min_host_27", - "minCPUCount": 2, - "minMemorySizeMB": 4096, - "minVersionHost": "27.0.0" - }, - { - "id": "min_host_26_virtual_installation", - "minCPUCount": 2, - "minMemorySizeMB": 4096, - "minVersionHost": "26.0.0", - "virtualInstallationBackend": true - } + { + "id" : "min_host_26", + "minCPUCount" : 2, + "minMemorySizeMB" : 4096, + "minVersionHost" : "26.0.0", + "virtualInstallationBackend" : false + }, + { + "id" : "min_host_27", + "minCPUCount" : 2, + "minMemorySizeMB" : 4096, + "minVersionHost" : "27.0.0", + "virtualInstallationBackend" : false + }, + { + "id" : "min_host_26_virtual_installation", + "minCPUCount" : 2, + "minMemorySizeMB" : 4096, + "minVersionHost" : "26.0.0", + "virtualInstallationBackend" : true + } ], "restoreImages" : [ {