diff --git a/.gitignore b/.gitignore index 624d9833..672c1df4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ Sharing.h Tests/Private Design/Icon Build/ -MockServer/ \ No newline at end of file +MockServer/ +CIProjectVersion.xcconfig \ No newline at end of file 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 dd627c22..2a277b42 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 75; objects = { /* Begin PBXAggregateTarget section */ @@ -88,6 +88,10 @@ F42C015E2888FC0C00EB15CD /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42C01592888FC0C00EB15CD /* SettingsScreen.swift */; }; F42C01612888FC3500EB15CD /* LibraryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42C01602888FC3500EB15CD /* LibraryItemView.swift */; }; F42CF4A82DF5FEC3001DE049 /* BlurHashToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42CF4A72DF5FEC3001DE049 /* BlurHashToken.swift */; }; + F43076E32FD8B19B002BB1EC /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = F43076E22FD8B19B002BB1EC /* SwiftUIIntrospect */; }; + F43087DF2FD8C255002BB1EC /* KeychainPasswordItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43087DD2FD8C255002BB1EC /* KeychainPasswordItem.swift */; }; + F43087E02FD8C255002BB1EC /* KeychainReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43087DE2FD8C255002BB1EC /* KeychainReference.swift */; }; + F43087E22FD8C3CA002BB1EC /* ProvisioningConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43087E12FD8C3CA002BB1EC /* ProvisioningConfigurationView.swift */; }; F436BC572FBF8F96007840E4 /* AirVisualEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = F436BC4E2FBF8F96007840E4 /* AirVisualEffect.swift */; }; F436BC582FBF8F96007840E4 /* AirGlassEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = F436BC4C2FBF8F96007840E4 /* AirGlassEffect.swift */; }; F436BC592FBF8F96007840E4 /* AirMaterial.swift in Sources */ = {isa = PBXBuildFile; fileRef = F436BC4D2FBF8F96007840E4 /* AirMaterial.swift */; }; @@ -120,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 */; }; @@ -223,6 +228,7 @@ F48E0D2F2888835A0080DDFA /* VirtualUIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48E0D2E2888835A0080DDFA /* VirtualUIConstants.swift */; }; F48E0D32288884A10080DDFA /* RestoreImageDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48E0D31288884A10080DDFA /* RestoreImageDownloadView.swift */; }; F48E0D34288889E60080DDFA /* InstallConfigurationStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48E0D33288889E60080DDFA /* InstallConfigurationStepView.swift */; }; + F492285C2FD9CC47007F619F /* VMTemplatesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F492285B2FD9CC47007F619F /* VMTemplatesController.swift */; }; F4959F3C2992A284001DF4CB /* GuestAppInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4959F3B2992A284001DF4CB /* GuestAppInstaller.swift */; }; F498AD012884BF13006F1C00 /* VirtualUI.h in Headers */ = {isa = PBXBuildFile; fileRef = F498AD002884BF13006F1C00 /* VirtualUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; F498AD042884BF13006F1C00 /* VirtualUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F498ACFE2884BF13006F1C00 /* VirtualUI.framework */; }; @@ -266,7 +272,6 @@ F4A7FB4A2BB5ED7E00E4C12A /* Save-2024-03-27_16;08;28.vbst in Copy Preview Saved States */ = {isa = PBXBuildFile; fileRef = F4A7FB432BB5ED4A00E4C12A /* Save-2024-03-27_16;08;28.vbst */; }; F4A7FB4B2BB5ED7E00E4C12A /* Save-2024-03-27_16;08;51.vbst in Copy Preview Saved States */ = {isa = PBXBuildFile; fileRef = F4A7FB442BB5ED4A00E4C12A /* Save-2024-03-27_16;08;51.vbst */; }; F4A7FB6E2BB7206C00E4C12A /* SelfSizingGroupedForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A7FB6D2BB7206C00E4C12A /* SelfSizingGroupedForm.swift */; }; - F4A7FB732BB7238A00E4C12A /* SwiftUIIntrospect-Static in Frameworks */ = {isa = PBXBuildFile; productRef = F4A7FB722BB7238A00E4C12A /* SwiftUIIntrospect-Static */; }; F4A7FB752BB7252A00E4C12A /* PreviewSupport-VirtualUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A7FB742BB7252A00E4C12A /* PreviewSupport-VirtualUI.swift */; }; F4B5C5D32886FA8D005AA632 /* SharedFoldersManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B5C5D22886FA8D005AA632 /* SharedFoldersManagementView.swift */; }; F4B5C5D52886FFB5005AA632 /* PointingDeviceConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B5C5D42886FFB5005AA632 /* PointingDeviceConfigurationView.swift */; }; @@ -332,6 +337,7 @@ F4DE1C0B2D6F54E700603527 /* VBSavedStateMetadata+Clone.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4DE1C0A2D6F54E000603527 /* VBSavedStateMetadata+Clone.swift */; }; F4DE1C0F2D6F603300603527 /* VBSavedStatePackage+VM.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4DE1C0E2D6F603300603527 /* VBSavedStatePackage+VM.swift */; }; F4DE1C112D6F642E00603527 /* VBStorageDeviceContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4DE1C102D6F642E00603527 /* VBStorageDeviceContainer.swift */; }; + F4DF12ED2FDC824800540CF4 /* VirtualInstallation.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F4E4F6C52DEF96C200B3B8BA /* ChromeBorderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E4F6C42DEF96C200B3B8BA /* ChromeBorderModifier.swift */; }; F4E4F7202DF080FC00B3B8BA /* RestoreImageSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E4F71F2DF080FC00B3B8BA /* RestoreImageSelectionController.swift */; }; F4E4F7262DF0A1CB00B3B8BA /* BuddyKit in Frameworks */ = {isa = PBXBuildFile; productRef = F4E4F7252DF0A1CB00B3B8BA /* BuddyKit */; }; @@ -343,6 +349,12 @@ F4E7DF972BB33E1700C459FC /* VMLibraryController+SavedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E7DF962BB33E1700C459FC /* VMLibraryController+SavedState.swift */; }; F4E7DFCF2BB3587D00C459FC /* VirtualMachineSessionUIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E7DFCE2BB3587D00C459FC /* VirtualMachineSessionUIManager.swift */; }; F4ECC6D52C63BFD5001DAC1D /* NumberDisplayMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4ECC6D42C63BFD5001DAC1D /* NumberDisplayMode.swift */; }; + F4EFB5692FDB2A0D00AF2A63 /* VirtualInstallation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */; }; + F4EFB56A2FDB2A0D00AF2A63 /* VirtualInstallation.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + F4EFB5BF2FDB2BD400AF2A63 /* VirtualInstallationService.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = F4EFB5B42FDB2BD300AF2A63 /* VirtualInstallationService.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F4EFB5CA2FDB2BEF00AF2A63 /* VirtualInstallation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */; }; + F4EFB5D02FDB2C9800AF2A63 /* VirtualInstallationRestoreBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EFB5CF2FDB2C9800AF2A63 /* VirtualInstallationRestoreBackend.swift */; }; + F4EFB5D12FDB2CD200AF2A63 /* VirtualInstallation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */; }; F4F9B416284CE0F900F21737 /* VBSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F9B415284CE0F900F21737 /* VBSettings.swift */; }; F4F9B418284CE12000F21737 /* VBSettingsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F9B417284CE12000F21737 /* VBSettingsContainer.swift */; }; F4F9B41A284CE37C00F21737 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F9B419284CE37C00F21737 /* Logging.swift */; }; @@ -439,6 +451,34 @@ remoteGlobalIDString = F4C189DF2848F59F00335EC7; remoteInfo = VirtualWormhole; }; + F4EFB5672FDB2A0D00AF2A63 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F4BE9C4627FF052100B648F8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F4EFB5622FDB2A0D00AF2A63; + remoteInfo = VirtualInstallation; + }; + F4EFB5BD2FDB2BD400AF2A63 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F4BE9C4627FF052100B648F8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F4EFB5B32FDB2BD300AF2A63; + remoteInfo = VirtualInstallationService; + }; + F4EFB5CC2FDB2BEF00AF2A63 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F4BE9C4627FF052100B648F8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F4EFB5622FDB2A0D00AF2A63; + remoteInfo = VirtualInstallation; + }; + F4EFB5D32FDB2CD200AF2A63 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F4BE9C4627FF052100B648F8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F4EFB5622FDB2A0D00AF2A63; + remoteInfo = VirtualInstallation; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -530,6 +570,7 @@ files = ( F4BE9C6C27FF053A00B648F8 /* VirtualCore.framework in Embed Frameworks */, F498AD052884BF13006F1C00 /* VirtualUI.framework in Embed Frameworks */, + F4EFB56A2FDB2A0D00AF2A63 /* VirtualInstallation.framework in Embed Frameworks */, F43B011C2AD858FE00164CD1 /* DeepLinkSecurity.framework in Embed Frameworks */, F4C189E72848F59F00335EC7 /* VirtualWormhole.framework in Embed Frameworks */, ); @@ -542,6 +583,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + F4DF12ED2FDC824800540CF4 /* VirtualInstallation.framework in Embed Frameworks */, F453C4252DF0B602007EAD5F /* VirtualUI.framework in Embed Frameworks */, F4C18A5328491B9D00335EC7 /* VirtualWormhole.framework in Embed Frameworks */, F428622F2AE87D7E0052F029 /* DeepLinkSecurity.framework in Embed Frameworks */, @@ -550,6 +592,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + F4EFB5C92FDB2BD400AF2A63 /* Embed XPC Services */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices"; + dstSubfolderSpec = 16; + files = ( + F4EFB5BF2FDB2BD400AF2A63 /* VirtualInstallationService.xpc in Embed XPC Services */, + ); + name = "Embed XPC Services"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -616,6 +669,9 @@ F42C01592888FC0C00EB15CD /* SettingsScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; F42C01602888FC3500EB15CD /* LibraryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItemView.swift; sourceTree = ""; }; F42CF4A72DF5FEC3001DE049 /* BlurHashToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashToken.swift; sourceTree = ""; }; + F43087DD2FD8C255002BB1EC /* KeychainPasswordItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainPasswordItem.swift; sourceTree = ""; }; + F43087DE2FD8C255002BB1EC /* KeychainReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainReference.swift; sourceTree = ""; }; + F43087E12FD8C3CA002BB1EC /* ProvisioningConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisioningConfigurationView.swift; sourceTree = ""; }; F436BC4C2FBF8F96007840E4 /* AirGlassEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirGlassEffect.swift; sourceTree = ""; }; F436BC4D2FBF8F96007840E4 /* AirMaterial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirMaterial.swift; sourceTree = ""; }; F436BC4E2FBF8F96007840E4 /* AirVisualEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirVisualEffect.swift; sourceTree = ""; }; @@ -646,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 = ""; }; @@ -741,6 +798,7 @@ F48E0D2E2888835A0080DDFA /* VirtualUIConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualUIConstants.swift; sourceTree = ""; }; F48E0D31288884A10080DDFA /* RestoreImageDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreImageDownloadView.swift; sourceTree = ""; }; F48E0D33288889E60080DDFA /* InstallConfigurationStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallConfigurationStepView.swift; sourceTree = ""; }; + F492285B2FD9CC47007F619F /* VMTemplatesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMTemplatesController.swift; sourceTree = ""; }; F4959F3B2992A284001DF4CB /* GuestAppInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestAppInstaller.swift; sourceTree = ""; }; F498ACFE2884BF13006F1C00 /* VirtualUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = VirtualUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F498AD002884BF13006F1C00 /* VirtualUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VirtualUI.h; sourceTree = ""; }; @@ -864,6 +922,11 @@ F4E7DF962BB33E1700C459FC /* VMLibraryController+SavedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VMLibraryController+SavedState.swift"; sourceTree = ""; }; F4E7DFCE2BB3587D00C459FC /* VirtualMachineSessionUIManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMachineSessionUIManager.swift; sourceTree = ""; }; F4ECC6D42C63BFD5001DAC1D /* NumberDisplayMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberDisplayMode.swift; sourceTree = ""; }; + F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = VirtualInstallation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F4EFB5AE2FDB2A7B00AF2A63 /* Security.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Security.xcconfig; sourceTree = ""; }; + F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = VirtualInstallation.xcconfig; sourceTree = ""; }; + F4EFB5B42FDB2BD300AF2A63 /* VirtualInstallationService.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = VirtualInstallationService.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; + F4EFB5CF2FDB2C9800AF2A63 /* VirtualInstallationRestoreBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualInstallationRestoreBackend.swift; sourceTree = ""; }; F4F9B415284CE0F900F21737 /* VBSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBSettings.swift; sourceTree = ""; }; F4F9B417284CE12000F21737 /* VBSettingsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBSettingsContainer.swift; sourceTree = ""; }; F4F9B419284CE37C00F21737 /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; @@ -875,6 +938,40 @@ F4FC983C2BB386DD00E511C9 /* VMProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMProgressOverlay.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + F4EFB5AD2FDB2A7600AF2A63 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + attributesByRelativePath = { + Source/SPI/MobileDevice.tbd = ( + Weak, + ); + }; + publicHeaders = ( + Source/Backend/VIWaitForDevice.h, + Source/Constants.h, + Source/SPI/MobileDeviceEnums.h, + Source/SPI/MobileDeviceHelper.h, + Source/SPI/MobileDeviceSPI.h, + Source/SPI/XPCSPI.h, + VirtualInstallation.h, + ); + target = F4EFB5622FDB2A0D00AF2A63 /* VirtualInstallation */; + }; + F4EFB5C02FDB2BD400AF2A63 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F4EFB5B32FDB2BD300AF2A63 /* VirtualInstallationService */; + }; +/* 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 */ + /* Begin PBXFrameworksBuildPhase section */ F41369A829918576002CE8D3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -897,7 +994,7 @@ F453C4632DF0E609007EAD5F /* BuddyKit in Frameworks */, F4510A7B2AE2B3B300E24DD9 /* DeepLinkSecurity.framework in Frameworks */, F498AD0C2884BF67006F1C00 /* VirtualCore.framework in Frameworks */, - F4A7FB732BB7238A00E4C12A /* SwiftUIIntrospect-Static in Frameworks */, + F43076E32FD8B19B002BB1EC /* SwiftUIIntrospect in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -915,6 +1012,7 @@ F43B011B2AD858FE00164CD1 /* DeepLinkSecurity.framework in Frameworks */, F453C42B2DF0B792007EAD5F /* ArgumentParser in Frameworks */, F4C189E62848F59F00335EC7 /* VirtualWormhole.framework in Frameworks */, + F4EFB5692FDB2A0D00AF2A63 /* VirtualInstallation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -922,6 +1020,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F4EFB5D12FDB2CD200AF2A63 /* VirtualInstallation.framework in Frameworks */, F4E4F7262DF0A1CB00B3B8BA /* BuddyKit in Frameworks */, F4C189F22848F5F500335EC7 /* VirtualWormhole.framework in Frameworks */, ); @@ -954,6 +1053,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F4EFB5602FDB2A0D00AF2A63 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F4EFB5B12FDB2BD300AF2A63 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F4EFB5CA2FDB2BEF00AF2A63 /* VirtualInstallation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1107,6 +1221,7 @@ F417256D2887543B004FF8A7 /* Storage */, F4B5C5D12886FA77005AA632 /* Sharing */, F422587D2885E2ED009420AE /* HardwareConfigurationView.swift */, + F43087E12FD8C3CA002BB1EC /* ProvisioningConfigurationView.swift */, F42258772885E14A009420AE /* DisplayConfigurationView.swift */, F422587B2885E1CE009420AE /* NetworkConfigurationView.swift */, F417255C288604A8004FF8A7 /* SoundConfigurationView.swift */, @@ -1355,6 +1470,7 @@ F453C4312DF0B7A5007EAD5F /* Core */, F453C4322DF0B7A5007EAD5F /* CatalogCommand.swift */, F453C4332DF0B7A5007EAD5F /* GroupCommand.swift */, + F43ED2BD2FDD9631001D143B /* LegacyGuestAppCommand.swift */, F453C4342DF0B7A5007EAD5F /* ImageCommand.swift */, F453C4352DF0B7A5007EAD5F /* IPSWCommand.swift */, F453C4362DF0B7A5007EAD5F /* MigrateCommand.swift */, @@ -1399,6 +1515,7 @@ F453C49F2DF1D7C0007EAD5F /* RestoreBackend.swift */, F453C4A12DF1D7F6007EAD5F /* SimulatedRestoreBackend.swift */, F453C4A32DF1D85C007EAD5F /* VirtualizationRestoreBackend.swift */, + F4EFB5CF2FDB2C9800AF2A63 /* VirtualInstallationRestoreBackend.swift */, ); path = Installation; sourceTree = ""; @@ -1640,6 +1757,7 @@ F4E7DF832BB30D8200C459FC /* Screenshot */, F4A21BF028032FBD001072B8 /* Helpers */, F4A21BF128032FD8001072B8 /* VMLibraryController.swift */, + F492285B2FD9CC47007F619F /* VMTemplatesController.swift */, F4A7FB3C2BB5E8A200E4C12A /* VMSavedStatesController.swift */, F46FFBAB28059FF600D61023 /* VMInstance.swift */, F4BE9C7927FF05B900B648F8 /* VMController.swift */, @@ -1720,6 +1838,7 @@ F4BE9C4527FF052100B648F8 = { isa = PBXGroup; children = ( + F43EC1962FDD8233001D143B /* Scripts */, F4BE9C5027FF052100B648F8 /* VirtualBuddy */, F4BE9C6627FF053A00B648F8 /* VirtualCore */, F498ACFF2884BF13006F1C00 /* VirtualUI */, @@ -1728,6 +1847,8 @@ F41369AC29918576002CE8D3 /* VirtualBuddyGuestHelper */, F4D3059D29B8DB700006E748 /* VirtualWormholeTests */, F43B01162AD858FE00164CD1 /* DeepLinkSecurity */, + F4EFB5642FDB2A0D00AF2A63 /* VirtualInstallation */, + F4EFB5B52FDB2BD400AF2A63 /* VirtualInstallationService */, F4BE9C4F27FF052100B648F8 /* Products */, F4C189F12848F5F500335EC7 /* Frameworks */, ); @@ -1744,6 +1865,8 @@ F41369AB29918576002CE8D3 /* VirtualBuddyGuestHelper.app */, F4D3059C29B8DB700006E748 /* VirtualWormholeTests.xctest */, F43B01152AD858FE00164CD1 /* DeepLinkSecurity.framework */, + F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */, + F4EFB5B42FDB2BD300AF2A63 /* VirtualInstallationService.xpc */, ); name = Products; sourceTree = ""; @@ -1814,10 +1937,12 @@ F4C1224D280715B500D359E2 /* Paths.xcconfig */, F4C122482807146200D359E2 /* Versions.xcconfig */, F4C1224A2807156200D359E2 /* Signing.xcconfig */, + F4EFB5AE2FDB2A7B00AF2A63 /* Security.xcconfig */, F482FC7228CB7A6C00F2BA4F /* InfoPlist.xcconfig */, F4B068B528882F5D003743BF /* AppTarget.xcconfig */, F4D0F71B28674F24004D5782 /* Features.xcconfig */, F4C1224E280715F200D359E2 /* Main.xcconfig */, + F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */, ); path = Config; sourceTree = ""; @@ -1922,6 +2047,8 @@ F485B91E2BB2F4AC004B3C2B /* Bundle+Version.swift */, F444D1332BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift */, F453C4BA2DF231B7007EAD5F /* PreventTerminationAssertion.swift */, + F43087DD2FD8C255002BB1EC /* KeychainPasswordItem.swift */, + F43087DE2FD8C255002BB1EC /* KeychainReference.swift */, ); path = Utilities; sourceTree = ""; @@ -2100,6 +2227,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F4EFB55E2FDB2A0D00AF2A63 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -2155,8 +2289,8 @@ ); name = VirtualUI; packageProductDependencies = ( - F4A7FB722BB7238A00E4C12A /* SwiftUIIntrospect-Static */, F453C4622DF0E609007EAD5F /* BuddyKit */, + F43076E22FD8B19B002BB1EC /* SwiftUIIntrospect */, ); productName = VirtualUI; productReference = F498ACFE2884BF13006F1C00 /* VirtualUI.framework */; @@ -2172,12 +2306,15 @@ F4BE9C7027FF053A00B648F8 /* Embed Frameworks */, F443620B29B79A5800745B43 /* Embed Guest App */, F453C4282DF0B65B007EAD5F /* Create Command-Line Tool Symlinks */, + F4EFB5C92FDB2BD400AF2A63 /* Embed XPC Services */, ); buildRules = ( ); dependencies = ( F498AD032884BF13006F1C00 /* PBXTargetDependency */, F4C18A5828491C0D00335EC7 /* PBXTargetDependency */, + F4EFB5682FDB2A0D00AF2A63 /* PBXTargetDependency */, + F4EFB5BE2FDB2BD400AF2A63 /* PBXTargetDependency */, ); name = VirtualBuddy; packageProductDependencies = ( @@ -2210,6 +2347,7 @@ dependencies = ( F453C3ED2DF0A4D1007EAD5F /* PBXTargetDependency */, F4C189F02848F5F100335EC7 /* PBXTargetDependency */, + F4EFB5D42FDB2CD200AF2A63 /* PBXTargetDependency */, ); name = VirtualCore; productName = VirtualCore; @@ -2246,6 +2384,7 @@ F4C18A4028491B8500335EC7 /* Resources */, F4C18A5628491B9D00335EC7 /* Embed Frameworks */, F41369BE2991861C002CE8D3 /* Embed Login Item */, + F43EC19B2FDD8920001D143B /* ShellScript */, ); buildRules = ( ); @@ -2279,6 +2418,53 @@ productReference = F4D3059C29B8DB700006E748 /* VirtualWormholeTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + F4EFB5622FDB2A0D00AF2A63 /* VirtualInstallation */ = { + isa = PBXNativeTarget; + buildConfigurationList = F4EFB5732FDB2A0D00AF2A63 /* Build configuration list for PBXNativeTarget "VirtualInstallation" */; + buildPhases = ( + F4EFB55E2FDB2A0D00AF2A63 /* Headers */, + F4EFB55F2FDB2A0D00AF2A63 /* Sources */, + F4EFB5602FDB2A0D00AF2A63 /* Frameworks */, + F4EFB5612FDB2A0D00AF2A63 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F43EC1962FDD8233001D143B /* Scripts */, + F4EFB5642FDB2A0D00AF2A63 /* VirtualInstallation */, + ); + name = VirtualInstallation; + packageProductDependencies = ( + ); + productName = VirtualInstallation; + productReference = F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */; + productType = "com.apple.product-type.framework"; + }; + F4EFB5B32FDB2BD300AF2A63 /* VirtualInstallationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = F4EFB5C12FDB2BD400AF2A63 /* Build configuration list for PBXNativeTarget "VirtualInstallationService" */; + buildPhases = ( + F4EFB5B02FDB2BD300AF2A63 /* Sources */, + F4EFB5B12FDB2BD300AF2A63 /* Frameworks */, + F4EFB5B22FDB2BD300AF2A63 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F4EFB5CD2FDB2BEF00AF2A63 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F4EFB5B52FDB2BD400AF2A63 /* VirtualInstallationService */, + ); + name = VirtualInstallationService; + packageProductDependencies = ( + ); + productName = VirtualInstallationService; + productReference = F4EFB5B42FDB2BD300AF2A63 /* VirtualInstallationService.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2286,8 +2472,8 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 2650; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; TargetAttributes = { F41369AA29918576002CE8D3 = { CreatedOnToolsVersion = 14.2; @@ -2322,10 +2508,15 @@ F4D3059B29B8DB700006E748 = { CreatedOnToolsVersion = 14.2; }; + F4EFB5622FDB2A0D00AF2A63 = { + CreatedOnToolsVersion = 27.0; + }; + F4EFB5B32FDB2BD300AF2A63 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = F4BE9C4927FF052100B648F8 /* Build configuration list for PBXProject "VirtualBuddy" */; - compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -2341,6 +2532,7 @@ F453C4012DF0AEF5007EAD5F /* XCRemoteSwiftPackageReference "swift-argument-parser" */, F453C4482DF0B7F6007EAD5F /* XCRemoteSwiftPackageReference "libfragmentzip" */, ); + preferredProjectObjectVersion = 55; productRefGroup = F4BE9C4F27FF052100B648F8 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -2353,6 +2545,8 @@ F498ACFD2884BF13006F1C00 /* VirtualUI */, F4D3059B29B8DB700006E748 /* VirtualWormholeTests */, F4C189DF2848F59F00335EC7 /* VirtualWormhole */, + F4EFB5622FDB2A0D00AF2A63 /* VirtualInstallation */, + F4EFB5B32FDB2BD300AF2A63 /* VirtualInstallationService */, F453C44F2DF0BCE3007EAD5F /* vctool */, ); }; @@ -2430,9 +2624,41 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F4EFB5612FDB2A0D00AF2A63 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F4EFB5B22FDB2BD300AF2A63 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* 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; @@ -2610,6 +2836,7 @@ F48E0D1A288882BD0080DDFA /* InstallationWizardTitle.swift in Sources */, F41725632886DD37004FF8A7 /* SharedFolderListItem.swift in Sources */, F42258782885E14A009420AE /* DisplayConfigurationView.swift in Sources */, + F43087E22FD8C3CA002BB1EC /* ProvisioningConfigurationView.swift in Sources */, F413697029916F6E002CE8D3 /* StatusItemManager.swift in Sources */, F48E0D1C288882BD0080DDFA /* RestoreImageSelectionStep.swift in Sources */, F47BCDD52C5C0B8F00165191 /* Array+Navigation.swift in Sources */, @@ -2669,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 */, @@ -2688,6 +2916,7 @@ F417257128877121004FF8A7 /* DiskImageGenerator.swift in Sources */, F4F9B416284CE0F900F21737 /* VBSettings.swift in Sources */, F42CF4A82DF5FEC3001DE049 /* BlurHashToken.swift in Sources */, + F492285C2FD9CC47007F619F /* VMTemplatesController.swift in Sources */, F41725762887758A004FF8A7 /* RandomNameGenerator.swift in Sources */, F45502162DF45E53005582A4 /* VBSettings+CatalogDownload.swift in Sources */, F44C00FB2889CE1600640BF5 /* VBVirtualMachine+Virtualization.swift in Sources */, @@ -2740,7 +2969,10 @@ F4C947BF2E0B0F71001ACC91 /* URL+ExtendedAttributes.swift in Sources */, F47BCDD72C5D2B4600165191 /* CatalogExtensions.swift in Sources */, F4BE9C7827FF055100B648F8 /* MacOSVirtualMachineConfigurationHelper.swift in Sources */, + F43087DF2FD8C255002BB1EC /* KeychainPasswordItem.swift in Sources */, + F43087E02FD8C255002BB1EC /* KeychainReference.swift in Sources */, F4510A782AE2A16F00E24DD9 /* WeakReference.swift in Sources */, + F4EFB5D02FDB2C9800AF2A63 /* VirtualInstallationRestoreBackend.swift in Sources */, F4DE1C112D6F642E00603527 /* VBStorageDeviceContainer.swift in Sources */, F46FFBAC28059FF600D61023 /* VMInstance.swift in Sources */, F4E7DF952BB336F600C459FC /* VBSavedStatePackage.swift in Sources */, @@ -2805,6 +3037,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F4EFB55F2FDB2A0D00AF2A63 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F4EFB5B02FDB2BD300AF2A63 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2872,6 +3118,26 @@ target = F4C189DF2848F59F00335EC7 /* VirtualWormhole */; targetProxy = F4D305A129B8DB700006E748 /* PBXContainerItemProxy */; }; + F4EFB5682FDB2A0D00AF2A63 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F4EFB5622FDB2A0D00AF2A63 /* VirtualInstallation */; + targetProxy = F4EFB5672FDB2A0D00AF2A63 /* PBXContainerItemProxy */; + }; + F4EFB5BE2FDB2BD400AF2A63 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F4EFB5B32FDB2BD300AF2A63 /* VirtualInstallationService */; + targetProxy = F4EFB5BD2FDB2BD400AF2A63 /* PBXContainerItemProxy */; + }; + F4EFB5CD2FDB2BEF00AF2A63 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F4EFB5622FDB2A0D00AF2A63 /* VirtualInstallation */; + targetProxy = F4EFB5CC2FDB2BEF00AF2A63 /* PBXContainerItemProxy */; + }; + F4EFB5D42FDB2CD200AF2A63 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F4EFB5622FDB2A0D00AF2A63 /* VirtualInstallation */; + targetProxy = F4EFB5D32FDB2CD200AF2A63 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2956,11 +3222,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; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -2971,7 +3237,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[sdk=macosx*]" = "VirtualBuddy Dev 2026"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -3235,11 +3501,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; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -3250,7 +3516,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[sdk=macosx*]" = "VirtualBuddy Dev 2026"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -3881,11 +4147,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; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -3896,7 +4162,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[sdk=macosx*]" = "VirtualBuddy Dev 2026"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -4160,11 +4426,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; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4175,7 +4441,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[sdk=macosx*]" = "VirtualBuddy Dev 2026"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -4358,11 +4624,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; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -4373,7 +4639,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[sdk=macosx*]" = "VirtualBuddy Dev 2026"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; @@ -4889,6 +5155,538 @@ }; name = Release_Managed; }; + F4EFB56B2FDB2A0D00AF2A63 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = "VirtualInstallation/Resources/RestoreStates-VMA2.txt"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/VirtualInstallation/Source/SPI", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + F4EFB56C2FDB2A0D00AF2A63 /* Debug_Managed */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = "VirtualInstallation/Resources/RestoreStates-VMA2.txt"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/VirtualInstallation/Source/SPI", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug_Managed; + }; + F4EFB56D2FDB2A0D00AF2A63 /* Beta_Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = "VirtualInstallation/Resources/RestoreStates-VMA2.txt"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/VirtualInstallation/Source/SPI", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Beta_Debug; + }; + F4EFB56E2FDB2A0D00AF2A63 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = "VirtualInstallation/Resources/RestoreStates-VMA2.txt"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/VirtualInstallation/Source/SPI", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + F4EFB56F2FDB2A0D00AF2A63 /* Release_Managed */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = "VirtualInstallation/Resources/RestoreStates-VMA2.txt"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/VirtualInstallation/Source/SPI", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Release_Managed; + }; + F4EFB5702FDB2A0D00AF2A63 /* Beta_Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = "VirtualInstallation/Resources/RestoreStates-VMA2.txt"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/VirtualInstallation/Source/SPI", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Beta_Release; + }; + F4EFB5712FDB2A0D00AF2A63 /* Dev_Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F4EFB5AF2FDB2A9500AF2A63 /* VirtualInstallation.xcconfig */; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = "VirtualInstallation/Resources/RestoreStates-VMA2.txt"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/VirtualInstallation/Source/SPI", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Dev_Release; + }; + F4EFB5C22FDB2BD400AF2A63 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8C7439RJLG; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VirtualInstallationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VirtualInstallationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Enable audio input for your virtual machines. Without this permission, virtual machines won't be able to record any audio."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST)_$(_BOOL_$(SKIP_INSTALL)))", + "$(inherited)", + "$(VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + F4EFB5C32FDB2BD400AF2A63 /* Debug_Managed */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8C7439RJLG; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VirtualInstallationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VirtualInstallationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Enable audio input for your virtual machines. Without this permission, virtual machines won't be able to record any audio."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST)_$(_BOOL_$(SKIP_INSTALL)))", + "$(inherited)", + "$(VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug_Managed; + }; + F4EFB5C42FDB2BD400AF2A63 /* Beta_Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8C7439RJLG; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VirtualInstallationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VirtualInstallationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Enable audio input for your virtual machines. Without this permission, virtual machines won't be able to record any audio."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST)_$(_BOOL_$(SKIP_INSTALL)))", + "$(inherited)", + "$(VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Beta_Debug; + }; + F4EFB5C52FDB2BD400AF2A63 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8C7439RJLG; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VirtualInstallationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VirtualInstallationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Enable audio input for your virtual machines. Without this permission, virtual machines won't be able to record any audio."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST)_$(_BOOL_$(SKIP_INSTALL)))", + "$(inherited)", + "$(VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + F4EFB5C62FDB2BD400AF2A63 /* Release_Managed */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8C7439RJLG; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VirtualInstallationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VirtualInstallationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Enable audio input for your virtual machines. Without this permission, virtual machines won't be able to record any audio."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST)_$(_BOOL_$(SKIP_INSTALL)))", + "$(inherited)", + "$(VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release_Managed; + }; + F4EFB5C72FDB2BD400AF2A63 /* Beta_Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8C7439RJLG; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VirtualInstallationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VirtualInstallationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Enable audio input for your virtual machines. Without this permission, virtual machines won't be able to record any audio."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST)_$(_BOOL_$(SKIP_INSTALL)))", + "$(inherited)", + "$(VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Beta_Release; + }; + F4EFB5C82FDB2BD400AF2A63 /* Dev_Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 8C7439RJLG; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VirtualInstallationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VirtualInstallationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Enable audio input for your virtual machines. Without this permission, virtual machines won't be able to record any audio."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST)_$(_BOOL_$(SKIP_INSTALL)))", + "$(inherited)", + "$(VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.VirtualBuddy.VirtualInstallationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Dev_Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -5032,6 +5830,34 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F4EFB5732FDB2A0D00AF2A63 /* Build configuration list for PBXNativeTarget "VirtualInstallation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F4EFB56B2FDB2A0D00AF2A63 /* Debug */, + F4EFB56C2FDB2A0D00AF2A63 /* Debug_Managed */, + F4EFB56D2FDB2A0D00AF2A63 /* Beta_Debug */, + F4EFB56E2FDB2A0D00AF2A63 /* Release */, + F4EFB56F2FDB2A0D00AF2A63 /* Release_Managed */, + F4EFB5702FDB2A0D00AF2A63 /* Beta_Release */, + F4EFB5712FDB2A0D00AF2A63 /* Dev_Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F4EFB5C12FDB2BD400AF2A63 /* Build configuration list for PBXNativeTarget "VirtualInstallationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F4EFB5C22FDB2BD400AF2A63 /* Debug */, + F4EFB5C32FDB2BD400AF2A63 /* Debug_Managed */, + F4EFB5C42FDB2BD400AF2A63 /* Beta_Debug */, + F4EFB5C52FDB2BD400AF2A63 /* Release */, + F4EFB5C62FDB2BD400AF2A63 /* Release_Managed */, + F4EFB5C72FDB2BD400AF2A63 /* Beta_Release */, + F4EFB5C82FDB2BD400AF2A63 /* Dev_Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -5072,7 +5898,7 @@ repositoryURL = "https://github.com/siteline/swiftui-introspect"; requirement = { kind = exactVersion; - version = "1.4.0-beta.2"; + version = "27.0.0-beta.1"; }; }; F4D0F71628674E4B004D5782 /* XCRemoteSwiftPackageReference "Sparkle" */ = { @@ -5094,6 +5920,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + F43076E22FD8B19B002BB1EC /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = F4A7FB712BB7238A00E4C12A /* XCRemoteSwiftPackageReference "swiftui-introspect" */; + productName = SwiftUIIntrospect; + }; F43B01462AD85A7D00164CD1 /* URLQueryItemCoder */ = { isa = XCSwiftPackageProductDependency; package = F43B01452AD85A7D00164CD1 /* XCRemoteSwiftPackageReference "URLQueryItemCoder" */; @@ -5129,11 +5960,6 @@ package = F453C3D82DF0A426007EAD5F /* XCRemoteSwiftPackageReference "BuddyKit" */; productName = BuddyKit; }; - F4A7FB722BB7238A00E4C12A /* SwiftUIIntrospect-Static */ = { - isa = XCSwiftPackageProductDependency; - package = F4A7FB712BB7238A00E4C12A /* XCRemoteSwiftPackageReference "swiftui-introspect" */; - productName = "SwiftUIIntrospect-Static"; - }; F4D0F71728674E4B004D5782 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = F4D0F71628674E4B004D5782 /* XCRemoteSwiftPackageReference "Sparkle" */; diff --git a/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 58ba0ccd..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" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/swiftui-introspect", "state" : { - "revision" : "c2b0625d0ef385994e4c6bc36116c0e7bfa6dafa", - "version" : "1.4.0-beta.2" + "revision" : "aead9358a55f635d62d885aeb9105752c0213aec", + "version" : "27.0.0-beta.1" } }, { diff --git a/VirtualBuddy.xcodeproj/xcshareddata/xcodecloud/manifest.json b/VirtualBuddy.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 00000000..74dd6458 --- /dev/null +++ b/VirtualBuddy.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "8e11e5f0-93f5-45e9-8b15-57ea4662691e", + "targets" : [ + { + "id" : "E4EC8AE8-A4E2-4AA2-9A6A-2E1FADB38BC2", + "name" : "VirtualBuddy" + } + ] +} \ No newline at end of file diff --git a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/DeepLinkSecurity.xcscheme b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/DeepLinkSecurity.xcscheme index 1195e013..15037b86 100644 --- a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/DeepLinkSecurity.xcscheme +++ b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/DeepLinkSecurity.xcscheme @@ -1,6 +1,6 @@ + + @@ -65,9 +69,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualInstallationService.xcscheme b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualInstallationService.xcscheme new file mode 100644 index 00000000..ab60eb82 --- /dev/null +++ b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualInstallationService.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualUI.xcscheme b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualUI.xcscheme index 66da1e1a..8ef42349 100644 --- a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualUI.xcscheme +++ b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualUI.xcscheme @@ -1,6 +1,6 @@ () 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/VirtualBuddy/Config/AppTarget.xcconfig b/VirtualBuddy/Config/AppTarget.xcconfig index eea6322d..e88fb303 100644 --- a/VirtualBuddy/Config/AppTarget.xcconfig +++ b/VirtualBuddy/Config/AppTarget.xcconfig @@ -31,9 +31,6 @@ CODE_SIGN_STYLE[config=Dev_Release][sdk=*][arch=*] = Manual CODE_SIGN_STYLE[config=Beta_Debug][sdk=*][arch=*] = Manual CODE_SIGN_STYLE[config=Beta_Release][sdk=*][arch=*] = Manual -OTHER_SWIFT_FLAGS[config=Release][sdk=*][arch=*] = -D BUILDING_NON_MANAGED_RELEASE -OTHER_SWIFT_FLAGS[config=Dev_Release][sdk=*][arch=*] = -D BUILDING_DEV_RELEASE - // Release Train Settings // Special app icon for development releases. Note that Xcode uses the first icon when sorted alphabetically, hence why the default icon has the -Default suffix. diff --git a/VirtualBuddy/Config/Main.xcconfig b/VirtualBuddy/Config/Main.xcconfig index d2478dd1..1b809ce9 100644 --- a/VirtualBuddy/Config/Main.xcconfig +++ b/VirtualBuddy/Config/Main.xcconfig @@ -3,5 +3,6 @@ #include "Signing.xcconfig" #include "Features.xcconfig" #include "InfoPlist.xcconfig" +#include "Security.xcconfig" ARCHS=arm64 diff --git a/VirtualBuddy/Config/Paths.xcconfig b/VirtualBuddy/Config/Paths.xcconfig index e7dbbb81..de9f1c2d 100644 --- a/VirtualBuddy/Config/Paths.xcconfig +++ b/VirtualBuddy/Config/Paths.xcconfig @@ -3,3 +3,4 @@ ENTITLEMENTS_DIR=VirtualBuddy/Config/Entitlements VB_APP_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks VB_FRAMEWORK_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @loader_path/Frameworks VB_CLI_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @executable_path/Frameworks @executable_path +VB_XPC_SERVICE_RUNPATH_SEARCH_PATHS=$(inherited) @executable_path/../../../../Frameworks diff --git a/VirtualBuddy/Config/Security.xcconfig b/VirtualBuddy/Config/Security.xcconfig new file mode 100644 index 00000000..c982bc4e --- /dev/null +++ b/VirtualBuddy/Config/Security.xcconfig @@ -0,0 +1,14 @@ +#include "Signing.xcconfig" + +// This team ID is used for code signing requirements +VB_DEVELOPMENT_TEAM = 8C7439RJLG + +// #defines a CURRENT_PROJECT_VERSION_FOR_CODE_REQUIREMENTS macro that can be used in code signing verification code +CURRENT_PROJECT_VERSION_FOR_CODE_REQUIREMENTS_STR=@"$(CURRENT_PROJECT_VERSION)" + +// #defines a TEAM_ID_FOR_CODE_REQUIREMENTS macro that can be used in code signing verification code +TEAM_ID_FOR_CODE_REQUIREMENTS_STR=@"$(VB_DEVELOPMENT_TEAM)" + +VIRTUALBUDDY_BUNDLE_ID_STR=@"$(VIRTUALBUDDY_BUNDLE_ID)" + +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) CURRENT_PROJECT_VERSION_FOR_CODE_REQUIREMENTS='$(CURRENT_PROJECT_VERSION_FOR_CODE_REQUIREMENTS_STR)' TEAM_ID_FOR_CODE_REQUIREMENTS='$(TEAM_ID_FOR_CODE_REQUIREMENTS_STR)' VIRTUALBUDDY_BUNDLE_ID='$(VIRTUALBUDDY_BUNDLE_ID_STR)' diff --git a/VirtualBuddy/Config/Signing.xcconfig b/VirtualBuddy/Config/Signing.xcconfig index f733701e..c5bdbc37 100644 --- a/VirtualBuddy/Config/Signing.xcconfig +++ b/VirtualBuddy/Config/Signing.xcconfig @@ -1,7 +1,11 @@ CODE_SIGN_IDENTITY = Apple Development VB_BUNDLE_ID_PREFIX = +VIRTUALBUDDY_BUNDLE_ID = $(VB_BUNDLE_ID_PREFIX)codes.rambo.VirtualBuddy 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)" GCC_PREPROCESSOR_DEFINITIONS=$(inherited) GUEST_LAUNCH_AT_LOGIN_HELPER_BUNDLE_ID='$(GUEST_LAUNCH_AT_LOGIN_HELPER_BUNDLE_ID_STR)' + +OTHER_SWIFT_FLAGS[config=Release][sdk=*][arch=*] = -D BUILDING_NON_MANAGED_RELEASE +OTHER_SWIFT_FLAGS[config=Dev_Release][sdk=*][arch=*] = -D BUILDING_DEV_RELEASE diff --git a/VirtualBuddy/Config/Versions.xcconfig b/VirtualBuddy/Config/Versions.xcconfig index 534f72d5..a8bc8db6 100644 --- a/VirtualBuddy/Config/Versions.xcconfig +++ b/VirtualBuddy/Config/Versions.xcconfig @@ -1,5 +1,9 @@ MARKETING_VERSION = 2.2 -CURRENT_PROJECT_VERSION = 326 -DYLIB_CURRENT_VERSION = $(CURRENT_PROJECT_VERSION) +CURRENT_PROJECT_VERSION = 348 VERSIONING_SYSTEM = apple-generic MACOSX_DEPLOYMENT_TARGET = 14.0 + +/// When building in Xcode Cloud, this file is created to override `CURRENT_PROJECT_VERSION` using the CI build version. +#include? "CIProjectVersion.xcconfig" + +DYLIB_CURRENT_VERSION = $(CURRENT_PROJECT_VERSION) diff --git a/VirtualBuddy/Config/VirtualInstallation.xcconfig b/VirtualBuddy/Config/VirtualInstallation.xcconfig new file mode 100644 index 00000000..b91d83cb --- /dev/null +++ b/VirtualBuddy/Config/VirtualInstallation.xcconfig @@ -0,0 +1,5 @@ +/// #defines a CURRENT_PROJECT_VERSION_FOR_CODE_REQUIREMENTS macro that can be used in code signing verification code +/// Appends `Service` to the end because this refers to the bundle identifier of the VirtualInstallation framework. +VI_SERVICE_BUNDLE_IDENTIFIER_STR=@"$(PRODUCT_BUNDLE_IDENTIFIER)Service" + +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) VI_SERVICE_BUNDLE_IDENTIFIER='$(VI_SERVICE_BUNDLE_IDENTIFIER_STR)' diff --git a/VirtualCore/Source/Definitions/PreviewSupport.swift b/VirtualCore/Source/Definitions/PreviewSupport.swift index 11353b6f..ea2b5e09 100644 --- a/VirtualCore/Source/Definitions/PreviewSupport.swift +++ b/VirtualCore/Source/Definitions/PreviewSupport.swift @@ -7,7 +7,7 @@ let previewLibraryDirName = "PreviewLibrary" public extension VBVirtualMachine { static func previewMachine(named name: String) -> VBVirtualMachine { - try! VBVirtualMachine(bundleURL: Bundle.virtualCore.url(forResource: name, withExtension: VBVirtualMachine.bundleExtension, subdirectory: previewLibraryDirName)!) + VBVirtualMachine(bundleURL: Bundle.virtualCore.url(forResource: name, withExtension: VBVirtualMachine.bundleExtension, subdirectory: previewLibraryDirName)!) } static let preview = VBVirtualMachine.previewMachine(named: "PreviewMac") static let previewBlurHash = VBVirtualMachine.previewMachine(named: "PreviewMacBlurHash") @@ -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/Import/VMImporter+Helpers.swift b/VirtualCore/Source/Import/VMImporter+Helpers.swift index a6f72d98..3bd706fe 100644 --- a/VirtualCore/Source/Import/VMImporter+Helpers.swift +++ b/VirtualCore/Source/Import/VMImporter+Helpers.swift @@ -13,7 +13,7 @@ extension VMImporter { throw "You already have a virtual machine named \(name.quoted). If you’d like to import this virtual machine from \(appName), please rename it first." } - let model = try VBVirtualMachine(bundleURL: vmURL) + let model = VBVirtualMachine(bundleURL: vmURL) return model } diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift index 174f705f..99cf4a0c 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift @@ -13,6 +13,18 @@ public extension VBMacConfiguration { "\(hardware.cpuCount) CPUs / \(hardware.memorySize / 1024 / 1024 / 1024) GB" } + var provisioningSummary: String { + if provisioningEnabled { + if provisioningSetup { + "Enabled" + } else { + "Not Set Up" + } + } else { + "Disabled" + } + } + var storageSummary: String { if hardware.storageDevices.count > 1 { return "\(hardware.storageDevices.count) Devices" diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift index cbf4fb41..0797b212 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Validation.swift @@ -44,20 +44,25 @@ public extension VBMacConfiguration { errors.append("\(hardware.pointingDevice.kind.name) requires macOS 13 or later.") } if hasSharedFolders { - if VBMacConfiguration.isFileSharingSupported { - warnings.append(VBMacConfiguration.fileSharingNotice) - } else { - errors.append(VBMacConfiguration.fileSharingNotice) - } + warnings.append(VBMacConfiguration.fileSharingNotice) } if hardware.networkDevices.contains(where: { $0.kind == .bridge }), !VBNetworkDevice.appSupportsBridgedNetworking { errors.append(VBNetworkDevice.bridgeUnsupportedMessage) } - + if provisioningEnabled, provisioningSetup, !VBMacConfiguration.hostSupportsProvisioning { + warnings.append(VBMacConfiguration.provisioningUnsupportedHostNotice) + } + return SupportState(errors: errors, warnings: warnings) } - static let isFileSharingSupported = true + static let hostSupportsProvisioning: Bool = { + if #available(macOS 27.0, *) { + true + } else { + false + } + }() static let rosettaSupported: Bool = { VZLinuxRosettaDirectoryShare.availability != VZLinuxRosettaAvailability.notSupported @@ -67,15 +72,9 @@ public extension VBMacConfiguration { VZLinuxRosettaDirectoryShare.availability == VZLinuxRosettaAvailability.installed } - static let fileSharingNotice: String = { - let tip = "For older versions, you can use the standard macOS file sharing feature in System Preferences > Sharing." + static let fileSharingNotice = "File sharing requires the virtual machine to be running macOS 13 or later. For older versions, you can use the standard macOS file sharing feature in System Preferences > Sharing." - if isFileSharingSupported { - return "File sharing requires the virtual machine to be running macOS 13 or later. \(tip)" - } else { - return "File sharing requires both the host Mac and the virtual machine to be running macOS 13 or later. \(tip)" - } - }() + static let provisioningUnsupportedHostNotice = "Skip Setup Assistant requires macOS 27 or later." static func rosettaSharingNotice() -> String? { if rosettaSupported { @@ -207,6 +206,8 @@ public extension VBGuestType { var supportsGuestApp: Bool { self == .mac } + var supportsProvisioning: Bool { self == .mac } + } public extension VBVirtualMachine { @@ -218,3 +219,15 @@ public extension UTType { static let iso = UTType(filenameExtension: "iso")! static let img = UTType(filenameExtension: "img")! } + +public extension VBMacProvisioningConfiguration { + /// Unlike the soft validations that are implemented by the model itself and designed to be updated and displayed as the user changes data in the UI, + /// this one actually creates the underlying Virtualization object and uses that to validate against internal requirements which might change between releases. + func validateWithVirtualization() throws { + guard #available(macOS 27.0, *) else { return } + + let options = MacOSVirtualMachineConfigurationHelper.createProvisioningOptions(with: self) + + try options.validate() + } +} diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index e6057723..79596355 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -36,6 +36,8 @@ public struct VBMacConfiguration: Hashable, Codable { case unsupported([String]) } + @DecodableDefault.EmptyPlaceholder public var provisioningUUID = UUID() + public static let currentVersion = 0 @DecodableDefault.Zero public var version = VBMacConfiguration.currentVersion @@ -52,8 +54,16 @@ public struct VBMacConfiguration: Hashable, Codable { @DecodableDefault.True public var captureSystemKeys = true + @DecodableDefault.False public var provisioningEnabled = false + public var provisioning: VBMacProvisioningConfiguration? = nil + 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 @@ -358,6 +368,136 @@ public struct VBMacDevice: Hashable, Codable { } } +// MARK: - Provisioning + +public struct VBMacProvisioningConfiguration: Hashable, Codable, Sendable { + public static let keychainItemService = "codes.rambo.VirtualBuddy.Provisioning" + + public var enablesRemoteLogin: Bool + public var logsInAutomatically: Bool + public var fullName: String + public var username: String + @KeychainReference public var password: String + + public init(enablesRemoteLogin: Bool = false, logsInAutomatically: Bool = false, fullName: String = "", username: String = "", password: KeychainReference) { + self.enablesRemoteLogin = enablesRemoteLogin + self.logsInAutomatically = logsInAutomatically + self.fullName = fullName + self.username = username + self._password = password + } +} + +public extension VBMacProvisioningConfiguration { + enum FormField: CaseIterable, Sendable { + case global + case fullName + case username + case password + case passwordConfirmation + } + + struct FormData: Hashable, Sendable { + public var fullName: String + public var username: String + public var password: String + public var passwordConfirmation: String + + public init(fullName: String = "", username: String = "", password: String = "", passwordConfirmation: String = "") { + self.fullName = fullName + self.username = username + self.password = password + self.passwordConfirmation = passwordConfirmation + } + } +} + +public extension VBMacProvisioningConfiguration.FormData { + static let minPasswordLength = 4 + + func validationErrorMessage(for field: VBMacProvisioningConfiguration.FormField, value: String? = nil) -> String? { + switch field { + case .global: nil + case .fullName: (value ?? fullName).isEmpty ? "Can’t be empty" : nil + case .username: (value ?? username).isEmpty ? "Can’t be empty" : nil + case .password: if (value ?? password).count < Self.minPasswordLength { + "Must be 4 characters or longer" + } else { + nil + } + case .passwordConfirmation: if !password.isEmpty { + if (value ?? passwordConfirmation) != password { + "Passwords don’t match" + } else { + nil + } + } else { + nil + } + } + } + + func validationErrorMessages() -> [VBMacProvisioningConfiguration.FormField: String] { + var result = [VBMacProvisioningConfiguration.FormField: String]() + + for field in VBMacProvisioningConfiguration.FormField.allCases { + result[field] = validationErrorMessage(for: field) + } + + return result + } + + init(_ configuration: VBMacProvisioningConfiguration) { + self.init( + fullName: configuration.fullName, + username: configuration.username, + password: configuration.password, + passwordConfirmation: configuration.password + ) + } +} + +public extension VBMacConfiguration { + struct ProvisioningSetupError: Error { + public let validationErrorMessages: [VBMacProvisioningConfiguration.FormField: String] + + fileprivate init(validationErrorMessages: [VBMacProvisioningConfiguration.FormField : String]) { + self.validationErrorMessages = validationErrorMessages + } + } + + /// Validates the provisioning form data, creates the corresponding ``VBMacProvisioningConfiguration`` and associates it with the virtual machine. + mutating func applyProvisioningConfiguration(with data: VBMacProvisioningConfiguration.FormData) throws { + let validationErrors = data.validationErrorMessages() + + guard validationErrors.isEmpty else { + throw ProvisioningSetupError(validationErrorMessages: validationErrors) + } + + /// Usernames with leading or trailing whitespace are rejected by Virtualization (https://github.com/insidegui/VirtualBuddy/discussions/686#discussioncomment-17278047) + /// It does not seem to reject full names or passwords with leading/trailing whitespaces, but I'm also trimming full name for consistency. + let sanitizedFullName = data.fullName.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitizedUsername = data.username.trimmingCharacters(in: .whitespacesAndNewlines) + + let configuration = VBMacProvisioningConfiguration( + fullName: sanitizedFullName, + username: sanitizedUsername, + password: KeychainReference( + service: VBMacProvisioningConfiguration.keychainItemService, + account: provisioningUUID.uuidString + ) + ) + + try configuration.$password.write(data.password) + + try configuration.validateWithVirtualization() + + self.provisioning = configuration + } + + var provisioningSetup: Bool { provisioning != nil } +} + // MARK: - Sharing And Other Features /// Configures a folder that's shared between the host and the guest. @@ -813,3 +953,82 @@ public extension NSScreen { (deviceDescription[NSDeviceDescriptionKey.resolution] as? CGSize) ?? CGSize(width: 72.0, height: 72.0) } } + +// MARK: - Duplication Support + +public extension VBMacConfiguration { + /// Creates a copy of the configuration that can be used to set up a new virtual machine with the same configuration. + func duplicate() throws -> VBMacConfiguration { + var copy = self + + /// Reset provisioning UUID. + copy.provisioningUUID = UUID() + + /// Create unique copy of provisioning with Keychain item pointing to the new UUID. + if let provisioning { + copy.provisioning = try provisioning.duplicate(destinationUUID: copy.provisioningUUID) + } + + /// Currently only the boot volume configuration is duplicated. + copy.hardware.storageDevices = hardware.storageDevices.filter { $0.isBootVolume } + + return copy + } +} + +extension VBMacProvisioningConfiguration { + func duplicate(destinationUUID: UUID) throws -> VBMacProvisioningConfiguration { + var copy = self + try copy._password.duplicate(newAccount: destinationUUID.uuidString) + return copy + } +} + +// MARK: - Templates + +/// Represents a template that can be applied to the configuration of a virtual machine. +/// +/// Currently the app dynamically offers configuration templates based on a user's existing virtual machines, but this will be +/// expanded in the future to allow arbitrary configuration templates that are not tied to existing virtual machines,. +/// +/// > Tip: To set a VM's configuration to a template, use ``VBMacConfiguration/apply(template:)``. +public struct VBConfigurationTemplate: Identifiable, Hashable, Codable { + public private(set) var id: String + public var name: String + + /// This is made private because clients are not supposed to access it directly. + /// + /// To set a VM's configuration to a template, use ``VBMacConfiguration/apply(template:)``. + fileprivate var configuration: VBMacConfiguration + + public var systemType: VBGuestType { configuration.systemType } + + public init(id: String, name: String, configuration: VBMacConfiguration) { + self.id = id + self.name = name + self.configuration = configuration + } + + public init(referencing virtualMachine: VBVirtualMachine) { + self.init( + id: virtualMachine.id, + name: virtualMachine.name, + configuration: virtualMachine.configuration + ) + } +} + +public extension VBMacConfiguration { + /// Replaces this configuration with a duplicate of the configuration from the template. + mutating func apply(template: VBConfigurationTemplate, includingStorageDevices: Bool) throws { + var duplicate = try template.configuration.duplicate() + + /// Restore our current storage devices when excluding storage from duplicate. + /// This is used when applying a configuration for a virtual machine in a post-install context. + if !includingStorageDevices { + duplicate.hardware.storageDevices = hardware.storageDevices + } + + self = duplicate + } +} diff --git a/VirtualCore/Source/Models/VBVirtualMachine.swift b/VirtualCore/Source/Models/VBVirtualMachine.swift index 1bf5c23b..f93bf20d 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine.swift @@ -13,7 +13,13 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { public var uuid = UUID() public var version = Self.currentVersion public var installFinished: Bool = false + /// The date of the first non-recovery virtual machine boot. + /// + /// - note: This might be set for older virtual machines created before VirtualBuddy 2.2 even if the VM has not been booted before. public var firstBootDate: Date? = nil + /// The date of the most recent non-recovery virtual machine boot. + /// + /// - note: This might be set for older virtual machines created before VirtualBuddy 2.2 even if the VM has not been booted before. public var lastBootDate: Date? = nil @DecodableDefault.EmptyPlaceholder public var backgroundHash: BlurHashToken = .virtualBuddyBackground @@ -70,7 +76,16 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { public var metadata: Metadata { /// Masking private `_metadata` since it's initialized dynamically from a file. get { _metadata ?? .init() } - set { _metadata = newValue } + set { + var effectiveNewValue = newValue + + /// Just in case: disallow overriding existing first boot date with `nil`. + if newValue.firstBootDate == nil, let existingFirstBootDate = _metadata?.firstBootDate { + effectiveNewValue.firstBootDate = existingFirstBootDate + } + + _metadata = effectiveNewValue + } } public var installRestoreData: Data? { @@ -78,7 +93,10 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { get { _installRestoreData } set { _installRestoreData = newValue } } - + + /// Whether this virtual machine has ever been booted to a non-recovery mode after installation. + public var hasBootedNonRecoveryAtLeastOnce: Bool { metadata.firstBootDate != nil } + } public extension VBVirtualMachine { @@ -184,7 +202,7 @@ public extension VBVirtualMachine { self.metadata = metadata } else { /// Migration from previous versions that didn't have a metadata file. - self.metadata = Metadata(installFinished: !isNewInstall, firstBootDate: .now, lastBootDate: .now) + self.metadata = Metadata(installFinished: !isNewInstall, firstBootDate: nil, lastBootDate: nil) } self.installRestoreData = installRestore @@ -202,7 +220,7 @@ public extension VBVirtualMachine { ] self.configuration = .init(systemType: .linux, hardware: hardware) - self.metadata = Metadata(installFinished: false, firstBootDate: .now, lastBootDate: .now) + self.metadata = Metadata(installFinished: false, firstBootDate: nil, lastBootDate: nil) metadata.setLinuxBackgroundHashIfNeeded() try saveMetadata() @@ -212,7 +230,7 @@ public extension VBVirtualMachine { #if DEBUG guard !ProcessInfo.isSwiftUIPreview else { return } #endif - + let configData = try PropertyListEncoder.virtualBuddy.encode(configuration) try write(configData, forMetadataFileNamed: Self.configurationFilename) diff --git a/VirtualCore/Source/Restore Images/VBAPIClient.swift b/VirtualCore/Source/Restore Images/VBAPIClient.swift index b6790ef7..6cbd1f87 100644 --- a/VirtualCore/Source/Restore Images/VBAPIClient.swift +++ b/VirtualCore/Source/Restore Images/VBAPIClient.swift @@ -23,7 +23,7 @@ public final class VBAPIClient { ) public static let development = Environment( - baseURL: URL(string: "https://virtualbuddy-api-dev.bestbuddyapps3496.workers.dev/v2")!, + baseURL: URL(string: "https://localhost:8787/v2")!, apiKey: "15A25D48-4A34-4EE4-A293-C22B0DE1B54E" ) #endif diff --git a/VirtualCore/Source/Restore/Installation/RestoreBackend.swift b/VirtualCore/Source/Restore/Installation/RestoreBackend.swift index afb4f834..b2b5f8e6 100644 --- a/VirtualCore/Source/Restore/Installation/RestoreBackend.swift +++ b/VirtualCore/Source/Restore/Installation/RestoreBackend.swift @@ -1,6 +1,7 @@ import Foundation import Virtualization import BuddyKit +import Combine @MainActor public protocol RestoreBackend: AnyObject { @@ -8,4 +9,10 @@ public protocol RestoreBackend: AnyObject { var progress: Progress { get } func install() async throws func cancel() async + var consolePredicate: LogStreamer.Predicate { get } +} + +@MainActor +public protocol VirtualMachineProvidingRestoreBackend: RestoreBackend { + var virtualMachine: AnyPublisher { get } } diff --git a/VirtualCore/Source/Restore/Installation/SimulatedRestoreBackend.swift b/VirtualCore/Source/Restore/Installation/SimulatedRestoreBackend.swift index 6c794fff..048f97ff 100644 --- a/VirtualCore/Source/Restore/Installation/SimulatedRestoreBackend.swift +++ b/VirtualCore/Source/Restore/Installation/SimulatedRestoreBackend.swift @@ -5,6 +5,8 @@ import Combine #if DEBUG public final class SimulatedRestoreBackend: NSObject, RestoreBackend, @unchecked Sendable { + public var consolePredicate: LogStreamer.Predicate { .process("VirtualBuddy") } + public init(model: VBVirtualMachine, restoringFromImageAt restoreImageFileURL: URL) { super.init() } diff --git a/VirtualCore/Source/Restore/Installation/VirtualInstallationRestoreBackend.swift b/VirtualCore/Source/Restore/Installation/VirtualInstallationRestoreBackend.swift new file mode 100644 index 00000000..10ff45da --- /dev/null +++ b/VirtualCore/Source/Restore/Installation/VirtualInstallationRestoreBackend.swift @@ -0,0 +1,115 @@ +import Foundation +import Virtualization +import BuddyKit +import OSLog +import Combine +import VirtualInstallation + +public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRestoreBackend { + private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VirtualInstallationRestoreBackend.self)) + + public var consolePredicate: LogStreamer.Predicate { .custom(kVirtualInstallationUnifiedLogPredicate) } + + public let model: VBVirtualMachine + public let restoreImageFileURL: URL + + public init(model: VBVirtualMachine, restoringFromImageAt restoreImageFileURL: URL) { + self.model = model + self.restoreImageFileURL = restoreImageFileURL + } + + private var cancellables = Set() + + public let progress = Progress() + + private var _installer: VIVirtualMachineInstaller? + private var _virtualMachine: VZVirtualMachine? + + private let virtualMachineSubject = PassthroughSubject() + public var virtualMachine: AnyPublisher { virtualMachineSubject.eraseToAnyPublisher() } + + public func install() async throws { + logger.debug("Install - creating configuration") + + let installModel = model.forInstallation() + + let config = try await VMInstance.makeConfiguration(for: installModel, installImageURL: restoreImageFileURL) + + let vm = VZVirtualMachine(configuration: config) + _virtualMachine = vm + + await MainActor.run { + virtualMachineSubject.send(vm) + } + + let options = VZMacOSVirtualMachineStartOptions() + options._forceDFU = true + + logger.debug("Requesting vm start") + + try await vm.start(options: options) + + let ecid = try installModel.ECID.require("Failed to obtain virtual machine ECID for installation.") + + logger.debug("Activating installer") + + let installer = VIVirtualMachineInstaller(ecid: ecid, bundleURL: restoreImageFileURL) + + _installer = installer + + createInternalProgressObservations(with: installer) + + defer { + cleanup() + } + + try Task.checkCancellation() + + try await installer.install() + } + + public func cancel() async { + logger.warning("Installation cancelled by client.") + + progress.cancel() + + if let _virtualMachine, _virtualMachine.canStop { + do { + logger.info("Stopping installation VM...") + + try await _virtualMachine.stop() + + logger.info("Installation VM stopped.") + } catch { + logger.error("Error forcing installation VM stop - \(error, privacy: .public)") + } + } + + cleanup() + } + + private func cleanup() { + logger.debug("Cleaning up installation.") + + cancellables.removeAll() + _installer = nil + _virtualMachine = nil + virtualMachineSubject.send(nil) + } + + private func createInternalProgressObservations(with installer: VIVirtualMachineInstaller) { + installer.progress + .publisher(for: \.totalUnitCount, options: [.initial, .new]) + .sink { [weak self] value in + self?.progress.totalUnitCount = value + } + .store(in: &cancellables) + + installer.progress + .publisher(for: \.completedUnitCount, options: [.initial, .new]) + .sink { [weak self] value in + self?.progress.completedUnitCount = value + } + .store(in: &cancellables) + } +} diff --git a/VirtualCore/Source/Restore/Installation/VirtualizationRestoreBackend.swift b/VirtualCore/Source/Restore/Installation/VirtualizationRestoreBackend.swift index 3d7be30f..faf899ce 100644 --- a/VirtualCore/Source/Restore/Installation/VirtualizationRestoreBackend.swift +++ b/VirtualCore/Source/Restore/Installation/VirtualizationRestoreBackend.swift @@ -4,9 +4,11 @@ import BuddyKit import OSLog import Combine -public final class VirtualizationRestoreBackend: RestoreBackend { +public final class VirtualizationRestoreBackend: VirtualMachineProvidingRestoreBackend { private let logger = Logger(subsystem: VirtualCoreConstants.subsystemName, category: "VirtualizationRestoreBackend") + public var consolePredicate: LogStreamer.Predicate { .process("com.apple.Virtualization.Installation") } + public let model: VBVirtualMachine public let restoreImageFileURL: URL diff --git a/VirtualCore/Source/Utilities/KeychainPasswordItem.swift b/VirtualCore/Source/Utilities/KeychainPasswordItem.swift new file mode 100644 index 00000000..cdc6db16 --- /dev/null +++ b/VirtualCore/Source/Utilities/KeychainPasswordItem.swift @@ -0,0 +1,108 @@ +import Foundation + +public struct KeychainPasswordItem: Sendable { + // MARK: Types + + public enum KeychainError: Error, Sendable { + case noPassword + case unexpectedPasswordData + case unexpectedItemData + case unhandledError(status: OSStatus) + } + + // MARK: Properties + + public let service: String + + public private(set) var account: String + + public let accessGroup: String? + + // MARK: Intialization + + public init(service: String, account: String, accessGroup: String? = nil) { + self.service = service + self.account = account + self.accessGroup = accessGroup + } + + // MARK: Keychain access + + public func readPassword() throws -> String { + let data = try readData() + return String(decoding: data, as: UTF8.self) + } + + public func readData() throws -> Data { + var query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnAttributes as String] = kCFBooleanTrue + query[kSecReturnData as String] = kCFBooleanTrue + + var queryResult: AnyObject? + let status = withUnsafeMutablePointer(to: &queryResult) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + guard status != errSecItemNotFound else { throw KeychainError.noPassword } + guard status == noErr else { throw KeychainError.unhandledError(status: status) } + + guard let existingItem = queryResult as? [String: AnyObject], + let passwordData = existingItem[kSecValueData as String] as? Data + else { + throw KeychainError.unexpectedPasswordData + } + + return passwordData + } + + public func savePassword(_ password: String) throws { + try saveData(Data(password.utf8)) + } + + public func saveData(_ password: Data) throws { + do { + try _ = readData() + + var attributesToUpdate = [String: AnyObject]() + attributesToUpdate[kSecValueData as String] = password as AnyObject? + + let query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + guard status == noErr else { throw KeychainError.unhandledError(status: status) } + } catch KeychainError.noPassword { + var newItem = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + newItem[kSecValueData as String] = password as AnyObject? + + let status = SecItemAdd(newItem as CFDictionary, nil) + + guard status == noErr else { throw KeychainError.unhandledError(status: status) } + } + } + + public func deleteItem() throws { + let query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup) + let status = SecItemDelete(query as CFDictionary) + + guard status == noErr || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) } + } + + // MARK: Convenience + + private static func keychainQuery(withService service: String, account: String? = nil, accessGroup: String? = nil) -> [String: AnyObject] { + var query = [String: AnyObject]() + query[kSecClass as String] = kSecClassGenericPassword + query[kSecAttrService as String] = service as AnyObject? + + if let account = account { + query[kSecAttrAccount as String] = account as AnyObject? + } + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup as AnyObject? + } + + return query + } +} diff --git a/VirtualCore/Source/Utilities/KeychainReference.swift b/VirtualCore/Source/Utilities/KeychainReference.swift new file mode 100644 index 00000000..51df35e9 --- /dev/null +++ b/VirtualCore/Source/Utilities/KeychainReference.swift @@ -0,0 +1,125 @@ +import Foundation +import OSLog + +/// A type that can be stored in an encodable parent that wants one of its properties to reference a Keychain item. +@propertyWrapper +public struct KeychainReference: Codable, Hashable, Sendable { + private let logger = Logger(subsystem: "codes.rambo.VirtualCore", category: String(describing: KeychainReference.self)) + + public var wrappedValue: String { + get { read() ?? "" } + nonmutating set { + do { + try write(newValue) + } catch { + logger.error("Error writing keychain item: \(error, privacy: .public)") + } + } + } + + public var projectedValue: Self { self } + + public static let stringPrefix = "@@KEYCHAIN@@" + + private let keychainItem: KeychainPasswordItem + + public init(service: String, account: String) { + self.keychainItem = KeychainPasswordItem(service: service, account: account) + } + + private init(jsonRepresentation: JSONRepresentation) { + self.init( + service: jsonRepresentation.service, + account: jsonRepresentation.account + ) + } + + public func read() -> String? { try? keychainItem.readPassword() } + + public func write(_ password: String) throws { + try keychainItem.savePassword(password) + } + + // MARK: - Duplicate + + public mutating func duplicate(newAccount: String) throws { + guard newAccount != keychainItem.account else { + throw "Keychain reference duplicate must use a different value for account." + } + + let value = try keychainItem.readPassword() + + let copy = KeychainReference(service: keychainItem.service, account: newAccount) + try copy.write(value) + + self = copy + } + + // MARK: - Encoding / Decoding + + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + public func encodableString() -> String { + do { + let jsonRep = jsonRepresentation() + let json = try String(decoding: Self.encoder.encode(jsonRep), as: UTF8.self) + return Self.stringPrefix + json + } catch { + preconditionFailure("Encoding a \(Self.self) should never fail. Error: \(error)") + } + } + + private struct JSONRepresentation: Codable { + let service: String + let account: String + } + + private func jsonRepresentation() -> JSONRepresentation { + JSONRepresentation( + service: keychainItem.service, + account: keychainItem.account + ) + } + + private init(encodedString: String) throws { + guard encodedString.hasPrefix(Self.stringPrefix) else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Encoded Keychain reference is missing \"\(Self.stringPrefix)\" prefix.")) + } + + let sanitized = encodedString.suffix(from: encodedString.index(encodedString.startIndex, offsetBy: Self.stringPrefix.count)) + + let json = Data(sanitized.utf8) + + let jsonRep = try Self.decoder.decode(JSONRepresentation.self, from: json) + + self.init(jsonRepresentation: jsonRep) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + let string = try container.decode(String.self) + + try self.init(encodedString: string) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + let string = encodableString() + + try container.encode(string) + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + let value = read() ?? "" + hasher.combine(value) + } + + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.read() == rhs.read() + } +} diff --git a/VirtualCore/Source/Utilities/LogStreamer.swift b/VirtualCore/Source/Utilities/LogStreamer.swift index 2770a040..9eefd244 100644 --- a/VirtualCore/Source/Utilities/LogStreamer.swift +++ b/VirtualCore/Source/Utilities/LogStreamer.swift @@ -1,6 +1,5 @@ import Foundation import OSLog -import Combine public struct LogEntry: Identifiable, Hashable, Codable, CustomStringConvertible { public enum Level: String, Codable { @@ -46,14 +45,13 @@ public extension LogEntry { public final class LogStreamer: ObservableObject { - private lazy var logger = Logger(for: Self.self) + private let logger = Logger(for: LogStreamer.self) + private var recoveryProcess: Process? private var logProcess: Process? @Published public private(set) var events = [LogEntry]() - public let onEvent = PassthroughSubject() - public enum Predicate: CustomStringConvertible { case library(String) case subsystem(String) @@ -63,7 +61,7 @@ public final class LogStreamer: ObservableObject { public var description: String { switch self { case .library(let name): - return "library = '\(name)'" + return "senderImagePath contains '\(name)'" case .subsystem(let name): return "subsystem = '\(name)'" case .process(let name): @@ -75,25 +73,16 @@ public final class LogStreamer: ObservableObject { } public let predicate: Predicate - public let throttleInterval: Double + public let startTime: Date - public init(predicate: Predicate, throttleInterval: Double = 0.5) { + public init(predicate: Predicate, startTime: Date = .now) { self.predicate = predicate - self.throttleInterval = throttleInterval + self.startTime = startTime } - private var eventsCancellable: Cancellable? - public func activate() { logger.debug(#function) - eventsCancellable = onEvent - .throttle(for: .init(throttleInterval), scheduler: RunLoop.main, latest: true) - .sink(receiveValue: { [weak self] newEvent in - guard let self = self else { return } - self.events.insert(newEvent, at: 0) - }) - let p = Process() p.executableURL = URL(fileURLWithPath: "/usr/bin/log") p.arguments = [ @@ -121,29 +110,79 @@ public final class LogStreamer: ObservableObject { } } + private func recoverLogMessagesIfNeeded() async { + let secondsSinceStart = Date.now.timeIntervalSince(startTime) + + /// No need to recover if we've just started. + guard secondsSinceStart >= 5.0 else { return } + + /// Only recover previous log messages if start time is not way too long ago (over 30 minutes). + guard secondsSinceStart < 60 * 30 else { return } + + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/log") + p.arguments = [ + "show", + "--debug", + "--last", + String(format: "%.0fs", secondsSinceStart), + "--style", + "ndjson", + "--predicate", + "\(predicate)" + ] + + let outPipe = Pipe() + p.standardError = Pipe() + p.standardOutput = outPipe + recoveryProcess = p + + defer { recoveryProcess = nil } + + do { + try p.run() + + /// Recover entries by getting all of them and injecting directly into our events to bypass throttling. + var recoveredEntries = [LogEntry]() + for try await line in outPipe.fileHandleForReading.bytes.lines { + guard let entry = try? decoder.decode(LogEntry.self, from: Data(line.utf8)) else { continue } + recoveredEntries.append(entry) + } + await MainActor.run { [weak self, recoveredEntries] in + self?.events = recoveredEntries + } + } catch { + logger.error("Error recovering logs: \(error, privacy: .public)") + } + } + private var streamTask: Task? private func startStreaming(with fileHandle: FileHandle) { - streamTask = Task { + streamTask = Task.detached { [weak self] in + await self?.recoverLogMessagesIfNeeded() + do { for try await line in fileHandle.bytes.lines { - await onTaskProduceEvent(for: line) + await self?.onTaskProduceEvent(for: line) } } catch { - logger.error("AsyncSequence error: \(String(describing: error), privacy: .public)") + self?.logger.error("AsyncSequence error: \(String(describing: error), privacy: .public)") } } } + private let decoder = JSONDecoder() + private func onTaskProduceEvent(for line: String) async { guard !line.contains("Filtering the log data using") else { return } do { - let entry = try JSONDecoder().decode(LogEntry.self, from: Data(line.utf8)) + let entry = try decoder.decode(LogEntry.self, from: Data(line.utf8)) - await MainActor.run { - onEvent.send(entry) + await MainActor.run { [weak self] in + self?.events.append(entry) } } catch { logger.error("Error decoding log entry \(line, privacy: .public): \(String(describing: error), privacy: .public)") diff --git a/VirtualCore/Source/VirtualCatalog/ResolvedCatalog.swift b/VirtualCore/Source/VirtualCatalog/ResolvedCatalog.swift index e351a2c0..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 } } @@ -100,6 +102,7 @@ public struct ResolvedRequirementSet: ResolvedCatalogModel { public var id: RequirementSet.ID { requirements.id } public var requirements: RequirementSet public var status: ResolvedFeatureStatus + public var shouldForceVirtualInstallationBackend: Bool { requirements.virtualInstallationBackend } public init(requirements: RequirementSet, status: ResolvedFeatureStatus) { self.requirements = requirements @@ -217,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) @@ -255,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 { @@ -274,7 +312,11 @@ public extension ResolvedVirtualizationFeature { } guard environment.guestVersion >= self.feature.minVersionGuest else { - if self.feature.minVersionGuest == self.feature.minVersionHost { + /// Only use aligned host and guest message when host does not support the feature and the version requirement is the same between guest and host, + /// otherwise use the more explicit guest-specific message. + if environment.hostVersion < self.feature.minVersionHost, + self.feature.minVersionHost == self.feature.minVersionGuest + { self.status = .unsupportedHostAndGuestAligned(feature) } else { self.status = .unsupportedGuest(feature) diff --git a/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift b/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift index 628b1832..3d5e5ecd 100644 --- a/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift +++ b/VirtualCore/Source/VirtualCatalog/SoftwareCatalog.swift @@ -14,12 +14,15 @@ public struct RequirementSet: CatalogModel { public var minMemorySizeMB: Int /// The minimum host operating system version required to run the system. public var minVersionHost: SoftwareVersion + /// Whether restore images with this requirement set should be restored using our custom VirtualInstallation backend. + public var virtualInstallationBackend: Bool - public init(id: String, minCPUCount: Int, minMemorySizeMB: Int, minVersionHost: SoftwareVersion) { + public init(id: String, minCPUCount: Int, minMemorySizeMB: Int, minVersionHost: SoftwareVersion, virtualInstallationBackend: Bool = false) { self.id = id self.minCPUCount = minCPUCount self.minMemorySizeMB = minMemorySizeMB self.minVersionHost = minVersionHost + self.virtualInstallationBackend = virtualInstallationBackend } } @@ -140,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 } @@ -203,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 @@ -213,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 { @@ -261,3 +305,14 @@ public extension VirtualizationFeature { self.unsupportedPlatform = (try? container.decodeIfPresent(Bool.self, forKey: .unsupportedPlatform)) ?? false } } + +public extension RequirementSet { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.minCPUCount = try container.decode(Int.self, forKey: .minCPUCount) + self.minMemorySizeMB = try container.decode(Int.self, forKey: .minMemorySizeMB) + self.minVersionHost = try container.decode(SoftwareVersion.self, forKey: .minVersionHost) + self.virtualInstallationBackend = try container.decodeIfPresent(Bool.self, forKey: .virtualInstallationBackend) ?? false + } +} diff --git a/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift b/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift index 0aaed447..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 @@ -60,6 +66,26 @@ struct MacOSVirtualMachineConfigurationHelper: VirtualMachineConfigurationHelper let xhci = VZXHCIControllerConfiguration() return [xhci] } + + @available(macOS 27.0, *) + static func createProvisioningOptions(for vm: VBVirtualMachine) -> VZMacGuestProvisioningOptions? { + guard vm.configuration.provisioningEnabled, let provisioning = vm.configuration.provisioning else { return nil } + + return createProvisioningOptions(with: provisioning) + } + + @available(macOS 27.0, *) + static func createProvisioningOptions(with provisioning: VBMacProvisioningConfiguration) -> VZMacGuestProvisioningOptions { + let options = VZMacGuestProvisioningOptions() + + options.enablesRemoteLogin = provisioning.enablesRemoteLogin + options.fullName = provisioning.fullName + options.username = provisioning.username + options.password = provisioning.password + options.logsInAutomatically = provisioning.logsInAutomatically + + return options + } } // MARK: - Configuration Models -> Virtualization diff --git a/VirtualCore/Source/Virtualization/VBVirtualMachine+Virtualization.swift b/VirtualCore/Source/Virtualization/VBVirtualMachine+Virtualization.swift index 33c6815d..14c70c75 100644 --- a/VirtualCore/Source/Virtualization/VBVirtualMachine+Virtualization.swift +++ b/VirtualCore/Source/Virtualization/VBVirtualMachine+Virtualization.swift @@ -111,10 +111,12 @@ extension VBVirtualMachine { } public extension VBVirtualMachine { + var ECID: UInt64? { (try? self.fetchExistingMachineIdentifier())?.ECID } +} + +public extension VZMacMachineIdentifier { var ECID: UInt64? { - guard let machineIdentifier = try? self.fetchExistingMachineIdentifier() else { return nil } - let data = machineIdentifier.dataRepresentation - guard let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil } + guard let dict = try? PropertyListSerialization.propertyList(from: dataRepresentation, format: nil) as? [String: Any] else { return nil } return dict["ECID"] as? UInt64 } } diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index fc11f1c3..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 @@ -133,7 +136,7 @@ public final class VMController: ObservableObject { /// Ensure configuration is persisted whenever it changes. $virtualMachineModel .dropFirst() - .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) + .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) .sink { updatedModel in do { try updatedModel.saveMetadata() @@ -197,6 +200,16 @@ public final class VMController: ObservableObject { let vm = try newInstance.virtualMachine state = .running(vm) + + /// Update boot date VM metadata with the current date, only if not booting in recovery mode. + if !newInstance.isRecoveryBoot { + if virtualMachineModel.metadata.firstBootDate == nil { + logger.debug("Setting first boot date") + virtualMachineModel.metadata.firstBootDate = .now + } + virtualMachineModel.metadata.lastBootDate = .now + } + virtualMachineModel.metadata.installFinished = true } } @@ -236,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) @@ -253,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 🚀") @@ -261,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...") } @@ -547,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/VirtualCore/Source/Virtualization/VMInstance.swift b/VirtualCore/Source/Virtualization/VMInstance.swift index f3c834dc..1b9f93da 100644 --- a/VirtualCore/Source/Virtualization/VMInstance.swift +++ b/VirtualCore/Source/Virtualization/VMInstance.swift @@ -36,7 +36,8 @@ public final class VMInstance: NSObject, ObservableObject { let wormhole: WormholeManager = .sharedHost private var isLoadingNVRAM = false - + private(set) var isRecoveryBoot = false + var virtualMachineModel: VBVirtualMachine { didSet { precondition(oldValue.id == virtualMachineModel.id, "Can't change the virtual machine identity after initializing the controller") @@ -255,6 +256,23 @@ public final class VMInstance: NSObject, ObservableObject { let vm = try ensureVM() + let configuration = virtualMachineModel.configuration + let startOptions: VZVirtualMachineStartOptions + + switch configuration.systemType { + case .mac: + let macOptions = VZMacOSVirtualMachineStartOptions(options: options) + if #available(macOS 27.0, *), + let provisioning = MacOSVirtualMachineConfigurationHelper.createProvisioningOptions(for: virtualMachineModel) + { + try macOptions.setGuestProvisioning(provisioning) + } + startOptions = macOptions + isRecoveryBoot = macOptions.startUpFromMacOSRecovery + case .linux: + startOptions = VZVirtualMachineStartOptions() + } + try await vm.start(options: startOptions) #if DEBUG @@ -276,16 +294,6 @@ public final class VMInstance: NSObject, ObservableObject { #endif } - @available(macOS 13, *) - private var startOptions: VZVirtualMachineStartOptions { - switch virtualMachineModel.configuration.systemType { - case .mac: - return VZMacOSVirtualMachineStartOptions(options: options) - case .linux: - return VZVirtualMachineStartOptions() - } - } - func pause() async throws { logger.debug(#function) diff --git a/VirtualCore/Source/Virtualization/VMLibraryController.swift b/VirtualCore/Source/Virtualization/VMLibraryController.swift index 50d6ea06..eb541382 100644 --- a/VirtualCore/Source/Virtualization/VMLibraryController.swift +++ b/VirtualCore/Source/Virtualization/VMLibraryController.swift @@ -42,7 +42,11 @@ public final class VMLibraryController: ObservableObject { var isVolumeNotMounted: Bool { id == .volumeNotMounted } } - + + /// ``reload(animated:)`` exits early if this lock is not available, preventing race conditions caused by virtual machine + /// bundles being loaded from the filesystem during critical library operations (such as when duplicating a virtual machine). + private let transactionLock = NSLock() + @Published public private(set) var state = State.loading { didSet { if case .loaded(let machines) = state { @@ -79,6 +83,8 @@ public final class VMLibraryController: ObservableObject { "heic" ] + public private(set) lazy var templatesController = VMTemplatesController(library: self) + public init(settingsContainer: VBSettingsContainer = .current) { self.settingsContainer = settingsContainer self.settings = settingsContainer.settings @@ -122,7 +128,7 @@ public final class VMLibraryController: ObservableObject { .store(in: &cancellables) updateSignal - .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main) .sink { [weak self] url in guard let self else { return } @@ -151,6 +157,12 @@ public final class VMLibraryController: ObservableObject { } public func loadMachines(createLibrary: Bool = false) { + guard transactionLock.try() else { + logger.warning("Skip load machines: transaction lock already taken") + return + } + defer { transactionLock.unlock() } + #if DEBUG guard !simulateState() else { return } #endif @@ -420,22 +432,30 @@ public extension VMLibraryController { @discardableResult func duplicate(_ vm: VBVirtualMachine) throws -> VBVirtualMachine { - let newName = "Copy of " + vm.name + /// Prevent ``reload(animated:)`` from reloading virtual machines due to filesystem changes + /// before we've had a chance to finish setting up and saving the duplicated virtual machine. + /// This addresses an issue that could cause some metadata to be lost when duplicating a virtual machine + /// because it was being reloaded from the filesystem before the duplication process could finish. + let duplicate = try transactionLock.withLock { + let newName = "Copy of " + vm.name - let copyURL = try urlForRenaming(vm, to: newName) + let copyURL = try urlForRenaming(vm, to: newName) - try fileManager.copyItem(at: vm.bundleURL, to: copyURL) + try fileManager.copyItem(at: vm.bundleURL, to: copyURL) - var newVM = try VBVirtualMachine(bundleURL: copyURL) + var newVM = try VBVirtualMachine(bundleURL: copyURL, isNewInstall: false, createIfNeeded: false) - newVM.bundleURL.creationDate = .now - newVM.uuid = UUID() + newVM.bundleURL.creationDate = .now + newVM.uuid = UUID() - try newVM.saveMetadata() + try newVM.saveMetadata() + + return newVM + } reload() - return newVM + return duplicate } func moveToTrash(_ vm: VBVirtualMachine) async throws { diff --git a/VirtualCore/Source/Virtualization/VMTemplatesController.swift b/VirtualCore/Source/Virtualization/VMTemplatesController.swift new file mode 100644 index 00000000..70c54229 --- /dev/null +++ b/VirtualCore/Source/Virtualization/VMTemplatesController.swift @@ -0,0 +1,52 @@ +// +// VMTemplatesController.swift +// VirtualBuddy +// +// Created by Guilherme Rambo on 10/6/26. +// + +import Foundation +import Combine + +@Observable +@MainActor +public final class VMTemplatesController { + public private(set) var templatesForMacGuest = [VBConfigurationTemplate]() + public private(set) var templatesForLinuxGuest = [VBConfigurationTemplate]() + + private var cancellables = Set() + + init(library: VMLibraryController) { + library.$virtualMachines.sink { [weak self] machines in + self?.loadTemplates(machines) + }.store(in: &cancellables) + } + + public func hasTemplates(for guestType: VBGuestType) -> Bool { + switch guestType { + case .mac: !templatesForMacGuest.isEmpty + case .linux: !templatesForLinuxGuest.isEmpty + } + } + + public func template(id: VBConfigurationTemplate.ID) -> VBConfigurationTemplate? { + (templatesForMacGuest + templatesForLinuxGuest).first { $0.id == id } + } + + private func loadTemplates(_ virtualMachines: [VBVirtualMachine]) { + /// Create templates for all virtual machines except ones that are not installed yet (such as the one being configured in a pre-install context). + let templates = virtualMachines + .filter(\.metadata.installFinished) + .map { VBConfigurationTemplate(referencing: $0) } + + let forMac = templates.filter { $0.systemType == .mac } + let forLinux = templates.filter { $0.systemType == .linux } + + if forMac != templatesForMacGuest { + templatesForMacGuest = forMac + } + if forLinux != templatesForLinuxGuest { + templatesForLinuxGuest = forLinux + } + } +} diff --git a/VirtualInstallation/Resources/RestoreStates-VMA2.txt b/VirtualInstallation/Resources/RestoreStates-VMA2.txt new file mode 100644 index 00000000..5a9cb333 --- /dev/null +++ b/VirtualInstallation/Resources/RestoreStates-VMA2.txt @@ -0,0 +1,5453 @@ +PROGRESS: { + DeviceState = 1; + Operation = 208; + OverallProgress = "-1"; + Progress = "-1"; + QueuePosition = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 2; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 3; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 4; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 6; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 7; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 8; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 9; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 10; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 12; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 13; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 14; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 15; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 17; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 18; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 19; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 20; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 21; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 23; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 24; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 26; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 28; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 29; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 30; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 31; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 32; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 34; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 35; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 36; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 37; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 38; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 40; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 41; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 42; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 43; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 45; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 46; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 47; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 48; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 49; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 51; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 52; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 53; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 54; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 55; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 55; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 56; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 57; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 58; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 59; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 60; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 62; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 63; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 64; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 65; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 67; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 68; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 69; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 70; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 71; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 73; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 74; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 76; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 77; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 78; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 78; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 79; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 80; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 81; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 82; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 83; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 83; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 84; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 85; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 86; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 87; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 88; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 90; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 91; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 92; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 93; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 95; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 96; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 97; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 98; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 99; + Status = Restoring; +} +PROGRESS: { + DeviceState = 1; + Operation = 204; + OverallProgress = "-1"; + Progress = "-1"; + QueuePosition = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 4; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 31; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 205; + OverallProgress = "-1"; + Progress = "-1"; + QueuePosition = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 4; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 43; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 8; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 5; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 6; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 7; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 9; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 2; + Operation = 205; + OverallProgress = "-1"; + Progress = "-1"; + QueuePosition = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 45; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 3; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 4; + OverallProgress = "-1"; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 28; + OverallProgress = 0; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 46; + OverallProgress = 0; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 11; + OverallProgress = 0; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 12; + OverallProgress = 7; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 12; + OverallProgress = 7; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 7; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 7; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 16; + OverallProgress = 7; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 16; + OverallProgress = 7; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 80; + OverallProgress = 7; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 80; + OverallProgress = 7; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 35; + OverallProgress = 7; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 18; + OverallProgress = 7; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 18; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 80; + OverallProgress = 12; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 80; + OverallProgress = 12; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 80; + OverallProgress = 12; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 55; + OverallProgress = 12; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 55; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 36; + OverallProgress = 12; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 36; + OverallProgress = 12; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 36; + OverallProgress = 12; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 36; + OverallProgress = 12; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 36; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 79; + OverallProgress = 12; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 79; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 85; + OverallProgress = 12; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 85; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 82; + OverallProgress = 12; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 82; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 89; + OverallProgress = 12; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 89; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 84; + OverallProgress = 12; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 84; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 74; + OverallProgress = 12; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 74; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 74; + OverallProgress = 12; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 12; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 13; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 13; + Progress = 2; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 14; + Progress = 3; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 14; + Progress = 4; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 15; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 15; + Progress = 6; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 15; + Progress = 7; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 16; + Progress = 8; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 16; + Progress = 9; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 17; + Progress = 10; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 17; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 18; + Progress = 12; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 18; + Progress = 13; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 19; + Progress = 14; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 19; + Progress = 15; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 20; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 20; + Progress = 17; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 20; + Progress = 18; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 21; + Progress = 19; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 21; + Progress = 20; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 22; + Progress = 21; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 22; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 23; + Progress = 23; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 23; + Progress = 24; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 24; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 24; + Progress = 26; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 25; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 25; + Progress = 28; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 26; + Progress = 29; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 26; + Progress = 30; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 26; + Progress = 31; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 27; + Progress = 32; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 27; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 28; + Progress = 34; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 28; + Progress = 35; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 29; + Progress = 36; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 29; + Progress = 37; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 30; + Progress = 38; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 30; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 31; + Progress = 40; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 31; + Progress = 41; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 32; + Progress = 42; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 32; + Progress = 43; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 32; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 33; + Progress = 45; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 33; + Progress = 46; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 34; + Progress = 47; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 34; + Progress = 48; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 35; + Progress = 49; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 35; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 36; + Progress = 51; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 36; + Progress = 52; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 37; + Progress = 53; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 37; + Progress = 54; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 37; + Progress = 55; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 38; + Progress = 56; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 38; + Progress = 57; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 39; + Progress = 58; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 39; + Progress = 59; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 40; + Progress = 60; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 40; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 41; + Progress = 62; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 41; + Progress = 63; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 42; + Progress = 64; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 42; + Progress = 65; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 43; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 43; + Progress = 67; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 43; + Progress = 68; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 44; + Progress = 69; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 44; + Progress = 70; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 45; + Progress = 71; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 45; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 46; + Progress = 73; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 46; + Progress = 74; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 47; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 47; + Progress = 76; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 48; + Progress = 77; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 48; + Progress = 78; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 48; + Progress = 79; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 49; + Progress = 80; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 49; + Progress = 81; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 50; + Progress = 82; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 50; + Progress = 83; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 51; + Progress = 84; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 51; + Progress = 85; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 52; + Progress = 86; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 52; + Progress = 87; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 53; + Progress = 88; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 53; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 54; + Progress = 90; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 54; + Progress = 91; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 54; + Progress = 92; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 55; + Progress = 93; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 55; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 56; + Progress = 95; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 56; + Progress = 96; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 57; + Progress = 97; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 57; + Progress = 98; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 58; + Progress = 99; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 13; + OverallProgress = 58; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 16; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 16; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 16; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 67; + OverallProgress = 58; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 2; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 3; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 4; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 6; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 7; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 8; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 9; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 10; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 12; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 13; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 14; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 15; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 17; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 18; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 19; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 20; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 21; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 23; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 24; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 26; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 28; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 29; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 30; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 31; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 32; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 34; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 35; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 36; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 37; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 38; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 40; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 41; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 42; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 43; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 45; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 46; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 47; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 48; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 49; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 51; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 52; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 53; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 54; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 55; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 56; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 57; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 58; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 59; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 60; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 62; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 63; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 64; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 65; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 67; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 68; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 69; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 70; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 71; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 73; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 74; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 76; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 77; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 78; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 79; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 80; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 81; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 82; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 83; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 84; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 85; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 86; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 87; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 88; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 90; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 91; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 92; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 93; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 95; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 96; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 97; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 98; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 99; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 58; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 2; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 3; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 4; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 6; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 58; + Progress = 7; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 8; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 9; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 10; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 12; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 13; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 14; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 15; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 17; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 18; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 19; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 20; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 21; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 59; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 26; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 28; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 29; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 30; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 31; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 32; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 34; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 35; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 36; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 37; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 38; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 40; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 41; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 60; + Progress = 42; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 43; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 45; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 46; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 47; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 48; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 49; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 52; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 54; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 56; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 61; + Progress = 58; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 60; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 62; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 64; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 65; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 67; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 68; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 70; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 73; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 62; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 77; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 79; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 80; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 82; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 84; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 85; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 87; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 88; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 90; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 91; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 92; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 93; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 63; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 64; + Progress = 95; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 64; + Progress = 97; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 64; + Progress = 99; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 81; + OverallProgress = 64; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 17; + OverallProgress = 64; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 25; + OverallProgress = 64; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 15; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 16; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 2; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 3; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 4; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 6; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 7; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 8; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 9; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 10; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 12; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 13; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 14; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 15; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 17; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 18; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 19; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 20; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 21; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 23; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 24; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 26; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 28; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 29; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 30; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 31; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 32; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 34; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 35; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 36; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 37; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 38; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 40; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 41; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 42; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 43; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 45; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 46; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 47; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 48; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 49; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 51; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 52; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 53; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 54; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 55; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 56; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 57; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 58; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 59; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 60; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 62; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 63; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 64; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 65; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 67; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 68; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 69; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 70; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 71; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 73; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 74; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 76; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 77; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 78; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 79; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 80; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 81; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 82; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 83; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 84; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 85; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 86; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 87; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 88; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 90; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 91; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 92; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 93; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 95; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 96; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 97; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 98; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 99; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 2; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 3; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 4; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 6; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 7; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 8; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 9; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 10; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 12; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 13; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 14; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 15; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 17; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 18; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 19; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 20; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 21; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 23; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 24; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 26; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 28; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 29; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 30; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 31; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 32; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 34; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 35; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 36; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 37; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 38; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 40; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 41; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 42; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 43; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 45; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 46; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 47; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 48; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 49; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 51; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 52; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 53; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 54; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 55; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 56; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 57; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 58; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 59; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 60; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 62; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 63; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 64; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 65; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 67; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 68; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 69; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 70; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 71; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 73; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 74; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 76; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 77; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 78; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 79; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 80; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 81; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 82; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 83; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 84; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 85; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 86; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 87; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 88; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 90; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 91; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 92; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 93; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 95; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 96; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 97; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 98; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 99; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 1; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 2; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 3; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 4; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 5; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 6; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 7; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 8; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 9; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 10; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 11; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 12; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 13; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 14; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 15; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 16; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 17; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 18; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 19; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 20; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 21; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 22; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 23; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 24; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 25; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 26; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 27; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 28; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 29; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 30; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 31; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 32; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 33; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 34; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 35; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 36; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 37; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 38; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 39; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 40; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 41; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 42; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 43; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 44; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 45; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 46; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 47; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 48; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 49; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 50; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 51; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 52; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 53; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 54; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 55; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 56; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 57; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 58; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 59; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 60; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 61; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 62; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 63; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 64; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 65; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 66; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 67; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 68; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 69; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 70; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 71; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 72; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 73; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 74; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 75; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 76; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 77; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 78; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 79; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 80; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 81; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 82; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 83; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 84; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 85; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 86; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 87; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 88; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 89; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 90; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 91; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 92; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 93; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 94; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 95; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 96; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 97; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 98; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 99; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 78; + OverallProgress = 66; + Progress = 100; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 77; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 29; + OverallProgress = 66; + Progress = "-1"; + Status = Restoring; +} +PROGRESS: { + DeviceState = 3; + Operation = 206; + OverallProgress = "-1"; + Progress = "-1"; + QueuePosition = 0; + Status = Restoring; +} +PROGRESS: { + DeviceState = 4; + Operation = 0; + OverallProgress = 100; + PersonalizedObj = "<_AMRestorablePersonalized 0x600000f11380 [0x200b1e220]>"; + Progress = 100; + Status = Successful; + UUID = "F2B29CC5-C249-4EDF-9B10-909B11FA7F7B"; +} diff --git a/VirtualInstallation/Source/Backend/AppleMobileDeviceRestoreBackend.swift b/VirtualInstallation/Source/Backend/AppleMobileDeviceRestoreBackend.swift new file mode 100644 index 00000000..9928c6b8 --- /dev/null +++ b/VirtualInstallation/Source/Backend/AppleMobileDeviceRestoreBackend.swift @@ -0,0 +1,55 @@ +import Foundation +import OSLog + +final class AppleMobileDeviceRestoreBackend: DeviceRestoreBackend, @unchecked Sendable { + private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: AppleMobileDeviceRestoreBackend.self)) + + private var progressHandler: DeviceRestoreProgressClosure? = nil + + func restore(deviceECID: ECID, options: [String : AnyHashable], loggers: DeviceRestoreLoggers, progress: @escaping DeviceRestoreProgressClosure) throws { + guard let device = VIWaitForDeviceWithECID(deviceECID, .unknown, 5000) else { + logger.fault("Couldn't find device with ECID \(deviceECID)") + throw NSError.viDeviceNotFound + } + + let deviceState = AMRestorableDeviceGetState(device) + logger.notice("Found device \(deviceECID) with state \(deviceState, privacy: .public)") + + self.progressHandler = progress + + if let global = loggers.global { + if !AMRestorableSetGlobalLogFileURL(global.fileURL as CFURL) { + logger.warning("Failed to set global log file URL") + } + } + if let serial = loggers.serial { + if !AMRestorableDeviceSetLogFileURL(device, serial.fileURL as CFURL, "SerialLogType" as CFString) { + logger.warning("Failed to set serial log file URL") + } + } + if let host = loggers.host { + if !AMRestorableDeviceSetLogFileURL(device, host.fileURL as CFURL, "HostLogType" as CFString) { + logger.warning("Failed to set host log file URL") + } + } + if let deviceLog = loggers.device { + if !AMRestorableDeviceSetLogFileURL(device, deviceLog.fileURL as CFURL, "DeviceLogType" as CFString) { + logger.warning("Failed to set device log file URL") + } + } + + let refCon = unsafeBitCast(self, to: UnsafeMutableRawPointer.self) + + AMRestorableDeviceRestore(device, options as CFDictionary, { device, info, refCon in + guard let refCon else { return } + + let backend = unsafeBitCast(refCon, to: AppleMobileDeviceRestoreBackend.self) + + #if DEBUG + backend.logger.trace("PROGRESS: \(info)") + #endif + + backend.progressHandler?(info) + }, refCon) + } +} diff --git a/VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift b/VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift new file mode 100644 index 00000000..14b7d0ec --- /dev/null +++ b/VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift @@ -0,0 +1,17 @@ +import Foundation + +struct DeviceRestoreLoggers: @unchecked Sendable { + var global: RestoreLog? = nil + var device: RestoreLog? = nil + var host: RestoreLog? = nil + var serial: RestoreLog? = nil +} + +typealias DeviceRestoreProgressClosure = @Sendable (_ info: CFDictionary) -> Void + +protocol DeviceRestoreBackend: AnyObject { + func restore(deviceECID: ECID, + options: [String: AnyHashable], + loggers: DeviceRestoreLoggers, + progress: @escaping DeviceRestoreProgressClosure) throws +} diff --git a/VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift b/VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift new file mode 100644 index 00000000..7745df91 --- /dev/null +++ b/VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift @@ -0,0 +1,96 @@ +import Foundation +import os +import Combine + +final class DeviceRestoreDriver: @unchecked Sendable { + private let logger: Logger + private let ecid: ECID + private let bundleURL: URL + private let variantName: String + private let backend: any DeviceRestoreBackend + + private let personalizedBundleURL: URL + + let artifactStorageURL: URL + let loggers: DeviceRestoreLoggers + + init(ecid: ECID, bundleURL: URL, variantName: String = "Customer Erase Install (IPSW)", backend: any DeviceRestoreBackend) throws { + self.logger = Logger(subsystem: kVirtualInstallationSubsystem, category: "\(String(describing: Self.self))(\(ecid))") + self.ecid = ecid + self.bundleURL = bundleURL + self.variantName = variantName + self.backend = backend + + self.artifactStorageURL = URL.viApplicationSupportURL + self.personalizedBundleURL = try artifactStorageURL + .appending(path: "Personalized_\(bundleURL.deletingPathExtension().lastPathComponent)_\(ecid)_\(Int(Date.now.timeIntervalSinceReferenceDate))", directoryHint: .isDirectory) + .ensureExistingDirectory(createIfNeeded: true) + + let logBaseURL = try artifactStorageURL + .appending(path: "Logs", directoryHint: .isDirectory) + .ensureExistingDirectory(createIfNeeded: true) + + let loggers = DeviceRestoreLoggers( + global: RestoreLog(fileURL: logBaseURL.appending(path: "global.log")), + device: RestoreLog(fileURL: logBaseURL.appending(path: "device.log")), + host: RestoreLog(fileURL: logBaseURL.appending(path: "host.log")), + serial: RestoreLog(fileURL: logBaseURL.appending(path: "serial.log")) + ) + + self.loggers = loggers + } + + func start(overrideOptions: RestoreOptionsDictionary? = nil, progressHandler: @escaping @Sendable (_ state: DeviceRestoreState) -> ()) throws { + let options: RestoreOptionsDictionary + if let overrideOptions { + options = overrideOptions + } else { + options = buildRestoreOptions() + } + + logger.debug("Start with options \(String(describing: options))") + + try backend.restore(deviceECID: ecid, options: options, loggers: loggers) { [weak self] info in + do { + let state = try DeviceRestoreState(info: info) + progressHandler(state) + } catch { + self?.logger.error("Failed to parse progress info: \(error, privacy: .public). Info:\n\(info, privacy: .public)") + } + } + } + + private static let preservePersonalizedBundles = ProcessInfo.processInfo.environment["VI_PRESERVE_PERSONALIZED_BUNDLES"] == "1" + + private func buildRestoreOptions() -> RestoreOptionsDictionary { + [ + "AuthInstallDemotionPolicyOverride": "Don't Demote", + "AuthInstallEnableSso": 0, + "AuthInstallPreservePersonalizedBundles": Self.preservePersonalizedBundles ? 1 : 0, + "AuthInstallSigningServerURL": "https://gs.apple.com:443", + "AuthInstallVariant": variantName, + "AutoBootDelay": 0, + "BootImageType": "User", + "CreateFilesystemPartitions": true, + "DFUFileType": "RELEASE", + "EncryptDataPartition": true, + "FlashNOR": true, + "InstallDiags": true, + "InstallRecoveryOS": true, + "KernelCacheType": "Release", + "NORImageType": "production", + "PersonalizedRestoreBundlePath": personalizedBundleURL.safePath, + "PostRestoreAction": "Shutdown", + "ReadOnlyRootFilesystem": true, + "RecoveryOSFailureIsFatal": true, + "RecoveryOSOnly": false, + "RecoveryOSUnpack": false, + "RelaxedImageVerification": false, + "RestoreBootArgs": "debug=0x14e serial=3 rd=md0 nand-enable-reformat=1 -progress -restore", + "RestoreBundlePath": bundleURL.safePath, + "SystemImageType": "User", + "UpdateBaseband": true, + "WaitForDeviceConnectionToFinishStateMachine": false, + ] + } +} diff --git a/VirtualInstallation/Source/Backend/RestoreLog.swift b/VirtualInstallation/Source/Backend/RestoreLog.swift new file mode 100644 index 00000000..1d052ddb --- /dev/null +++ b/VirtualInstallation/Source/Backend/RestoreLog.swift @@ -0,0 +1,83 @@ +import Foundation +import os + +public final class RestoreLog: @unchecked Sendable { + private let logger: Logger + public let fileURL: URL + + public init(fileURL: URL) { + self.fileURL = fileURL + let name = "RestoreLog(\(fileURL.deletingPathExtension().lastPathComponent))" + self.logger = Logger(subsystem: kVirtualInstallationSubsystem, category: name) + } + + public func stream() -> AsyncThrowingStream { + AsyncThrowingStream { [weak self] continuation in + guard let self else { + continuation.finish() + return + } + + let task = Task { + let handle = try self.fileHandle + + guard !Task.isCancelled else { return } + + do { + for try await line in handle.bytes.lines { + continuation.yield(line) + } + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public func invalidate() { + logger.debug(#function) + + _fileHandle.withLock { + _ = try? $0?.close() + } + } + + private let _fileHandle = OSAllocatedUnfairLock(initialState: nil) + private var fileHandle: FileHandle { + get throws { + try _fileHandle.withLock { handle in + if let handle { + return handle + } else { + let newHandle = try openFileHandle() + + logger.info("Opened file handle at \(self.fileURL.path)") + + handle = newHandle + + return newHandle + } + } + } + } + + private func openFileHandle() throws -> FileHandle { + do { + if !fileURL.exists { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + } + + return try FileHandle(forReadingFrom: fileURL) + } catch { + logger.error("Error opening file handle: \(error, privacy: .public)") + + throw error + } + } + + deinit { invalidate() } +} diff --git a/VirtualInstallation/Source/Backend/TestDeviceRestoreBackend.swift b/VirtualInstallation/Source/Backend/TestDeviceRestoreBackend.swift new file mode 100644 index 00000000..afd7f6ba --- /dev/null +++ b/VirtualInstallation/Source/Backend/TestDeviceRestoreBackend.swift @@ -0,0 +1,156 @@ +import Foundation + +final class TestDeviceRestoreBackend: DeviceRestoreBackend, @unchecked Sendable { + let stateDictionaries: [CFDictionary] + let minTransitionIntervalMS: Int + let maxTransitionIntervalMS: Int + + init(stateDictionaries: [CFDictionary] = DeviceRestoreState.testDictionaries, minTransitionIntervalMS: Int = 5, maxTransitionIntervalMS: Int = 30) { + self.stateDictionaries = stateDictionaries + self.minTransitionIntervalMS = minTransitionIntervalMS + self.maxTransitionIntervalMS = maxTransitionIntervalMS + } + + func restore(deviceECID: ECID, options: [String : AnyHashable], loggers: DeviceRestoreLoggers, progress: @escaping DeviceRestoreProgressClosure) throws { + Task { + for dictionary in stateDictionaries { + progress(dictionary) + + do { + try await Task.sleep(for: .milliseconds(Int.random(in: minTransitionIntervalMS...maxTransitionIntervalMS))) + } catch { break } + } + } + } +} + +private extension String { + static let testRestoreBackendLog: String = { + guard let url = Bundle.virtualInstallation.url(forResource: "RestoreStates-VMA2", withExtension: "txt") else { + fatalError("Missing RestoreStates-VMA2 file in VirtualInstallation bundle for test") + } + return try! String(contentsOf: url, encoding: .utf8) + }() +} + +extension DeviceRestoreState { + nonisolated(unsafe) static var testDictionaries: [CFDictionary] = { + let dictionaries = readRestoreStatesFromLog(String.testRestoreBackendLog) + return dictionaries + }() + + static let testStates: [DeviceRestoreState] = { + return try! testDictionaries.map { + try DeviceRestoreState(info: $0) + } + }() + + static let testStatesDistinctOperationsOnly: [DeviceRestoreState] = { + var statesByOperation: [RestoreOperation: DeviceRestoreState] = [:] + + for state in testStates { + let operation = state.operation + if statesByOperation[operation] == nil { statesByOperation[operation] = state } + } + + return Array(statesByOperation.values).sorted { stateA, stateB in + guard let idxA = testStates.firstIndex(of: stateA), + let idxB = testStates.firstIndex(of: stateB) else { + return false + } + + return idxA < idxB + } + }() + + static let testStatesError: [DeviceRestoreState] = { + testStatesDistinctOperationsOnly.prefix(testStatesDistinctOperationsOnly.count - 2) + [.testError] + }() +} + +// MARK: - Test Content + +private extension DeviceRestoreState { + static let test1 = DeviceRestoreState( + progress: 0.5, + overallProgress: 0.34, + operation: 200, + operationName: AMRLocalizedCopyStringForAMROperation(200) as String, + status: "Restoring", + outcome: nil + ) + + static let testError = DeviceRestoreState( + progress: 1, + overallProgress: 1, + operation: 0, + operationName: AMRLocalizedCopyStringForAMROperation(0) as String, + status: "Failed", + outcome: .failure(CodableError(NSError.testRestoreError)) + ) +} + +private extension NSError { + static var testRestoreError: NSError { + NSError(domain: "AMRestoreErrorDomain", code: 3194, userInfo: [ + NSLocalizedDescriptionKey : "Personalization failed", + NSUnderlyingErrorKey: NSError(domain: "AMRestoreErrorDomain", code: 3194, userInfo: [ + NSLocalizedDescriptionKey: "Declined to authorize this image on this device for this user." + ]) + ]) + } +} + +/** + Given a log string containing raw logged restore states from MobileDevice, constructs CFDictionary entries matching the restore states. + Log format is expected to be the debug descriptions of CFDictionary as printed by RestoreBuddy when running, example: + ``` + PROGRESS: { + DeviceState = 1; + Operation = 208; + OverallProgress = "-1"; + Progress = "-1"; + QueuePosition = 1; + Status = Restoring; + } + PROGRESS: { + DeviceState = 1; + Operation = 2; + OverallProgress = "-1"; + Progress = 0; + Status = Restoring; + } + ``` + */ +private func readRestoreStatesFromLog(_ log: String) -> [CFDictionary] { + let regex = /\s{1,}?(.*)\s?\=\s?(.*);/ + let valueCleanupRegex = /[=;\"]/ + var output = [CFDictionary]() + let entries = log.components(separatedBy: "}").filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + for entry in entries { + var dict = [String: Any]() + + let matches = entry.matches(of: regex) + + for match in matches { + let key = match.output.1.trimmingCharacters(in: .whitespacesAndNewlines) + let value = match.output.2 + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacing(valueCleanupRegex, with: { _ in "" }) + switch key { + case "DeviceState", "Progress", "OverallProgress", "QueuePosition": + dict[key] = Int(value) + case "Operation": + dict[key] = RestoreOperation(value) + default: + dict[key] = value + } + + } + + output.append(dict as CFDictionary) + } + + return output +} diff --git a/VirtualInstallation/Source/Backend/VIWaitForDevice.h b/VirtualInstallation/Source/Backend/VIWaitForDevice.h new file mode 100644 index 00000000..bf27988e --- /dev/null +++ b/VirtualInstallation/Source/Backend/VIWaitForDevice.h @@ -0,0 +1,20 @@ +#pragma once + +#import + +#import + +/// Waits for the specified device to be connected. +/// - Parameters: +/// - ecid: The ECID of the device to wait for. +/// - state: The state the device needs to be in. Specify `kAMRestorableDeviceStateUnknown` if you don't care about the state. +/// - timeoutInMilliseconds: The maximum amount of time in milliseconds to wait for the device. +/// +/// This blocks the current queue and waits for a restorable device with the specified ECID and state to show up. +/// If state is set to `kAMRestorableDeviceStateUnknown`, this returns as soon as the device shows up, in any state. +/// +/// Returns the device once found in the specified state. +/// If more than `timeoutInMilliseconds` elapses and the device is not found in the specified state, returns `nil`. +/// +/// - note: Because this function blocks until the device is found or it times out, you should not call it from the main queue. +AMRestorableDeviceRef _Nullable VIWaitForDeviceWithECID(uint64_t ecid, AMRestorableDeviceState state, int timeoutInMilliseconds); diff --git a/VirtualInstallation/Source/Backend/VIWaitForDevice.m b/VirtualInstallation/Source/Backend/VIWaitForDevice.m new file mode 100644 index 00000000..0ad4fcdd --- /dev/null +++ b/VirtualInstallation/Source/Backend/VIWaitForDevice.m @@ -0,0 +1,116 @@ +#import +#import + +NSString *RUCopyRestorableDeviceStateStringFromState(AMRestorableDeviceState state); + +@import os.log; + +typedef void(^VIWaitForDeviceBlock)(AMRestorableDeviceRef device, AMRestorableClientID clientID); + +typedef struct { + VIWaitForDeviceBlock callback; + AMRestorableClientID clientID; + dispatch_queue_t queue; +} VIWaitForDeviceContext; + +void __VIWaitForDeviceEventCallback(AMRestorableDeviceRef device, AMRestorableDeviceEvent event, void *context); +void __VIInvalidateContext(VIWaitForDeviceContext context); + +AMRestorableDeviceRef _Nullable VIWaitForDeviceWithECID(uint64_t ecid, AMRestorableDeviceState state, int timeoutInMilliseconds) +{ + os_log_t log = os_log_create(kVirtualInstallationSubsystem.UTF8String, "VIWaitForDevice"); + os_log_debug(log, "⏰ BEGIN wait for device %@", @(ecid)); + + __block AMRestorableDeviceRef outDevice = NULL; + __block BOOL finalized = NO; + + NSString *label = [NSString stringWithFormat:@"WaitForDevice(%@)", @(ecid)]; + dispatch_queue_t queue = dispatch_queue_create(label.UTF8String, dispatch_queue_attr_make_with_qos_class(NULL, QOS_CLASS_USER_INTERACTIVE, 0)); + dispatch_semaphore_t sema = dispatch_semaphore_create(0); + + VIWaitForDeviceBlock callback = ^(AMRestorableDeviceRef device, AMRestorableClientID cid) { + if (finalized) return; + if (AMRestorableDeviceGetECID(device) != ecid) return; + + AMRestorableDeviceState deviceState = AMRestorableDeviceGetState(device); + if (state != kAMRestorableDeviceStateUnknown && deviceState != state) { + os_log_info(log, "Found device %@, but its state is %@ instead of %@. Keep waiting...", @(ecid), RUCopyRestorableDeviceStateStringFromState(deviceState), RUCopyRestorableDeviceStateStringFromState(state)); + return; + } + + finalized = YES; + + os_log_info(log, "Found target device %@ with state %@", @(ecid), RUCopyRestorableDeviceStateStringFromState(deviceState)); + + outDevice = device; + + dispatch_async(queue, ^{ + dispatch_semaphore_signal(sema); + }); + }; + + __block VIWaitForDeviceContext context = { + callback, + kAMRestorableInvalidClientID, + queue + }; + + dispatch_async(queue, ^{ + CFErrorRef error; + context.clientID = AMRestorableDeviceRegisterForNotifications(__VIWaitForDeviceEventCallback, (void *)&context, &error); + + if (context.clientID == kAMRestorableInvalidClientID) { + os_log_fault(log, "Error registering for restorable device notifications. %{public}@", error); + dispatch_semaphore_signal(sema); + } + }); + + intptr_t result = dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeoutInMilliseconds * NSEC_PER_MSEC))); + + if (result == 0) { + os_log_debug(log, "⏰ END wait for device %@ with %@", @(ecid), [NSString stringWithFormat:@"%@", outDevice]); + } else { + os_log_error(log, "⏰ END wait for device %@: timed out after %dms", @(ecid), timeoutInMilliseconds); + } + + dispatch_async(queue, ^{ + __VIInvalidateContext(context); + }); + + return outDevice; +} + +void __VIWaitForDeviceEventCallback(AMRestorableDeviceRef device, AMRestorableDeviceEvent event, void *context) +{ + if (!context) return; + + if (event == AMRestorableDeviceEventFound) { + VIWaitForDeviceContext *deviceContext = (VIWaitForDeviceContext *)context; + + assert(deviceContext != NULL); + if (!deviceContext) return; + + deviceContext->callback(device, deviceContext->clientID); + } +} + +void __VIInvalidateContext(VIWaitForDeviceContext context) +{ + if (context.clientID == kAMRestorableInvalidClientID) return; + + dispatch_async(context.queue, ^{ + AMRestorableDeviceUnregisterForNotifications(context.clientID); + }); +} + +NSString *RUCopyRestorableDeviceStateStringFromState(AMRestorableDeviceState state) +{ + switch(state) { + case kAMRestorableDeviceStateUnknown: return @"Unknown"; + case kAMRestorableDeviceStateDFU: return @"DFU"; + case kAMRestorableDeviceStateRecovery: return @"Recovery"; + case kAMRestorableDeviceStateRestoreOS: return @"RestoreOS"; + case kAMRestorableDeviceStateBootedOS: return @"BootedOS"; + default: return [NSString stringWithFormat:@"Unexpected state %@", @(state)]; + } +} diff --git a/VirtualInstallation/Source/Constants.h b/VirtualInstallation/Source/Constants.h new file mode 100644 index 00000000..d489b7f9 --- /dev/null +++ b/VirtualInstallation/Source/Constants.h @@ -0,0 +1,17 @@ +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +extern NSString * const kVirtualInstallationSubsystem; + +extern NSString * const kVirtualInstallationServiceName; + +extern NSString * const kVirtualInstallationProjectVersionForCodeSigningRequirements; +extern NSString * const kVirtualInstallationTeamIDForCodeSigningRequirements; +extern NSString * const kVirtualBuddyBundleID; + +extern NSString * const kVirtualInstallationUnifiedLogPredicate; + +NS_ASSUME_NONNULL_END diff --git a/VirtualInstallation/Source/Constants.m b/VirtualInstallation/Source/Constants.m new file mode 100644 index 00000000..0f9d47fb --- /dev/null +++ b/VirtualInstallation/Source/Constants.m @@ -0,0 +1,11 @@ +#import + +NSString * const kVirtualInstallationSubsystem = @"codes.rambo.VirtualInstallation"; + +NSString * const kVirtualInstallationServiceName = VI_SERVICE_BUNDLE_IDENTIFIER; + +NSString * const kVirtualInstallationProjectVersionForCodeSigningRequirements = CURRENT_PROJECT_VERSION_FOR_CODE_REQUIREMENTS; +NSString * const kVirtualInstallationTeamIDForCodeSigningRequirements = TEAM_ID_FOR_CODE_REQUIREMENTS; +NSString * const kVirtualBuddyBundleID = VIRTUALBUDDY_BUNDLE_ID; + +NSString * const kVirtualInstallationUnifiedLogPredicate = @"senderImagePath contains 'VirtualInstallation' OR (process contains 'VirtualInstallationService' AND senderImagePath contains 'MobileDevice')"; diff --git a/VirtualInstallation/Source/Helpers.swift b/VirtualInstallation/Source/Helpers.swift new file mode 100644 index 00000000..318759f3 --- /dev/null +++ b/VirtualInstallation/Source/Helpers.swift @@ -0,0 +1,88 @@ +import Foundation + +extension URL { + var safePath: String { absoluteURL.path(percentEncoded: false) } + + var exists: Bool { FileManager.default.fileExists(atPath: path) } + + var isExistingDirectory: Bool { + var isDir = ObjCBool(false) + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { return false } + return isDir.boolValue + } + + func ensureExistingDirectory(createIfNeeded: Bool = false) throws -> URL { + if !exists { + guard createIfNeeded else { + throw CocoaError(.fileNoSuchFile) + } + + try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true) + } + try requireExistingDirectory() + return self + } + + func requireExistingDirectory() throws { + guard exists else { + throw CocoaError(.fileNoSuchFile) + } + guard isExistingDirectory else { + throw CocoaError(.fileReadInvalidFileName) + } + } + + static var viApplicationSupportURL: URL { + do { + let url: URL + + url = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appending(path: Bundle.main.bundleURL.deletingPathExtension().lastPathComponent, directoryHint: .isDirectory) + + return try url.ensureExistingDirectory(createIfNeeded: true) + } catch { + assertionFailure("Failed to create application support directory: \(error)") + return URL(filePath: NSTemporaryDirectory()) + } + } +} + +extension PropertyListEncoder { + static let xpc: PropertyListEncoder = { + let e = PropertyListEncoder() + e.outputFormat = .binary + return e + }() +} + +extension PropertyListDecoder { + static let xpc = PropertyListDecoder() +} + +private final class _VILookupClass { } +extension Bundle { + static let virtualInstallation = Bundle(for: _VILookupClass.self) +} + +extension ProcessInfo { + #if DEBUG + /// When `VI_TEST_MODE` is set to `1` in the environment, installation uses the test backend instead of attempting to restore a real device. + static let virtualInstallationTestModeEnabled: Bool = processInfo.environment["VI_TEST_MODE"] == "1" + #else + /// Test mode always disabled in release builds. + static let virtualInstallationTestModeEnabled = false + #endif +} + +extension AMRestorableDeviceState: CustomStringConvertible { + public var description: String { + switch self { + case .unknown: return "Unknown" + case .DFU: return "DFU" + case .recovery: return "Recovery" + case .restoreOS: return "RestoreOS" + case .bootedOS: return "BootedOS" + @unknown default: return "Unknown(\(rawValue))" + } + } +} diff --git a/VirtualInstallation/Source/SPI/MobileDevice.tbd b/VirtualInstallation/Source/SPI/MobileDevice.tbd new file mode 100644 index 00000000..7b9482dd --- /dev/null +++ b/VirtualInstallation/Source/SPI/MobileDevice.tbd @@ -0,0 +1,19 @@ +--- !tapi-tbd +tbd-version: 4 +targets: [ arm64-macos, arm64e-macos, x86_64-macos ] +install-name: '/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice' +current-version: 1857.0 +exports: + - targets: [ arm64-macos, arm64e-macos, x86_64-macos ] + symbols: [ '_AMRestorableDeviceRegisterForNotifications', + '_AMRestorableDeviceUnregisterForNotifications', + '_kAMRestorableInvalidClientID', + '_AMRestorableDeviceGetDeviceClass', + '_AMRestorableDeviceGetDeviceClass', + '_AMRestorableDeviceGetECID', + '_AMRestorableDeviceGetState', + '_AMRestorableSetGlobalLogFileURL', + '_AMRestorableDeviceSetLogFileURL', + '_AMRestorableDeviceRestore', + '_AMRLocalizedCopyStringForAMROperation' ] +... diff --git a/VirtualInstallation/Source/SPI/MobileDeviceEnums.h b/VirtualInstallation/Source/SPI/MobileDeviceEnums.h new file mode 100644 index 00000000..5675cf5b --- /dev/null +++ b/VirtualInstallation/Source/SPI/MobileDeviceEnums.h @@ -0,0 +1,38 @@ +#pragma once + +#import + +typedef NS_ENUM(int, AMRestorableDeviceEvent) { + AMRestorableDeviceEventFound, + AMRestorableDeviceEventLost +}; + +typedef NS_ENUM(int, AMRestorableDeviceState) { + kAMRestorableDeviceStateUnknown, + kAMRestorableDeviceStateDFU, + kAMRestorableDeviceStateRecovery, + kAMRestorableDeviceStateRestoreOS, + kAMRestorableDeviceStateBootedOS +}; + +typedef NS_ENUM(int, AMRestorableDeviceFusing) { + AMRestorableDeviceFusingUnknown, + AMRestorableDeviceFusingDevelopment, + AMRestorableDeviceFusingProduction, + AMRestorableDeviceFusingInsecure, +}; + +typedef NS_ENUM(uint, AMRestorableDeviceClass) { + AMRestorableDeviceClassUnknown = 0, + AMRestorableDeviceClassiPhone = 1 << 0, + AMRestorableDeviceClassiPad = 1 << 1, + AMRestorableDeviceClassWatch = 1 << 2, + AMRestorableDeviceClassTV = 1 << 3, + AMRestorableDeviceClassBridge = 1 << 4, + AMRestorableDeviceClassAudioAccessory = 1 << 5, + AMRestorableDeviceClassiPod = 1 << 6, + AMRestorableDeviceClassMac = 1 << 7, + AMRestorableDeviceClassDarwin = 1 << 8, + AMRestorableDeviceClassVision = 1 << 9, + AMRestorableDeviceClassComputeModule = 1 << 10, +}; diff --git a/VirtualInstallation/Source/SPI/MobileDeviceHelper.h b/VirtualInstallation/Source/SPI/MobileDeviceHelper.h new file mode 100644 index 00000000..c1886fb8 --- /dev/null +++ b/VirtualInstallation/Source/SPI/MobileDeviceHelper.h @@ -0,0 +1,9 @@ +#pragma once + +#import + +@interface MobileDeviceHelper : NSObject + ++ (BOOL)verifyMobileDeviceSoftLink; + +@end diff --git a/VirtualInstallation/Source/SPI/MobileDeviceHelper.m b/VirtualInstallation/Source/SPI/MobileDeviceHelper.m new file mode 100644 index 00000000..cd77ac45 --- /dev/null +++ b/VirtualInstallation/Source/SPI/MobileDeviceHelper.m @@ -0,0 +1,40 @@ +#import "MobileDeviceHelper.h" + +#import + +#define ShouldTestMobileDeviceFailure [[NSUserDefaults standardUserDefaults] boolForKey:@"VITestMobileDeviceFailure"] + +#define CheckMobileDeviceSymbol( sym ) \ + if (sym == NULL) { \ + if (!ShouldTestMobileDeviceFailure) NSAssert(NO, @"MobileDevice symbol not available: " #sym); \ + _result = NO; \ + return; \ + } + +@implementation MobileDeviceHelper + ++ (BOOL)verifyMobileDeviceSoftLink +{ + static dispatch_once_t onceToken; + static BOOL _result; + dispatch_once(&onceToken, ^{ + if (ShouldTestMobileDeviceFailure) CheckMobileDeviceSymbol(NULL); + + CheckMobileDeviceSymbol(AMRestorableDeviceRegisterForNotifications); + CheckMobileDeviceSymbol(AMRestorableDeviceUnregisterForNotifications); + CheckMobileDeviceSymbol(&kAMRestorableInvalidClientID); + CheckMobileDeviceSymbol(AMRestorableDeviceGetDeviceClass); + CheckMobileDeviceSymbol(AMRestorableDeviceGetDeviceClass); + CheckMobileDeviceSymbol(AMRestorableDeviceGetECID); + CheckMobileDeviceSymbol(AMRestorableDeviceGetState); + CheckMobileDeviceSymbol(AMRestorableSetGlobalLogFileURL); + CheckMobileDeviceSymbol(AMRestorableDeviceSetLogFileURL); + CheckMobileDeviceSymbol(AMRestorableDeviceRestore); + CheckMobileDeviceSymbol(AMRLocalizedCopyStringForAMROperation); + + _result = YES; + }); + return _result; +} + +@end diff --git a/VirtualInstallation/Source/SPI/MobileDeviceSPI.h b/VirtualInstallation/Source/SPI/MobileDeviceSPI.h new file mode 100644 index 00000000..d550735a --- /dev/null +++ b/VirtualInstallation/Source/SPI/MobileDeviceSPI.h @@ -0,0 +1,32 @@ +#pragma once + +#import +#import + +#pragma mark Types + +typedef int AMDError; + +typedef struct __AMRestorableDevice *AMRestorableDeviceRef; + +typedef int AMRestorableClientID; +extern const AMRestorableClientID kAMRestorableInvalidClientID WEAK_IMPORT_ATTRIBUTE; + +typedef void (*AMRestorableDeviceNotificationCallback)(AMRestorableDeviceRef _Nonnull device, AMRestorableDeviceEvent event, void *_Nullable context); +typedef void (*AMRestorableDeviceProgressCallback)(AMRestorableDeviceRef _Nonnull device, CFDictionaryRef _Nonnull info, void *_Nullable context); + +#pragma mark - Functions + +extern AMRestorableClientID AMRestorableDeviceRegisterForNotifications(AMRestorableDeviceNotificationCallback _Nonnull callback, + void *_Nullable context, + CFErrorRef _Nullable *_Nonnull error) WEAK_IMPORT_ATTRIBUTE; +extern bool AMRestorableDeviceUnregisterForNotifications(AMRestorableClientID clientID) WEAK_IMPORT_ATTRIBUTE; + +extern AMRestorableDeviceClass AMRestorableDeviceGetDeviceClass(AMRestorableDeviceRef _Nonnull device) WEAK_IMPORT_ATTRIBUTE; +extern uint64_t AMRestorableDeviceGetECID(AMRestorableDeviceRef _Nonnull device) WEAK_IMPORT_ATTRIBUTE; +extern AMRestorableDeviceState AMRestorableDeviceGetState(AMRestorableDeviceRef _Nonnull device) WEAK_IMPORT_ATTRIBUTE; + +extern BOOL AMRestorableSetGlobalLogFileURL(CFURLRef _Nonnull url) WEAK_IMPORT_ATTRIBUTE; +extern BOOL AMRestorableDeviceSetLogFileURL(AMRestorableDeviceRef _Nonnull device, CFURLRef _Nonnull url, CFStringRef _Nonnull type) WEAK_IMPORT_ATTRIBUTE; +extern void AMRestorableDeviceRestore(AMRestorableDeviceRef _Nonnull device, CFDictionaryRef _Nonnull options, AMRestorableDeviceProgressCallback _Nonnull callback, void *_Nullable refCon) WEAK_IMPORT_ATTRIBUTE; +extern CFStringRef _Nonnull AMRLocalizedCopyStringForAMROperation(int operation) CF_RETURNS_RETAINED WEAK_IMPORT_ATTRIBUTE; diff --git a/VirtualInstallation/Source/SPI/XPCSPI.h b/VirtualInstallation/Source/SPI/XPCSPI.h new file mode 100644 index 00000000..36b58fdc --- /dev/null +++ b/VirtualInstallation/Source/SPI/XPCSPI.h @@ -0,0 +1,14 @@ +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSXPCConnection (SPIHelper) + ++ (BOOL)__vi_safeSetInstanceUUID:(NSUUID *)uuid onConnection:(NSXPCConnection *)connection error:(NSError **)outError; +- (BOOL)__vi_safeSetInstanceUUID:(NSUUID *)uuid error:(NSError **)outError; + +@end + +NS_ASSUME_NONNULL_END diff --git a/VirtualInstallation/Source/SPI/XPCSPI.m b/VirtualInstallation/Source/SPI/XPCSPI.m new file mode 100644 index 00000000..c0fd4149 --- /dev/null +++ b/VirtualInstallation/Source/SPI/XPCSPI.m @@ -0,0 +1,70 @@ +#import +#import + +@interface NSXPCConnection (Private) + +@property (readonly) xpc_connection_t _xpcConnection; + +@end + +extern void xpc_connection_set_instance(xpc_connection_t connection, uuid_t instance) WEAK_IMPORT_ATTRIBUTE; + +@implementation NSXPCConnection (SPIHelper) + ++ (BOOL)__vi_has_xpc_connection_set_instance +{ + static dispatch_once_t onceToken; + static BOOL _has; + dispatch_once(&onceToken, ^{ + _has = xpc_connection_set_instance != NULL; + }); + return _has; +} + ++ (BOOL)__vi_has_NSXPCConnection_xpcConnection_property +{ + static dispatch_once_t onceToken; + static BOOL _has; + dispatch_once(&onceToken, ^{ + _has = [NSXPCConnection instancesRespondToSelector:@selector(_xpcConnection)]; + }); + return _has; +} + ++ (BOOL)__vi_safeSetInstanceUUID:(NSUUID *)uuid onConnection:(NSXPCConnection *)connection error:(NSError **)outError +{ + if (![NSXPCConnection __vi_has_xpc_connection_set_instance]) { + if (outError) *outError = [NSError errorWithDomain:kVirtualInstallationSubsystem + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"Current system is missing xpc_connection_set_instance function"}]; + return NO; + } + if (![NSXPCConnection __vi_has_NSXPCConnection_xpcConnection_property]) { + if (outError) *outError = [NSError errorWithDomain:kVirtualInstallationSubsystem + code:2 + userInfo:@{NSLocalizedDescriptionKey: @"Current system is missing -[NSXPCConnection _xpcConnection]"}]; + return NO; + } + + xpc_connection_t conn = connection._xpcConnection; + if (conn == NULL) { + if (outError) *outError = [NSError errorWithDomain:kVirtualInstallationSubsystem + code:3 + userInfo:@{NSLocalizedDescriptionKey: @"NULL xpc_connection_t"}]; + return NO; + } + + uuid_t uuidBytes; + [uuid getUUIDBytes:uuidBytes]; + + xpc_connection_set_instance(conn, uuidBytes); + + return YES; +} + +- (BOOL)__vi_safeSetInstanceUUID:(NSUUID *)uuid error:(NSError **)outError +{ + return [NSXPCConnection __vi_safeSetInstanceUUID:uuid onConnection:self error:outError]; +} + +@end diff --git a/VirtualInstallation/Source/VIVirtualMachineInstaller.swift b/VirtualInstallation/Source/VIVirtualMachineInstaller.swift new file mode 100644 index 00000000..ef7e8a38 --- /dev/null +++ b/VirtualInstallation/Source/VIVirtualMachineInstaller.swift @@ -0,0 +1,65 @@ +import Foundation +import Virtualization +import os + +public final class VIVirtualMachineInstaller: @unchecked Sendable { + public let ecid: ECID + public let bundleURL: URL + private let client: VirtualInstallationClient + public let progress: Progress + private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VIVirtualMachineInstaller.self)) + + public init(ecid: ECID, bundleURL: URL) { + self.ecid = ecid + self.bundleURL = bundleURL + self.client = VirtualInstallationClient() + self.progress = Progress() + progress.totalUnitCount = 100 + } + + public func install() async throws { + defer { logger.debug("\(#function, privacy: .public) is returning now") } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) -> () in + Task { + for await event in client.eventPublisher.values { + switch event { + case .connectionFailed(let failure): + continuation.resume(throwing: failure) + case .stateChanged(let state): + await updateProgress(with: state) + + if let outcome = state.outcome { + logger.info("Received state update with outcome: \(String(describing: outcome), privacy: .public)") + + switch outcome { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error ?? CocoaError(.coderValueNotFound)) + } + } + } + } + } + + client.startVirtualMachineInstallation(ecid: ecid, restoreBundleURL: bundleURL) { error in + if let error { + continuation.resume(throwing: error) + } + } + } + } + + @MainActor private func updateProgress(with state: DeviceRestoreState) { + if let status = state.status { + progress.localizedDescription = status + } + if let operationName = state.operationName { + progress.localizedAdditionalDescription = operationName + } + if let overallProgressPercent = state.overallProgress { + progress.completedUnitCount = Int64(overallProgressPercent * 100) + } + } +} diff --git a/VirtualInstallation/Source/VirtualInstallation.swift b/VirtualInstallation/Source/VirtualInstallation.swift new file mode 100644 index 00000000..1e67a450 --- /dev/null +++ b/VirtualInstallation/Source/VirtualInstallation.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct VirtualInstallation { + /// Returns `true` if VirtualInstallation is available in the current system. + static let isAvailable = MobileDeviceHelper.verifyMobileDeviceSoftLink() +} diff --git a/VirtualInstallation/Source/XPC/Protocols.swift b/VirtualInstallation/Source/XPC/Protocols.swift new file mode 100644 index 00000000..202d1af6 --- /dev/null +++ b/VirtualInstallation/Source/XPC/Protocols.swift @@ -0,0 +1,12 @@ +import Foundation + +@objc(VirtualInstallationServiceProtocol) +public protocol VirtualInstallationServiceProtocol { + func startVirtualMachineInstallation(ecid: ECID, restoreBundleURL: URL, reply: @escaping @Sendable (_ error: Error?) -> ()) + func cancelVirtualMachineInstallation(ecid: ECID, reply: @escaping @Sendable (_ error: Error?) -> ()) +} + +@objc(VirtualInstallationClientProtocol) +public protocol VirtualInstallationClientProtocol { + func virtualMachineInstallationStateChanged(state: Data) +} diff --git a/VirtualInstallation/Source/XPC/Security.swift b/VirtualInstallation/Source/XPC/Security.swift new file mode 100644 index 00000000..051bf99b --- /dev/null +++ b/VirtualInstallation/Source/XPC/Security.swift @@ -0,0 +1,39 @@ +import Foundation +import OSLog + +public extension NSXPCConnection { + static let virtualInstallationClientAllowList: [String] = [ + kVirtualInstallationServiceName, + kVirtualBuddyBundleID, + "codes.rambo.experiment.VMRestoreServicePrototype", + ] + + func setVirtualInstallationCodeSigningRequirement() { + /// Disable XPC code signing checks for non-managed releases so open-source contributors don't have to modify configuration files. + #if !BUILDING_NON_MANAGED_RELEASE + setCodeSigningRequirement(NSXPCConnection.virtualInstallationCodeSigningRequirementString) + #endif + } +} + +private extension NSXPCConnection { + static let virtualInstallationCodeSigningRequirementString: String = { + let bundleIdentifierClause = virtualInstallationClientAllowList.map { + """ + info["CFBundleIdentifier"] = "\($0)" + """ + }.joined(separator: " or ") + + let requirement = String(format: """ + anchor apple generic \ + and certificate leaf[subject.OU] = "%@" \ + and info["CFBundleVersion"] >= "%@" \ + and (%@) + """, kVirtualInstallationTeamIDForCodeSigningRequirements, kVirtualInstallationProjectVersionForCodeSigningRequirements, bundleIdentifierClause) + + Logger(subsystem: kVirtualInstallationSubsystem, category: "Security") + .debug("XPC code signing requirement: \(requirement)") + + return requirement + }() +} diff --git a/VirtualInstallation/Source/XPC/Types.swift b/VirtualInstallation/Source/XPC/Types.swift new file mode 100644 index 00000000..d8690dae --- /dev/null +++ b/VirtualInstallation/Source/XPC/Types.swift @@ -0,0 +1,118 @@ +import Foundation +import OSLog + +public typealias ECID = UInt64 + +public typealias RestoreOptionsDictionary = [String : AnyHashable] + +public typealias RestoreOperation = Int32 + +/// A type that can be used to wrap any `Error` as a `Codable` and `Hashable` container that can be stored as part of another type. +public nonisolated struct CodableError: LocalizedError, CustomNSError, Codable, Hashable, Sendable { + public private(set) var domain: String + public private(set) var code: Int + public private(set) var errorDescription: String + public private(set) var failureReason: String? + public private(set) var helpAnchor: String? + public private(set) var recoverySuggestion: String? + public private(set) var info: [String: String] +} + +public nonisolated extension CodableError { + init(_ error: any Error) { + let nsError = error as NSError + self.domain = nsError.domain + self.code = nsError.code + self.errorDescription = nsError.localizedDescription + self.failureReason = nsError.localizedFailureReason + self.helpAnchor = nsError.helpAnchor + self.recoverySuggestion = nsError.localizedRecoverySuggestion + self.info = [:] + + for (key, value) in nsError.userInfo { + self.info[key] = String(describing: value) + } + } + + init(message: String) { + self.domain = kVirtualInstallationSubsystem + self.code = 0 + self.errorDescription = message + self.info = [NSLocalizedFailureReasonErrorKey : message] + } +} + + +public enum DeviceRestoreOutcome: Hashable, Codable, Sendable { + case success + case failure(_ error: CodableError?) + + public var isFailure: Bool { + if case .failure = self { + true + } else { + false + } + } +} + +public struct DeviceRestoreState: Hashable, Codable, Sendable { + public let progress: Double + public let overallProgress: Double? + public let operation: RestoreOperation + public let operationName: String? + public let status: String? + public private(set) var outcome: DeviceRestoreOutcome? +} + +// MARK: - AMD Serialization + +private extension DeviceRestoreOutcome { + init?(info: [String: Any], status: String) { + if status.caseInsensitiveCompare("Successful") == .orderedSame { + self = .success + } else if status.caseInsensitiveCompare("Failed") == .orderedSame { + self = .failure((info["Error"] as? NSError).flatMap(CodableError.init)) + } else { + return nil + } + } +} + +extension DeviceRestoreState { + static let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: DeviceRestoreState.self)) + + init(info: CFDictionary) throws(CodableError) { + guard let dict = info as? [String: Any] else { + throw CodableError(message: "Info dictionary in progress report doesn't match expected dictionary type") + } + + let intProgress = dict["Progress"] as? Int ?? 0 + let intOverallProgress = dict["OverallProgress"] as? Int ?? 0 + self.status = dict["Status"] as? String + self.progress = Double(intProgress) / 100.0 + self.overallProgress = intOverallProgress <= 0 ? nil : Double(intOverallProgress) / 100.0 + self.operation = dict["Operation"] as? RestoreOperation ?? 0 + + let operationNameFormat = AMRLocalizedCopyStringForAMROperation(operation) as String + + /// When there's a `QueuePosition`, the string is a format string. + if let queuePosition = dict["QueuePosition"] as? Int, operationNameFormat.contains("%d") { + self.operationName = String(format: operationNameFormat, queuePosition) + } else { + self.operationName = operationNameFormat + } + + if let status, status != "Restoring" { + self.outcome = DeviceRestoreOutcome(info: dict, status: status) + } else { + self.outcome = nil + } + } + + func replacingOutcome(with error: NSError) -> Self { + var mSelf = self + mSelf.outcome = .failure(CodableError(error)) + return mSelf + } +} diff --git a/VirtualInstallation/Source/XPC/VirtualInstallationClient.swift b/VirtualInstallation/Source/XPC/VirtualInstallationClient.swift new file mode 100644 index 00000000..8e86aa62 --- /dev/null +++ b/VirtualInstallation/Source/XPC/VirtualInstallationClient.swift @@ -0,0 +1,167 @@ +import Foundation +import Combine +import os + +@objc(VirtualInstallationClient) +final class VirtualInstallationClient: NSObject, VirtualInstallationClientProtocol, @unchecked Sendable { + private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VirtualInstallationClient.self)) + + enum Failure: Error, Sendable { + case connectionInvalidated + case connectionInterrupted + case invalidService + case serialization + case service(_ error: Error) + } + + enum Event: Sendable { + case stateChanged(_ state: DeviceRestoreState) + case connectionFailed(_ error: Failure) + + var isStateChanged: Bool { + if case .stateChanged = self { + true + } else { + false + } + } + } + + private let _invalidated = OSAllocatedUnfairLock(initialState: false) + private var invalidated: Bool { + get { _invalidated.withLock { $0 } } + set { _invalidated.withLock { $0 = newValue } } + } + + private let id = UUID() + + private let eventSubject = PassthroughSubject() + var eventPublisher: AnyPublisher { eventSubject.eraseToAnyPublisher() } + + // MARK: - Lifecycle + + private let _connectionLock = OSAllocatedUnfairLock(uncheckedState: nil) + private var _connection: NSXPCConnection? { + get { _connectionLock.withLockUnchecked { $0 } } + set { _connectionLock.withLockUnchecked { $0 = newValue } } + } + + private func createConnection() throws -> NSXPCConnection { + let connection = NSXPCConnection(serviceName: kVirtualInstallationServiceName) + + connection.remoteObjectInterface = NSXPCInterface(with: VirtualInstallationServiceProtocol.self) + connection.exportedInterface = NSXPCInterface(with: VirtualInstallationClientProtocol.self) + connection.exportedObject = self + + connection.invalidationHandler = { [weak self] in + self?.send(.connectionFailed(.connectionInvalidated)) + self?.invalidated = true + } + connection.interruptionHandler = { [weak self] in + self?.send(.connectionFailed(.connectionInterrupted)) + self?.invalidated = true + } + + connection.setVirtualInstallationCodeSigningRequirement() + + try connection.__vi_safeSetInstanceUUID(id) + + return connection + } + + private func withService(perform block: @escaping (Result) -> ()) { + let connection: NSXPCConnection + if let _connection { + connection = _connection + } else { + do { + connection = try createConnection() + _connection = connection + + connection.activate() + } catch { + block(.failure(.service(error))) + return + } + } + + guard let service = connection.remoteObjectProxy as? VirtualInstallationServiceProtocol else { + block(.failure(.invalidService)) + return + } + + block(.success(service)) + } + + private func send(_ event: Event) { + DispatchQueue.main.async { [self] in + /// Allow state changed events to go through even when invalidated, as we must report + /// a final state event for the installer to report its completion and it may occur shortly after + /// the service instance has already been invalidated. + guard event.isStateChanged || !invalidated else { return } + eventSubject.send(event) + } + } + + // MARK: - Client -> Server + + func startVirtualMachineInstallation(ecid: ECID, restoreBundleURL: URL, completion: @escaping @Sendable (_ error: Error?) -> ()) { + logger.debug("Start for ECID \(ecid), bundle \(restoreBundleURL.safePath)") + + withService { [weak self] result in + do { + let service = try result.get() + + service.startVirtualMachineInstallation(ecid: ecid, restoreBundleURL: restoreBundleURL) { [weak self] error in + if let error { + self?.logger.error("Received startVirtualMachineInstallation reply with error: \(error, privacy: .public)") + } else { + self?.logger.notice("Received startVirtualMachineInstallation reply") + } + + completion(error) + } + } catch { + completion(error) + } + } + } + + func cancelVirtualMachineInstallation(ecid: ECID, completion: @escaping @Sendable (_ error: Error?) -> ()) { + logger.debug("Cancel for ECID \(ecid)") + + invalidated = true + + withService { [weak self] result in + do { + let service = try result.get() + + service.cancelVirtualMachineInstallation(ecid: ecid) { [weak self] error in + if let error { + self?.logger.error("Received cancelVirtualMachineInstallation reply with error: \(error, privacy: .public)") + } else { + self?.logger.notice("Received cancelVirtualMachineInstallation reply") + } + + completion(error) + } + } catch { + completion(error) + } + } + } + + // MARK: - Server -> Client + + func virtualMachineInstallationStateChanged(state: Data) { + do { + let decodedState = try PropertyListDecoder.xpc.decode(DeviceRestoreState.self, from: state) + + send(.stateChanged(decodedState)) + } catch { + logger.fault("Error decoding state update payload: \(error, privacy: .public)") + + send(.connectionFailed(.serialization)) + } + } +} diff --git a/VirtualInstallation/Source/XPC/VirtualInstallationService.swift b/VirtualInstallation/Source/XPC/VirtualInstallationService.swift new file mode 100644 index 00000000..0beb6df3 --- /dev/null +++ b/VirtualInstallation/Source/XPC/VirtualInstallationService.swift @@ -0,0 +1,143 @@ +import Foundation +import os + +@objc(VirtualInstallationService) +@_spi(VirtualInstallationService) public final class VirtualInstallationService: NSObject, VirtualInstallationServiceProtocol, @unchecked Sendable { + private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VirtualInstallationService.self)) + + weak let clientConnection: NSXPCConnection! + + public init(clientConnection: NSXPCConnection) { + self.clientConnection = clientConnection + + super.init() + } + + private let backend: any DeviceRestoreBackend = { + if ProcessInfo.virtualInstallationTestModeEnabled { + TestDeviceRestoreBackend() + } else { + AppleMobileDeviceRestoreBackend() + } + }() + + private let _driver = OSAllocatedUnfairLock(initialState: nil) + private var driver: DeviceRestoreDriver? { + get { _driver.withLock { $0 } } + set { _driver.withLock { $0 = newValue } } + } + + private let _cancelled = OSAllocatedUnfairLock(initialState: false) + private var cancelled: Bool { + get { _cancelled.withLock { $0 } } + set { _cancelled.withLock { $0 = newValue } } + } + + // MARK: - Service -> Client + + private func send(_ state: DeviceRestoreState) { + logger.debug("Sending state update to client: \(String(describing: state))") + + let proxy = clientConnection.remoteObjectProxyWithErrorHandler { [weak self] error in + self?.logger.fault("Remote object proxy error: \(error, privacy: .public)") + } + guard let client = proxy as? VirtualInstallationClientProtocol else { + logger.fault("Client object proxy is of unexpected type") + return + } + + do { + let payload = try PropertyListEncoder.xpc.encode(state) + + client.virtualMachineInstallationStateChanged(state: payload) + } catch { + logger.fault("Error encoding state update payload: \(error, privacy: .public)") + } + } + + // MARK: - Client -> Service + + public func startVirtualMachineInstallation(ecid: ECID, restoreBundleURL: URL, reply: @escaping @Sendable ((any Error)?) -> ()) { + logger.notice("Installation requested for ECID \(ecid), bundle \(restoreBundleURL.safePath)") + + do { + guard !cancelled else { + throw NSError.viInstallationCancelled + } + guard driver == nil else { + throw NSError.viInstallationConflict + } + + do { + let newDriver = try DeviceRestoreDriver(ecid: ecid, bundleURL: restoreBundleURL, backend: backend) + + self.driver = newDriver + + try newDriver.start { [weak self] state in + guard let self else { return } + + send(state) + + if state.outcome != nil { + terminate(reason: "Received final state update") + } + } + } catch { + throw NSError.viFailedToStart(error) + } + } catch { + logger.error("Start installation error: \(error, privacy: .public)") + + reply(error) + } + } + + public func cancelVirtualMachineInstallation(ecid: ECID, reply: @escaping @Sendable ((any Error)?) -> ()) { + logger.notice("Cancellation requested for ECID \(ecid)") + + do { + guard !cancelled else { + throw NSError.viInstallationCancelled + } + guard driver != nil else { + throw NSError.viInstallationNotStarted + } + + reply(nil) + + terminate(reason: "Cancelled") + } catch { + logger.error("Cancel installation error: \(error, privacy: .public)") + + reply(error) + } + } + + public func terminate(reason: String) { + logger.notice("Terminating for reason: \(reason, privacy: .public)") + + self.driver = nil + self.clientConnection.invalidate() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.logger.notice("Service terminating after cancellation") + exit(0) + } + } +} + +extension NSError { + convenience init(viCode: Int, message: String, underlying: (any Error)? = nil) { + var info: [String : Any] = [NSLocalizedFailureReasonErrorKey : message] + if let underlying { + info[NSUnderlyingErrorKey] = underlying + } + self.init(domain: kVirtualInstallationSubsystem, code: viCode, userInfo: info) + } + + static let viDeviceNotFound = NSError(viCode: 1, message: "A device with the specified ECID could not be found.") + static let viInstallationConflict = NSError(viCode: 2, message: "Attempting to start an installation after it has already started.") + static let viInstallationCancelled = NSError(viCode: 3, message: "Installation was already cancelled.") + static let viInstallationNotStarted = NSError(viCode: 4, message: "Installation was not started.") + static func viFailedToStart(_ error: any Error) -> NSError { NSError(viCode: 5, message: "Failed to start installation.", underlying: error) } +} diff --git a/VirtualInstallation/VirtualInstallation.h b/VirtualInstallation/VirtualInstallation.h new file mode 100644 index 00000000..7e223213 --- /dev/null +++ b/VirtualInstallation/VirtualInstallation.h @@ -0,0 +1,13 @@ +#import + +//! Project version number for VirtualInstallation. +FOUNDATION_EXPORT double VirtualInstallationVersionNumber; + +//! Project version string for VirtualInstallation. +FOUNDATION_EXPORT const unsigned char VirtualInstallationVersionString[]; + +#import +#import +#import +#import +#import diff --git a/VirtualInstallationService/Info.plist b/VirtualInstallationService/Info.plist new file mode 100644 index 00000000..a6505e5d --- /dev/null +++ b/VirtualInstallationService/Info.plist @@ -0,0 +1,17 @@ + + + + + XPCService + + RunLoopType + _NSApplicationMain + ServiceType + Application + _MultipleInstances + + _ProcessType + App + + + diff --git a/VirtualInstallationService/VIServiceApp.swift b/VirtualInstallationService/VIServiceApp.swift new file mode 100644 index 00000000..3535ecaf --- /dev/null +++ b/VirtualInstallationService/VIServiceApp.swift @@ -0,0 +1,54 @@ +import Cocoa +import OSLog +@_spi(VirtualInstallationService) import VirtualInstallation + +final class VIServiceDelegate: NSObject, NSXPCListenerDelegate { + private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VIServiceDelegate.self)) + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + logger.debug("New connection: \(newConnection)") + + let service = VirtualInstallationService(clientConnection: newConnection) + + newConnection.setVirtualInstallationCodeSigningRequirement() + + newConnection.invalidationHandler = { [self, weak service] in + logger.notice("Connection invalidated: \(newConnection)") + service?.terminate(reason: "Connection invalidated") + } + + newConnection.interruptionHandler = { [self, weak service] in + logger.warning("Connection interrupted: \(newConnection)") + service?.terminate(reason: "Connection interrupted") + } + + newConnection.exportedInterface = NSXPCInterface(with: VirtualInstallationServiceProtocol.self) + newConnection.exportedObject = service + + newConnection.remoteObjectInterface = NSXPCInterface(with: VirtualInstallationClientProtocol.self) + + newConnection.activate() + + return true + } + + private lazy var listener = NSXPCListener.service() + + func bootstrap() { + listener.delegate = self + listener.activate() + } +} + +@main +struct VIServiceApp { + private static let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VIServiceApp.self)) + + private static let delegate = VIServiceDelegate() + + static func main() { + logger.log("Service main.") + + delegate.bootstrap() + } +} diff --git a/VirtualUI/Source/Components/LogConsole.swift b/VirtualUI/Source/Components/LogConsole.swift index 85bcd95b..e188df22 100644 --- a/VirtualUI/Source/Components/LogConsole.swift +++ b/VirtualUI/Source/Components/LogConsole.swift @@ -3,11 +3,13 @@ import VirtualCore import UniformTypeIdentifiers struct LogConsole: View { - + + static var padding: CGFloat { 16 } + @StateObject private var streamer: LogStreamer - init(predicate: LogStreamer.Predicate) { - self._streamer = .init(wrappedValue: LogStreamer(predicate: predicate)) + init(predicate: LogStreamer.Predicate, startTime: Date = .now) { + self._streamer = .init(wrappedValue: LogStreamer(predicate: predicate, startTime: startTime)) } init(streamer: LogStreamer) { @@ -15,44 +17,58 @@ struct LogConsole: View { } @State private var searchTerm = "" + @AppStorage("LogConsole.ScrollAutomatically") private var autoscroll = true + + @State private var throttledEvents = [LogEntry]() private var filteredEvents: [LogEntry] { - guard searchTerm.count >= 3 else { return streamer.events } - return streamer.events.filter { + guard searchTerm.count >= 3 else { return throttledEvents } + return throttledEvents.filter { $0.message.localizedCaseInsensitiveContains(searchTerm) } } var body: some View { - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(filteredEvents) { entry in - Text(entry.formattedTime + " ") - .foregroundColor(.secondary) - + Text(entry.message) - .foregroundColor(entry.level.color) + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(filteredEvents) { entry in + Text(entry.formattedTime + " ") + .foregroundColor(.secondary) + + Text(entry.message) + .foregroundColor(entry.level.color) + } + + Color.clear.frame(height: 1).id("BOTTOM") + } + .font(.system(.body).monospaced()) + .textSelection(.enabled) + } + .onChange(of: filteredEvents.count) { + guard autoscroll else { return } + proxy.scrollTo("BOTTOM", anchor: .bottom) + } + .onChange(of: autoscroll) { oldValue, newValue in + guard !oldValue, newValue else { return } + proxy.scrollTo("BOTTOM", anchor: .bottom) + } + .virtualBuddyBottomBar { bottomBar } + .overlay(alignment: .topTrailing) { + Toggle(isOn: $autoscroll) { + Label("Scroll automatically", systemImage: "chevron.up.chevron.down") + .labelStyle(.iconOnly) + .padding(3) } + .toggleStyle(.button) + .airGlassButtonStyle() + .buttonBorderShape(.circle) + .help("Scroll automatically") + .padding([.top, .trailing], Self.padding) } - .font(.system(.body).monospaced()) - .padding(.horizontal) - .padding(.top, 6) - .textSelection(.enabled) } - .safeAreaInset(edge: .top, content: { searchBar }) - .safeAreaInset(edge: .bottom, content: { bottomBar }) .onAppear(perform: streamer.activate) - } - - @ViewBuilder - private var searchBar: some View { - ZStack { - searchField - } - .frame(maxWidth: .infinity) - .padding() - .background(Material.thick, in: Rectangle()) - .overlay(alignment: .bottom) { - Divider() + .onReceive(streamer.$events.throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true)) { events in + throttledEvents = events } } @@ -66,7 +82,7 @@ struct LogConsole: View { if searchTerm == "" { searchFieldFocused = false } searchTerm = "" } - .textFieldStyle(.roundedBorder) + .textFieldStyle(.plain) } private var fullLogText: String { @@ -77,26 +93,35 @@ struct LogConsole: View { @ViewBuilder private var bottomBar: some View { - ZStack { - HStack(spacing: 16) { - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(fullLogText, forType: .string) - } label: { - Text("Copy Text") - } + AirGlassEffectContainer { + HStack { + searchField + .frame(height: 32) + .frame(maxWidth: 240) + .padding(.horizontal, 14) + .airMaterialBackground(visualEffect: .menu, glassEffect: .regular, in: Capsule()) + + Spacer() + + HStack(spacing: 16) { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(fullLogText, forType: .string) + } label: { + Text("Copy Text") + } - Button { - NSSavePanel.run(saving: Data(fullLogText.utf8), as: .logFile) - } label: { - Text("Save to File…") + Button { + NSSavePanel.run(saving: Data(fullLogText.utf8), as: .logFile) + } label: { + Text("Save to File…") + } } + .frame(height: 32) + .padding(.horizontal, 14) + .airMaterialBackground(visualEffect: .menu, glassEffect: .regular, in: Capsule()) } - .padding(.vertical, 8) - .padding(.horizontal, 14) - .controlGroup(Capsule(style: .continuous), level: .secondary) } - .padding() .frame(maxWidth: .infinity, alignment: .bottomTrailing) .controlSize(.small) .buttonStyle(.link) diff --git a/VirtualUI/Source/Components/SelfSizingGroupedForm.swift b/VirtualUI/Source/Components/SelfSizingGroupedForm.swift index 08a213de..2b374070 100644 --- a/VirtualUI/Source/Components/SelfSizingGroupedForm.swift +++ b/VirtualUI/Source/Components/SelfSizingGroupedForm.swift @@ -17,7 +17,7 @@ struct SelfSizingGroupedForm: View { content() } .formStyle(.grouped) - .introspect(.scrollView, on: .macOS(.v13, .v14, .v15, .v26)) { scrollView in + .introspect(.scrollView, on: .macOS(.v13, .v14, .v15, .v26, .v27)) { scrollView in guard !disabled else { return } guard let frame = scrollView.documentView?.frame else { return } guard frame.height != contentHeight else { return } diff --git a/VirtualUI/Source/Installer/Components/InstallationConsole.swift b/VirtualUI/Source/Installer/Components/InstallationConsole.swift index 8bee9a0f..a20b5fbe 100644 --- a/VirtualUI/Source/Installer/Components/InstallationConsole.swift +++ b/VirtualUI/Source/Installer/Components/InstallationConsole.swift @@ -10,23 +10,19 @@ import VirtualCore struct InstallationConsole: View { - var overridePredicate: LogStreamer.Predicate? = nil - - private var predicate: LogStreamer.Predicate { - overridePredicate ?? .process("com.apple.Virtualization.Installation") - } + var predicate: LogStreamer.Predicate + var startTime: Date var body: some View { - LogConsole(predicate: predicate) - .frame(minWidth: 200, maxWidth: .infinity, minHeight: 100, maxHeight: 400) - .controlGroup(level: .secondary) + LogConsole(predicate: predicate, startTime: startTime) + .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) } } #if DEBUG #Preview { - InstallationConsole(overridePredicate: .process("Xcode")) + InstallationConsole(predicate: .process("Xcode"), startTime: .now) .padding() .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity, alignment: .bottom) } diff --git a/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift b/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift index 2a3321fd..39a460de 100644 --- a/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift +++ b/VirtualUI/Source/Installer/Steps/InstallConfigurationStepView.swift @@ -26,7 +26,7 @@ struct InstallConfigurationStepView: View { self.vm = updatedVM onSave(updatedVM) }) - .environmentObject(viewModel) + .environmentObject(viewModel) } } diff --git a/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift b/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift index d38d03ba..7999b985 100644 --- a/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift +++ b/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift @@ -40,7 +40,8 @@ struct InstallProgressStepView: View { VirtualBuddyMonoProgressView(progress: progress, status: status, style: style) .textSelection(.enabled) } else if let virtualMachine = viewModel.virtualMachine { - InstallerVirtualMachineView(virtualMachine: virtualMachine) + SwiftUIVMView(controllerState: .constant(.running(virtualMachine)), captureSystemKeys: false, isDFUModeVM: false, automaticallyReconfiguresDisplay: .constant(false)) + .virtualMachineInteractionDisabled() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { VirtualBuddyMonoProgressView(progress: progress, status: Text(""), style: style) @@ -48,20 +49,6 @@ struct InstallProgressStepView: View { } } -private struct InstallerVirtualMachineView: NSViewRepresentable { - typealias NSViewType = VZVirtualMachineView - - let virtualMachine: VZVirtualMachine - - func makeNSView(context: Context) -> VZVirtualMachineView { - VZVirtualMachineView(frame: .zero) - } - - func updateNSView(_ nsView: VZVirtualMachineView, context: Context) { - nsView.virtualMachine = virtualMachine - } -} - #if DEBUG #Preview { VMInstallationWizard.preview(step: .install) 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/Installer/Steps/Restore Image Selection/Components/VirtualDisplayView.swift b/VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/VirtualDisplayView.swift index 5ccf192a..cf358b8e 100644 --- a/VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/VirtualDisplayView.swift +++ b/VirtualUI/Source/Installer/Steps/Restore Image Selection/Components/VirtualDisplayView.swift @@ -1,12 +1,25 @@ import SwiftUI import BuddyKit +private extension EnvironmentValues { + @Entry var virtualDisplayChromeDisabled = false +} + +extension View { + func virtualDisplayChromeDisabled(_ disabled: Bool = true) -> some View { + environment(\.virtualDisplayChromeDisabled, disabled) + } +} + /// A view that simulates a display chrome, currently used during installation. struct VirtualDisplayView: View { @ViewBuilder var content: () -> Content @EnvironmentObject private var viewModel: VMInstallationViewModel + @Environment(\.virtualDisplayChromeDisabled) + private var chromeDisabled + static var cornerRadius: CGFloat { 12 } var body: some View { @@ -15,14 +28,22 @@ struct VirtualDisplayView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .aspectRatio(16/9, contentMode: .fit) - .background(Color(white: 0.03)) - .overlay { - LinearGradient(colors: [.white.opacity(0.7), .white.opacity(0.1)], startPoint: .init(x: 0.2, y: 0), endPoint: .init(x: 0.3, y: 1.2)) - .blendMode(.plusLighter) - .opacity(0.1) + .modifier { view in + if chromeDisabled { + view + .clipShape(shape) + } else { + view + .background(Color(white: 0.03)) + .overlay { + LinearGradient(colors: [.white.opacity(0.7), .white.opacity(0.1)], startPoint: .init(x: 0.2, y: 0), endPoint: .init(x: 0.3, y: 1.2)) + .blendMode(.plusLighter) + .opacity(0.1) + } + .clipShape(shape) + .chromeBorder(shape: shape, highlightEnabled: false) + } } - .clipShape(shape) - .chromeBorder(shape: shape, highlightEnabled: false) } private var shape: some InsettableShape { diff --git a/VirtualUI/Source/Installer/VMInstallationViewModel.swift b/VirtualUI/Source/Installer/VMInstallationViewModel.swift index 103d78fd..63e25b07 100644 --- a/VirtualUI/Source/Installer/VMInstallationViewModel.swift +++ b/VirtualUI/Source/Installer/VMInstallationViewModel.swift @@ -476,6 +476,8 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { } private func startInstallation() async { + installationStartTime = .now + switch machine?.configuration.systemType { case .mac: startMacInstallation() @@ -535,7 +537,7 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { } } - private func createRestoreBackend(for model: VBVirtualMachine, restoreURL: URL) -> RestoreBackend { + private func createRestoreBackend(for model: VBVirtualMachine, restoreURL: URL, forceVirtualInstallation: Bool) -> RestoreBackend { let Backend: RestoreBackend.Type #if DEBUG if UserDefaults.standard.bool(forKey: "VBSimulateInstall") || ProcessInfo.isSwiftUIPreview { @@ -543,17 +545,25 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { } else if restoreURL == SimulatedDownloadBackend.localFileURL { UILog("⚠️ Using simulated installer because the download was also simulated.") Backend = SimulatedRestoreBackend.self + } else if forceVirtualInstallation { + Backend = VirtualInstallationRestoreBackend.self } else { Backend = VirtualizationRestoreBackend.self } #else - Backend = VirtualizationRestoreBackend.self + if forceVirtualInstallation { + Backend = VirtualInstallationRestoreBackend.self + } else { + Backend = VirtualizationRestoreBackend.self + } #endif return Backend.init(model: model, restoringFromImageAt: restoreURL) } @Published private(set) var virtualMachine: VZVirtualMachine? = nil + @Published private(set) var consolePredicate: LogStreamer.Predicate? = ProcessInfo.isSwiftUIPreview ? .process("Xcode") : nil + @Published private(set) var installationStartTime = Date.now private var installationTask: Task? @@ -574,11 +584,36 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { state = .loading(nil, "Preparing Installation\nThis may take a moment") - let backend = createRestoreBackend(for: model, restoreURL: restoreURL) + let forceVirtualInstallation: Bool + if data.systemType == .mac { + if UserDefaults.standard.bool(forKey: "VBForceVirtualInstallationBackend") { + UILog("Will use VirtualInstallation restore backend (VBForceVirtualInstallationBackend in user defaults)") + + forceVirtualInstallation = true + } else if let resolvedRestoreImage = data.resolvedRestoreImage { + if resolvedRestoreImage.requirements.shouldForceVirtualInstallationBackend { + UILog("Resolved restore image requirement set requires VirtualInstallation backend, enforcing it") + + forceVirtualInstallation = true + } else { + forceVirtualInstallation = false + } + } else { + UILog("⚠️ No resolved restore image, so can't determine restore backend to use") + + forceVirtualInstallation = false + } + } else { + forceVirtualInstallation = false + } + + let backend = createRestoreBackend(for: model, restoreURL: restoreURL, forceVirtualInstallation: forceVirtualInstallation) installer = backend - if let realBackend = backend as? VirtualizationRestoreBackend { - realBackend.virtualMachine.assign(to: &$virtualMachine) + consolePredicate = backend.consolePredicate + + if let vmProvidingBackend = backend as? VirtualMachineProvidingRestoreBackend { + vmProvidingBackend.virtualMachine.assign(to: &$virtualMachine) } progressObservation = backend.progress.observe(\.completedUnitCount) { [weak self] progress, _ in diff --git a/VirtualUI/Source/Installer/VMInstallationWizard.swift b/VirtualUI/Source/Installer/VMInstallationWizard.swift index d015e52e..38b1a05b 100644 --- a/VirtualUI/Source/Installer/VMInstallationWizard.swift +++ b/VirtualUI/Source/Installer/VMInstallationWizard.swift @@ -60,6 +60,7 @@ public struct VMInstallationWizard: View { } } + /// Not making this persist because having the console open immediately upon installation start slows down installation. @State private var showingConsole = false public var body: some View { @@ -77,7 +78,7 @@ public struct VMInstallationWizard: View { case .name: renameVM case .download, .install, .done: - InstallProgressDisplayView().environmentObject(viewModel) + installProgress } } .onReceive(stepValidationStateChanged) { isValid in @@ -111,7 +112,7 @@ public struct VMInstallationWizard: View { } ToolbarItemGroup(placement: .primaryAction) { - if viewModel.step == .install { + if viewModel.step == .install, viewModel.consolePredicate != nil { Toggle(isOn: $showingConsole) { Image(systemName: "terminal") } @@ -120,15 +121,6 @@ public struct VMInstallationWizard: View { } } .frame(minWidth: 800, maxWidth: .infinity, minHeight: 600, maxHeight: .infinity) - .overlay(alignment: .bottom) { - if showingConsole { - InstallationConsole() - .padding(.horizontal, Self.padding * 2) - .padding(.bottom, Self.padding) - .transition(.move(edge: .bottom)) - } - } - .animation(.snappy, value: showingConsole) .virtualBuddyBottomBar(hidden: hideBottomBar) { bottomBar } .background { BlurHashFullBleedBackground(blurHash: viewModel.data.backgroundHash) @@ -243,6 +235,7 @@ public struct VMInstallationWizard: View { viewModel.next() } + .environment(library.templatesController) } else { preparingStatus } @@ -260,6 +253,23 @@ public struct VMInstallationWizard: View { VirtualMachineNameInputView(name: $viewModel.data.name) } + @ViewBuilder + private var installProgress: some View { + ZStack { + InstallProgressDisplayView() + + if showingConsole, let consolePredicate = viewModel.consolePredicate { + VirtualDisplayView { + InstallationConsole(predicate: consolePredicate, startTime: viewModel.installationStartTime) + .background(Color.black.opacity(0.5)) + .contentMargins(.all, EdgeInsets(top: LogConsole.padding, leading: LogConsole.padding, bottom: 0, trailing: LogConsole.padding), for: .scrollContent) + } + .virtualDisplayChromeDisabled() + } + } + .environmentObject(viewModel) + } + } extension VMInstallationStep { diff --git a/VirtualUI/Source/Session/Components/SwiftUIVMView.swift b/VirtualUI/Source/Session/Components/SwiftUIVMView.swift index 5b6aaf77..41c8987f 100644 --- a/VirtualUI/Source/Session/Components/SwiftUIVMView.swift +++ b/VirtualUI/Source/Session/Components/SwiftUIVMView.swift @@ -10,6 +10,16 @@ import Cocoa import Virtualization import VirtualCore +private extension EnvironmentValues { + @Entry var virtualMachineInteractionDisabled = false +} + +extension View { + func virtualMachineInteractionDisabled(_ disabled: Bool = true) -> some View { + environment(\.virtualMachineInteractionDisabled, disabled) + } +} + struct SwiftUIVMView: NSViewControllerRepresentable { typealias NSViewControllerType = VMViewController @@ -34,6 +44,7 @@ struct SwiftUIVMView: NSViewControllerRepresentable { nsViewController.vmECID = vmECID nsViewController.isDFUModeVM = isDFUModeVM + nsViewController.interactionDisabled = context.environment.virtualMachineInteractionDisabled if case .running(let vm) = controllerState { nsViewController.virtualMachine = vm @@ -91,8 +102,13 @@ final class VMViewController: NSViewController { #endif } - private lazy var vmView: VZVirtualMachineView = { - VZVirtualMachineView(frame: .zero) + var interactionDisabled: Bool { + get { vmView.isViewOnly } + set { vmView.isViewOnly = newValue } + } + + private lazy var vmView: VirtualBuddyVMView = { + VirtualBuddyVMView(frame: .zero) }() override func loadView() { @@ -215,6 +231,65 @@ struct DFUStatusView: View { } } +final class VirtualBuddyVMView: VZVirtualMachineView { + var isViewOnly = false + + override func hitTest(_ point: NSPoint) -> NSView? { + guard !isViewOnly else { return nil } + return super.hitTest(point) + } + + override func isMousePoint(_ point: NSPoint, in rect: NSRect) -> Bool { + guard !isViewOnly else { return false } + return super.isMousePoint(point, in: rect) + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + guard !isViewOnly else { return false } + return super.acceptsFirstMouse(for: event) + } + + override func mouseDown(with event: NSEvent) { + guard !isViewOnly else { return } + super.mouseDown(with: event) + } + + override var acceptsFirstResponder: Bool { + guard !isViewOnly else { return false } + return super.acceptsFirstResponder + } + + override func updateTrackingAreas() { + guard !isViewOnly else { return } + super.updateTrackingAreas() + } + + override func cursorUpdate(with event: NSEvent) { + guard !isViewOnly else { return } + super.cursorUpdate(with: event) + } + + override func resetCursorRects() { + guard !isViewOnly else { return } + super.resetCursorRects() + } + + override func discardCursorRects() { + guard !isViewOnly else { return } + super.discardCursorRects() + } + + override func addCursorRect(_ rect: NSRect, cursor object: NSCursor) { + guard !isViewOnly else { return } + super.addCursorRect(rect, cursor: object) + } + + override func removeCursorRect(_ rect: NSRect, cursor object: NSCursor) { + guard !isViewOnly else { return } + super.removeCursorRect(rect, cursor: object) + } +} + #if DEBUG #Preview("VM View - DFU") { SwiftUIVMView( diff --git a/VirtualUI/Source/Session/Components/VirtualMachineControls.swift b/VirtualUI/Source/Session/Components/VirtualMachineControls.swift index 898a2c2b..0848f8cc 100644 --- a/VirtualUI/Source/Session/Components/VirtualMachineControls.swift +++ b/VirtualUI/Source/Session/Components/VirtualMachineControls.swift @@ -125,8 +125,8 @@ struct VirtualMachineControls: View { .disabled(actionTask != nil) } - private func runToolbarAction(alertForErrors: Bool = false, action: @escaping () async throws -> Void) { - actionTask = Task { + private func runToolbarAction(alertForErrors: Bool = false, action: @escaping @MainActor () async throws -> Void) { + actionTask = Task { @MainActor in defer { actionTask = nil } do { @@ -139,6 +139,7 @@ struct VirtualMachineControls: View { } } + @MainActor private func saveState() async throws { do { try await controller.saveState(snapshotName: textFieldContent) diff --git a/VirtualUI/Source/Session/VirtualMachineSessionView.swift b/VirtualUI/Source/Session/VirtualMachineSessionView.swift index ee21f963..3f106068 100644 --- a/VirtualUI/Source/Session/VirtualMachineSessionView.swift +++ b/VirtualUI/Source/Session/VirtualMachineSessionView.swift @@ -33,49 +33,52 @@ public struct VirtualMachineSessionView: View { public var body: some View { ZStack { + if !controller.state.isRunning { + backgroundView + } + controllerStateView } - .edgesIgnoringSafeArea(.all) - .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) - .background(backgroundView) - .environmentObject(controller) - .windowTitle(controller.virtualMachineModel.name) - .windowStyleMask([.titled, .miniaturizable, .closable, .resizable]) - .confirmBeforeClosingWindow(callback: confirmBeforeClosing) - .onWindowKeyChange { [weak sessionManager, weak ui] isKey in - guard let sessionManager, let ui else { return } - sessionManager.focusedSessionChanged.send(isKey ? .init(ui) : nil) - } - .onAppearOnce { - guard vbWindow?.hasSavedFrame == false else { return } - guard let display = controller.virtualMachineModel.configuration.hardware.displayDevices.first else { return } - vbWindow?.resize(to: .fitScreen, for: display) + .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) + .environmentObject(controller) + .windowTitle(controller.virtualMachineModel.name) + .windowTitleBarTransparent(!controller.state.isRunning) + .windowStyleMask([.titled, .miniaturizable, .closable, .resizable]) + .confirmBeforeClosingWindow(callback: confirmBeforeClosing) + .onWindowKeyChange { [weak sessionManager, weak ui] isKey in + guard let sessionManager, let ui else { return } + sessionManager.focusedSessionChanged.send(isKey ? .init(ui) : nil) + } + .onAppearOnce { + guard vbWindow?.hasSavedFrame == false else { return } + guard let display = controller.virtualMachineModel.configuration.hardware.displayDevices.first else { return } + vbWindow?.resize(to: .fitScreen, for: display) + } + .onReceive(ui.resizeWindow) { size in + guard let display = controller.virtualMachineModel.configuration.hardware.displayDevices.first else { + assertionFailure("VM doesn't have a display") + return } - .onReceive(ui.resizeWindow) { size in - guard let display = controller.virtualMachineModel.configuration.hardware.displayDevices.first else { - assertionFailure("VM doesn't have a display") - return - } - vbWindow?.resize(to: size, for: display) - } - .onReceive(ui.setWindowAspectRatio) { ratio in - vbWindow?.applyAspectRatio(ratio) - } - .onReceive(ui.makeWindowKey) { - window?.makeKeyAndOrderFront(nil) - } - .task { - if controller.options.autoBoot { - Task { try? await controller.start() } - } + vbWindow?.resize(to: size, for: display) + } + .onReceive(ui.setWindowAspectRatio) { ratio in + vbWindow?.applyAspectRatio(ratio) + } + .onReceive(ui.makeWindowKey) { + window?.makeKeyAndOrderFront(nil) + } + .task { + if controller.options.autoBoot { + Task { try? await controller.start() } } - .toolbar { - if #available(macOS 14.0, *) { - VirtualMachineControls() - .environmentObject(controller) - } + } + .toolbar { + if #available(macOS 14.0, *) { + VirtualMachineControls() + .environmentObject(controller) } + } } @ViewBuilder @@ -157,6 +160,7 @@ public struct VirtualMachineSessionView: View { VMSessionConfigurationView() .environment(\.backgroundMaterial, Material.thin) .environmentObject(controller) + .environment(library.templatesController) .frame(maxWidth: 400) } } @@ -194,6 +198,7 @@ public struct VirtualMachineSessionView: View { content: controller.virtualMachineModel.blurHashBackgroundContent, isRunning: controller.isRunning ) + .ignoresSafeArea() } private var confirmBeforeClosing: () async -> Bool { @@ -316,6 +321,7 @@ struct VirtualMachineSessionViewPreview: View { .environmentObject(VMController.preview) .environmentObject(VirtualMachineSessionUI.preview) .environmentObject(VirtualMachineSessionUIManager.shared) + .environment(VMLibraryController.preview.templatesController) } } 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/VirtualUI/Source/VM Configuration/Sections/ProvisioningConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/ProvisioningConfigurationView.swift new file mode 100644 index 00000000..a7eb2002 --- /dev/null +++ b/VirtualUI/Source/VM Configuration/Sections/ProvisioningConfigurationView.swift @@ -0,0 +1,325 @@ +// +// ProvisioningConfigurationView.swift +// VirtualUI +// +// Created by Guilherme Rambo on 09/06/26. +// + +import SwiftUI +import VirtualCore + +struct ProvisioningConfigurationView: View { + + @Binding var configuration: VBMacConfiguration + var contextForbidden = false + @State private var isShowingProvisioningFormSheet = false + + @Environment(\.resolvedRestoreImage) + private var resolvedRestoreImage + + private var feature: ResolvedVirtualizationFeature? { resolvedRestoreImage?.feature(id: CatalogFeatureID.provisioning) } + + private var unsupported: Bool { feature?.status.isUnsupported == true } + + private var logsInAutomatically: Binding { + Binding { + configuration.provisioning?.logsInAutomatically ?? false + } set: { newValue in + configuration.provisioning?.logsInAutomatically = newValue + } + } + + private var enablesRemoteLogin: Binding { + Binding { + configuration.provisioning?.enablesRemoteLogin ?? false + } set: { newValue in + configuration.provisioning?.enablesRemoteLogin = newValue + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Toggle("Automatically create a macOS account on first boot", isOn: $configuration.provisioningEnabled) + + Spacer() + + Button { + isShowingProvisioningFormSheet = true + } label: { + Text(configuration.provisioningSetup ? "Account Details…" : "Set Up Account…") + } + .controlSize(.small) + .disabled(!configuration.provisioningEnabled) + .modifier(AttentionBounceViewModifier(enabled: configuration.provisioningEnabled && !configuration.provisioningSetup)) + } + + Group { + Toggle("Log in automatically", isOn: logsInAutomatically) + .help(configuration.provisioningSetup ? "Automatically log in using this account, bypassing the macOS Lock Screen" : "") + + Toggle("Enable remote login (SSH)", isOn: enablesRemoteLogin) + .help(configuration.provisioningSetup ? "Allow logging in with this account using SSH" : "") + } + .disabled(!configuration.provisioningSetup) + .help(!configuration.provisioningSetup ? "Please set up account first" : "") + + if unsupported, let feature, let message = feature.status.supportMessage { + Text(verbatim: message) + // HACK: Force yellow warning when host supports provisioning, red only when host doesn't support provisioning + .foregroundStyle(VBMacConfiguration.hostSupportsProvisioning ? .yellow : .red) + } + } + .sheet(isPresented: $isShowingProvisioningFormSheet) { + NavigationStack { + ProvisioningForm(configuration: $configuration) + .navigationTitle(Text("Mac User Account")) + } + } + .onChange(of: configuration.provisioningEnabled) { oldValue, newValue in + /// Automatically present form sheet when provisioning is enabled unless it's already set up. + guard !configuration.provisioningSetup else { return } + guard !oldValue, newValue else { return } + guard !ProcessInfo.isSwiftUIPreview else { + UILog("I would present the provisioning form sheet now, but I'm in a preview") + return + } + + isShowingProvisioningFormSheet = true + } + .opacity(contextForbidden ? 0 : 1) + .disabled(contextForbidden || unsupported) + .overlay { + if contextForbidden { + Text("Available only before the virtual machine is started for the first time.") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + } + } + + fileprivate struct ProvisioningForm: View { + typealias FormData = VBMacProvisioningConfiguration.FormData + typealias Field = VBMacProvisioningConfiguration.FormField + + @Binding var configuration: VBMacConfiguration + @State private var data = FormData() + + @Environment(\.isEnabled) private var isEnabled + + @State private var usernameEdited = false + + @FocusState private var focusedField: Field? + + @Environment(\.dismiss) private var dismiss + + @State private var errors = [Field: String]() + + var body: some View { + Form { + validatedField("Full Name", text: $data.fullName, field: .fullName, nextField: .username) + + validatedField("Username", text: $data.username, field: .username, nextField: .password) + + validatedField("Password", text: $data.password, field: .password, nextField: .passwordConfirmation, secure: true) + + validatedField("Confirm Password", text: $data.passwordConfirmation, field: .passwordConfirmation, nextField: nil, secure: true) { + save(dismiss: true) + } + } + #if DEBUG + .task { + guard ProcessInfo.isSwiftUIPreview else { return } +// errors[.username] = data.validationErrorMessage(for: .username, value: "") +// errors[.global] = "Invalid username for guest provisioning. Short name 'abcd' is not valid" + } + #endif + .formStyle(.grouped) + .safeAreaInset(edge: .top, spacing: 0) { + if let globalError = errors[.global] { + Text(globalError) + .font(.headline.weight(.medium)) + .foregroundStyle(.red) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.red.opacity(0.1)) + .transition(.blurReplace) + } + } + .animation(.default, value: errors[.global] != nil) + .onChange(of: isEnabled) { oldValue, newValue in + guard !oldValue, newValue else { return } + focusedField = .fullName + } + .onChange(of: data.username) { oldValue, newValue in + guard focusedField == .username, newValue != oldValue else { return } + usernameEdited = !newValue.isEmpty + } + .onChange(of: data.fullName) { _, newValue in + guard !usernameEdited, focusedField == .fullName else { return } + data.username = newValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "") + .lowercased() + } + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Text("Cancel") + } + } + + ToolbarItemGroup(placement: .confirmationAction) { + Button { + save(dismiss: true) + } label: { + Text("Save") + } + } + } + /// Recover existing provisioning configuration if available. + .task { + guard let provisioning = configuration.provisioning else { return } + data = FormData(provisioning) + } + } + + @ViewBuilder + private func validatedField(_ label: LocalizedStringKey, text: Binding, field: Field, nextField: Field?, secure: Bool = false, onSubmit: (() -> ())? = nil) -> some View { + LabeledContent { + Group { + if secure { + SecureField(label, text: text) + } else { + TextField(label, text: text) + } + } + .labelsHidden() + .opacity(errors[field] != nil ? 0.1 : 1.0) + .overlay(alignment: .trailing) { + ZStack { + if let error = errors[field] { + Text(error) + .foregroundStyle(.red) + .fontWeight(.medium) + .minimumScaleFactor(0.7) + .monospacedDigit() + .contentShape(.rect) + .highPriorityGesture(TapGesture().onEnded({ + errors[field] = nil + focusedField = field + })) + .transition(.blurReplace) + } + } + /// Hide error when field is focused so that user can see what they're typing. + .onChange(of: focusedField) { oldValue, newValue in + guard errors[field] != nil, oldValue != field, newValue == field else { return } + errors[field] = nil + } + /// Reset errors when editing value so that user can see what they're typing even before validation changes. + .onChange(of: text.wrappedValue) { + guard errors[field] != nil, focusedField == field else { return } + errors[field] = nil + } + .animation(.default, value: errors[field] != nil) + } + } label: { + Text(label) + } + .focused($focusedField, equals: field) + .onSubmit { + if let nextField { + focusedField = nextField + } else { + errors[field] = data.validationErrorMessage(for: field, value: text.wrappedValue) + + if errors[field] == nil { + onSubmit?() + } + } + } + .onChange(of: focusedField) { oldValue, newValue in + guard isEnabled, oldValue == field, newValue != field else { return } + + /// Ignore validation errors when unfocusing field if there are already errors for other fields. + guard errors.keys.filter({ $0 != field }).isEmpty else { return } + + errors[field] = data.validationErrorMessage(for: field, value: text.wrappedValue) + } + .onChange(of: text.wrappedValue) { _, newValue in + /// Always reset global errors when editing any field. + if errors[.global] != nil { errors[.global] = nil } + + guard errors[field] != nil else { return } + errors[field] = data.validationErrorMessage(for: field, value: text.wrappedValue) + } + } + + private func save(dismiss: Bool = false) { + guard errors.isEmpty else { return } + + do { + try configuration.applyProvisioningConfiguration(with: data) + + guard !dismiss else { + self.dismiss() + return + } + + focusedField = nil + } catch let error as VBMacConfiguration.ProvisioningSetupError { + errors = error.validationErrorMessages + } catch { + errors[.global] = error.localizedDescription + } + } + } + +} + +struct AttentionBounceViewModifier: ViewModifier { + let enabled: Bool + + func body(content: Content) -> some View { + content + .phaseAnimator([0, 1, 2], trigger: enabled) { content, phase in + content + .overlay { + RoundedRectangle(cornerRadius: 6) + .fill(Color.orange) + .opacity(enabled ? (phase == 1 ? 0.7 : 0.0) : 0.0) + .visualEffect { content, _ in + content.blur(radius: 2) + } + .blendMode(.overlay) + } + .visualEffect { [enabled] content, _ in + content + .scaleEffect(enabled ? (phase == 1 ? 1.1 : 1.0) : 1.0) + } + } animation: { phase in + if enabled { + Animation.smooth(duration: phase == 1 ? 0.5 : 0.3, extraBounce: 0) + } else { + Animation.linear(duration: 0) + } + } + } +} + +#if DEBUG +#Preview("Section") { + _ConfigurationSectionPreview { ProvisioningConfigurationView(configuration: $0) } +} + +#Preview("Account Sheet") { + @Previewable @State var config: VBMacConfiguration = .preview + + ProvisioningConfigurationView.ProvisioningForm(configuration: $config) +} +#endif diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index e47cc0dd..318d5986 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -9,19 +9,22 @@ import SwiftUI import VirtualCore struct ManagedDiskImageEditor: View { - @State private var image: VBManagedDiskImage + private var image: VBManagedDiskImage var minimumSize: UInt64 var isExistingDiskImage: Bool var onSave: (VBManagedDiskImage) -> Void var isBootVolume: Bool + @State private var size: UInt64 + init(image: VBManagedDiskImage, isExistingDiskImage: Bool, isForBootVolume: Bool, onSave: @escaping (VBManagedDiskImage) -> Void) { - self._image = .init(wrappedValue: image) + self.image = image self.isExistingDiskImage = isExistingDiskImage self.onSave = onSave let fallbackMinimumSize = isForBootVolume ? VBManagedDiskImage.minimumBootDiskImageSize : VBManagedDiskImage.minimumExtraDiskImageSize self.minimumSize = isExistingDiskImage ? image.size : fallbackMinimumSize self.isBootVolume = isForBootVolume + self.size = image.size } private let formatter: ByteCountFormatter = { @@ -52,7 +55,7 @@ struct ManagedDiskImageEditor: View { let maximumSize = isBootVolume ? VBManagedDiskImage.maximumBootDiskImageSize : VBManagedDiskImage.maximumExtraDiskImageSize NumericPropertyControl( - value: $image.size.gbStorageValue, + value: $size.gbStorageValue, range: minimumSize.gbStorageValue...maximumSize.gbStorageValue, hideSlider: isExistingDiskImage, label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", @@ -88,8 +91,14 @@ struct ManagedDiskImageEditor: View { .fixedSize(horizontal: false, vertical: true) .lineLimit(nil) } - .onChange(of: image) { _, newValue in - onSave(newValue) + .onChange(of: size) { _, newValue in + var image = self.image + image.size = newValue + onSave(image) + } + .onChange(of: image.size, initial: true) { _, newValue in + guard newValue != size else { return } + size = newValue } } diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift b/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift index 87bd6700..dc8af7bf 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationSheet.swift @@ -144,20 +144,24 @@ struct VMConfigurationSheet_Previews: PreviewProvider { struct _Template: View { @State var vm: VBVirtualMachine var context: VMConfigurationContext + @State private var library = VMLibraryController.preview var body: some View { - if context == .postInstall { - PreviewSheet { + Group { + if context == .postInstall { + PreviewSheet { + VMConfigurationSheet(configuration: $vm.configuration) + .environmentObject(VMConfigurationViewModel(vm, context: context)) + .frame(width: VMConfigurationSheet.minWidth, height: VMConfigurationSheet_Previews.height, alignment: .top) + } + } else { VMConfigurationSheet(configuration: $vm.configuration) .environmentObject(VMConfigurationViewModel(vm, context: context)) .frame(width: VMConfigurationSheet.minWidth, height: VMConfigurationSheet_Previews.height, alignment: .top) + .background(BlurHashFullBleedBackground(blurHash: .virtualBuddyBackground)) } - } else { - VMConfigurationSheet(configuration: $vm.configuration) - .environmentObject(VMConfigurationViewModel(vm, context: context)) - .frame(width: VMConfigurationSheet.minWidth, height: VMConfigurationSheet_Previews.height, alignment: .top) - .background(BlurHashFullBleedBackground(blurHash: .virtualBuddyBackground)) } + .environment(library.templatesController) } } } diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift index c3e8db52..9e910618 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift @@ -32,6 +32,7 @@ enum CatalogFeatureID { static let stateRestoration = "state_restoration" static let displayResize = "display_resize" static let rosettaSharing = "rosetta_sharing" + static let provisioning = "provisioning" } extension ResolvedRestoreImage { @@ -64,7 +65,9 @@ extension ResolvedFeatureStatus { struct VMConfigurationView: View { @EnvironmentObject private var viewModel: VMConfigurationViewModel - + + @Environment(VMTemplatesController.self) private var templatesController + var initialConfiguration: VBMacConfiguration static var labelSpacing: CGFloat { 2 } @@ -72,6 +75,9 @@ struct VMConfigurationView: View { @AppStorage("config.general.collapsed") private var generalCollapsed = true + @AppStorage("config.provisioning.collapsed") + private var provisioningCollapsed = true + @AppStorage("config.storage.collapsed") private var storageCollapsed = true @@ -108,14 +114,26 @@ struct VMConfigurationView: View { private var showGuestAppSection: Bool { systemType.supportsGuestApp } + private var showProvisioningSection: Bool { systemType.supportsProvisioning } + + private var showTemplatePicker: Bool { templatesController.hasTemplates(for: viewModel.config.systemType) } + var body: some View { VStack(alignment: .leading, spacing: 16) { + if showTemplatePicker { + templatePicker + } + if showBootDiskSection { bootDisk } general + if showProvisioningSection { + provisioning + } + storage display @@ -144,6 +162,23 @@ struct VMConfigurationView: View { .environment(\.resolvedRestoreImage, viewModel.resolvedRestoreImage) } + @ViewBuilder + private var templatePicker: some View { + ConfigurationSection(.constant(false)) { + VMConfigurationTemplatePicker( + controller: templatesController, + context: viewModel.context, + configuration: $viewModel.config + ) { updatedConfiguration in + if let image = viewModel.config.hardware.storageDevices.first(where: { $0.isBootVolume })?.managedImage { + viewModel.updateBootStorageDevice(with: image) + } + } + } header: { + SummaryHeader("Copy Configuration", systemImage: "square.on.square") + } + } + @ViewBuilder private var general: some View { ConfigurationSection($generalCollapsed) { @@ -157,6 +192,22 @@ struct VMConfigurationView: View { } } + @ViewBuilder + private var provisioning: some View { + ConfigurationSection($provisioningCollapsed) { + ProvisioningConfigurationView( + configuration: $viewModel.config, + contextForbidden: viewModel.context != .preInstall && viewModel.vm.hasBootedNonRecoveryAtLeastOnce + ) + } header: { + SummaryHeader( + "Skip Setup Assistant", + systemImage: "person.crop.circle", + summary: viewModel.config.provisioningSummary + ) + } + } + @ViewBuilder private var bootDisk: some View { ConfigurationSection(.constant(false), collapsingDisabled: true) { @@ -308,6 +359,73 @@ struct VMConfigurationView: View { } } +struct VMConfigurationTemplatePicker: View { + let controller: VMTemplatesController + let context: VMConfigurationContext + @Binding var configuration: VBMacConfiguration + var onApply: (_ configuration: VBMacConfiguration) -> () + + var templates: [VBConfigurationTemplate] { + switch configuration.systemType { + case .mac: controller.templatesForMacGuest + case .linux: controller.templatesForLinuxGuest + } + } + + @State private var selectedTemplateID: VBConfigurationTemplate.ID? + @State private var buttonNeedsAttention = false + + private var selectedTemplate: VBConfigurationTemplate? { + selectedTemplateID.flatMap { controller.template(id: $0) } + } + + var body: some View { + HStack { + Picker("Copy configuration", selection: $selectedTemplateID) { + Text("Choose existing configuration…") + .tag(Optional.none) + + ForEach(templates) { template in + Text(template.name) + .tag(Optional.some(template.id)) + } + } + .labelsHidden() + + Spacer() + + Button("Apply") { + applySelection() + } + .modifier(AttentionBounceViewModifier(enabled: buttonNeedsAttention)) + .disabled(selectedTemplate == nil) + } + .onChange(of: selectedTemplateID) { _, newValue in + buttonNeedsAttention = newValue != nil + } + } + + private func applySelection() { + guard let selectedTemplate else { return } + + do { + var updatedConfiguration = configuration + try updatedConfiguration.apply( + template: selectedTemplate, + includingStorageDevices: context == .preInstall + ) + + configuration = updatedConfiguration + + onApply(updatedConfiguration) + + buttonNeedsAttention = false + } catch { + NSApp.presentError(error) + } + } +} + // MARK: - Section Header private struct SummaryHeader: View { diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift index 415b455c..43091e07 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift @@ -6,11 +6,20 @@ // import SwiftUI +import Combine import VirtualCore public enum VMConfigurationContext: Int { case preInstall case postInstall + + /// Whether the configuration should be saved continuously as it's changed by the user. + var shouldAutoSave: Bool { + switch self { + case .preInstall: true + case .postInstall: false + } + } } public final class VMConfigurationViewModel: ObservableObject { @@ -40,7 +49,9 @@ public final class VMConfigurationViewModel: ObservableObject { @Published private(set) var vm: VBVirtualMachine public let context: VMConfigurationContext - + + private var cancellables = Set() + public init(_ vm: VBVirtualMachine, context: VMConfigurationContext = .postInstall, resolvedRestoreImage: ResolvedRestoreImage? = nil) { self.config = vm.configuration self.vm = vm @@ -50,6 +61,23 @@ public final class VMConfigurationViewModel: ObservableObject { applyResolvedFeatureDefaultsIfNeeded() Task { await validate() } + + /// Automatically save configuration as it changes when in a pre-install context. + /// In a post-install context, configuration is only saved when the user confirms it. + if context.shouldAutoSave { + $config + .removeDuplicates() + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] config in + do { + self?.vm.configuration = config + try self?.vm.saveMetadata() + } catch { + assert(ProcessInfo.isSwiftUIPreview, "Unexpected metadata write failure: \(error)") + } + } + .store(in: &cancellables) + } } @discardableResult diff --git a/VirtualWormhole/Source/Services/DarwinNotifications/WHDarwinNotificationsService.swift b/VirtualWormhole/Source/Services/DarwinNotifications/WHDarwinNotificationsService.swift index c2cc3498..768b2106 100644 --- a/VirtualWormhole/Source/Services/DarwinNotifications/WHDarwinNotificationsService.swift +++ b/VirtualWormhole/Source/Services/DarwinNotifications/WHDarwinNotificationsService.swift @@ -38,8 +38,12 @@ final class WHDarwinNotificationsService: WormholeService { logger.debug(#function) Task { - for try await message in connection.stream(for: DarwinNotificationMessage.self) { - handle(message.payload, from: message.senderID) + do { + for try await message in connection.stream(for: DarwinNotificationMessage.self) { + handle(message.payload, from: message.senderID) + } + } catch { + logger.info("Connection stream terminated: \(error, privacy: .public)") } } } diff --git a/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportService.swift b/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportService.swift index 5e74d010..9b67168f 100644 --- a/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportService.swift +++ b/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportService.swift @@ -43,8 +43,12 @@ public final class WHDefaultsImportService: WormholeService { logger.debug(#function) Task { - for try await message in connection.stream(for: DefaultsImportMessage.self) { - await handle(message.payload, from: message.senderID) + do { + for try await message in connection.stream(for: DefaultsImportMessage.self) { + await handle(message.payload, from: message.senderID) + } + } catch { + logger.info("Connection stream terminated: \(error, privacy: .public)") } } } diff --git a/VirtualWormhole/Source/Services/DesktopPicture/WHDesktopPictureService.swift b/VirtualWormhole/Source/Services/DesktopPicture/WHDesktopPictureService.swift index 4b2c6d47..b04b5fd0 100644 --- a/VirtualWormhole/Source/Services/DesktopPicture/WHDesktopPictureService.swift +++ b/VirtualWormhole/Source/Services/DesktopPicture/WHDesktopPictureService.swift @@ -44,10 +44,14 @@ final class WHDesktopPictureService: WormholeService { logger.debug(#function) Task { - for try await message in connection.stream(for: DesktopPictureMessage.self) { - logger.debug("Received desktop picture message with \(message.payload.content.count) bytes of image data.") - - peerSentDesktopPictureSubject.send((message.payload, message.senderID)) + do { + for try await message in connection.stream(for: DesktopPictureMessage.self) { + logger.debug("Received desktop picture message with \(message.payload.content.count) bytes of image data.") + + peerSentDesktopPictureSubject.send((message.payload, message.senderID)) + } + } catch { + logger.info("Connection stream terminated: \(error, privacy: .public)") } } @@ -96,5 +100,3 @@ final class WHDesktopPictureService: WormholeService { } } - -extension NSImage: @retroactive @unchecked Sendable { } diff --git a/VirtualWormhole/Source/Services/WHSharedClipboardService.swift b/VirtualWormhole/Source/Services/WHSharedClipboardService.swift index 85915e2b..d3448521 100644 --- a/VirtualWormhole/Source/Services/WHSharedClipboardService.swift +++ b/VirtualWormhole/Source/Services/WHSharedClipboardService.swift @@ -38,8 +38,12 @@ final class WHSharedClipboardService: WormholeService { logger.debug(#function) Task { - for try await message in connection.stream(for: ClipboardMessage.self) { - handle(message.payload) + do { + for try await message in connection.stream(for: ClipboardMessage.self) { + handle(message.payload) + } + } catch { + logger.info("Connection stream terminated: \(error, privacy: .public)") } } diff --git a/ci_scripts/ci_pre_xcodebuild.sh b/ci_scripts/ci_pre_xcodebuild.sh new file mode 100755 index 00000000..02ee2e12 --- /dev/null +++ b/ci_scripts/ci_pre_xcodebuild.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env zsh + +set -e + +CONFIG_FILE_PATH="${CI_PRIMARY_REPOSITORY_PATH}/VirtualBuddy/Config/CIProjectVersion.xcconfig" + +if [ -n "${CI_BUILD_NUMBER}" ]; then + echo "Writing CURRENT_PROJECT_VERSION with Xcode Cloud build: ${CI_BUILD_NUMBER} in ${CONFIG_FILE_PATH}" + echo "CURRENT_PROJECT_VERSION = ${CI_BUILD_NUMBER}" > "${CONFIG_FILE_PATH}" +fi \ No newline at end of file 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 bf9d2597..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 @@ -73,6 +73,13 @@ "minVersionHost" : "14.0.0", "name" : "Rosetta Sharing", "unsupportedPlatform" : true + }, + { + "id" : "provisioning", + "minVersionGuest" : "27.0.0", + "minVersionHost" : "27.0.0", + "name" : "Skip Setup Assistant", + "unsupportedPlatform" : false } ], "groups" : [ @@ -227,32 +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", + "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" : [ { @@ -263,7 +307,7 @@ "id" : "26A5353q", "mobileDeviceMinVersion" : "1827.100.14", "name" : "macOS 27.0 Developer Beta 1", - "requirements" : "min_host_27", + "requirements" : "min_host_26_virtual_installation", "url" : "https:\/\/updates.cdn-apple.com\/2026SummerSeed\/fullrestores\/122-99636\/BB03A927-E303-44C1-BB94-6197F130A163\/UniversalMac_27.0_26A5353q_Restore.ipsw", "version" : "27.0.0" }, diff --git a/data/linux_v2.json b/data/linux_v2.json index fab347a7..90fbfb82 100644 --- a/data/linux_v2.json +++ b/data/linux_v2.json @@ -69,7 +69,14 @@ "minVersionHost" : "14.0.0", "name" : "Rosetta Sharing", "unsupportedPlatform" : false - } + }, + { + "id": "provisioning", + "minVersionGuest": "0.0.0", + "minVersionHost": "27.0.0", + "name": "Skip Setup Assistant", + "unsupportedPlatform": true + } ], "deviceSupportVersions": [ ], diff --git a/delete_extracted_restore_bundles b/delete_extracted_restore_bundles new file mode 100755 index 00000000..99cae669 --- /dev/null +++ b/delete_extracted_restore_bundles @@ -0,0 +1,5 @@ +#!/usr/bin/env zsh + +echo "Finding and deleting restore bundles extracted by MobileDevice..." + +sudo find /private/var/folders -maxdepth 4 -iname 'restore_bundle_*' -exec rm -Rf {} \; \ No newline at end of file