diff --git a/Documentation/GuestAppSupport.md b/Documentation/GuestAppSupport.md new file mode 100644 index 00000000..85bee1a4 --- /dev/null +++ b/Documentation/GuestAppSupport.md @@ -0,0 +1,13 @@ +# VirtualBuddyGuest support + +| Guest | Supported features | +| --- | --- | +| macOS 14 or later with the latest VirtualBuddyGuest | Clipboard sharing and all current guest integration features | +| macOS 13 with a legacy VirtualBuddyGuest | Automatic mounting of shared folders only | +| macOS 12 or earlier | VirtualBuddyGuest is not supported | + +Archived guest apps do not support clipboard sharing with the current host, even when selected on a newer guest OS. The host no longer creates a serial connection or handles the legacy protocol. Linux SPICE integration is unchanged. + +`GuestAppSupport` applies the same policy to configuration UI, guest installer attachment, and guest communication startup. The latest app’s minimum OS comes from its embedded `LSMinimumSystemVersion`. Unsupported guests skip installer preparation and attachment even if an existing configuration has the guest app enabled. + +As with the guest app version picker, OS detection uses restore-image metadata, which records the OS used to create the VM rather than inspecting the running guest. Imported VMs without this metadata use their explicit legacy app selection. Unknown VMs without an override use the latest app. Existing macOS 12 overrides remain recognizable so the UI can flag them as unsupported. diff --git a/README.md b/README.md index a98f10bb..2f9632fe 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,9 @@ If you’d just like to download the latest version, [go to GitHub releases](htt #### VirtualBuddyGuest app -VirtualBuddy automatically mounts a disk image with the VirtualBuddyGuest app when you boot up a virtual machine running macOS. To install the VirtualBuddyGuest app, just select the “Guest” disk on Finder’s side bar then double-click the “VirtualBuddyGuest” app icon. +VirtualBuddy automatically mounts a disk image with the VirtualBuddyGuest app when you boot a supported macOS virtual machine with the guest app enabled. To install the VirtualBuddyGuest app, just select the “Guest” disk on Finder’s side bar then double-click the “VirtualBuddyGuest” app icon. -VirtualBuddyGuest enables clipboard sharing between host and guest and automatic mounting of the shared folders configured for the virtual machine. +The latest VirtualBuddyGuest app requires macOS 14 or later and supports clipboard sharing and automatic mounting of shared folders. On macOS 13, the legacy guest app only mounts shared folders automatically; clipboard sharing is unavailable. VirtualBuddyGuest is not supported on macOS 12 or earlier. ![](./assets/GuestApp.jpg) diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index def87667..7968960d 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -23,6 +23,9 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 35C9D84FABD14D5A9535F0EF /* VMBridge in Frameworks */ = {isa = PBXBuildFile; productRef = 91FC62A28F764E37BFFAA951 /* VMBridge */; }; + 781FD61DBFAD4B389CB9682E /* VMBridge in Frameworks */ = {isa = PBXBuildFile; productRef = 252AC97268424F96B307D189 /* VMBridge */; }; + AE3D052420DE463B85CE31B2 /* VMBridge in Frameworks */ = {isa = PBXBuildFile; productRef = D7B6E9E58E03491A8DA72A9B /* VMBridge */; }; CD4422E92FE9B99300DD1670 /* BuddyFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CD4422E82FE9B99300DD1670 /* BuddyFoundation */; }; CD4422ED2FE9B9D900DD1670 /* BuddyFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CD4422EC2FE9B9D900DD1670 /* BuddyFoundation */; }; F40F5DA53040AF22005C19FD /* USBIDKit in Frameworks */ = {isa = PBXBuildFile; productRef = F40F5DA43040AF22005C19FD /* USBIDKit */; }; @@ -62,6 +65,8 @@ 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 */; }; F4EFB5D12FDB2CD200AF2A63 /* VirtualInstallation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4EFB5632FDB2A0D00AF2A63 /* VirtualInstallation.framework */; }; + F4F0F4973053041E00D25236 /* VMBridge in Frameworks */ = {isa = PBXBuildFile; productRef = F4F0F4963053041E00D25236 /* VMBridge */; }; + F4F0F4993053041E00D25236 /* VMBridgeVirtualization in Frameworks */ = {isa = PBXBuildFile; productRef = F4F0F4983053041E00D25236 /* VMBridgeVirtualization */; }; VB02DISKTEST00003A0103 /* VirtualCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4BE9C6527FF053A00B648F8 /* VirtualCore.framework */; }; /* End PBXBuildFile section */ @@ -496,6 +501,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 781FD61DBFAD4B389CB9682E /* VMBridge in Frameworks */, F453C44C2DF0B835007EAD5F /* libcurl.tbd in Frameworks */, F43B01472AD85A7D00164CD1 /* URLQueryItemCoder in Frameworks */, F498AD042884BF13006F1C00 /* VirtualUI.framework in Frameworks */, @@ -517,6 +523,7 @@ F4EFB5D12FDB2CD200AF2A63 /* VirtualInstallation.framework in Frameworks */, CD4422E92FE9B99300DD1670 /* BuddyFoundation in Frameworks */, F4C189F22848F5F500335EC7 /* VirtualWormhole.framework in Frameworks */, + F4F0F4993053041E00D25236 /* VMBridgeVirtualization in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -524,6 +531,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F4F0F4973053041E00D25236 /* VMBridge in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -531,6 +539,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AE3D052420DE463B85CE31B2 /* VMBridge in Frameworks */, F453C4242DF0B602007EAD5F /* VirtualUI.framework in Frameworks */, F4C18A5228491B9D00335EC7 /* VirtualWormhole.framework in Frameworks */, F428622E2AE87D7E0052F029 /* DeepLinkSecurity.framework in Frameworks */, @@ -543,6 +552,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 35C9D84FABD14D5A9535F0EF /* VMBridge in Frameworks */, VB02DISKTEST00003A0103 /* VirtualCore.framework in Frameworks */, F4D305A029B8DB700006E748 /* VirtualWormhole.framework in Frameworks */, ); @@ -747,6 +757,7 @@ ); name = VirtualBuddy; packageProductDependencies = ( + 252AC97268424F96B307D189 /* VMBridge */, F4D0F71728674E4B004D5782 /* Sparkle */, F43B01462AD85A7D00164CD1 /* URLQueryItemCoder */, F453C42A2DF0B792007EAD5F /* ArgumentParser */, @@ -780,6 +791,9 @@ F4FFF064301D13FD00620720 /* VirtualCore */, ); name = VirtualCore; + packageProductDependencies = ( + F4F0F4983053041E00D25236 /* VMBridgeVirtualization */, + ); productName = VirtualCore; productReference = F4BE9C6527FF053A00B648F8 /* VirtualCore.framework */; productType = "com.apple.product-type.framework"; @@ -802,6 +816,7 @@ ); name = VirtualWormhole; packageProductDependencies = ( + F4F0F4963053041E00D25236 /* VMBridge */, ); productName = VirtualWormhole; productReference = F4C189E02848F59F00335EC7 /* VirtualWormhole.framework */; @@ -831,6 +846,9 @@ F4FFEFA4301D13C500620720 /* VirtualBuddyGuest */, ); name = VirtualBuddyGuest; + packageProductDependencies = ( + D7B6E9E58E03491A8DA72A9B /* VMBridge */, + ); productName = VirtualBuddyGuest; productReference = F4C18A4228491B8500335EC7 /* VirtualBuddyGuest.app */; productType = "com.apple.product-type.application"; @@ -852,6 +870,9 @@ F4FFEF87301D13BE00620720 /* VirtualWormholeTests */, ); name = VirtualWormholeTests; + packageProductDependencies = ( + 91FC62A28F764E37BFFAA951 /* VMBridge */, + ); productName = VirtualWormholeTests; productReference = F4D3059C29B8DB700006E748 /* VirtualWormholeTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -970,6 +991,7 @@ F453C4012DF0AEF5007EAD5F /* XCRemoteSwiftPackageReference "swift-argument-parser" */, F453C4482DF0B7F6007EAD5F /* XCRemoteSwiftPackageReference "libfragmentzip" */, F40F5DA33040AF22005C19FD /* XCRemoteSwiftPackageReference "USBIDKit" */, + F4F0F4953053041E00D25236 /* XCRemoteSwiftPackageReference "VMBridge" */, ); preferredProjectObjectVersion = 55; productRefGroup = F4BE9C4F27FF052100B648F8 /* Products */; @@ -4011,9 +4033,25 @@ minimumVersion = 2.9.4; }; }; + F4F0F4953053041E00D25236 /* XCRemoteSwiftPackageReference "VMBridge" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/insidegui/VMBridge.git"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 252AC97268424F96B307D189 /* VMBridge */ = { + isa = XCSwiftPackageProductDependency; + productName = VMBridge; + }; + 91FC62A28F764E37BFFAA951 /* VMBridge */ = { + isa = XCSwiftPackageProductDependency; + productName = VMBridge; + }; CD4422E82FE9B99300DD1670 /* BuddyFoundation */ = { isa = XCSwiftPackageProductDependency; package = F453C3D82DF0A426007EAD5F /* XCRemoteSwiftPackageReference "BuddyKit" */; @@ -4024,6 +4062,10 @@ package = F453C3D82DF0A426007EAD5F /* XCRemoteSwiftPackageReference "BuddyKit" */; productName = BuddyFoundation; }; + D7B6E9E58E03491A8DA72A9B /* VMBridge */ = { + isa = XCSwiftPackageProductDependency; + productName = VMBridge; + }; F40F5DA43040AF22005C19FD /* USBIDKit */ = { isa = XCSwiftPackageProductDependency; package = F40F5DA33040AF22005C19FD /* XCRemoteSwiftPackageReference "USBIDKit" */; @@ -4064,6 +4106,14 @@ package = F4D0F71628674E4B004D5782 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + F4F0F4963053041E00D25236 /* VMBridge */ = { + isa = XCSwiftPackageProductDependency; + productName = VMBridge; + }; + F4F0F4983053041E00D25236 /* VMBridgeVirtualization */ = { + isa = XCSwiftPackageProductDependency; + productName = VMBridgeVirtualization; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F4BE9C4627FF052100B648F8 /* Project object */; diff --git a/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bfd446ec..1f34b3b9 100644 --- a/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/VirtualBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4f601ef463814f8125ce94139ffcec51c2dc63c8d26aca793f89ab6406ed9d7d", + "originHash" : "be13290b58e4b62a7a7beb8d2e31972ce95303287855ae90d0c1d3c646a7bfe3", "pins" : [ { "identity" : "buddykit", @@ -63,6 +63,15 @@ "revision" : "561f6f6268e8edb98dfb34168d766b3dc0c2fc9c", "version" : "1.1.0" } + }, + { + "identity" : "vmbridge", + "kind" : "remoteSourceControl", + "location" : "https://github.com/insidegui/VMBridge.git", + "state" : { + "branch" : "main", + "revision" : "a6ea8b99d63c02b901087180733e33f393acefc9" + } } ], "version" : 3 diff --git a/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift b/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift index 376a0f6e..9b9325ec 100644 --- a/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift +++ b/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift @@ -103,6 +103,7 @@ import SwiftUI logger.info("User cancelled termination request. Good.") case .terminateNow: logger.info("User decided to terminate now despite assertions :(") + return finishTerminationAfterGuestTeardown() case .terminateLater: logger.info("User wants app to terminate when assertions preventing termination are invalidated.") @@ -115,10 +116,22 @@ import SwiftUI return reply } else { - return .terminateNow + return finishTerminationAfterGuestTeardown() } } + private var terminationTask: Task? + + private func finishTerminationAfterGuestTeardown() -> NSApplication.TerminateReply { + if terminationTask == nil { + terminationTask = Task { + await library.stopGuestCommunication() + NSApp.reply(toApplicationShouldTerminate: true) + } + } + return .terminateLater + } + private var settingsWindow: NSWindow? private(set) lazy var openSettingsAction = OpenVirtualBuddySettingsAction { [weak self] in diff --git a/VirtualBuddyGuest/Dashboard/GuestDashboard.swift b/VirtualBuddyGuest/Dashboard/GuestDashboard.swift index b927c21c..2181bcda 100644 --- a/VirtualBuddyGuest/Dashboard/GuestDashboard.swift +++ b/VirtualBuddyGuest/Dashboard/GuestDashboard.swift @@ -10,7 +10,7 @@ import VirtualWormhole struct GuestDashboard: View { @EnvironmentObject private var launchAtLoginManager: GuestLaunchAtLoginManager - @EnvironmentObject private var hostConnection: WormholeManager + @Environment(GuestHostSession.self) private var hostConnection @EnvironmentObject private var sharedFolders: GuestSharedFoldersManager @State var activated = false @@ -35,7 +35,7 @@ struct GuestDashboard: View { showingDefaultsPopover.toggle() } .popover(isPresented: $showingDefaultsPopover) { - GuestDefaultsImportView() + GuestDefaultsImportView(connection: hostConnection) } #endif } @@ -104,12 +104,9 @@ struct GuestDashboard_Previews: PreviewProvider { static var previews: some View { GuestDashboard() .environmentObject(GuestLaunchAtLoginManager()) - .environmentObject(WormholeManager.sharedGuest) + .environment(GuestHostSession()) .environmentObject(GuestSharedFoldersManager()) } } -final class MockHostConnectionStateProvider: HostConnectionStateProvider { - var isConnected: Bool = false -} #endif diff --git a/VirtualBuddyGuest/Dashboard/GuestDefaultsImportView.swift b/VirtualBuddyGuest/Dashboard/GuestDefaultsImportView.swift index f6cbe679..d2c96a53 100644 --- a/VirtualBuddyGuest/Dashboard/GuestDefaultsImportView.swift +++ b/VirtualBuddyGuest/Dashboard/GuestDefaultsImportView.swift @@ -5,42 +5,32 @@ import VirtualUI import VirtualWormhole import Combine +@MainActor final class DefaultsImportViewModel: ObservableObject { - let connection: WormholeManager + let connection: GuestHostSession let controller = DefaultsImportController() @Published private(set) var domains = [DefaultsDomainDescriptor]() - init(connection: WormholeManager = .sharedGuest) { + init(connection: GuestHostSession) { self.connection = connection controller.$sortedDomains.assign(to: &$domains) } - private var _client: WHDefaultsImportClient? - private var client: WHDefaultsImportClient { - get throws { - if let _client { return _client } - - let newClient = try connection.makeClient(WHDefaultsImportClient.self) - - _client = newClient - - return newClient - } - } - func importDomain(with id: DefaultsDomainDescriptor.ID) async throws { - let defaultsClient = try client - - try await defaultsClient.importDomain(with: id) + try await connection.importDomain(with: id) } } struct GuestDefaultsImportView: View { - @StateObject var viewModel = DefaultsImportViewModel() + @StateObject private var viewModel: DefaultsImportViewModel + + init(connection: GuestHostSession) { + _viewModel = StateObject(wrappedValue: DefaultsImportViewModel(connection: connection)) + } var body: some View { List { @@ -101,7 +91,7 @@ struct DefaultsItemView: View { #if DEBUG struct GuestDefaultsImportView_Previews: PreviewProvider { static var previews: some View { - GuestDefaultsImportView() + GuestDefaultsImportView(connection: GuestHostSession()) } } #endif diff --git a/VirtualBuddyGuest/Dashboard/HostConnectionStateProvider.swift b/VirtualBuddyGuest/Dashboard/HostConnectionStateProvider.swift deleted file mode 100644 index c35d75bb..00000000 --- a/VirtualBuddyGuest/Dashboard/HostConnectionStateProvider.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation -import VirtualWormhole - -protocol HostConnectionStateProvider: ObservableObject { - var isConnected: Bool { get } -} - -extension WormholeManager: HostConnectionStateProvider { } diff --git a/VirtualBuddyGuest/GuestAppDelegate.swift b/VirtualBuddyGuest/GuestAppDelegate.swift index b59a6b4d..8902a4f0 100644 --- a/VirtualBuddyGuest/GuestAppDelegate.swift +++ b/VirtualBuddyGuest/GuestAppDelegate.swift @@ -5,12 +5,18 @@ import VirtualWormhole import OSLog @NSApplicationMain +@MainActor final class GuestAppDelegate: NSObject, NSApplicationDelegate { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Guest", category: "GuestAppDelegate") private lazy var launchAtLoginManager = GuestLaunchAtLoginManager() + private let hostConnection = GuestHostSession() + private var connectionTask: Task? + + deinit { connectionTask?.cancel() } + private lazy var sharedFolders = GuestSharedFoldersManager() private lazy var dashboardItem: StatusItemManager = { @@ -19,7 +25,7 @@ final class GuestAppDelegate: NSObject, NSApplicationDelegate { statusItem: .button(label: { Image("StatusItem") }), content: GuestDashboard() .environmentObject(self.launchAtLoginManager) - .environmentObject(WormholeManager.sharedGuest) + .environment(self.hostConnection) .environmentObject(self.sharedFolders) ) }() @@ -49,7 +55,7 @@ final class GuestAppDelegate: NSObject, NSApplicationDelegate { launchAtLoginManager.autoEnableIfNeeded() - WormholeManager.sharedGuest.activate() + connectionTask = hostConnection.start() Task { try? await sharedFolders.mount() @@ -92,13 +98,7 @@ final class GuestAppDelegate: NSObject, NSApplicationDelegate { startedDesktopPictureSendBeforeTermination = true Task { - logger.notice("Requesting WH send desktop picture") - - try? await Task.sleep(for: .milliseconds(500)) - - await WormholeManager.sharedGuest.sendDesktopPicture() - - try? await Task.sleep(for: .seconds(500)) + await hostConnection.prepareForTermination() NSApp.reply(toApplicationShouldTerminate: true) } diff --git a/VirtualCore/Source/GuestSupport/GuestAppSupport.swift b/VirtualCore/Source/GuestSupport/GuestAppSupport.swift new file mode 100644 index 00000000..a79eaaf2 --- /dev/null +++ b/VirtualCore/Source/GuestSupport/GuestAppSupport.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Features supported by the guest OS and the configured guest app version. +public enum GuestAppSupport: Hashable, Sendable { + case unsupported + case sharedFoldersOnly + case full + + public static let minimumSystemVersion: SoftwareVersion = "13" + + static func resolve( + guestType: VBGuestType, + guestVersion: SoftwareVersion?, + latestMinimumVersion: SoftwareVersion, + guestAppVersion: CatalogLegacyGuestAppVersion.ID?, + legacyApps: [CatalogLegacyGuestAppVersion] + ) -> Self { + guard guestType == .mac else { return .unsupported } + + if let guestVersion, guestVersion > .empty { + guard guestVersion >= minimumSystemVersion else { return .unsupported } + if guestVersion < latestMinimumVersion { return .sharedFoldersOnly } + } else if let legacy = legacyApps.first(where: { $0.id == guestAppVersion }), + legacy.minGuestVersion < legacy.maxGuestVersion, + legacy.maxGuestVersion <= minimumSystemVersion { + // Imported VMs may only have an explicit legacy app selection to + // identify their OS. Keep recognizing archived macOS 12 selections. + return .unsupported + } + + // An override selects an archived app, even on an OS that can run the + // latest app. Archived apps cannot communicate with the current host. + return guestAppVersion == nil ? .full : .sharedFoldersOnly + } +} + +public extension VBMacConfiguration { + @MainActor + func guestAppSupport(for guestVersion: SoftwareVersion?) -> GuestAppSupport { + GuestAppSupport.resolve( + guestType: systemType, + guestVersion: guestVersion, + latestMinimumVersion: Bundle.embeddedGuestApp.minimumSystemVersion, + guestAppVersion: guestAppVersion, + legacyApps: SoftwareCatalog.currentMacCatalog.legacyGuestAppVersions + ) + } +} + +@MainActor +extension VBVirtualMachine { + var guestAppSupport: GuestAppSupport { + let catalog = SoftwareCatalog.currentMacCatalog + let version = [metadata.remoteInstallImageURL, metadata.installImageURL] + .compactMap { $0 } + .lazy.compactMap { catalog.resolvedRestoreImage(matching: $0, guestType: .mac)?.version } + .first + return configuration.guestAppSupport(for: version) + } +} diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift index 0c3f0847..6dbbba1d 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels+Summary.swift @@ -73,8 +73,11 @@ public extension VBMacConfiguration { var keyboardDeviceSummary: String { hardware.keyboardDevice.kind.name } - var guestAppSummary: String { - guestAdditionsEnabled ? "Enabled" : "Disabled" + @MainActor func guestAppSummary(for guestVersion: SoftwareVersion?) -> String { + let support = guestAppSupport(for: guestVersion) + if support == .unsupported { return "Not Supported" } + guard guestAdditionsEnabled else { return "Disabled" } + return support == .sharedFoldersOnly ? "Shared Folders Only" : "Enabled" } } diff --git a/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift b/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift index f43defd6..f9f5aebe 100644 --- a/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift +++ b/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift @@ -31,7 +31,7 @@ struct MacOSVirtualMachineConfigurationHelper: VirtualMachineConfigurationHelper func createAdditionalBlockDevices() async throws -> [VZVirtioBlockDeviceConfiguration] { var devices = try storageDeviceContainer.additionalBlockDevices(guestType: vm.configuration.systemType) - if vm.configuration.guestAdditionsEnabled { + if vm.configuration.guestAdditionsEnabled, await vm.guestAppSupport != .unsupported { do { if let disk = try await VZVirtioBlockDeviceConfiguration.guestAdditionsDisk(for: vm.configuration) { devices.append(disk) diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index 00829bf4..9b67ad57 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -285,7 +285,7 @@ public final class VMController: ObservableObject { /// It will also alert the user in case guest disk image generation has failed so that they know there's something wrong/ private func waitForGuestDiskImageReadyIfNeeded() async { guard virtualMachineModel.configuration.guestAdditionsEnabled, - virtualMachineModel.configuration.systemType.supportsGuestApp + virtualMachineModel.guestAppSupport != .unsupported else { return } /// Kick off legacy guest app download if needed. diff --git a/VirtualCore/Source/Virtualization/VMInstance.swift b/VirtualCore/Source/Virtualization/VMInstance.swift index 716e0807..df187847 100644 --- a/VirtualCore/Source/Virtualization/VMInstance.swift +++ b/VirtualCore/Source/Virtualization/VMInstance.swift @@ -11,6 +11,8 @@ import Virtualization import Combine import OSLog import VirtualWormhole +import VMBridge +import VMBridgeVirtualization @MainActor public final class VMInstance: NSObject, ObservableObject { @@ -43,7 +45,11 @@ public final class VMInstance: NSObject, ObservableObject { } } - let wormhole: WormholeManager = .sharedHost + private var guestSession: HostGuestSession? + private var guestRunTask: Task? + private var didHandleStop = false + + deinit { guestRunTask?.cancel() } private var isLoadingNVRAM = false private(set) var isRecoveryBoot = false @@ -123,6 +129,9 @@ public final class VMInstance: NSObject, ObservableObject { } let c = VZVirtualMachineConfiguration() + if model.guestAppSupport == .full { + c.socketDevices = [VZVirtioSocketDeviceConfiguration()] + } c.platform = platform c.bootLoader = try helper.createBootLoader() c.cpuCount = model.configuration.hardware.cpuCount @@ -152,6 +161,7 @@ public final class VMInstance: NSObject, ObservableObject { private func createVirtualMachine(savedState: VBSavedStatePackage?) async throws { logger.debug(#function) + await stopGuestCommunication() let installImage: URL? if options.bootOnInstallDevice { installImage = virtualMachineModel.metadata.installImageURL @@ -160,8 +170,6 @@ public final class VMInstance: NSObject, ObservableObject { } let config = try await Self.makeConfiguration(for: virtualMachineModel, installImageURL: installImage, savedState: savedState) // add install iso here for linux (hack) - await setupWormhole(for: config) - do { try config.validate() @@ -175,6 +183,7 @@ public final class VMInstance: NSObject, ObservableObject { networkAttachmentHelper?.stop() let virtualMachine = VZVirtualMachine(configuration: config) + didHandleStop = false _virtualMachine = virtualMachine networkAttachmentHelper = VMNetworkAttachmentHelper( virtualMachine: virtualMachine, @@ -183,122 +192,77 @@ public final class VMInstance: NSObject, ObservableObject { ) } - private func setupWormhole(for config: VZVirtualMachineConfiguration) async { - guard virtualMachineModel.configuration.systemType == .mac else { return } - - wormhole.activate() - - let guestPort = VZVirtioConsoleDeviceSerialPortConfiguration() - - let inputPipe = Pipe() - let outputPipe = Pipe() - - let inputHandle = inputPipe.fileHandleForWriting - let outputHandle = outputPipe.fileHandleForReading - - guestPort.attachment = VZFileHandleSerialPortAttachment( - fileHandleForReading: outputHandle, - fileHandleForWriting: inputHandle - ) - - config.serialPorts = [guestPort] - - await wormhole.register( - input: inputPipe.fileHandleForReading, - output: outputPipe.fileHandleForWriting, - for: virtualMachineModel.wormholeID - ) - - streamGuestNotifications() - streamGuestDesktopPictureMessages() - } - - private lazy var guestIOTasks = [Task]() - - public func streamGuestNotifications() { - logger.debug(#function) - - let notificationNames: Set = [ - "com.apple.shieldWindowRaised", - "com.apple.shieldWindowLowered" - ] - - let task = Task { - do { - for await notification in try await wormhole.darwinNotifications(matching: notificationNames, from: virtualMachineModel.wormholeID) { - if notification == "com.apple.shieldWindowRaised" { - logger.debug("🔒 Guest locked") - } else if notification == "com.apple.shieldWindowLowered" { - logger.debug("🔓 Guest unlocked") - } - } - } catch { - logger.error("Error subscribing to Darwin notifications: \(error, privacy: .public)") - } + private func startGuestCommunication() throws { + guard virtualMachineModel.guestAppSupport == .full else { return } + let vm = try virtualMachine + guard let device = vm.socketDevices.first as? VZVirtioSocketDevice else { + throw Failure("The guest communication socket device is unavailable.") } - guestIOTasks.append(task) - } - - public func streamGuestDesktopPictureMessages() { - logger.debug(#function) - - let task = Task { + let session = HostGuestSession(connection: .host(socketDevice: device, port: GuestCommunication.port)) + session.onNotification = { [weak self] name in + self?.logger.debug("Guest system notification: \(name, privacy: .public)") + } + session.onDesktopPicture = { [weak self] picture in + guard let self, let image = NSImage(data: picture.content) else { return } do { - for await message in try await wormhole.desktopPictureMessages(from: virtualMachineModel.wormholeID) { - do { - let fileURL = virtualMachineModel.metadataFileURL(VBVirtualMachine.thumbnailFileName) - - try message.content.write(to: fileURL, options: .atomic) - - if let image = NSImage(data: message.content), - let blurHash = image.blurHash(numberOfComponents: (Int.vbBlurHashSize, Int.vbBlurHashSize)) - { - virtualMachineModel.metadata.backgroundHash = BlurHashToken(value: blurHash, size: .vbBlurHashSize) - } - - try virtualMachineModel.saveMetadata() - } catch { - logger.error("Error handling desktop picture message: \(error, privacy: .public)") - } + let url = self.virtualMachineModel.metadataFileURL(VBVirtualMachine.thumbnailFileName) + try picture.content.write(to: url, options: .atomic) + if let hash = image.blurHash(numberOfComponents: (Int.vbBlurHashSize, Int.vbBlurHashSize)) { + self.virtualMachineModel.metadata.backgroundHash = BlurHashToken(value: hash, size: .vbBlurHashSize) } + try self.virtualMachineModel.saveMetadata() } catch { - logger.error("Error subscribing to desktop picture messages: \(error, privacy: .public)") + self.logger.error("Error saving guest desktop picture: \(error, privacy: .public)") } } + guestSession = session + guestRunTask = session.start() + } - guestIOTasks.append(task) + func stopGuestCommunication() async { + let session = guestSession + guestSession = nil + guestRunTask?.cancel() + await session?.stop() + guestRunTask = nil } func startVM() async throws { try await bootstrap() - 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) + do { + 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() } - startOptions = macOptions - isRecoveryBoot = macOptions.startUpFromMacOSRecovery - case .linux: - startOptions = VZVirtualMachineStartOptions() - } - try await vm.start(options: startOptions) + try await vm.start(options: startOptions) - networkAttachmentHelper?.startMonitoringHostInterfaces() - startUSBDeviceMonitoring(for: vm) + networkAttachmentHelper?.startMonitoringHostInterfaces() + startUSBDeviceMonitoring(for: vm) - #if DEBUG - VBDebugUtil.debugVirtualMachine(afterStart: vm) - #endif + #if DEBUG + VBDebugUtil.debugVirtualMachine(afterStart: vm) + #endif + } catch { + await stopGuestCommunication() + library.unregisterBootedVM(self) + throw error + } } private func bootstrap(savedState: VBSavedStatePackage? = nil) async throws { @@ -307,6 +271,7 @@ public final class VMInstance: NSObject, ObservableObject { let vm = try ensureVM() vm.delegate = self + try startGuestCommunication() library.registerBootedVM(self) @@ -344,7 +309,13 @@ public final class VMInstance: NSObject, ObservableObject { let vm = try ensureVM() - try await vm.stop() + await stopGuestCommunication() + do { + try await vm.stop() + } catch { + try? startGuestCommunication() + throw error + } networkAttachmentHelper?.stop() stopUSBDeviceMonitoring() @@ -488,6 +459,8 @@ public final class VMInstance: NSObject, ObservableObject { VBDebugUtil.debugVirtualMachine(afterStart: vm) #endif } catch { + await stopGuestCommunication() + library.unregisterBootedVM(self) logger.error("VM state restoration failed: \(error, privacy: .public). State file: \(package.dataFileURL.path)") throw error @@ -579,12 +552,14 @@ extension VMInstance: VZVirtualMachineDelegate { public nonisolated func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) { MainActor.assumeIsolated { + guard virtualMachine === self._virtualMachine else { return } handleGuestStopped(with: error) } } public nonisolated func guestDidStop(_ virtualMachine: VZVirtualMachine) { MainActor.assumeIsolated { + guard virtualMachine === self._virtualMachine else { return } handleGuestStopped(with: nil) } } @@ -603,8 +578,8 @@ extension VMInstance: VZVirtualMachineDelegate { networkAttachmentHelper?.stop() stopUSBDeviceMonitoring() - guestIOTasks.forEach { $0.cancel() } - guestIOTasks.removeAll() + guard !didHandleStop else { return } + didHandleStop = true if let error { logger.error("Guest stopped with error: \(String(describing: error), privacy: .public)") @@ -612,13 +587,9 @@ extension VMInstance: VZVirtualMachineDelegate { logger.debug("Guest stopped") } - DispatchQueue.main.async { [self] in + Task { [self] in + await stopGuestCommunication() library.unregisterBootedVM(self) - - Task { - await wormhole.unregister(virtualMachineModel.wormholeID) - } - onVMStop(error) } } @@ -642,17 +613,6 @@ extension NSApplication { } -private extension VBVirtualMachine { - /// ``VBVirtualMachine/id`` uses the VM's filesystem URL, - /// but that looks ugly in logs and whatnot, so this returns a cleaned up version. - var wormholeID: WHPeerID { - let cleanID = URL(fileURLWithPath: id) - .deletingPathExtension() - .lastPathComponent - return cleanID.removingPercentEncoding ?? cleanID - } -} - extension VZMacOSVirtualMachineStartOptions { convenience init(options: VMSessionOptions) { self.init() diff --git a/VirtualCore/Source/Virtualization/VMLibraryController.swift b/VirtualCore/Source/Virtualization/VMLibraryController.swift index eb541382..e5115853 100644 --- a/VirtualCore/Source/Virtualization/VMLibraryController.swift +++ b/VirtualCore/Source/Virtualization/VMLibraryController.swift @@ -537,6 +537,16 @@ public extension VMLibraryController { } } } + + /// Closes guest connections before the application exits. + func stopGuestCommunication() async { + let instances = Array(bootedInstances.dictionaryRepresentation().values) + await withTaskGroup(of: Void.self) { group in + for instance in instances { + group.addTask { await instance.stopGuestCommunication() } + } + } + } } // MARK: - App Termination Assertion diff --git a/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift index dc6260b1..e552ab03 100644 --- a/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/GuestAppConfigurationView.swift @@ -18,76 +18,87 @@ struct GuestAppConfigurationView: View { resolvedRestoreImage?.feature(id: CatalogFeatureID.guestApp)?.status } - private var guestAppUnsupported: Bool { guestAppStatus?.isUnsupported == true } - private var guestAppHelp: String? { - guestAppUnsupported ? (guestAppStatus?.supportMessage ?? "Not supported.") : nil - } + private var support: GuestAppSupport { + configuration.guestAppSupport(for: resolvedRestoreImage?.version) + } + + private var guestAppUnsupported: Bool { + support == .unsupported || guestAppStatus?.isUnsupported == true + } private var availableGuestAppVersions: [CatalogLegacyGuestAppVersion] { SoftwareCatalog.currentMacCatalog.legacyGuestAppVersions - .filter { $0.supports(resolvedRestoreImage) } + .filter { + // Preserve an existing override so imported VMs can display its + // support status and let the user choose a supported version. + $0.id == configuration.guestAppVersion + || ($0.maxGuestVersion > GuestAppSupport.minimumSystemVersion && $0.supports(resolvedRestoreImage)) + } .sorted(by: { $0.minGuestVersion > $1.minGuestVersion }) } - private var disableVersionPicker: Bool { availableGuestAppVersions.count <= 1 } + private var supportsLatest: Bool { + CatalogLegacyGuestAppVersion.default.supports(resolvedRestoreImage) + } + + private var disableVersionPicker: Bool { + availableGuestAppVersions.count + (supportsLatest ? 1 : 0) <= 1 + } var body: some View { VStack(alignment: .leading, spacing: 16) { - Group { - if let guestAppHelp { - Toggle("Enable VirtualBuddy Guest App", isOn: $configuration.guestAdditionsEnabled) - .disabled(true) - .help(guestAppHelp) - } else { - Toggle("Enable VirtualBuddy Guest App", isOn: $configuration.guestAdditionsEnabled) - } - } - .onChange(of: guestAppUnsupported) { _, isUnsupported in - if isUnsupported { - configuration.guestAdditionsEnabled = false - } - } - .onAppear { - if guestAppUnsupported { - configuration.guestAdditionsEnabled = false + Toggle("Enable VirtualBuddy Guest App", isOn: $configuration.guestAdditionsEnabled) + .disabled(guestAppUnsupported) + .onChange(of: guestAppUnsupported, initial: true) { _, isUnsupported in + if isUnsupported { + configuration.guestAdditionsEnabled = false + } } - } - - /** - 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() + // The override also identifies the OS for imported VMs without + // restore-image metadata. Keep it available for those VMs. + if !guestAppUnsupported || resolvedRestoreImage == nil { + Picker("Override Guest App Version", selection: $configuration.guestAppVersion) { + if supportsLatest { + Text(CatalogLegacyGuestAppVersion.default.title) + .tag(Optional.none) + } + + ForEach(availableGuestAppVersions) { option in + Text(option.title) + .tag(Optional.some(option.id)) + } } - - ForEach(availableGuestAppVersions) { option in - Text(option.title) - .tag(Optional.some(option.id)) + .onChange(of: resolvedRestoreImage, initial: true) { _, _ in + if !guestAppUnsupported, !supportsLatest, configuration.guestAppVersion == nil { + configuration.guestAppVersion = availableGuestAppVersions.first?.id + } } + .disabled(disableVersionPicker) + .help("Choose a compatible guest app version for an older version of macOS. Legacy guest apps support automatic mounting of shared folders only.") } - .task { - if !CatalogLegacyGuestAppVersion.default.supports(resolvedRestoreImage), configuration.guestAppVersion == nil { - configuration.guestAppVersion = availableGuestAppVersions.first(where: { $0.supports(resolvedRestoreImage) })?.id + + VStack(alignment: .leading, spacing: 12) { + if support == .unsupported { + Text("VirtualBuddyGuest is not supported on macOS 12 or earlier. Clipboard sharing and automatic mounting of shared folders are unavailable.") + } else if guestAppUnsupported { + Text(guestAppStatus?.supportMessage ?? "VirtualBuddyGuest is not supported for this virtual machine.") + } else { + switch support { + case .sharedFoldersOnly: + Text("This legacy version of VirtualBuddyGuest only mounts shared folders automatically. Clipboard sharing requires the latest VirtualBuddyGuest app and macOS \(Bundle.embeddedGuestApp.minimumSystemVersion.shortDescription) or later in the virtual machine.") + case .full: + Text("VirtualBuddyGuest mounts shared folders automatically and shares the clipboard between your Mac and the virtual machine.") + case .unsupported: + EmptyView() + } + + Text("To install the app in your virtual machine, select the “Guest” disk in the Finder sidebar, then double-click the VirtualBuddyGuest app icon.") } } - /// 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. - - To install the app in your virtual machine, look for a disk image named “Guest” in the Finder sidebar. \ - Double-click the VirtualBuddyGuest app icon to install the app. - """) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) } } } @@ -113,8 +124,18 @@ extension CatalogLegacyGuestAppVersion { } #if DEBUG -#Preview { +#Preview("Latest Guest App") { + _ConfigurationSectionPreview { GuestAppConfigurationView(configuration: $0) } + .environment(\.resolvedRestoreImage, ResolvedRestoreImage.previewMac) +} + +#Preview("macOS 13") { + _ConfigurationSectionPreview { GuestAppConfigurationView(configuration: $0) } + .environment(\.resolvedRestoreImage, ResolvedRestoreImage.previewMacLegacyVentura) +} + +#Preview("macOS 12") { _ConfigurationSectionPreview { GuestAppConfigurationView(configuration: $0) } -// .environment(\.resolvedRestoreImage, ResolvedRestoreImage.previewMac) + .environment(\.resolvedRestoreImage, ResolvedRestoreImage.previewMacLegacyMonterey) } #endif diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift index b5cd8add..9abf900a 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationView.swift @@ -370,7 +370,7 @@ struct VMConfigurationView: View { } header: { SummaryHeader( "Guest App", - summary: viewModel.config.guestAppSummary + summary: viewModel.config.guestAppSummary(for: viewModel.resolvedRestoreImage?.version) ) { Image(.guestSymbol) .resizable() diff --git a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift index 43091e07..b52e787b 100644 --- a/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift +++ b/VirtualUI/Source/VM Configuration/VMConfigurationViewModel.swift @@ -122,7 +122,8 @@ private extension VMConfigurationViewModel { var updated = config - if resolvedRestoreImage.feature(id: CatalogFeatureID.guestApp)?.status.isUnsupported == true { + if resolvedRestoreImage.version < GuestAppSupport.minimumSystemVersion + || resolvedRestoreImage.feature(id: CatalogFeatureID.guestApp)?.status.isUnsupported == true { updated.guestAdditionsEnabled = false } diff --git a/VirtualWormhole/README.md b/VirtualWormhole/README.md new file mode 100644 index 00000000..0d5251c4 --- /dev/null +++ b/VirtualWormhole/README.md @@ -0,0 +1,51 @@ +# Guest communication + +VirtualWormhole contains VirtualBuddy's application integrations over VMBridge. +`HostGuestSession` belongs to a single `VMInstance`; `GuestHostSession` belongs to +the guest app. Both endpoints use Virtio socket port 51780. Creating a session is +inert; `start()` returns its owned lifetime task and `stop()` cancels and awaits +teardown. Cancel the returned task if its VM owner is being released. + +Each connection initializes a fresh application session token. Clipboard updates, +notifications, pictures, and defaults operations are checked against this token. +The host sends its current clipboard after initialization; guest clipboard +observation starts after that snapshot is applied. Later clipboard transfers are +superseded by newer remote revisions or local edits. A shared host coordinator +relays guest edits to the other connected guests unless +`WHDisablePayloadPropagation` is enabled. + +Clipboard snapshots and defaults replies fitting VMBridge's encoded-message limit +travel inline. Larger payloads use verified file transfers with a purpose, +session token, and operation ID. A single dispatcher accepts only recognized +offers into temporary files chosen locally. Defaults transfers additionally +require a pending import operation. Failed, cancelled, and superseded transfers +are discarded. Defaults request and transfer waits each have a 30-second deadline. + +The guest dashboard uses the app-owned observable session. Defaults import remains +behind `ENABLE_USERDEFAULTS_SYNC`. Guest termination gives the last desktop picture +two seconds, then closes the connection before joining the final send task. + +## Verification + +Run the VirtualWormhole scheme's tests through Xcode. `GuestSessionTests` exercises +the application layer with injected connections and feature providers; VMBridge's +own suite covers framing and socket transport. Build both VirtualBuddy and +VirtualBuddyGuest, including a guest build with `ENABLE_USERDEFAULTS_SYNC` enabled. + +Use a macOS VM with the rebuilt guest app for runtime verification: + +1. Confirm `Connected to VirtualBuddy` and host-first clipboard synchronization. +2. Copy supported text and image formats both ways, including an image whose + encoded data exceeds 8 MiB. Copy new content while a large transfer is pending. +3. Restart the guest app and confirm reconnection, a refreshed desktop thumbnail, + and one notification registration per name. Exercise lock/unlock notifications. +4. Import a configured defaults domain in a feature-enabled build, checking the + existing restart confirmation. Interrupt an import by quitting the guest app. +5. Pause/resume, save/restore, request guest shutdown, and force-stop a VM. Verify + subsequent boots connect and interrupted operations do not affect them. +6. Repeat clipboard and VM-specific events with two guests; disable clipboard + relay and verify isolation. Disconnect the VM's IP network and verify clipboard + communication still works over Virtio sockets. + +Also launch the guest app outside Xcode: a successful local build can resolve +dynamic package frameworks from DerivedData and conceal missing embedded libraries. diff --git a/VirtualWormhole/Source/Definitions/VirtualWormholeConstants.swift b/VirtualWormhole/Source/Definitions/VirtualWormholeConstants.swift index 1539c316..e7f8d20d 100644 --- a/VirtualWormhole/Source/Definitions/VirtualWormholeConstants.swift +++ b/VirtualWormhole/Source/Definitions/VirtualWormholeConstants.swift @@ -11,21 +11,7 @@ import OSLog struct VirtualWormholeConstants { static let subsystemName = "codes.rambo.VirtualWormhole" - static let verboseLoggingEnabled: Bool = { - #if DEBUG - return UserDefaults.standard.bool(forKey: "WHVerbosePacketLogging") - #else - return false - #endif - }() - static let payloadPropagationEnabled: Bool = { - !UserDefaults.standard.bool(forKey: "WHDisablePayloadPropagation") - }() - - static let connectionTimeoutInNanoseconds: UInt64 = 15 * NSEC_PER_SEC - - static let pingIntervalInSeconds: TimeInterval = 5.0 } extension Logger { diff --git a/VirtualWormhole/Source/GuestConnection.swift b/VirtualWormhole/Source/GuestConnection.swift new file mode 100644 index 00000000..c2987c38 --- /dev/null +++ b/VirtualWormhole/Source/GuestConnection.swift @@ -0,0 +1,135 @@ +import Foundation +import VMBridge + +// The reply closure stays typed at the VMBridge boundary. Only the two app +// request types have responders; there is no second wire protocol here. +struct InitializationRequest: Sendable { + let payload: InitializeGuest + let reply: @Sendable (InitializeGuestReply) async throws -> Void +} + +struct DefaultsRequest: Sendable { + let payload: ExportDefaults + let reply: @Sendable (ExportDefaultsReply) async throws -> Void +} + +struct GuestFileOffer: Sendable { + let metadata: BulkTransferMetadata + let receive: @Sendable (URL) async throws -> Void + let reject: @Sendable () async -> Void +} + +protocol GuestConnection: Sendable { + var state: AsyncStream { get } + var initializationRequests: AsyncStream { get } + var defaultsRequests: AsyncStream { get } + var fileOffers: AsyncStream { get } + func messages(of type: M.Type) -> AsyncStream + func send(_ message: M) async throws + func request(_ message: M, reply: R.Type, timeout: Duration) async throws -> R + func sendFile(at url: URL, metadata: BulkTransferMetadata) async throws + func run() async throws +} + +struct LiveGuestConnection: GuestConnection { + let connection: VMConnection + var state: AsyncStream { connection.state } + var initializationRequests: AsyncStream { + connection.requests(of: InitializeGuest.self).mapped { request in + InitializationRequest(payload: request.payload, reply: { try await request.reply($0) }) + } + } + var defaultsRequests: AsyncStream { + connection.requests(of: ExportDefaults.self).mapped { request in + DefaultsRequest(payload: request.payload, reply: { try await request.reply($0) }) + } + } + var fileOffers: AsyncStream { + connection.bulkTransferOffers.mapped { offer in + GuestFileOffer(metadata: offer.metadata, receive: { url in + let transfer = try await offer.accept(to: url) + do { + try await withTaskCancellationHandler { + _ = try await transfer.waitForCompletion() + try Task.checkCancellation() + } onCancel: { + Task { await transfer.cancel() } + } + } catch { + await transfer.cancel() + throw error + } + }, reject: { try? await offer.reject() }) + } + } + func messages(of type: M.Type) -> AsyncStream { + connection.messages(of: type).mapped { $0.payload } + } + func send(_ message: M) async throws { try await connection.send(message) } + func request(_ message: M, reply: R.Type, timeout: Duration) async throws -> R { + try await connection.send(message, expecting: reply, timeout: timeout) + } + func sendFile(at url: URL, metadata: BulkTransferMetadata) async throws { + let transfer = try await connection.sendFile(at: url, metadata: metadata) + do { + try await withTaskCancellationHandler { + _ = try await transfer.waitForCompletion() + try Task.checkCancellation() + } onCancel: { + Task { await transfer.cancel() } + } + } catch { + await transfer.cancel() + throw error + } + } + func run() async throws { try await connection.run() } +} + +extension AsyncStream where Element: Sendable { + func mapped(_ transform: @escaping @Sendable (Element) -> Value) -> AsyncStream { + AsyncStream(unfolding: { + var iterator = self.makeAsyncIterator() + guard let element = await iterator.next() else { return nil } + return transform(element) + }) + } +} + +enum GuestPayloadIO { + static func withTemporaryFile(_ operation: @MainActor (URL) async throws -> Void) async throws { + let url = temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + try await operation(url) + } + + static func remove(at url: URL) async { + try? FileManager.default.removeItem(at: url) + } + + static func fits(_ message: M) async throws -> Bool { + try Task.checkCancellation() + return try JSONEncoder().encode(message).count <= VMConnection.maximumMessageSize + } + static func writeClipboard(_ items: [ClipboardItem], to url: URL) async throws { + try Task.checkCancellation() + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + try encoder.encode(items).write(to: url, options: .atomic) + } + static func readClipboard(at url: URL) async throws -> [ClipboardItem] { + try Task.checkCancellation() + return try PropertyListDecoder().decode([ClipboardItem].self, from: Data(contentsOf: url)) + } + static func read(at url: URL) async throws -> Data { + try Task.checkCancellation() + return try Data(contentsOf: url) + } + static func write(_ data: Data, to url: URL) async throws { + try Task.checkCancellation() + try data.write(to: url, options: .atomic) + } + static func temporaryURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("VirtualBuddy-\(UUID().uuidString).plist") + } +} diff --git a/VirtualWormhole/Source/GuestFeatures.swift b/VirtualWormhole/Source/GuestFeatures.swift new file mode 100644 index 00000000..2c051cec --- /dev/null +++ b/VirtualWormhole/Source/GuestFeatures.swift @@ -0,0 +1,152 @@ +import AppKit +import AVFoundation +import VMBridge + +@MainActor +protocol GuestClipboard: AnyObject { + var changeCount: Int { get } + func read() -> [ClipboardItem] + func write(_ items: [ClipboardItem]) +} + +@MainActor +final class SystemGuestClipboard: GuestClipboard { + static let shared = SystemGuestClipboard() + private let pasteboard = NSPasteboard.general + var changeCount: Int { pasteboard.changeCount } + func read() -> [ClipboardItem] { + let types: [NSPasteboard.PasteboardType] = [.string, .rtf, .rtfd, .pdf, .png, .tiff] + return types.compactMap { type in + if type == .tiff, pasteboard.types?.contains(.png) == true { return nil } + return pasteboard.data(forType: type).map { ClipboardItem(type: type.rawValue, data: $0) } + } + } + func write(_ items: [ClipboardItem]) { + pasteboard.clearContents() + for item in items { pasteboard.setData(item.data, forType: .init(item.type)) } + } +} + +@MainActor +final class HostClipboardCoordinator { + static let shared = HostClipboardCoordinator(clipboard: SystemGuestClipboard.shared) + let clipboard: any GuestClipboard + private let relayEnabled: () -> Bool + private var observers: [UUID: ([ClipboardItem]) -> Void] = [:] + private var changeCount: Int + private var items: [ClipboardItem] + + init(clipboard: any GuestClipboard, relayEnabled: @escaping () -> Bool = { !UserDefaults.standard.bool(forKey: "WHDisablePayloadPropagation") }) { + self.clipboard = clipboard + self.relayEnabled = relayEnabled + changeCount = clipboard.changeCount + items = clipboard.read() + } + + func register(_ id: UUID, receive: @escaping ([ClipboardItem]) -> Void) { + poll() + observers[id] = receive + receive(items) + } + + func unregister(_ id: UUID) { + observers[id] = nil + } + + func poll() { + guard changeCount != clipboard.changeCount else { return } + changeCount = clipboard.changeCount + let current = clipboard.read() + guard current != items else { return } + items = current + for receive in observers.values { receive(current) } + } + + func receive(_ current: [ClipboardItem], from id: UUID) { + guard observers[id] != nil else { return } + guard current != clipboard.read() else { return } + clipboard.write(current) + changeCount = clipboard.changeCount + items = current + guard relayEnabled() else { return } + for (other, receive) in observers where other != id { receive(current) } + } + +} + +@MainActor +struct GuestFeatureProviders { + var clipboard: any GuestClipboard + var notifications: (Set, @escaping @MainActor (String) -> Void) throws -> (() -> Void) + var desktopPicture: () async throws -> GuestDesktopPicture? + var exportDefaults: (String, URL) async throws -> Void + var importDefaults: (String, URL) async throws -> Void + + static var live: Self { + Self(clipboard: SystemGuestClipboard.shared, notifications: { names, receive in + var darwin: [SystemNotification] = [] + var distributed: [NSObjectProtocol] = [] + let center = DistributedNotificationCenter.default() + do { + for name in names { + let notification = SystemNotification(with: name) { + MainActor.assumeIsolated { receive(name) } + } + try notification.activate() + darwin.append(notification) + distributed.append(center.addObserver(forName: .init(name), object: nil, queue: .main) { _ in + MainActor.assumeIsolated { receive(name) } + }) + } + } catch { + darwin.forEach { $0.invalidate() } + distributed.forEach { center.removeObserver($0) } + throw error + } + return { + darwin.forEach { $0.invalidate() } + distributed.forEach { center.removeObserver($0) } + } + }, desktopPicture: { + if let image = NSImage.desktopPicture, + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil), + let picture = try await DesktopPictureEncoder.encode(cgImage) { + return picture + } + // Modern wallpaper providers may not expose a Dock wallpaper window. + guard let screen = NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen) else { return nil } + return try await DesktopPictureEncoder.encodeFile(at: url) + }, exportDefaults: { id, url in + guard let descriptor = DefaultsImportController().descriptors[id] else { throw GuestSessionError.unavailableDomain } + try await descriptor.exportDefaults(to: url) + }, importDefaults: { id, url in + guard let descriptor = DefaultsImportController().descriptors[id] else { throw GuestSessionError.unavailableDomain } + try await descriptor.importDefaults(from: url) + }) + } +} + +private enum DesktopPictureEncoder { + static func encodeFile(at url: URL) async throws -> GuestDesktopPicture? { + try Task.checkCancellation() + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return nil } + return try await encode(image) + } + + static func encode(_ image: CGImage) async throws -> GuestDesktopPicture? { + try Task.checkCancellation() + guard !image.isFullyTransparent() else { return nil } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, AVFileType.heic.rawValue as CFString, 1, nil) else { + throw CocoaError(.fileWriteUnknown) + } + CGImageDestinationAddImage(destination, image, [ + kCGImageDestinationLossyCompressionQuality: 0.8, + kCGImageDestinationImageMaxPixelSize: 512 + ] as CFDictionary) + guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) } + return GuestDesktopPicture(type: AVFileType.heic.rawValue, content: data as Data) + } +} diff --git a/VirtualWormhole/Source/GuestMessages.swift b/VirtualWormhole/Source/GuestMessages.swift new file mode 100644 index 00000000..b09a53e3 --- /dev/null +++ b/VirtualWormhole/Source/GuestMessages.swift @@ -0,0 +1,90 @@ +import Foundation +import VMBridge + +public enum GuestCommunication { + public static let port: UInt32 = 51_780 +} + +struct InitializeGuest: VMMessage { let session: UUID } +struct InitializeGuestReply: VMMessage { let notifications: Set } + +struct ClipboardItem: Codable, Sendable, Equatable { + let type: String + let data: Data +} + +struct ClipboardUpdate: VMMessage { + let session: UUID + let revision: UInt64 + let items: [ClipboardItem] +} + +struct GuestNotification: VMMessage { + let session: UUID + let name: String +} + +public struct GuestDesktopPicture: Sendable { + public let type: String + public let content: Data +} + +struct DesktopPictureUpdate: VMMessage { + let session: UUID + let type: String + let content: Data +} + +struct ExportDefaults: VMMessage { + let session: UUID + let operation: UUID + let domain: String +} + +enum ExportDefaultsReply: VMMessage { + case inline(Data) + case file + case failure(String) +} + +struct TransferIdentity: Sendable { + enum Purpose: String { case clipboard, defaults } + let session: UUID + let operation: UUID + let purpose: Purpose + let revision: UInt64 + + var metadata: BulkTransferMetadata { + .init(name: "\(purpose.rawValue).plist", contentType: "com.apple.property-list", userInfo: [ + "session": session.uuidString, "operation": operation.uuidString, + "purpose": purpose.rawValue, "revision": String(revision) + ]) + } + + init(session: UUID, operation: UUID, purpose: Purpose, revision: UInt64 = 0) { + self.session = session + self.operation = operation + self.purpose = purpose + self.revision = revision + } + + init?(_ metadata: BulkTransferMetadata) { + guard let session = metadata.userInfo["session"].flatMap(UUID.init(uuidString:)), + let operation = metadata.userInfo["operation"].flatMap(UUID.init(uuidString:)), + let purpose = metadata.userInfo["purpose"].flatMap(Purpose.init(rawValue:)), + let revision = metadata.userInfo["revision"].flatMap(UInt64.init) else { return nil } + self.init(session: session, operation: operation, purpose: purpose, revision: revision) + } +} + +enum GuestSessionError: LocalizedError { + case disconnected, invalidReply, unavailableDomain, remote(String) + var errorDescription: String? { + switch self { + case .disconnected: "The connection to VirtualBuddy ended." + case .invalidReply: "The host returned an invalid response." + case .unavailableDomain: "This defaults domain is not available for import." + case .remote(let message): message + } + } +} diff --git a/VirtualWormhole/Source/GuestSessions.swift b/VirtualWormhole/Source/GuestSessions.swift new file mode 100644 index 00000000..f8e6ca04 --- /dev/null +++ b/VirtualWormhole/Source/GuestSessions.swift @@ -0,0 +1,450 @@ +import Foundation +import Observation +import OSLog +import VMBridge + +@MainActor +public final class HostGuestSession { + private let engine: GuestSessionEngine + private var task: Task? + public var isConnected: Bool { engine.isConnected } + public var onDesktopPicture: ((GuestDesktopPicture) -> Void)? { + get { engine.onDesktopPicture } + set { engine.onDesktopPicture = newValue } + } + public var onNotification: ((String) -> Void)? { + get { engine.onNotification } + set { engine.onNotification = newValue } + } + public init(connection: VMConnection) { + engine = GuestSessionEngine(connection: LiveGuestConnection(connection: connection), hostClipboard: .shared, providers: .live) + } + @discardableResult public func start() -> Task { + let task = engine.start() + self.task = task + return task + } + public func stop() async { await engine.stop(); task = nil } + deinit { task?.cancel() } +} + +@MainActor @Observable +public final class GuestHostSession { + private let engine: GuestSessionEngine + @ObservationIgnored private var task: Task? + public var isConnected: Bool { engine.isConnected } + public init() { + engine = GuestSessionEngine(connection: LiveGuestConnection(connection: .guest(port: GuestCommunication.port)), providers: .live) + } + init(engine: GuestSessionEngine) { self.engine = engine } + @discardableResult public func start() -> Task { + let task = engine.start() + self.task = task + return task + } + public func stop() async { await engine.stop(); task = nil } + deinit { task?.cancel() } + public func sendDesktopPicture() async throws { try await engine.sendDesktopPicture() } + public func importDomain(with id: String) async throws { try await engine.importDomain(id) } + + public func prepareForTermination() async { + await withTaskGroup(of: Void.self) { group in + group.addTask { try? await self.sendDesktopPicture() } + group.addTask { try? await Task.sleep(for: .seconds(2)) } + await group.next() + group.cancelAll() + // Cancelling a send cannot interrupt an in-flight frame. Close the + // connection before joining the final-send task to enforce the deadline. + await stop() + } + } +} + +@MainActor @Observable +final class GuestSessionEngine { + private(set) var isConnected = false + var onDesktopPicture: ((GuestDesktopPicture) -> Void)? + var onNotification: ((String) -> Void)? + private let connection: any GuestConnection + private let hostClipboard: HostClipboardCoordinator? + private let providers: GuestFeatureProviders + private let logger = Logger(subsystem: "codes.rambo.VirtualWormhole", category: "GuestSession") + private let identity = UUID() + private var session: UUID? + private var runTask: Task? + private var operations: [UUID: Task] = [:] + private var imports: [UUID: Task] = [:] + private let requestTimeout: Duration + private var outboundClipboard: Task? + private var inboundClipboard: Task? + private var outgoingRevision: UInt64 = 0 + private var incomingRevision: UInt64 = 0 + private var observedClipboardChange = 0 + private var previousClipboard: [ClipboardItem] = [] + private var clipboardInitialized = false + private var removeNotifications: (() -> Void)? + private static let notificationNames: Set = ["com.apple.shieldWindowRaised", "com.apple.shieldWindowLowered"] + + private struct PendingDefaults { + let session: UUID + let continuation: AsyncThrowingStream.Continuation + var file: URL? + var transfer: Task? + } + private var pendingDefaults: [UUID: PendingDefaults] = [:] + + init(connection: any GuestConnection, hostClipboard: HostClipboardCoordinator? = nil, providers: GuestFeatureProviders, requestTimeout: Duration = .seconds(30)) { + self.requestTimeout = requestTimeout + self.connection = connection + self.hostClipboard = hostClipboard + self.providers = providers + } + + @discardableResult func start() -> Task { + if let runTask { return runTask } + // Obtain every subscription synchronously, before the connection can run. + let states = connection.state + let initializations = connection.initializationRequests + let defaults = connection.defaultsRequests + let clipboard = connection.messages(of: ClipboardUpdate.self) + let pictures = connection.messages(of: DesktopPictureUpdate.self) + let notifications = connection.messages(of: GuestNotification.self) + let offers = connection.fileOffers + let task = Task { + await withTaskGroup(of: Void.self) { group in + group.addTask { for await state in states { await self.stateChanged(state) } } + group.addTask { for await request in initializations { await self.initialize(request) } } + group.addTask { for await request in defaults { await self.exportDefaults(request) } } + group.addTask { for await update in clipboard { await self.receiveClipboard(update) } } + group.addTask { for await picture in pictures { await self.receivePicture(picture) } } + group.addTask { for await notification in notifications { await self.receiveNotification(notification) } } + group.addTask { for await offer in offers { await self.receiveOffer(offer) } } + group.addTask { + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(500)) } catch { break } + await self.pollClipboard() + } + } + group.addTask { + do { try await self.connection.run() } + catch { await self.log(error) } + } + // Any unexpectedly finished stream ends this run as well. + await group.next() + group.cancelAll() + } + invalidateSession() + let remaining = Array(operations.values) + let pendingImports = Array(imports.values) + for task in remaining { await task.value } + for task in pendingImports { _ = await task.result } + runTask = nil + } + runTask = task + return task + } + + func stop() async { + guard let runTask else { return } + runTask.cancel() + await runTask.value + } + + private func log(_ error: Error) { + guard !(error is CancellationError) else { return } + logger.error("Guest communication: \(error.localizedDescription, privacy: .public)") + } + + @discardableResult + private func schedule(_ action: @escaping @MainActor () async throws -> Void) -> Task { + let id = UUID() + let task = Task { + defer { operations[id] = nil } + do { try Task.checkCancellation(); try await action() } catch { log(error) } + } + operations[id] = task + return task + } + + private func invalidateSession() { + session = nil + isConnected = false + clipboardInitialized = false + hostClipboard?.unregister(identity) + removeNotifications?() + removeNotifications = nil + for task in operations.values { task.cancel() } + for task in imports.values { task.cancel() } + outboundClipboard = nil + inboundClipboard = nil + outgoingRevision = 0 + incomingRevision = 0 + for pending in pendingDefaults.values { pending.continuation.finish(throwing: GuestSessionError.disconnected) } + } + + private func requireSession(_ token: UUID) throws { + try Task.checkCancellation() + guard session == token else { throw GuestSessionError.disconnected } + } + + private func stateChanged(_ state: VMConnectionState) { + guard !Task.isCancelled else { return } + logger.debug("Transport state: \(String(describing: state), privacy: .public)") + if state != .connected { invalidateSession(); return } + guard hostClipboard == nil else { return } + invalidateSession() + let token = UUID() + session = token + schedule { [self] in + let reply = try await self.connection.request(InitializeGuest(session: token), reply: InitializeGuestReply.self, timeout: self.requestTimeout) + try self.requireSession(token) + self.isConnected = true + self.logger.notice("Connected to host") + do { + self.removeNotifications = try self.providers.notifications(reply.notifications.intersection(Self.notificationNames)) { [weak self] name in + guard let self, self.session == token else { return } + self.schedule { try self.requireSession(token); try await self.connection.send(GuestNotification(session: token, name: name)) } + } + } catch { + self.log(error) + } + try await self.sendDesktopPicture(token: token) + } + } + + private func initialize(_ request: InitializationRequest) { + guard hostClipboard != nil, !Task.isCancelled else { return } + invalidateSession() + let token = request.payload.session + session = token + schedule { [self] in + try await request.reply(.init(notifications: Self.notificationNames)) + try self.requireSession(token) + self.isConnected = true + self.logger.notice("Initialized guest session") + self.hostClipboard?.register(self.identity) { [weak self] items in self?.sendClipboard(items) } + } + } + + private func pollClipboard() { + if let hostClipboard { + hostClipboard.poll() + return + } + guard hostClipboard == nil, isConnected, clipboardInitialized, observedClipboardChange != providers.clipboard.changeCount else { return } + observedClipboardChange = providers.clipboard.changeCount + let current = providers.clipboard.read() + guard current != previousClipboard else { return } + previousClipboard = current + inboundClipboard?.cancel() + sendClipboard(current) + } + + private func sendClipboard(_ items: [ClipboardItem]) { + guard let token = session else { return } + outgoingRevision += 1 + let revision = outgoingRevision + outboundClipboard?.cancel() + outboundClipboard = schedule { + let message = ClipboardUpdate(session: token, revision: revision, items: items) + let inline = try await GuestPayloadIO.fits(message) + try self.requireSession(token) + if inline { + try await self.connection.send(message) + } else { + try await GuestPayloadIO.withTemporaryFile { url in + try await GuestPayloadIO.writeClipboard(items, to: url) + try self.requireSession(token) + let metadata = TransferIdentity(session: token, operation: UUID(), purpose: .clipboard, revision: revision).metadata + try await self.connection.sendFile(at: url, metadata: metadata) + } + } + } + } + + private func beginClipboard(session token: UUID, revision: UInt64) -> Int? { + guard session == token, revision > incomingRevision, + hostClipboard == nil || isConnected else { return nil } + incomingRevision = revision + inboundClipboard?.cancel() + return providers.clipboard.changeCount + } + + private func applyClipboard(_ items: [ClipboardItem], session token: UUID, revision: UInt64, changeCount: Int) { + guard !Task.isCancelled, session == token, revision == incomingRevision else { return } + // The initial host snapshot wins even if the guest clipboard changed + // while downloading it. Subsequent transfers must preserve local edits. + guard (hostClipboard == nil && !clipboardInitialized) || changeCount == providers.clipboard.changeCount else { return } + if let hostClipboard { + hostClipboard.receive(items, from: identity) + } else { + if providers.clipboard.read() != items { providers.clipboard.write(items) } + observedClipboardChange = providers.clipboard.changeCount + previousClipboard = items + clipboardInitialized = true + } + } + + private func receiveClipboard(_ message: ClipboardUpdate) { + guard let stamp = beginClipboard(session: message.session, revision: message.revision) else { return } + applyClipboard(message.items, session: message.session, revision: message.revision, changeCount: stamp) + } + + private func receiveOffer(_ offer: GuestFileOffer) { + guard !Task.isCancelled, let metadata = TransferIdentity(offer.metadata), metadata.session == session else { + schedule { await offer.reject() }; return + } + switch metadata.purpose { + case .clipboard: + guard let stamp = beginClipboard(session: metadata.session, revision: metadata.revision) else { + schedule { await offer.reject() }; return + } + inboundClipboard = schedule { + try await GuestPayloadIO.withTemporaryFile { url in + try await offer.receive(url) + try self.requireSession(metadata.session) + let items = try await GuestPayloadIO.readClipboard(at: url) + self.applyClipboard(items, session: metadata.session, revision: metadata.revision, changeCount: stamp) + } + } + case .defaults: + guard hostClipboard == nil, var pending = pendingDefaults[metadata.operation], + pending.session == metadata.session, pending.file == nil else { + schedule { await offer.reject() }; return + } + let url = GuestPayloadIO.temporaryURL() + pending.file = url + pendingDefaults[metadata.operation] = pending + let task = schedule { + do { + try await offer.receive(url) + try self.requireSession(metadata.session) + guard let pending = self.pendingDefaults[metadata.operation] else { throw CancellationError() } + pending.continuation.yield(url) + pending.continuation.finish() + } catch { + await GuestPayloadIO.remove(at: url) + self.pendingDefaults[metadata.operation]?.continuation.finish(throwing: error) + throw error + } + } + pendingDefaults[metadata.operation]?.transfer = task + } + } + + private func exportDefaults(_ request: DefaultsRequest) { + guard hostClipboard != nil, request.payload.session == session, !Task.isCancelled else { return } + schedule { + try await GuestPayloadIO.withTemporaryFile { url in + let token = request.payload.session + let response: ExportDefaultsReply + do { + try await self.providers.exportDefaults(request.payload.domain, url) + try self.requireSession(token) + let data = try await GuestPayloadIO.read(at: url) + let inline = ExportDefaultsReply.inline(data) + response = try await GuestPayloadIO.fits(inline) ? inline : .file + } catch { + try self.requireSession(token) + try await request.reply(.failure(error.localizedDescription)) + return + } + try self.requireSession(token) + try await request.reply(response) + if case .file = response { + try self.requireSession(token) + try await self.connection.sendFile(at: url, metadata: TransferIdentity(session: token, operation: request.payload.operation, purpose: .defaults).metadata) + } + } + } + } + + func importDomain(_ domain: String) async throws { + let id = UUID() + let task = Task { try await self.performImport(domain) } + imports[id] = task + defer { imports[id] = nil } + try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + private func cleanupDefaults(_ operation: UUID) async { + guard let pending = pendingDefaults.removeValue(forKey: operation) else { return } + pending.continuation.finish() + pending.transfer?.cancel() + await pending.transfer?.value + if let file = pending.file { await GuestPayloadIO.remove(at: file) } + } + + private func performImport(_ domain: String) async throws { + try await GuestPayloadIO.withTemporaryFile { url in + try await self.performImport(domain, inlineURL: url) + } + } + + private func performImport(_ domain: String, inlineURL: URL) async throws { + try Task.checkCancellation() + guard hostClipboard == nil, isConnected, let token = session else { throw GuestSessionError.disconnected } + let operation = UUID() + let (files, continuation) = AsyncThrowingStream.makeStream() + pendingDefaults[operation] = PendingDefaults(session: token, continuation: continuation) + do { + let reply = try await connection.request(ExportDefaults(session: token, operation: operation, domain: domain), reply: ExportDefaultsReply.self, timeout: self.requestTimeout) + try requireSession(token) + let url: URL + switch reply { + case .failure(let error): throw GuestSessionError.remote(error) + case .inline(let data): + try await GuestPayloadIO.write(data, to: inlineURL) + url = inlineURL + case .file: + url = try await withThrowingTaskGroup(of: URL.self) { group in + group.addTask { + for try await file in files { return file } + throw GuestSessionError.disconnected + } + let timeout = requestTimeout + group.addTask { try await Task.sleep(for: timeout); throw VMBridgeError.requestTimedOut } + defer { group.cancelAll() } + guard let file = try await group.next() else { throw GuestSessionError.invalidReply } + return file + } + } + try requireSession(token) + try await providers.importDefaults(domain, url) + } catch { + await cleanupDefaults(operation) + throw error + } + await cleanupDefaults(operation) + } + + func sendDesktopPicture() async throws { + guard hostClipboard == nil, let token = session else { throw GuestSessionError.disconnected } + try await sendDesktopPicture(token: token) + } + + private func sendDesktopPicture(token: UUID) async throws { + guard let picture = try await providers.desktopPicture() else { + logger.debug("No desktop picture is available") + return + } + try requireSession(token) + logger.debug("Sending desktop picture (\(picture.content.count) bytes)") + try await connection.send(DesktopPictureUpdate(session: token, type: picture.type, content: picture.content)) + } + + private func receivePicture(_ picture: DesktopPictureUpdate) { + guard hostClipboard != nil, session == picture.session, !Task.isCancelled else { return } + logger.debug("Received desktop picture (\(picture.content.count) bytes)") + onDesktopPicture?(.init(type: picture.type, content: picture.content)) + } + + private func receiveNotification(_ notification: GuestNotification) { + guard hostClipboard != nil, session == notification.session, Self.notificationNames.contains(notification.name), !Task.isCancelled else { return } + onNotification?(notification.name) + } +} diff --git a/VirtualWormhole/Source/Services/Base/WormholeServiceClient.swift b/VirtualWormhole/Source/Services/Base/WormholeServiceClient.swift deleted file mode 100644 index 8739ac45..00000000 --- a/VirtualWormhole/Source/Services/Base/WormholeServiceClient.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// WormholeServiceClient.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 10/03/23. -// - -import Foundation - -public protocol WormholeServiceClient { - associatedtype ServiceType: WormholeService - - init(with service: ServiceType) -} diff --git a/VirtualWormhole/Source/Services/Base/WormholeServiceProtocol.swift b/VirtualWormhole/Source/Services/Base/WormholeServiceProtocol.swift deleted file mode 100644 index dca14ce1..00000000 --- a/VirtualWormhole/Source/Services/Base/WormholeServiceProtocol.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// WormholeServiceProtocol.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 02/06/22. -// - -import Foundation -import Virtualization - -public protocol WormholeMultiplexer: AnyObject { - - var side: WHConnectionSide { get } - - func send(_ payload: T, to peerID: WHPeerID?) async - - func stream(for payloadType: T.Type) -> AsyncThrowingStream<(senderID: WHPeerID, payload: T), Error> - -} - -public protocol WormholeService: AnyObject { - - static var id: String { get } - - init(with connection: WormholeMultiplexer) - - func activate() - -} diff --git a/VirtualWormhole/Source/Services/DarwinNotifications/WHDarwinNotificationsService.swift b/VirtualWormhole/Source/Services/DarwinNotifications/WHDarwinNotificationsService.swift deleted file mode 100644 index 768b2106..00000000 --- a/VirtualWormhole/Source/Services/DarwinNotifications/WHDarwinNotificationsService.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// WHDarwinNotificationsService.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 08/03/23. -// - -import Cocoa -import OSLog -import Combine - -enum DarwinNotificationMessage: WHPayload { - static let resendOnReconnect = true - - case post(String) - case subscribe(String) -} - -final class WHDarwinNotificationsService: WormholeService { - - static let id = "darwinNotifications" - - private lazy var logger = Logger(for: Self.self) - - private let peerPostedNotificationSubject = PassthroughSubject<(name: String, peerID: WHPeerID), Never>() - - var onPeerNotificationReceived: AnyPublisher<(name: String, peerID: WHPeerID), Never> { - peerPostedNotificationSubject.eraseToAnyPublisher() - } - - var connection: WormholeMultiplexer - - init(with connection: WormholeMultiplexer) { - self.connection = connection - } - - func activate() { - logger.debug(#function) - - Task { - 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)") - } - } - } - - private func handle(_ message: DarwinNotificationMessage, from peerID: WHPeerID) { - logger.debug("Handle message: \(String(describing: message))") - - switch message { - case .post(let name): - peerPostedNotificationSubject.send((name, peerID)) - case .subscribe(let name): - createSubscription(for: name, peerID: peerID) - } - } - - private var subscriptions = [SystemNotification]() - - private func createSubscription(for name: String, peerID: WHPeerID) { - do { - let note = SystemNotification(with: name) { [weak self] in - guard let self = self else { return } - self.sendPostMessage(with: name, to: peerID) - } - subscriptions.append(note) - - DistributedNotificationCenter.default().addObserver(forName: .init(name), object: nil, queue: nil) { [weak self] _ in - guard let self = self else { return } - self.sendPostMessage(with: name, to: peerID) - } - - try note.activate() - } catch { - logger.error("Error creating notification subscription for \"\(name)\": \(error, privacy: .public)") - } - } - - private func sendPostMessage(with name: String, to peerID: WHPeerID) { - #if DEBUG - logger.debug("Posting \(name, privacy: .public) to \(peerID, privacy: .public)") - #endif - - Task { - await connection.send(DarwinNotificationMessage.post(name), to: peerID) - } - } - -} diff --git a/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomain+ExportImport.swift b/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomain+ExportImport.swift index 6e2ec3e4..958c41d4 100644 --- a/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomain+ExportImport.swift +++ b/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomain+ExportImport.swift @@ -23,18 +23,12 @@ public extension DefaultsDomainDescriptor { } private func runDefaults(_ verb: String, domainName: String, plistPath: String) async throws { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") - proc.arguments = [ - verb, - domainName, - plistPath - ] - - try proc.checkRun() + try await DefaultsCommand().run(executable: "/usr/bin/defaults", arguments: [verb, domainName, plistPath]) } + @MainActor func performRestartIfNeeded() async throws { + try Task.checkCancellation() guard let restart else { return } guard target.isRunning else { return } @@ -53,13 +47,7 @@ public extension DefaultsDomainDescriptor { guard shouldRestart else { return } } - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/sh") - proc.arguments = [ - "-c", - restart.command - ] - try proc.checkRun() + try await DefaultsCommand().run(executable: "/bin/sh", arguments: ["-c", restart.command]) guard restart.shouldRelaunch, let url = target.bundleURL else { return } @@ -86,42 +74,48 @@ public extension DefaultsDomainDescriptor { } -extension Pipe { - func readString() -> String? { - guard let data = try? fileHandleForReading.readToEnd() else { return nil } - guard !data.isEmpty else { return nil } - return String(decoding: data, as: UTF8.self) - } -} - -extension Process { - - private static let logger = Logger(subsystem: VirtualWormholeConstants.subsystemName, category: "Process") - - @discardableResult - func checkRun(expectedStatus: Int32 = 0) throws -> Data? { - let errPipe = Pipe() - let outPipe = Pipe() - standardError = errPipe - standardOutput = outPipe - - try run() - waitUntilExit() - - let errStr = errPipe.readString() - - guard terminationStatus == expectedStatus else { - var info: [String: Any] = [ - NSLocalizedDescriptionKey: "Command failed with exit code \(terminationStatus)" - ] - if let errStr { - Self.logger.error("Command \(self.executableURL?.lastPathComponent ?? "", privacy: .public) failed with exit code \(self.terminationStatus, privacy: .public): \(errStr, privacy: .public)") - info[NSLocalizedFailureReasonErrorKey] = errStr +// A separate actor owns each subprocess. Output goes to files so a full pipe +// cannot deadlock a command, and suspension leaves cancellation free to run. +private actor DefaultsCommand { + private var process: Process? + + func run(executable: String, arguments: [String]) async throws { + try Task.checkCancellation() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: directory) } + let output = directory.appendingPathComponent("output") + let errors = directory.appendingPathComponent("errors") + try Data().write(to: output) + try Data().write(to: errors) + let outHandle = try FileHandle(forWritingTo: output) + defer { try? outHandle.close() } + let errHandle = try FileHandle(forWritingTo: errors) + defer { try? errHandle.close() } + let command = Process() + command.executableURL = URL(fileURLWithPath: executable) + command.arguments = arguments + command.standardOutput = outHandle + command.standardError = errHandle + process = command + defer { command.terminationHandler = nil; process = nil } + let status: Int32 = try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + command.terminationHandler = { process in continuation.resume(returning: process.terminationStatus) } + do { try command.run() } catch { continuation.resume(throwing: error) } } - throw CocoaError(.coderReadCorrupt, userInfo: info) + } onCancel: { + Task { await self.cancel() } + } + try Task.checkCancellation() + guard status == 0 else { + let reason = String(decoding: try Data(contentsOf: errors).prefix(4096), as: UTF8.self) + throw CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: "Defaults command failed (exit \(status)). \(reason)"]) } - - return try? outPipe.fileHandleForReading.readToEnd() } + private func cancel() { + if let process, process.isRunning { process.terminate() } + } } diff --git a/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomainDescriptor.swift b/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomainDescriptor.swift index 4f9654f3..452d83a9 100644 --- a/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomainDescriptor.swift +++ b/VirtualWormhole/Source/Services/DefaultsImport/Implementation/DefaultsDomainDescriptor.swift @@ -8,15 +8,15 @@ import Cocoa import UniformTypeIdentifiers -public struct DefaultsDomainDescriptor: Identifiable, Codable { - public struct Target: Identifiable, Codable { +public struct DefaultsDomainDescriptor: Identifiable, Codable, Sendable { + public struct Target: Identifiable, Codable, Sendable { public var id: String { bundleIdentifier } public var bundleIdentifier: String public var name: String public var isSystemService: Bool } - public struct Restart: Codable { + public struct Restart: Codable, Sendable { public var command: String public var needsConfirmation = true public var shouldRelaunch = true diff --git a/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportClient.swift b/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportClient.swift deleted file mode 100644 index 54eee40d..00000000 --- a/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportClient.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// WHDefaultsImportClient.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 10/03/23. -// - -import Foundation -import OSLog - -public final class WHDefaultsImportClient: WormholeServiceClient { - - private lazy var logger = Logger(subsystem: VirtualWormholeConstants.subsystemName, category: String(describing: Self.self)) - - public typealias ServiceType = WHDefaultsImportService - - let service: WHDefaultsImportService - - public init(with service: WHDefaultsImportService) { - self.service = service - } - - public func importDomain(with id: DefaultsDomainDescriptor.ID) async throws { - logger.debug("Requesting export for \(id, privacy: .public)") - - let response = Task { - let stream = service.onDomainResponseReceived.filter({ $0.domainID == id }).values - - for await response in stream { - switch response { - case .failure(_, let message): - logger.error("Export request for \(id, privacy: .public) resolved with error: \(message, privacy: .public)") - - throw CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: message]) - case .success(let id, let data): - logger.debug("Export request for \(id, privacy: .public) resolved successfully") - - try await performImport(for: id, with: data) - default: - continue - } - break - } - } - - await service.sendExportRequest(for: id) - - logger.debug("Export request for \(id, privacy: .public) sent, waiting for response") - - try await response.value - } - - private func performImport(for domainID: String, with data: Data) async throws { - let domain = try service.fetchDescriptor(for: domainID) - - let tempURL = service.temporaryURL(for: domainID) - - try data.write(to: tempURL) - - try await domain.importDefaults(from: tempURL) - - try? FileManager.default.removeItem(at: tempURL) - } - -} diff --git a/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportService.swift b/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportService.swift deleted file mode 100644 index 9b67168f..00000000 --- a/VirtualWormhole/Source/Services/DefaultsImport/WHDefaultsImportService.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// WHDefaultsImportService.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 09/03/23. -// - -import Cocoa -import OSLog -import Combine - -enum DefaultsImportMessage: WHPayload { - /// Guest requesting domain export from host. - case request(domainID: String) - /// Host responding to guest request with domain ID and associated plist. - case success(domainID: String, plist: Data) - /// Host responding to guest request with domain ID and error message. - case failure(domainID: String, error: String) -} - -extension DefaultsImportMessage { - var domainID: String { - switch self { - case .request(let domainID), .success(let domainID, _), .failure(let domainID, _): - return domainID - } - } -} - -public final class WHDefaultsImportService: WormholeService { - - public static let id = "defaultsImport" - - private lazy var logger = Logger(for: Self.self) - - var connection: WormholeMultiplexer - - public init(with connection: WormholeMultiplexer) { - self.connection = connection - } - - public func activate() { - logger.debug(#function) - - Task { - 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)") - } - } - } - - private lazy var controller = DefaultsImportController() - - let onDomainResponseReceived = PassthroughSubject() - - func sendExportRequest(for domainID: String) async { - assert(connection.side == .guest, "Requesting defaults export is only possible from guest to host") - - await connection.send(DefaultsImportMessage.request(domainID: domainID), to: nil) - } - - private func handle(_ message: DefaultsImportMessage, from peerID: WHPeerID) async { - logger.debug("Handle message: \(String(describing: message))") - - switch message { - case .request(let domainID): - await handleDomainRequest(for: domainID, from: peerID) - case .success, .failure: - await MainActor.run { - onDomainResponseReceived.send(message) - } - } - } - - private func handleDomainRequest(for domainID: String, from peerID: WHPeerID) async { - do { - let data = try await fetchDomainData(for: domainID) - - await connection.send(DefaultsImportMessage.success(domainID: domainID, plist: data), to: peerID) - } catch { - logger.error("Export failed: \(error, privacy: .public)") - - await connection.send(DefaultsImportMessage.failure(domainID: domainID, error: error.localizedDescription), to: peerID) - } - } - - func fetchDescriptor(for domainID: String) throws -> DefaultsDomainDescriptor { - guard let domain = controller.descriptors[domainID] else { - throw CocoaError(.fileNoSuchFile, userInfo: [NSLocalizedDescriptionKey: "Domain \(domainID) not found."]) - } - return domain - } - - func temporaryURL(for domainID: String) -> URL { - URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("VirtualBuddyDefaultsExport-\(domainID)-\(Int(Date.now.timeIntervalSinceReferenceDate))") - .appendingPathExtension("plist") - } - - private func fetchDomainData(for domainID: String) async throws -> Data { - let domain = try fetchDescriptor(for: domainID) - - let tempURL = temporaryURL(for: domainID) - - try await domain.exportDefaults(to: tempURL) - - let result = try Data(contentsOf: tempURL) - - try? FileManager.default.removeItem(at: tempURL) - - return result - } - -} diff --git a/VirtualWormhole/Source/Services/DesktopPicture/WHDesktopPictureService.swift b/VirtualWormhole/Source/Services/DesktopPicture/WHDesktopPictureService.swift deleted file mode 100644 index b04b5fd0..00000000 --- a/VirtualWormhole/Source/Services/DesktopPicture/WHDesktopPictureService.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// WHDesktopPictureService.swift -// VirtualBuddy -// -// Created by Guilherme Rambo on 18/06/25. -// - -import Cocoa -import OSLog -import AVFoundation -import Combine - -public struct DesktopPictureMessage: WHPayload { - public internal(set) var type: String - public internal(set) var content: Data - - public static let resendOnReconnect = true -} - -final class WHDesktopPictureService: WormholeService { - - static let id = "desktopPicture" - - private lazy var logger = Logger(for: Self.self) - - var connection: WormholeMultiplexer - - init(with connection: WormholeMultiplexer) { - self.connection = connection - } - - static let imageProperties = [ - kCGImageDestinationLossyCompressionQuality: 0.8, - kCGImageDestinationImageMaxPixelSize: 512 - ] as CFDictionary - - private let peerSentDesktopPictureSubject = PassthroughSubject<(message: DesktopPictureMessage, peerID: WHPeerID), Never>() - - var onPeerPeerDesktopPictureReceived: AnyPublisher<(message: DesktopPictureMessage, peerID: WHPeerID), Never> { - peerSentDesktopPictureSubject.eraseToAnyPublisher() - } - - func activate() { - logger.debug(#function) - - Task { - 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)") - } - } - - guard connection.side == .guest else { return } - - Task { - try? await Task.sleep(for: .seconds(2)) - - await sendDesktopPicture() - } - } - - func sendDesktopPicture() async { - guard let image = await MainActor.run(body: { NSImage.desktopPicture }) else { - logger.error("Error getting desktop picture for main screen.") - return - } - - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - logger.error("Error getting CGImage from desktop picture.") - return - } - - guard !cgImage.isFullyTransparent() else { - logger.warning("Skipping send desktop picture because it generated a fully transparent image.") - return - } - - guard let cfData = CFDataCreateMutable(kCFAllocatorDefault, 0) else { - logger.error("Failed to create CFMutableData") - return - } - guard let destination = CGImageDestinationCreateWithData(cfData, AVFileType.heic.rawValue as CFString, 1, nil) else { - logger.error("Failed to create CGImageDestination") - return - } - - CGImageDestinationAddImage(destination, cgImage, Self.imageProperties) - CGImageDestinationFinalize(destination) - - let payload = DesktopPictureMessage(type: AVFileType.heic.rawValue, content: cfData as Data) - - logger.info("Sending payload with \(payload.content.count) bytes") - - await connection.send(payload, to: nil) - } - -} diff --git a/VirtualWormhole/Source/Services/WHSharedClipboardService.swift b/VirtualWormhole/Source/Services/WHSharedClipboardService.swift deleted file mode 100644 index d3448521..00000000 --- a/VirtualWormhole/Source/Services/WHSharedClipboardService.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// WHSharedClipboardService.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 02/06/22. -// - -import Cocoa -import OSLog - -struct ClipboardData: Codable, Hashable { - var type: NSPasteboard.PasteboardType.RawValue - var value: Data -} - -struct ClipboardMessage: WHPayload, Hashable { - var timestamp: Date - var data: [ClipboardData] - - static let propagateBetweenGuests = true -} - -final class WHSharedClipboardService: WormholeService { - - static let id = "clipboard" - - private lazy var logger = Logger(for: Self.self) - - var connection: WormholeMultiplexer - - init(with connection: WormholeMultiplexer) { - self.connection = connection - } - - private var previousMessage: ClipboardMessage? - - func activate() { - logger.debug(#function) - - Task { - do { - for try await message in connection.stream(for: ClipboardMessage.self) { - handle(message.payload) - } - } catch { - logger.info("Connection stream terminated: \(error, privacy: .public)") - } - } - - startObservingClipboard() - } - - private let pasteboard = NSPasteboard.general - - private func handle(_ message: ClipboardMessage) { - guard !message.data.isEmpty, message.data != previousMessage?.data else { return } - - logger.debug("Handle clipboard message: \(String(describing: message))") - - previousMessage = message - - pasteboard.read(from: message.data) - - #if DEBUG - logger.debug("⏱️ Clipboard message roundtrip time: \(String(format: "%.03f", Date.now.timeIntervalSince(message.timestamp)), privacy: .public)") - #endif - } - - private var clipboardTimer: Timer? - - private func startObservingClipboard() { - clipboardTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { [weak self] _ in - self?.updateIfNeeded() - }) - } - - private func updateIfNeeded() { - let currentData = ClipboardData.current - guard currentData != previousMessage?.data else { return } - - #if DEBUG - logger.debug("Clipboard contents changed: \(String(describing: currentData), privacy: .public)") - #endif - - let message = ClipboardMessage( - timestamp: .now, - data: currentData - ) - - previousMessage = message - - Task { - await connection.send(message, to: nil) - } - } - -} - -private extension ClipboardData { - static let supportedTypes: [NSPasteboard.PasteboardType] = [ - .string, - .rtf, - .rtfd, - .pdf, - .png, - .tiff, - ] - - static var current: [ClipboardData] { - guard let availableTypes = NSPasteboard.general.types else { return [] } - - return supportedTypes.compactMap { type in - /// PNG and TIFF data are often present at the same time. - /// Ignore TIFF data and preserve only the PNG data when that's the case, - /// since TIFF data is usually much larger and unused. - if type == .tiff { - guard !availableTypes.contains(.png) else { return nil } - } - guard let data = NSPasteboard.general.data(forType: type) else { - return nil - } - return ClipboardData(type: type.rawValue, value: data) - } - } -} - -private extension NSPasteboard { - func read(from data: [ClipboardData]) { - clearContents() - - for item in data { - setData(item.value, forType: PasteboardType(rawValue: item.type)) - } - } -} diff --git a/VirtualWormhole/Source/WireProtocol/WHPayload.swift b/VirtualWormhole/Source/WireProtocol/WHPayload.swift deleted file mode 100644 index 54c7ce25..00000000 --- a/VirtualWormhole/Source/WireProtocol/WHPayload.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// WHPayload.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 25/10/23. -// - -import Foundation - -/// Protocol adopted by types that can be sent over the guest <> host connection. -public protocol WHPayload: Codable, Sendable { - /// When `true`, the payload will be sent again if connection gets interrupted and re-established. - static var resendOnReconnect: Bool { get } - - /// When `true`, the host will distribute the payload to all booted guests - /// upon receiving the payload from one of the guests. - static var propagateBetweenGuests: Bool { get } -} - -public extension WHPayload { - static var resendOnReconnect: Bool { false } - static var propagateBetweenGuests: Bool { false } -} diff --git a/VirtualWormhole/Source/WireProtocol/WHPing.swift b/VirtualWormhole/Source/WireProtocol/WHPing.swift deleted file mode 100644 index d15a2ca2..00000000 --- a/VirtualWormhole/Source/WireProtocol/WHPing.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// WHPing.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 08/03/23. -// - -import Foundation - -struct WHPing: WHPayload { - var date = Date.now -} - -struct WHPong: WHPayload { - var date = Date.now -} - -extension WormholePacket { - var isPing: Bool { payloadType == String(describing: WHPing.self) } - var isPong: Bool { payloadType == String(describing: WHPong.self) } -} - -extension WormholePacket { - static var ping: WormholePacket { - get throws { try WormholePacket(WHPing()) } - } - static var pong: WormholePacket { - get throws { try WormholePacket(WHPong()) } - } -} diff --git a/VirtualWormhole/Source/WireProtocol/WormholePacket.swift b/VirtualWormhole/Source/WireProtocol/WormholePacket.swift deleted file mode 100644 index 9336a565..00000000 --- a/VirtualWormhole/Source/WireProtocol/WormholePacket.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// WormholePacket.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 08/03/23. -// - -import Foundation - -struct WormholePacket { - var magic: UInt32 = Self.magicValue - var payloadType: String - var payloadLength: UInt64 - var payload: Data -} - -extension WormholePacket { - - static let magicValue: UInt32 = 0x0DF0FECA - static let magicValueCompressed: UInt32 = 0x01F0FECA - static let maxUncompressedPayloadSize = 1000000 - static let compressionAlgorithm = NSData.CompressionAlgorithm.lzma - - /// The absolute minimum size an entire packet could be. - /// Any packet that's not at least this size has something wrong with it. - static let minimumSize: Int = { - MemoryLayout.size // magic - + 2 // payloadType // 1 byte for single character + null terminator - + MemoryLayout.size // payloadLength - + 1 // payload // at least 1 byte of payload data - }() -} - -// MARK: - Encoding - -extension WormholePacket { - - init(_ payload: T) throws { - let data = try JSONEncoder.wormhole.encode(payload) - let typeName = String(describing: type(of: payload)) - - self.init(payloadType: typeName, payloadLength: UInt64(data.count), payload: data) - } - - func encoded() throws -> Data { - var encodedMagic = magic - var encodedPayloadLength = payloadLength - var encodedPayload = payload - - if payload.count >= Self.maxUncompressedPayloadSize { - encodedMagic = Self.magicValueCompressed - let compressedPayload = try (payload as NSData).compressed(using: Self.compressionAlgorithm) - encodedPayloadLength = UInt64(compressedPayload.count) - encodedPayload = compressedPayload as Data - } - - return Data(bytes: &encodedMagic, count: MemoryLayout.size) - + Data(payloadType.utf8 + [0]) - + Data(bytes: &encodedPayloadLength, count: MemoryLayout.size) - + encodedPayload - } - -} - -// MARK: - Decoding - -extension WormholePacket { - - static func decode(from data: Data) throws -> WormholePacket { - guard data.count >= Self.minimumSize else { - throw CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: "Packet data with length \(data.count) is smaller than the minimum packet length"]) - } - - return try data.withUnsafeBytes { buffer in - guard let pointer = buffer.baseAddress else { - throw CocoaError(.coderReadCorrupt, userInfo: [NSLocalizedDescriptionKey: "Couldn't get buffer base address"]) - } - - var byteOffset = 0 - - let magic = pointer.load(as: UInt32.self) - - byteOffset += MemoryLayout.size - - let strptr = pointer - .advanced(by: byteOffset) - .assumingMemoryBound(to: UInt8.self) - - let payloadType = String(cString: strptr) - - byteOffset += payloadType.count + 1 - - var payloadLength = pointer.loadUnaligned(fromByteOffset: byteOffset, as: UInt64.self) - - byteOffset += MemoryLayout.size - - guard UInt64(data.count) > payloadLength else { - throw CocoaError(.coderReadCorrupt, userInfo: [NSLocalizedDescriptionKey: "Packet payload length \(payloadLength) is out of bounds"]) - } - - let upperBound = Int(byteOffset)+Int(truncatingIfNeeded: payloadLength) - - guard data.count >= upperBound else { - throw CocoaError(.coderReadCorrupt, userInfo: [NSLocalizedDescriptionKey: "Packet payload length \(payloadLength) is out of bounds"]) - } - - var payload = Data(data[byteOffset.. AsyncThrowingStream { - AsyncThrowingStream { continuation in - Self.logger.debug("Activating stream") - - let task = Task { - do { - var buffer = Data(capacity: WormholePacket.minimumSize) - - for try await byte in bytes { - guard !Task.isCancelled else { break } - -// Self.logger.debug("RECV: \(buffer.map({ String(format: "%02X", $0) }).joined())") - - buffer.append(byte) - - guard buffer.count >= WormholePacket.minimumSize else { continue } - - if let packet = try? WormholePacket.decode(from: buffer) { - continuation.yield(packet) - buffer = Data(capacity: WormholePacket.minimumSize) - } - } - - Self.logger.debug("Stream ended/cancelled") - } catch { - Self.logger.error("Stream failed: \(error, privacy: .public)") - - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - -} - -extension JSONDecoder { - static let wormhole = JSONDecoder() -} - -extension JSONEncoder { - static let wormhole = JSONEncoder() -} diff --git a/VirtualWormhole/Source/WormholeManager.swift b/VirtualWormhole/Source/WormholeManager.swift deleted file mode 100644 index ab59de30..00000000 --- a/VirtualWormhole/Source/WormholeManager.swift +++ /dev/null @@ -1,565 +0,0 @@ -// -// WormholeManager.swift -// VirtualWormhole -// -// Created by Guilherme Rambo on 02/06/22. -// - -import Foundation -import Virtualization -import OSLog -@preconcurrency import Combine - -public typealias WHPeerID = String - -public extension WHPeerID { - static let host = "Host" -} - -public enum WHConnectionSide: Hashable, CustomStringConvertible { - case host - case guest - - public var description: String { - switch self { - case .host: - return "Host" - case .guest: - return "Guest" - } - } -} - -public final class WormholeManager: NSObject, ObservableObject, WormholeMultiplexer { - - /// Singleton manager used by the VirtualBuddy app to talk - /// to VirtualBuddyGuest running in virtual machines. - public static let sharedHost = WormholeManager(for: .host) - - /// Singleton manager used by the VirtualBuddyGuest app in a virtual machine - /// to talk to VirtualBuddy running in the host. - public static let sharedGuest = WormholeManager(for: .guest) - - @Published private(set) var peers = [WHPeerID: WormholeChannel]() - - @Published public private(set) var isConnected = false - - private lazy var logger = Logger(for: Self.self) - - let serviceTypes: [WormholeService.Type] = [ - WHSharedClipboardService.self, - WHDarwinNotificationsService.self, - WHDefaultsImportService.self, - WHDesktopPictureService.self - ] - - var activeServices: [WormholeService] = [] - - public let side: WHConnectionSide - - public init(for side: WHConnectionSide) { - self.side = side - - super.init() - } - - public func makeClient(_ type: C.Type) throws -> C { - guard let service = activeServices.compactMap({ $0 as? C.ServiceType }).first else { - throw CocoaError(.coderInvalidValue, userInfo: [NSLocalizedDescriptionKey: "Service unavailable."]) - } - return C(with: service) - } - - private var activated = false - - private lazy var cancellables = Set() - - public func activate() { - guard !activated else { return } - activated = true - - logger.debug("Activate side \(String(describing: self.side))") - - Task { - do { - try await activateGuestIfNeeded() - } catch { - logger.fault("Failed to register host peer: \(error, privacy: .public)") - } - } - - activeServices = serviceTypes - .map { $0.init(with: self) } - activeServices.forEach { $0.activate() } - - #if DEBUG - $peers.removeDuplicates(by: { $0.keys != $1.keys }).sink { [weak self] currentPeers in - guard let self = self else { return } - self.logger.debug("Peers: \(currentPeers.keys.joined(separator: ", "), privacy: .public)") - } - .store(in: &cancellables) - #endif - - Timer - .publish(every: VirtualWormholeConstants.pingIntervalInSeconds, tolerance: VirtualWormholeConstants.pingIntervalInSeconds * 0.5, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - guard let self = self else { return } - - Task { - await self.send(WHPing(), to: nil) - } - } - .store(in: &cancellables) - } - - private let packetSubject = PassthroughSubject<(peerID: WHPeerID, packet: WormholePacket), Never>() - - public func register(input: FileHandle, output: FileHandle, for peerID: WHPeerID) async { - if let existing = peers[peerID] { - await existing.invalidate() - } - - let channel = await WormholeChannel( - input: input, - output: output, - peerID: peerID - ).onPacketReceived { [weak self] senderID, packet in - guard let self = self else { return } - self.packetSubject.send((senderID, packet)) - } - - /// When running in guest mode, observe the channel's connection state and bind it to the manager's state. - if self.side == .guest { - await channel.$isConnected.removeDuplicates().receive(on: DispatchQueue.main).sink { [weak self] isConnected in - guard let self = self else { return } - self.logger.notice("Connection to host changed state (isConnected = \(isConnected, privacy: .public))") - self.isConnected = isConnected - }.store(in: &cancellables) - } - - peers[peerID] = channel - - await channel.activate() - } - - public func unregister(_ peerID: WHPeerID) async { - guard let channel = peers[peerID] else { return } - - await channel.invalidate() - - peers[peerID] = nil - } - - public func send(_ payload: T, to peerID: WHPeerID?) async { - guard !peers.isEmpty else { return } - - if side == .guest { - guard peerID == nil || peerID == .host else { - logger.fault("Guest can only send messages to host!") - assertionFailure("Guest can only send messages to host!") - return - } - } - - do { - let packet = try WormholePacket(payload) - - if let peerID { - guard let channel = peers[peerID] else { - logger.error("Couldn't find channel for peer \(peerID)") - return - } - - /// Message will be repeated if other side disconnects and reconnects. - if T.resendOnReconnect { - await channel.connected { - do { - /// Make sure there's a fresh packet every time the message is sent. - let newPacket = try WormholePacket(payload) - - try await $0.send(newPacket) - } catch { - assertionFailure("Failed to send packet: \(error)") - } - } - } else { - try await channel.send(packet) - } - } else { - for channel in peers.values { - try await channel.send(packet) - } - } - } catch { - logger.fault("Failed to send packet: \(error, privacy: .public)") - assertionFailure("Failed to send packet: \(error)") - } - } - - public func stream(for payloadType: T.Type) -> AsyncThrowingStream<(senderID: WHPeerID, payload: T), Error> { - AsyncThrowingStream { [weak self] continuation in - guard let self = self else { - continuation.finish() - return - } - - let typeName = String(describing: payloadType) - - let cancellable = self.packetSubject - .filter { $0.packet.payloadType == typeName } - .sink { [weak self] peerID, packet in - guard let self = self else { return } - - guard let decodedPayload = try? JSONDecoder().decode(payloadType, from: packet.payload) else { return } - - self.propagateIfNeeded(packet, type: payloadType, from: peerID) - - continuation.yield((peerID, decodedPayload)) - } - - continuation.onTermination = { @Sendable _ in - cancellable.cancel() - } - } - } - - private func propagateIfNeeded(_ packet: WormholePacket, type: P.Type, from senderID: WHPeerID) { - guard type.propagateBetweenGuests, VirtualWormholeConstants.payloadPropagationEnabled else { return } - - let propagationChannels = self.peers.filter({ $0.key != senderID }) - - Task { - for (id, channel) in propagationChannels { - do { - if VirtualWormholeConstants.verboseLoggingEnabled { - logger.debug("⬆️ PROPAGATE \(packet.payloadType, privacy: .public) from \(senderID, privacy: .public) to \(id, privacy: .public)") - } - try await channel.send(packet) - } catch { - logger.error("Packet propagation to \(id, privacy: .public) failed: \(error, privacy: .public)") - } - } - } - } - - // MARK: - Ping - - /// Waits for a connection with the given peer. - private func wait(for peerID: WHPeerID) async { - guard let channel = peers[peerID] else { - logger.error("Can't wait for peer \(peerID) for which a channel doesn't exist") - return - } - - guard await channel.isConnected == false else { return } - - for await state in await channel.$isConnected.values { - guard state else { continue } - break - } - } - - /// Performs the specified asynchronous closure whenever the connection state for the peer changes - /// from not connected to connected. Also runs the closure if peer is already connected at the time of calling. - private func connected(to peerID: WHPeerID, perform block: @escaping (WormholeChannel) async -> Void) async { - guard let channel = peers[peerID] else { - logger.error("Can't wait for peer \(peerID) for which a channel doesn't exist") - return - } - - await channel.connected(perform: block) - } - - // MARK: - Service Interfaces - - private func service(_ serviceType: T.Type) -> T? { - activeServices.first(where: { type(of: $0).id == serviceType.id }) as? T - } - - public func darwinNotifications(matching names: Set, from peerID: WHPeerID) async throws -> AsyncStream { - try ensurePeerAvailable(peerID) - - guard let notificationService = service(WHDarwinNotificationsService.self) else { - throw CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Darwin notifications service not available"]) - } - - try Task.checkCancellation() - - for name in names { - await send(DarwinNotificationMessage.subscribe(name), to: peerID) - } - - var iterator = notificationService.onPeerNotificationReceived.values - .filter { $0.peerID == peerID } - .map(\.name) - .makeAsyncIterator() - - return AsyncStream { await iterator.next() } - } - - public func desktopPictureMessages(from peerID: WHPeerID) async throws -> AsyncStream { - try ensurePeerAvailable(peerID) - - guard let desktopPictureService = service(WHDesktopPictureService.self) else { - throw CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Desktop picture service not available"]) - } - - try Task.checkCancellation() - - var iterator = desktopPictureService.onPeerPeerDesktopPictureReceived.values - .filter { $0.peerID == peerID } - .map(\.message) - .makeAsyncIterator() - - return AsyncStream { await iterator.next() } - } - - /// Can be called on the guest to force-send the current desktop picture. - public func sendDesktopPicture() async { - guard side == .guest else { return } - - do { - guard let desktopPictureService = service(WHDesktopPictureService.self) else { - throw CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Desktop picture service not available"]) - } - - logger.debug("Sending desktop picture") - - await desktopPictureService.sendDesktopPicture() - - logger.debug("Finished sending desktop picture") - } catch { - logger.error("Send desktop picture failed - \(error, privacy: .public)") - } - } - - private func ensurePeerAvailable(_ peerID: WHPeerID) throws { - guard peers[peerID] != nil else { - throw CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Peer \(peerID) is not registered"]) - } - } - - // MARK: - Guest Mode - - private let ttyPath = "/dev/cu.virtio" - - private var hostOutputHandle: FileHandle { - get throws { - try FileHandle(forReadingFrom: URL(fileURLWithPath: ttyPath)) - } - } - - private var hostInputHandle: FileHandle { - get throws { - try FileHandle(forWritingTo: URL(fileURLWithPath: ttyPath)) - } - } - - private func activateGuestIfNeeded() async throws { - guard side == .guest else { return } - - configureTTY() - - logger.debug("Running in guest mode, registering host peer") - - let input = try hostOutputHandle - let output = try hostInputHandle - - await register(input: input, output: output, for: .host) - } - - private func configureTTY() { - do { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/stty") - proc.arguments = [ - "-f", - ttyPath, - "115200" - ] - let errPipe = Pipe() - let outPipe = Pipe() - proc.standardError = errPipe - proc.standardOutput = outPipe - - try proc.run() - proc.waitUntilExit() - - if let errData = try? errPipe.fileHandleForReading.readToEnd(), !errData.isEmpty { - logger.debug("stty stdout: \(String(decoding: errData, as: UTF8.self), privacy: .public)") - } - if let outData = try? outPipe.fileHandleForReading.readToEnd(), !outData.isEmpty { - logger.debug("stty stderr: \(String(decoding: outData, as: UTF8.self), privacy: .public)") - } - } catch { - logger.error("stty error: \(error, privacy: .public)") - } - } - -} - -// MARK: - Channel Actor - -actor WormholeChannel: ObservableObject { - - let input: FileHandle - let output: FileHandle - let peerID: WHPeerID - private let logger: Logger - - init(input: FileHandle, output: FileHandle, peerID: WHPeerID) { - self.input = input - self.output = output - self.peerID = peerID - self.logger = Logger(subsystem: VirtualWormholeConstants.subsystemName, category: "WormholeChannel-\(peerID)") - } - - @Published private(set) var isConnected = false { - didSet { - guard isConnected != oldValue else { return } - logger.debug("isConnected = \(self.isConnected, privacy: .public)") - } - } - - private let packetSubject = PassthroughSubject() - - private lazy var cancellables = Set() - - @discardableResult - func onPacketReceived(perform block: @escaping (WHPeerID, WormholePacket) -> Void) -> Self { - packetSubject.sink { [weak self] packet in - guard let self = self else { return } - - block(self.peerID, packet) - } - .store(in: &cancellables) - - return self - } - - private var activated = false - - func activate() { - guard !activated else { return } - activated = true - - logger.debug(#function) - - stream() - } - - func invalidate() { - guard activated else { return } - activated = false - - logger.debug(#function) - - cancellables.removeAll() - - timeoutTask?.cancel() - - internalTasks.forEach { $0.cancel() } - internalTasks.removeAll() - } - - func send(_ packet: WormholePacket) async throws { - let data = try packet.encoded() - - if VirtualWormholeConstants.verboseLoggingEnabled { - if !packet.isPing, !packet.isPong { - logger.debug("⬆️ SEND \(packet.payloadType, privacy: .public) (\(packet.payload.count) bytes)") - logger.debug("⏫ \(data.map({ String(format: "%02X", $0) }).joined(), privacy: .public)") - } - } - - try output.write(contentsOf: data) - } - - private var internalTasks = [Task]() - - private func stream() { - logger.debug(#function) - - let streamingTask = Task { - do { - for try await packet in WormholePacket.stream(from: input.bytes) { - if VirtualWormholeConstants.verboseLoggingEnabled { - if !packet.isPing, !packet.isPong { - logger.debug("⬇️ RECEIVE \(packet.payloadType, privacy: .public) (\(packet.payload.count) bytes)") - logger.debug("⏬ \(packet.payload.map({ String(format: "%02X", $0) }).joined(), privacy: .public)") - } - } - - guard !Task.isCancelled else { break } - - guard !packet.isPing && !packet.isPong else { - await handlePingPong(packet) - continue - } - - packetSubject.send(packet) - } - - logger.debug("⬇️ Packet streaming cancelled") - } catch { - logger.error("⬇️ Serial read failure: \(error, privacy: .public)") - - try? await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) - - guard !Task.isCancelled else { return } - - stream() - } - } - internalTasks.append(streamingTask) - } - - private var timeoutTask: Task? - - private func handlePingPong(_ packet: WormholePacket) async { - self.isConnected = true - - if packet.isPing { - if VirtualWormholeConstants.verboseLoggingEnabled { - logger.debug("🏓 Received ping") - } - - do { - try await send(.pong) - } catch { - logger.error("🏓 Pong send failure: \(error, privacy: .public)") - } - } else { - if VirtualWormholeConstants.verboseLoggingEnabled { - logger.debug("🏓 Received pong") - } - } - - timeoutTask?.cancel() - - timeoutTask = Task { - try? await Task.sleep(nanoseconds: VirtualWormholeConstants.connectionTimeoutInNanoseconds) - - guard !Task.isCancelled else { return } - - logger.warning("🏓 Connection timed out") - - self.isConnected = false - } - } - - func connected(perform block: @escaping (WormholeChannel) async -> Void) { - let task = Task { [weak self] in - guard let self = self else { return } - - for await state in await self.$isConnected.removeDuplicates().values { - if state { - await block(self) - } - } - } - internalTasks.append(task) - } - -} diff --git a/VirtualWormholeTests/GuestAppSupportTests.swift b/VirtualWormholeTests/GuestAppSupportTests.swift new file mode 100644 index 00000000..4beb6aa0 --- /dev/null +++ b/VirtualWormholeTests/GuestAppSupportTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import VirtualCore + +struct GuestAppSupportTests { + private static let legacyApps = [ + CatalogLegacyGuestAppVersion(id: "monterey", url: URL(fileURLWithPath: "/monterey.dmg"), sha384: "", + guestAppVersion: "1.4", minGuestVersion: "12.3", maxGuestVersion: "12.99.99"), + CatalogLegacyGuestAppVersion(id: "ventura", url: URL(fileURLWithPath: "/ventura.dmg"), sha384: "", + guestAppVersion: "2.1", minGuestVersion: "13", maxGuestVersion: "13.99.99"), + CatalogLegacyGuestAppVersion(id: "sonoma", url: URL(fileURLWithPath: "/sonoma.dmg"), sha384: "", + guestAppVersion: "2.2", minGuestVersion: "14", maxGuestVersion: "14.99.99") + ] + + @Test(arguments: [ + ("12.0", GuestAppSupport.unsupported), + ("12.6.1", .unsupported), + ("13.0", .sharedFoldersOnly), + ("13.7", .sharedFoldersOnly), + ("14.0", .full), + ("15.0", .full), + ("27.0", .full) + ]) + func knownGuestOS(version: String, expected: GuestAppSupport) { + #expect(resolve(version: SoftwareVersion(string: version)) == expected) + } + + @Test func legacyOverridesNeverEnableClipboard() { + for app in Self.legacyApps { + #expect(resolve(version: "15", override: app.id) == .sharedFoldersOnly) + #expect(resolve(version: "12.6.1", override: app.id) == .unsupported) + } + #expect(resolve(version: "15", override: "removed-catalog-entry") == .sharedFoldersOnly) + } + + @Test func importedGuestsUseLegacySelection() { + #expect(resolve(version: nil, override: "monterey") == .unsupported) + #expect(resolve(version: .empty, override: "monterey") == .unsupported) + #expect(resolve(version: nil, override: "ventura") == .sharedFoldersOnly) + #expect(resolve(version: nil, override: "sonoma") == .sharedFoldersOnly) + #expect(resolve(version: nil, override: "removed-catalog-entry") == .sharedFoldersOnly) + #expect(resolve(version: nil) == .full) + } + + @Test func latestRequirementComesFromEmbeddedApp() { + #expect(GuestAppSupport.resolve(guestType: .mac, guestVersion: "14", latestMinimumVersion: "15", + guestAppVersion: nil, legacyApps: Self.legacyApps) == .sharedFoldersOnly) + } + + @Test func linuxDoesNotUseMacGuestApp() { + #expect(GuestAppSupport.resolve(guestType: .linux, guestVersion: nil, latestMinimumVersion: "14", + guestAppVersion: nil, legacyApps: Self.legacyApps) == .unsupported) + } + + private func resolve(version: SoftwareVersion?, override: String? = nil) -> GuestAppSupport { + GuestAppSupport.resolve(guestType: .mac, guestVersion: version, latestMinimumVersion: "14", + guestAppVersion: override, legacyApps: Self.legacyApps) + } +} diff --git a/VirtualWormholeTests/GuestSessionTests.swift b/VirtualWormholeTests/GuestSessionTests.swift new file mode 100644 index 00000000..3338e722 --- /dev/null +++ b/VirtualWormholeTests/GuestSessionTests.swift @@ -0,0 +1,408 @@ +import Foundation +import Testing +import VMBridge +import os +@testable import VirtualWormhole + +@Suite @MainActor +struct GuestSessionTests { + @Test func hostClipboardWinsAndRelaysWithoutEcho() async throws { + let fixture = await SessionFixture.make() + try await fixture.connect() + #expect(fixture.guestClipboard.items == clipboard("host")) + fixture.guestClipboard.write(clipboard("guest edit")) + try await eventually { fixture.hostClipboard.items == clipboard("guest edit") } + fixture.hostClipboard.write(clipboard("host edit")) + fixture.coordinator.poll() + try await eventually { fixture.guestClipboard.items == clipboard("host edit") } + let count = await fixture.guestConnection.sentClipboardCount + try await Task.sleep(for: .milliseconds(600)) + #expect(await fixture.guestConnection.sentClipboardCount == count) + await fixture.stop() + #expect(!fixture.guest.isConnected) + #expect(fixture.guestFeatures.notificationRegistrations == fixture.guestFeatures.notificationRemovals) + #expect(await fixture.guestConnection.running == false) + } + + @Test func clipboardRelayCanBeDisabledAndVMEventsStaySeparate() async throws { + let first = await SessionFixture.make() + let second = await SessionFixture.make(coordinator: first.coordinator) + try await first.connect() + try await second.connect() + first.guestClipboard.write(clipboard("shared")) + try await eventually { second.guestClipboard.items == clipboard("shared") } + #expect(first.hostFeatures.pictures.count == 1) + #expect(second.hostFeatures.pictures.count == 1) + first.guestFeatures.postNotification?("com.apple.shieldWindowRaised") + try await eventually { first.hostFeatures.notifications.count == 1 } + #expect(second.hostFeatures.notifications.isEmpty) + await first.stop() + await second.stop() + + let disabled = await SessionFixture.make(relay: false) + let other = await SessionFixture.make(coordinator: disabled.coordinator) + try await disabled.connect() + try await other.connect() + disabled.guestClipboard.write(clipboard("private")) + try await eventually { disabled.hostClipboard.items == clipboard("private") } + #expect(other.guestClipboard.items == clipboard("host")) + await disabled.stop() + await other.stop() + } + + @Test func reconnectRefreshesStateAndRejectsStaleMessages() async throws { + let fixture = await SessionFixture.make() + try await fixture.connect() + let oldToken = try #require(await fixture.guestConnection.token) + await fixture.disconnect() + try await eventually { !fixture.guest.isConnected } + #expect(fixture.guestFeatures.notificationRemovals == 1) + fixture.hostClipboard.write(clipboard("while disconnected")) + await fixture.hostConnection.transition(.connected) + await fixture.guestConnection.transition(.connected) + try await eventually { fixture.guest.isConnected && fixture.guestFeatures.notificationRegistrations == 2 } + #expect(fixture.guestClipboard.items == clipboard("while disconnected")) + try await fixture.hostConnection.send(ClipboardUpdate(session: oldToken, revision: 999, items: clipboard("stale"))) + try await fixture.guestConnection.send(DesktopPictureUpdate(session: oldToken, type: "public.heic", content: Data([9]))) + try await Task.sleep(for: .milliseconds(50)) + #expect(fixture.guestClipboard.items == clipboard("while disconnected")) + #expect(fixture.hostFeatures.pictures.allSatisfy { $0.content == Data([1, 2, 3]) }) + await fixture.stop() + try await fixture.connect() + await fixture.stop() + #expect(fixture.guestFeatures.notificationRegistrations == fixture.guestFeatures.notificationRemovals) + } + + @Test func oversizedClipboardUsesFilesAndCleansThemUp() async throws { + let large = [ClipboardItem(type: "public.png", data: Data(repeating: 0xA5, count: 7 * 1024 * 1024))] + let fixture = await SessionFixture.make(hostItems: large) + try await fixture.connect() + #expect(fixture.guestClipboard.items == large) + #expect(await fixture.hostConnection.sentFiles == 1) + fixture.guestClipboard.write(large + clipboard("different")) + try await eventually { fixture.hostClipboard.items == large + clipboard("different") } + #expect(await fixture.guestConnection.sentFiles == 1) + await fixture.stop() + let files = await fixture.hostConnection.files + fixture.guestConnection.files + #expect(files.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) }) + } + + @Test func newerClipboardSupersedesSlowTransfer() async throws { + let fixture = await SessionFixture.make() + try await fixture.connect() + await fixture.hostConnection.delayTransfers(by: .seconds(2)) + fixture.hostClipboard.write([ClipboardItem(type: "public.png", data: Data(repeating: 4, count: 7 * 1024 * 1024))]) + fixture.coordinator.poll() + try await eventually { await fixture.guestConnection.receivedFiles > 0 } + fixture.hostClipboard.write(clipboard("newer")) + fixture.coordinator.poll() + try await eventually { fixture.guestClipboard.items == clipboard("newer") } + await fixture.stop() + let files = await fixture.hostConnection.files + fixture.guestConnection.files + #expect(files.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) }) + } + + @Test func initialSnapshotWinsButLaterTransfersPreserveLocalEdits() async throws { + let large = [ClipboardItem(type: "public.png", data: Data(repeating: 1, count: 7 * 1024 * 1024))] + let fixture = await SessionFixture.make(hostItems: large) + await fixture.hostConnection.delayTransfers(by: .milliseconds(200)) + fixture.host.start() + fixture.guest.start() + try await eventually { await fixture.guestConnection.receivedFiles == 1 } + fixture.guestClipboard.write(clipboard("edit during initialization")) + try await eventually { fixture.guestClipboard.items == large } + + fixture.hostClipboard.write(large + clipboard("another snapshot")) + fixture.coordinator.poll() + try await eventually { await fixture.guestConnection.receivedFiles == 2 } + fixture.guestClipboard.write(clipboard("keep this local edit")) + try await Task.sleep(for: .milliseconds(300)) + #expect(fixture.guestClipboard.items == clipboard("keep this local edit")) + await fixture.stop() + } + + @Test func defaultsRequestsAreCorrelatedAndLargeExportsUseFiles() async throws { + let fixture = await SessionFixture.make() + fixture.hostFeatures.defaults["small"] = Data("small plist".utf8) + fixture.hostFeatures.defaults["large"] = Data(repeating: 7, count: 7 * 1024 * 1024) + try await fixture.connect() + async let first: Void = fixture.guest.importDomain("small") + async let second: Void = fixture.guest.importDomain("large") + try await first + try await second + #expect(fixture.guestFeatures.imported == fixture.hostFeatures.defaults) + #expect(await fixture.hostConnection.sentFiles == 1) + await #expect(throws: GuestSessionError.self) { try await fixture.guest.importDomain("missing") } + await fixture.stop() + let files = await fixture.hostConnection.files + fixture.guestConnection.files + #expect(files.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) }) + } + + @Test func defaultsTimeoutCancellationAndDisconnectDoNotImport() async throws { + let fixture = await SessionFixture.make(timeout: .milliseconds(100)) + fixture.hostFeatures.exportDelay = .seconds(5) + fixture.hostFeatures.defaults["slow"] = Data([1]) + try await fixture.connect() + await #expect(throws: VMBridgeError.requestTimedOut) { try await fixture.guest.importDomain("slow") } + let cancelled = Task { try await fixture.guest.importDomain("slow") } + cancelled.cancel() + await #expect(throws: CancellationError.self) { try await cancelled.value } + let disconnected = Task { try await fixture.guest.importDomain("slow") } + try await Task.sleep(for: .milliseconds(20)) + await fixture.disconnect() + do { try await disconnected.value; Issue.record("Disconnected import succeeded") } catch { } + #expect(fixture.guestFeatures.imported.isEmpty) + await fixture.stop() + } + + @Test func failedTransferLeavesClipboardUntouched() async throws { + let fixture = await SessionFixture.make() + try await fixture.connect() + let token = try #require(await fixture.guestConnection.token) + let identity = TransferIdentity(session: token, operation: UUID(), purpose: .clipboard, revision: 100) + let paths = RecordedPaths() + fixture.guestConnection.offerInput.yield(.init(metadata: identity.metadata, receive: { url in + paths.append(url) + try Data([1]).write(to: url) + throw VMBridgeError.bulkTransferIntegrityFailure + }, reject: {})) + try await eventually { !paths.values.isEmpty && paths.values.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) } } + #expect(fixture.guestClipboard.items == clipboard("host")) + await fixture.stop() + } + + @Test func terminationClosesConnectionBeforeJoiningBlockedSend() async throws { + let fixture = await SessionFixture.make() + try await fixture.connect() + await fixture.guestConnection.blockPicturesUntilStop() + let guest = GuestHostSession(engine: fixture.guest) + let start = ContinuousClock.now + await guest.prepareForTermination() + #expect(start.duration(to: .now) < .seconds(3)) + #expect(await fixture.guestConnection.running == false) + await fixture.host.stop() + } +} + +@MainActor private func eventually(_ condition: () async -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !(await condition()) { + guard ContinuousClock.now < deadline else { throw TestFailure.deadline } + try await Task.sleep(for: .milliseconds(10)) + } +} + +private enum TestFailure: Error { case deadline, missingPeer } +private func clipboard(_ text: String) -> [ClipboardItem] { [.init(type: "public.utf8-plain-text", data: Data(text.utf8))] } + +@MainActor private final class TestClipboard: GuestClipboard { + var items: [ClipboardItem] + var changeCount = 0 + init(_ items: [ClipboardItem]) { self.items = items } + func read() -> [ClipboardItem] { items } + func write(_ items: [ClipboardItem]) { self.items = items; changeCount += 1 } +} + +@MainActor private final class TestFeatures { + var notificationRegistrations = 0 + var notificationRemovals = 0 + var postNotification: (@MainActor (String) -> Void)? + var pictures: [GuestDesktopPicture] = [] + var notifications: [String] = [] + var defaults: [String: Data] = [:] + var imported: [String: Data] = [:] + var exportDelay: Duration = .zero + + func providers(clipboard: TestClipboard) -> GuestFeatureProviders { + .init(clipboard: clipboard, notifications: { [self] _, receive in + notificationRegistrations += 1 + postNotification = receive + return { [self] in notificationRemovals += 1; postNotification = nil } + }, desktopPicture: { GuestDesktopPicture(type: "public.heic", content: Data([1, 2, 3])) }, exportDefaults: { [self] domain, url in + if exportDelay > .zero { try await Task.sleep(for: exportDelay) } + guard let data = defaults[domain] else { throw GuestSessionError.unavailableDomain } + try data.write(to: url) + }, importDefaults: { [self] domain, url in imported[domain] = try Data(contentsOf: url) }) + } +} + +@MainActor private final class SessionFixture { + let hostConnection: TestConnection + let guestConnection: TestConnection + let coordinator: HostClipboardCoordinator + let hostClipboard: TestClipboard + let guestClipboard: TestClipboard + let hostFeatures = TestFeatures() + let guestFeatures = TestFeatures() + let host: GuestSessionEngine + let guest: GuestSessionEngine + + static func make(coordinator: HostClipboardCoordinator? = nil, relay: Bool = true, hostItems: [ClipboardItem] = clipboard("host"), timeout: Duration = .seconds(30)) async -> SessionFixture { + let host = TestConnection() + let guest = TestConnection() + await host.connect(to: guest) + await guest.connect(to: host) + return SessionFixture(host: host, guest: guest, coordinator: coordinator, relay: relay, hostItems: hostItems, timeout: timeout) + } + + private init(host: TestConnection, guest: TestConnection, coordinator: HostClipboardCoordinator?, relay: Bool, hostItems: [ClipboardItem], timeout: Duration) { + hostConnection = host + guestConnection = guest + hostClipboard = coordinator?.clipboard as? TestClipboard ?? TestClipboard(hostItems) + guestClipboard = TestClipboard(clipboard("old guest")) + self.coordinator = coordinator ?? HostClipboardCoordinator(clipboard: hostClipboard, relayEnabled: { relay }) + self.host = GuestSessionEngine(connection: host, hostClipboard: self.coordinator, providers: hostFeatures.providers(clipboard: hostClipboard)) + self.guest = GuestSessionEngine(connection: guest, providers: guestFeatures.providers(clipboard: guestClipboard), requestTimeout: timeout) + self.host.onDesktopPicture = { [hostFeatures] in hostFeatures.pictures.append($0) } + self.host.onNotification = { [hostFeatures] in hostFeatures.notifications.append($0) } + } + + func connect() async throws { + host.start() + guest.start() + try await eventually { self.guest.isConnected && self.guestFeatures.postNotification != nil && !self.hostFeatures.pictures.isEmpty && self.guestClipboard.items == self.hostClipboard.items } + } + func disconnect() async { + await hostConnection.transition(.disconnected) + await guestConnection.transition(.disconnected) + } + func stop() async { await guest.stop(); await host.stop() } +} + +private final class RecordedPaths: Sendable { + private let storage = OSAllocatedUnfairLock(initialState: [URL]()) + var values: [URL] { storage.withLock { $0 } } + func append(_ url: URL) { storage.withLock { $0.append(url) } } +} + +private final class MessageBus: Sendable { + private let streams = OSAllocatedUnfairLock(initialState: [String: [UUID: AsyncStream.Continuation]]()) + func subscribe(_ type: M.Type) -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + streams.withLock { $0[M.messageID, default: [:]][id] = continuation } + continuation.onTermination = { [weak self] _ in self?.streams.withLock { $0[M.messageID]?[id] = nil } } + return AsyncStream(unfolding: { + var iterator = stream.makeAsyncIterator() + guard let data = await iterator.next() else { return nil } + return try? JSONDecoder().decode(M.self, from: data) + }) + } + func send(_ message: M) throws { + let data = try JSONEncoder().encode(message) + let subscribers = streams.withLock { Array(($0[M.messageID] ?? [:]).values) } + for continuation in subscribers { continuation.yield(data) } + } +} + +private struct TestReply: Sendable { + let stream: AsyncThrowingStream + let continuation: AsyncThrowingStream.Continuation + init() { (stream, continuation) = AsyncThrowingStream.makeStream() } + func reply(_ message: M) throws { continuation.yield(try JSONEncoder().encode(message)); continuation.finish() } + func value() async throws -> Data { + for try await data in stream { return data } + throw CancellationError() + } +} + +private actor TestConnection: GuestConnection { + nonisolated let stateInput = TestEvents() + nonisolated var state: AsyncStream { stateInput.stream() } + nonisolated let initializationInput = TestEvents() + nonisolated var initializationRequests: AsyncStream { initializationInput.stream() } + nonisolated let defaultsInput = TestEvents() + nonisolated var defaultsRequests: AsyncStream { defaultsInput.stream() } + nonisolated let offerInput = TestEvents() + nonisolated var fileOffers: AsyncStream { offerInput.stream() } + nonisolated private let bus = MessageBus() + private var peer: TestConnection? + private var transferDelay: Duration = .zero + private var blockPictures = false + private var pictureWaiter: CheckedContinuation? + private(set) var token: UUID? + private(set) var sentClipboardCount = 0 + private(set) var sentFiles = 0 + private(set) var receivedFiles = 0 + private(set) var files: [URL] = [] + private(set) var running = false + + func connect(to peer: TestConnection) { self.peer = peer } + func delayTransfers(by delay: Duration) { transferDelay = delay } + func blockPicturesUntilStop() { blockPictures = true } + func transition(_ state: VMConnectionState) { stateInput.yield(state) } + nonisolated func messages(of type: M.Type) -> AsyncStream { bus.subscribe(type) } + func send(_ message: M) async throws { + if message is DesktopPictureUpdate, blockPictures { + try await withCheckedThrowingContinuation { pictureWaiter = $0 } + } + try Task.checkCancellation() + guard let peer else { throw TestFailure.missingPeer } + if message is ClipboardUpdate { sentClipboardCount += 1 } + try peer.bus.send(message) + } + func request(_ message: M, reply: R.Type, timeout: Duration) async throws -> R { + try Task.checkCancellation() + guard let peer else { throw TestFailure.missingPeer } + let response = TestReply() + if let message = message as? InitializeGuest { + token = message.session + peer.initializationInput.yield(.init(payload: message, reply: { try response.reply($0) })) + } else if let message = message as? ExportDefaults { + peer.defaultsInput.yield(.init(payload: message, reply: { try response.reply($0) })) + } + let data = try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask { try await response.value() } + group.addTask { try await Task.sleep(for: timeout); throw VMBridgeError.requestTimedOut } + defer { group.cancelAll() } + return try await group.next()! + } + return try JSONDecoder().decode(R.self, from: data) + } + private func receivedFile(_ url: URL) { receivedFiles += 1; files.append(url) } + func sendFile(at url: URL, metadata: BulkTransferMetadata) async throws { + guard let peer else { throw TestFailure.missingPeer } + sentFiles += 1 + files.append(url) + let completion = TestReply() + let delay = transferDelay + peer.offerInput.yield(.init(metadata: metadata, receive: { destination in + await peer.receivedFile(destination) + do { + if delay > .zero { try await Task.sleep(for: delay) } + try Task.checkCancellation() + try FileManager.default.copyItem(at: url, to: destination) + completion.continuation.yield(Data()) + completion.continuation.finish() + } catch { + completion.continuation.finish(throwing: error) + throw error + } + }, reject: { completion.continuation.finish(throwing: VMBridgeError.bulkTransferRejected) })) + _ = try await completion.value() + } + func run() async throws { + running = true + stateInput.yield(.connected) + do { try await Task.sleep(for: .seconds(3600)) } catch { } + running = false + pictureWaiter?.resume(throwing: GuestSessionError.disconnected) + pictureWaiter = nil + stateInput.yield(.stopped) + } +} + +private final class TestEvents: Sendable { + private let subscribers = OSAllocatedUnfairLock(initialState: [UUID: AsyncStream.Continuation]()) + func stream() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + subscribers.withLock { $0[id] = continuation } + continuation.onTermination = { [weak self] _ in self?.subscribers.withLock { $0[id] = nil } } + return stream + } + func yield(_ value: Element) { + let continuations = subscribers.withLock { Array($0.values) } + for continuation in continuations { continuation.yield(value) } + } +} diff --git a/VirtualWormholeTests/Resources/TestStream.bin b/VirtualWormholeTests/Resources/TestStream.bin deleted file mode 100644 index bbb238f9..00000000 Binary files a/VirtualWormholeTests/Resources/TestStream.bin and /dev/null differ diff --git a/VirtualWormholeTests/WormholePacketTests.swift b/VirtualWormholeTests/WormholePacketTests.swift deleted file mode 100644 index dccc8507..00000000 --- a/VirtualWormholeTests/WormholePacketTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// VirtualWormholeTests.swift -// VirtualWormholeTests -// -// Created by Guilherme Rambo on 08/03/23. -// - -import XCTest -@testable import VirtualWormhole - -final class WormholePacketTests: XCTestCase { - - func testPacketEncodingWithTestPayload() throws { - let payload = TestPayload() - let packet = try WormholePacket(payload) - let data = try packet.encoded() - - let headerLength = data.count - packet.payload.count - XCTAssertEqual(Data(data.prefix(headerLength)).hexDump, "CAFEF00D546573745061796C6F6164003A00000000000000") - XCTAssertEqual( - try JSONSerialization.jsonObject(with: data.suffix(packet.payload.count)) as? NSDictionary, - try JSONSerialization.jsonObject(with: Data(#"{"message":"Hello, World!","number":42,"data":"qrvM3e7\/"}"#.utf8)) as? NSDictionary - ) - } - - func testPacketDecodingWithTestPayload() throws { - let payload = TestPayload() - let packet = try WormholePacket(payload) - let data = try packet.encoded() - - let decodedPacket = try WormholePacket.decode(from: data) - - XCTAssertEqual(decodedPacket.magic, packet.magic) - XCTAssertEqual(decodedPacket.payloadType, packet.payloadType) - XCTAssertEqual(decodedPacket.payloadLength, packet.payloadLength) - XCTAssertEqual(decodedPacket.payload.count, packet.payload.count) - XCTAssertEqual(decodedPacket.payload, packet.payload) - } - - func testPacketDecodingRespectsLength() throws { - let payload = TestPayload() - - var packet = try WormholePacket(payload) - packet.payloadLength = 1 - - let data = try packet.encoded() - - let decodedPacket = try WormholePacket.decode(from: data) - XCTAssertEqual(Int(packet.payloadLength), decodedPacket.payload.count) - } - - func testPacketStreaming() async throws { - let handle = FileHandle.testStream - - var packets = [WormholePacket]() - - for try await packet in WormholePacket.stream(from: handle.bytes) { - packets.append(packet) - - guard packets.count < 6 else { break } - } - - XCTAssertEqual(packets.count, 6) - - for packet in packets { - XCTAssertEqual(packet.magic, 0x0DF0FECA) - XCTAssertEqual(packet.payloadType, "TestPayload") - XCTAssertEqual(packet.payloadLength, 58) - XCTAssertEqual(packet.payload.count, 58) - XCTAssertEqual(packet.payload, Data("{\"message\":\"Hello, World!\",\"number\":42,\"data\":\"qrvM3e7\\/\"}".utf8)) - } - } - - func testCompressedPacketEncodeDecode() async throws { - let payload = TestPayload(data: .empty(count: WormholePacket.maxUncompressedPayloadSize + 1)) - - let packet = try WormholePacket(payload) - - let data = try packet.encoded() - - let decodedPacket = try WormholePacket.decode(from: data) - - XCTAssertEqual(decodedPacket.magic, WormholePacket.magicValueCompressed) - XCTAssertEqual(decodedPacket.payload, packet.payload) - } - -} - -struct TestPayload: Codable { - var message = "Hello, World!" - var number = 42 - var data = Data([0xAA,0xBB,0xCC,0xDD,0xEE,0xFF]) -} - -extension Data { - var hexDump: String { - map { String(format: "%02X", $0) }.joined() - } - - static func empty(count: Int) -> Data { - let bytes = [UInt8](repeating: 0, count: count) - return Data(bytes) - } -} - -extension FileHandle { - static var testStream: FileHandle { - guard let url = Bundle(for: WormholePacketTests.self).url(forResource: "TestStream", withExtension: "bin") else { - fatalError("Missing TestStream.bin in WormholeTests bundle!") - } - return try! FileHandle(forReadingFrom: url) - } -} diff --git a/data/LegacyGuestApp/README.md b/data/LegacyGuestApp/README.md index b81bad49..3252ac94 100644 --- a/data/LegacyGuestApp/README.md +++ b/data/LegacyGuestApp/README.md @@ -1,5 +1,7 @@ # 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. +This directory contains archived versions of VirtualBuddyGuest referenced by the software catalog. -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 +In current VirtualBuddy releases, legacy guest apps only support automatic mounting of shared folders on macOS 13 or later. Clipboard sharing requires the latest guest app. VirtualBuddyGuest is not supported on macOS 12 or earlier. + +The macOS 12 catalog entry is retained to recognize existing configurations and for older VirtualBuddy releases. Current releases do not offer it for new selections or attach its installer. diff --git a/data/ipsws_v2.json b/data/ipsws_v2.json index 36a5117c..96569565 100644 --- a/data/ipsws_v2.json +++ b/data/ipsws_v2.json @@ -34,7 +34,7 @@ }, { "id" : "guest_app", - "minVersionGuest" : "12.3.0", + "minVersionGuest" : "13.0.0", "minVersionHost" : "13.0.0", "name" : "VirtualBuddyGuest app", "unsupportedPlatform" : false