From e94b83d6abec589893b2e1b247a2ef826fada05f Mon Sep 17 00:00:00 2001 From: Nikolai Shangin Date: Wed, 22 Jul 2026 16:33:05 +0500 Subject: [PATCH] vm(apple): support USB passthrough on macOS 27 Use Accessory Access and the public Virtualization USB passthrough APIs to attach host USB devices to Apple Virtualization macOS guests. Preserve device intent across VM lifecycle transitions and serialize hot-plug operations around pause, snapshot, restart, and stop. Refs #3778 --- .../VMDisplayAppleWindowController.swift | 171 +++ Platform/macOS/SettingsView.swift | 5 +- Platform/macOS/macOS.entitlements | 2 + Services/UTMAppleUSBManager.swift | 1315 +++++++++++++++++ Services/UTMAppleVirtualMachine.swift | 420 +++++- Services/UTMRegistryEntry.swift | 35 + UTM.xcodeproj/project.pbxproj | 4 + 7 files changed, 1887 insertions(+), 65 deletions(-) create mode 100644 Services/UTMAppleUSBManager.swift diff --git a/Platform/macOS/Display/VMDisplayAppleWindowController.swift b/Platform/macOS/Display/VMDisplayAppleWindowController.swift index 36d359608e..236da4c648 100644 --- a/Platform/macOS/Display/VMDisplayAppleWindowController.swift +++ b/Platform/macOS/Display/VMDisplayAppleWindowController.swift @@ -46,6 +46,7 @@ class VMDisplayAppleWindowController: VMDisplayWindowController { // MARK: - User preferences @Setting("SharePathAlertShown") private var isSharePathAlertShownPersistent: Bool = false + @Setting("NoUsbPrompt") private var isNoUsbPrompt: Bool = false override func windowDidLoad() { mainView!.translatesAutoresizingMaskIntoConstraints = false @@ -96,14 +97,26 @@ class VMDisplayAppleWindowController: VMDisplayWindowController { if #available(macOS 15, *) { setControl(.drives, isEnabled: true) } + if let usbManager = appleVM.usbManager, usbManager.isSupported { + setControl(.usb, isEnabled: true) + if !isSecondary, usbManager.isAvailable { + usbManager.delegate = self + } + } } override func enterSuspended(isBusy busy: Bool) { + if !isSecondary { + appleVM.usbManager?.delegate = nil + } super.enterSuspended(isBusy: busy) } override func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) { super.virtualMachine(vm, didTransitionToState: state) + if state == .stopped && !isSecondary { + appleVM.usbManager?.delegate = nil + } if state == .stopped && isInstallSuccessful { isInstallSuccessful = false vm.requestVmStart() @@ -166,6 +179,164 @@ class VMDisplayAppleWindowController: VMDisplayWindowController { } } +// MARK: - USB passthrough + +extension VMDisplayAppleWindowController: UTMAppleUSBManagerDelegate { + func usbManager(_ usbManager: UTMAppleUSBManager, didDiscover device: UTMAppleUSBDevice) { + logger.debug("USB device discovered: \(device.name)") + guard !isSecondary, + !isNoUsbPrompt, + window?.isKeyWindow == true, + vm.state == .started, + case .available = device.state else { + return + } + showConnectPrompt(for: device, using: usbManager) + } + + func usbManager(_ usbManager: UTMAppleUSBManager, didRemove device: UTMAppleUSBDevice) { + logger.debug("USB device removed: \(device.name)") + } + + private func showConnectPrompt(for device: UTMAppleUSBDevice, using usbManager: UTMAppleUSBManager) { + guard !isSecondary, let window = window, window.isKeyWindow, vm.state == .started else { + return + } + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = NSLocalizedString("USB Device", comment: "VMDisplayAppleWindowController") + alert.informativeText = String.localizedStringWithFormat(NSLocalizedString("Would you like to connect '%@' to this virtual machine?", comment: "VMDisplayAppleWindowController"), device.name) + alert.showsSuppressionButton = true + alert.addButton(withTitle: NSLocalizedString("Connect", comment: "VMDisplayAppleWindowController")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayAppleWindowController")) + alert.beginSheetModal(for: window) { response in + if alert.suppressionButton?.state == .on { + self.isNoUsbPrompt = true + } + guard response == .alertFirstButtonReturn, self.vm.state == .started else { + return + } + self.withErrorAlert { + try await usbManager.connect(device) + } + } + } +} + +extension VMDisplayAppleWindowController { + override func updateUsbMenu(_ menu: NSMenu) { + menu.autoenablesItems = false + setUsbMenuMessage(NSLocalizedString("Querying USB devices...", comment: "VMDisplayAppleWindowController"), in: menu) + guard let usbManager = appleVM.usbManager, usbManager.isSupported else { + setUsbMenuMessage(NSLocalizedString("USB passthrough is unavailable.", comment: "VMDisplayAppleWindowController"), in: menu) + return + } + if let unavailableReason = usbManager.unavailableReason { + setUsbMenuMessage(unavailableReason, in: menu) + return + } + Task { @MainActor [weak self] in + guard let self = self else { + return + } + do { + let devices = try await usbManager.usbDevices() + self.updateUsbDevicesMenu(menu, devices: devices) + } catch { + logger.debug("Failed to query USB devices: \(error.localizedDescription)") + self.setUsbMenuMessage(NSLocalizedString("Unable to query USB devices.", comment: "VMDisplayAppleWindowController"), + toolTip: error.localizedDescription, + in: menu) + } + } + } + + private func updateUsbDevicesMenu(_ menu: NSMenu, devices: [UTMAppleUSBDevice]) { + guard !devices.isEmpty else { + setUsbMenuMessage(NSLocalizedString("No USB devices assigned to UTM.", comment: "VMDisplayAppleWindowController"), + toolTip: NSLocalizedString("Use the USB Accessories menu in the menu bar to allow a device.", comment: "VMDisplayAppleWindowController"), + in: menu) + return + } + menu.removeAllItems() + for device in devices { + let item = NSMenuItem() + item.title = device.name + item.representedObject = device + + let actionItem = NSMenuItem() + actionItem.representedObject = device + actionItem.target = self + switch device.state { + case .available: + item.isEnabled = true + item.state = .off + actionItem.title = NSLocalizedString("Connect…", comment: "VMDisplayAppleWindowController") + actionItem.isEnabled = true + actionItem.action = #selector(connectUsbDevice(_:)) + case .connected: + item.isEnabled = true + item.state = .on + actionItem.title = NSLocalizedString("Disconnect…", comment: "VMDisplayAppleWindowController") + actionItem.isEnabled = true + actionItem.action = #selector(disconnectUsbDevice(_:)) + case .inUse: + item.isEnabled = false + item.state = .off + item.toolTip = NSLocalizedString("This device is connected to another virtual machine.", comment: "VMDisplayAppleWindowController") + actionItem.title = NSLocalizedString("Connect…", comment: "VMDisplayAppleWindowController") + actionItem.isEnabled = false + actionItem.action = #selector(connectUsbDevice(_:)) + } + + let submenu = NSMenu() + submenu.autoenablesItems = false + submenu.addItem(actionItem) + item.submenu = submenu + menu.addItem(item) + } + menu.update() + } + + private func setUsbMenuMessage(_ message: String, toolTip: String? = nil, in menu: NSMenu) { + menu.removeAllItems() + let item = NSMenuItem() + item.title = message + item.toolTip = toolTip + item.isEnabled = false + menu.addItem(item) + menu.update() + } + + @objc private func connectUsbDevice(_ sender: NSMenuItem) { + guard let device = sender.representedObject as? UTMAppleUSBDevice else { + logger.debug("Missing USB device for connect action") + return + } + guard let usbManager = appleVM.usbManager else { + logger.debug("USB manager is missing for connect action") + return + } + withErrorAlert { + try await usbManager.connect(device) + } + } + + @objc private func disconnectUsbDevice(_ sender: NSMenuItem) { + guard let device = sender.representedObject as? UTMAppleUSBDevice else { + logger.debug("Missing USB device for disconnect action") + return + } + guard let usbManager = appleVM.usbManager else { + logger.debug("USB manager is missing for disconnect action") + return + } + withErrorAlert { + try await usbManager.disconnect(device) + } + } +} + extension VMDisplayAppleWindowController { override func updateSharedFolderMenu(_ menu: NSMenu) { let entry = appleVM.registryEntry diff --git a/Platform/macOS/SettingsView.swift b/Platform/macOS/SettingsView.swift index f3846eef31..78b287c435 100644 --- a/Platform/macOS/SettingsView.swift +++ b/Platform/macOS/SettingsView.swift @@ -189,10 +189,13 @@ struct ApplicationSettingsView: View { Text("Do not show confirmation when closing a running VM") }).help("Closing a VM without properly shutting it down could result in data loss.") - Section(header: Text("QEMU USB")) { + Section(header: Text("USB")) { Toggle(isOn: $isNoUsbPrompt, label: { Text("Do not show prompt when USB device is plugged in") }) + } + + Section(header: Text("QEMU USB")) { Button("Reset auto connect devices…") { isConfirmResetAutoConnect.toggle() }.help("Clears all saved USB devices.") diff --git a/Platform/macOS/macOS.entitlements b/Platform/macOS/macOS.entitlements index de00e8fc0f..08b3eb001e 100644 --- a/Platform/macOS/macOS.entitlements +++ b/Platform/macOS/macOS.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.accessory-access.usb + com.apple.security.app-sandbox com.apple.security.application-groups diff --git a/Services/UTMAppleUSBManager.swift b/Services/UTMAppleUSBManager.swift new file mode 100644 index 0000000000..63b80b12a4 --- /dev/null +++ b/Services/UTMAppleUSBManager.swift @@ -0,0 +1,1315 @@ +// +// Copyright © 2026 osy. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Darwin +import Foundation +@preconcurrency import Virtualization + +#if canImport(AccessoryAccess) +import AccessoryAccess +#endif + +struct UTMAppleUSBDevice: Hashable, Identifiable, Sendable { + enum State: Hashable, Sendable { + case available + case connected + case inUse + } + + let registryID: UInt64 + + let identity: UTMRegistryEntry.AppleUSBDevice + + let name: String + + let state: State + + var id: UInt64 { + registryID + } +} + +@MainActor protocol UTMAppleUSBManagerDelegate: AnyObject { + func usbManager(_ usbManager: UTMAppleUSBManager, didDiscover device: UTMAppleUSBDevice) + + func usbManager(_ usbManager: UTMAppleUSBManager, didRemove device: UTMAppleUSBDevice) +} + +private enum UTMAppleUSBManagerError: LocalizedError { + case notSupported + case dockIconRequired + case noUsbController + case virtualMachineNotRunning + case deviceUnavailable + case deviceInUse + case invalidDeviceDescriptor + + var errorDescription: String? { + switch self { + case .notSupported: + return NSLocalizedString("USB passthrough requires macOS 27 or later on an Apple Silicon Mac.", comment: "UTMAppleUSBManager") + case .dockIconRequired: + return NSLocalizedString("USB passthrough requires UTM to appear in the Dock. Enable “Show dock icon” in Settings, then restart UTM.", comment: "UTMAppleUSBManager") + case .noUsbController: + return NSLocalizedString("The virtual machine does not have a USB controller.", comment: "UTMAppleUSBManager") + case .virtualMachineNotRunning: + return NSLocalizedString("The virtual machine must be running to change USB devices.", comment: "UTMAppleUSBManager") + case .deviceUnavailable: + return NSLocalizedString("The USB device is no longer available.", comment: "UTMAppleUSBManager") + case .deviceInUse: + return NSLocalizedString("The USB device is connected to another virtual machine.", comment: "UTMAppleUSBManager") + case .invalidDeviceDescriptor: + return NSLocalizedString("The USB device provided an invalid descriptor.", comment: "UTMAppleUSBManager") + } + } +} + +@available(macOS 11, *) +final class UTMAppleUSBManager: @unchecked Sendable { + private let virtualMachineQueue: DispatchQueue + + private let ownerID = UUID() + + private let coordinatorLock = NSLock() + + private var coordinatorStorage: AnyObject? + + private let lifecycleLock = NSLock() + + private var pendingStopTask: Task? + + @MainActor weak var delegate: (any UTMAppleUSBManagerDelegate)? + + @MainActor var onConnectedDevicesChange: (([UTMRegistryEntry.AppleUSBDevice]) -> Void)? + + init(virtualMachineQueue: DispatchQueue) { + self.virtualMachineQueue = virtualMachineQueue + } + + @MainActor var isAvailable: Bool { + unavailableError == nil + } + + @MainActor var isSupported: Bool { + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + return true + } + #endif + return false + } + + @MainActor var unavailableReason: String? { + unavailableError?.localizedDescription + } + + func start(with virtualMachine: VZVirtualMachine, + restoring devices: [UTMRegistryEntry.AppleUSBDevice]) async throws { + await waitForPendingStop() + try await checkAvailability() + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + try await coordinator.start(with: virtualMachine, restoring: devices) + return + } + #endif + throw UTMAppleUSBManagerError.notSupported + } + + func stop(with virtualMachine: VZVirtualMachine) { + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *), let coordinator = existingCoordinator { + lifecycleLock.lock() + let previousStopTask = pendingStopTask + pendingStopTask = Task { + await previousStopTask?.value + await coordinator.stop(with: virtualMachine) + } + lifecycleLock.unlock() + } + #endif + } + + func usbDevices() async throws -> [UTMAppleUSBDevice] { + try await checkAvailability() + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + return try await coordinator.usbDevices() + } + #endif + throw UTMAppleUSBManagerError.notSupported + } + + func connect(_ device: UTMAppleUSBDevice) async throws { + try await checkAvailability() + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + try await coordinator.connect(device) + return + } + #endif + throw UTMAppleUSBManagerError.notSupported + } + + func disconnect(_ device: UTMAppleUSBDevice) async throws { + try await checkAvailability() + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + try await coordinator.disconnect(device) + return + } + #endif + throw UTMAppleUSBManagerError.notSupported + } + + func detachAllForSnapshot(with virtualMachine: VZVirtualMachine) async throws -> [UTMRegistryEntry.AppleUSBDevice] { + let isAvailable = await isAvailable + guard isAvailable else { + return [] + } + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + return try await coordinator.detachAllForSnapshot(with: virtualMachine) + } + #endif + return [] + } + + func releaseSnapshotReservations(for virtualMachine: VZVirtualMachine) async { + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *), let coordinator = existingCoordinator { + await coordinator.releaseSnapshotReservations(for: virtualMachine) + } + #endif + } + + func restoreConnections(from devices: [UTMRegistryEntry.AppleUSBDevice], + with virtualMachine: VZVirtualMachine) async throws { + try await checkAvailability() + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + try await coordinator.restoreConnections(from: devices, with: virtualMachine) + return + } + #endif + throw UTMAppleUSBManagerError.notSupported + } + + @MainActor private var unavailableError: UTMAppleUSBManagerError? { + #if canImport(AccessoryAccess) && arch(arm64) + if #available(macOS 27, *) { + return NSApp.activationPolicy() == .regular ? nil : .dockIconRequired + } + #endif + return .notSupported + } + + private func checkAvailability() async throws { + if let error = await unavailableError { + throw error + } + } + + private func waitForPendingStop() async { + let pendingStopTask = pendingStopTaskSnapshot() + await pendingStopTask?.value + } + + private func pendingStopTaskSnapshot() -> Task? { + lifecycleLock.lock() + let pendingStopTask = pendingStopTask + lifecycleLock.unlock() + return pendingStopTask + } + + @MainActor fileprivate func didDiscover(_ device: UTMAppleUSBDevice) { + delegate?.usbManager(self, didDiscover: device) + } + + @MainActor fileprivate func didRemove(_ device: UTMAppleUSBDevice) { + delegate?.usbManager(self, didRemove: device) + } + + @MainActor fileprivate func connectedDevicesDidChange(_ devices: [UTMRegistryEntry.AppleUSBDevice]) { + onConnectedDevicesChange?(devices) + } + + #if canImport(AccessoryAccess) && arch(arm64) + @available(macOS 27, *) + private var coordinator: UTMAppleUSBPassthroughCoordinator { + coordinatorLock.lock() + defer { + coordinatorLock.unlock() + } + if let coordinator = coordinatorStorage as? UTMAppleUSBPassthroughCoordinator { + return coordinator + } + let coordinator = UTMAppleUSBPassthroughCoordinator(owner: self, + ownerID: ownerID, + virtualMachineQueue: virtualMachineQueue) + coordinatorStorage = coordinator + return coordinator + } + + @available(macOS 27, *) + private var existingCoordinator: UTMAppleUSBPassthroughCoordinator? { + coordinatorLock.lock() + defer { + coordinatorLock.unlock() + } + return coordinatorStorage as? UTMAppleUSBPassthroughCoordinator + } + #endif +} + +#if canImport(AccessoryAccess) && arch(arm64) + +private actor UTMAppleUSBOperationGate { + private var isLocked = false + + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !isLocked { + isLocked = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if waiters.isEmpty { + isLocked = false + } else { + waiters.removeFirst().resume() + } + } +} + +@available(macOS 27, *) +private struct UTMAppleUSBAccessoryClaim: @unchecked Sendable { + let accessory: AAUSBAccessory + + let identity: UTMRegistryEntry.AppleUSBDevice + + let claimID: UUID +} + +@available(macOS 27, *) +private struct UTMAppleUSBAccessoryEvent: Sendable { + enum Kind: Sendable { + case discovered + case removed + } + + let kind: Kind + + let registryID: UInt64 + + let identity: UTMRegistryEntry.AppleUSBDevice + + let previousOwnerID: UUID? +} + +@available(macOS 27, *) +private final class UTMAppleUSBAccessoryListener: NSObject, AAUSBAccessoryListener, @unchecked Sendable { + weak var broker: UTMAppleUSBAccessoryBroker? + + private let eventTaskLock = NSLock() + + private var eventTask: Task? + + init(broker: UTMAppleUSBAccessoryBroker) { + self.broker = broker + } + + func usbAccessoryDidConnect(_ usbAccessory: AAUSBAccessory) { + enqueue(usbAccessory, isConnected: true) + } + + func usbAccessoryDidDisconnect(_ usbAccessory: AAUSBAccessory) { + enqueue(usbAccessory, isConnected: false) + } + + private func enqueue(_ accessory: AAUSBAccessory, isConnected: Bool) { + eventTaskLock.lock() + let previousTask = eventTask + eventTask = Task { [weak broker] in + await previousTask?.value + if isConnected { + await broker?.accessoryDidConnect(accessory) + } else { + await broker?.accessoryDidDisconnect(accessory) + } + } + eventTaskLock.unlock() + } +} + +@available(macOS 27, *) +private actor UTMAppleUSBAccessoryBroker { + private struct Record { + let accessory: AAUSBAccessory + + let identity: UTMRegistryEntry.AppleUSBDevice + } + + private struct Ownership { + let ownerID: UUID + + let claimID: UUID + } + + static let shared = UTMAppleUSBAccessoryBroker() + + private var listener: UTMAppleUSBAccessoryListener? + + private var registrationTask: Task<[AAUSBAccessory], Error>? + + private var isRegistered = false + + private var records: [UInt64: Record] = [:] + + private var owners: [UInt64: Ownership] = [:] + + private var observers: [UUID: @Sendable (UTMAppleUSBAccessoryEvent) -> Void] = [:] + + func register() async throws { + if isRegistered { + return + } + let task: Task<[AAUSBAccessory], Error> + if let registrationTask = registrationTask { + task = registrationTask + } else { + let listener = makeListener() + task = Task { + try await AAUSBAccessoryManager.shared.registerListener(listener, matchingCriteria: []) + } + registrationTask = task + } + do { + let accessories = try await task.value + if !isRegistered { + for accessory in accessories { + do { + let identity = try makeIdentity(for: accessory) + records[accessory.registryID] = Record(accessory: accessory, identity: identity) + } catch { + logger.debug("Failed to read USB device descriptor: \(error.localizedDescription)") + } + } + isRegistered = true + } + registrationTask = nil + } catch { + registrationTask = nil + throw error + } + } + + func addObserver(ownerID: UUID, observer: @escaping @Sendable (UTMAppleUSBAccessoryEvent) -> Void) { + observers[ownerID] = observer + } + + func removeObserver(ownerID: UUID) { + observers.removeValue(forKey: ownerID) + } + + func devices(for ownerID: UUID) -> [UTMAppleUSBDevice] { + records.values.map { record in + let state: UTMAppleUSBDevice.State + if let ownership = owners[record.accessory.registryID] { + state = ownership.ownerID == ownerID ? .connected : .inUse + } else { + state = .available + } + return UTMAppleUSBDevice(registryID: record.accessory.registryID, + identity: record.identity, + name: record.identity.displayName, + state: state) + }.sorted { lhs, rhs in + if lhs.name == rhs.name { + return lhs.registryID < rhs.registryID + } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + func claim(device: UTMAppleUSBDevice, ownerID: UUID, claimID: UUID) throws -> UTMAppleUSBAccessoryClaim { + guard let record = records[device.registryID], + record.identity.deviceDescriptorData == device.identity.deviceDescriptorData else { + throw UTMAppleUSBManagerError.deviceUnavailable + } + return try claim(record: record, ownerID: ownerID, claimID: claimID) + } + + func claim(resolved identity: UTMRegistryEntry.AppleUSBDevice, + ownerID: UUID, + claimID: UUID) throws -> UTMAppleUSBAccessoryClaim? { + guard let registryID = identity.registryID, + let record = records[registryID], + record.identity.deviceDescriptorData == identity.deviceDescriptorData else { + return nil + } + return try claim(record: record, ownerID: ownerID, claimID: claimID) + } + + func release(registryID: UInt64, ownerID: UUID, claimID: UUID? = nil) { + guard let ownership = owners[registryID], ownership.ownerID == ownerID else { + return + } + if let claimID = claimID, ownership.claimID != claimID { + return + } + owners.removeValue(forKey: registryID) + } + + func accessoryDidConnect(_ accessory: AAUSBAccessory) { + do { + let identity = try makeIdentity(for: accessory) + records[accessory.registryID] = Record(accessory: accessory, identity: identity) + notify(UTMAppleUSBAccessoryEvent(kind: .discovered, + registryID: accessory.registryID, + identity: identity, + previousOwnerID: owners[accessory.registryID]?.ownerID)) + } catch { + logger.debug("Failed to read USB device descriptor: \(error.localizedDescription)") + } + } + + func accessoryDidDisconnect(_ accessory: AAUSBAccessory) { + let record = records.removeValue(forKey: accessory.registryID) + let previousOwnerID = owners.removeValue(forKey: accessory.registryID)?.ownerID + guard let identity = record?.identity ?? (try? makeIdentity(for: accessory)) else { + return + } + notify(UTMAppleUSBAccessoryEvent(kind: .removed, + registryID: accessory.registryID, + identity: identity, + previousOwnerID: previousOwnerID)) + } + + private func makeListener() -> UTMAppleUSBAccessoryListener { + if let listener = listener { + return listener + } + let listener = UTMAppleUSBAccessoryListener(broker: self) + self.listener = listener + return listener + } + + private func claim(record: Record, ownerID: UUID, claimID: UUID) throws -> UTMAppleUSBAccessoryClaim { + let registryID = record.accessory.registryID + let activeClaimID: UUID + if let ownership = owners[registryID] { + guard ownership.ownerID == ownerID else { + throw UTMAppleUSBManagerError.deviceInUse + } + activeClaimID = ownership.claimID + } else { + owners[registryID] = Ownership(ownerID: ownerID, claimID: claimID) + activeClaimID = claimID + } + return UTMAppleUSBAccessoryClaim(accessory: record.accessory, + identity: record.identity, + claimID: activeClaimID) + } + + private func notify(_ event: UTMAppleUSBAccessoryEvent) { + for observer in observers.values { + observer(event) + } + } +} + +@available(macOS 27, *) +private final class UTMAppleUSBPassthroughCoordinator: NSObject, VZUSBController.Delegate, @unchecked Sendable { + private struct Connection { + let identity: UTMRegistryEntry.AppleUSBDevice + + let device: VZUSBPassthroughDevice + + let claimID: UUID + } + + private struct ConnectionSnapshot: Sendable { + let registryID: UInt64 + + let identity: UTMRegistryEntry.AppleUSBDevice + + let claimID: UUID + } + + private weak var owner: UTMAppleUSBManager? + + private let ownerID: UUID + + private let virtualMachineQueue: DispatchQueue + + private let broker = UTMAppleUSBAccessoryBroker.shared + + private let operationGate = UTMAppleUSBOperationGate() + + private var virtualMachine: VZVirtualMachine? + + private var connections: [UInt64: Connection] = [:] + + private var persistentConnections: [UTMRegistryEntry.AppleUSBDevice] = [] + + private var reservedClaims: [UInt64: UUID] = [:] + + private var pendingClaims: [UInt64: UUID] = [:] + + private var pendingDetachClaims: [UInt64: UUID] = [:] + + private var generation = 0 + + private var claimSessionID = UUID() + + private var isSuspended = true + + private let eventTaskLock = NSLock() + + private var eventTask: Task? + + init(owner: UTMAppleUSBManager, ownerID: UUID, virtualMachineQueue: DispatchQueue) { + self.owner = owner + self.ownerID = ownerID + self.virtualMachineQueue = virtualMachineQueue + } + + deinit { + let ownerID = ownerID + Task { + await UTMAppleUSBAccessoryBroker.shared.removeObserver(ownerID: ownerID) + } + } + + func start(with virtualMachine: VZVirtualMachine, + restoring devices: [UTMRegistryEntry.AppleUSBDevice]) async throws { + try await withOperationGate { + try await startLocked(with: virtualMachine, restoring: devices) + } + } + + private func startLocked(with virtualMachine: VZVirtualMachine, + restoring devices: [UTMRegistryEntry.AppleUSBDevice]) async throws { + let staleClaims = try await configure(virtualMachine: virtualMachine) + for (registryID, claimID) in staleClaims { + await broker.release(registryID: registryID, ownerID: ownerID, claimID: claimID) + } + await addObserver() + await setPersistentConnections(devices) + try await setSuspended(false) + try await broker.register() + try await restoreConnectionsLocked(from: devices) + } + + func stop(with virtualMachine: VZVirtualMachine) async { + await withOperationGate { + await stopLocked(with: virtualMachine) + } + } + + private func stopLocked(with virtualMachine: VZVirtualMachine) async { + let claims: [UInt64: UUID]? = await withCheckedContinuation { continuation in + virtualMachineQueue.async { + guard self.virtualMachine === virtualMachine else { + continuation.resume(returning: nil) + return + } + self.virtualMachine?.usbControllers.first?.delegate = nil + self.virtualMachine = nil + self.isSuspended = true + self.generation += 1 + var claims = self.connections.mapValues(\.claimID) + claims.merge(self.reservedClaims) { _, reservedClaimID in reservedClaimID } + claims.merge(self.pendingClaims) { _, pendingClaimID in pendingClaimID } + self.connections.removeAll() + self.reservedClaims.removeAll() + self.pendingClaims.removeAll() + self.pendingDetachClaims.removeAll() + continuation.resume(returning: claims) + } + } + guard let claims = claims else { + return + } + for (registryID, claimID) in claims { + await broker.release(registryID: registryID, ownerID: ownerID, claimID: claimID) + } + } + + func usbDevices() async throws -> [UTMAppleUSBDevice] { + await addObserver() + try await broker.register() + let devices = await broker.devices(for: ownerID) + let connectedRegistryIDs = await connectedRegistryIDs() + return devices.map { device in + guard device.state == .connected, !connectedRegistryIDs.contains(device.registryID) else { + return device + } + return UTMAppleUSBDevice(registryID: device.registryID, + identity: device.identity, + name: device.name, + state: .available) + } + } + + func connect(_ device: UTMAppleUSBDevice) async throws { + try await withOperationGate { + try await connectLocked(device) + } + } + + private func connectLocked(_ device: UTMAppleUSBDevice) async throws { + try await ensureVirtualMachineIsRunning() + let claimSessionID = await claimSessionIDSnapshot() + let claim = try await broker.claim(device: device, ownerID: ownerID, claimID: claimSessionID) + do { + if try await attach(claim) { + await notifyConnectedDevicesChanged() + } + } catch { + await releaseClaim(claim) + throw error + } + } + + func disconnect(_ device: UTMAppleUSBDevice) async throws { + try await withOperationGate { + try await disconnectLocked(device) + } + } + + private func disconnectLocked(_ device: UTMAppleUSBDevice) async throws { + try await ensureVirtualMachineIsRunning() + let identity = try await detach(registryID: device.registryID, retainingOwnership: false) + if identity != nil { + await notifyConnectedDevicesChanged() + } + } + + func detachAllForSnapshot(with virtualMachine: VZVirtualMachine) async throws -> [UTMRegistryEntry.AppleUSBDevice] { + try await withOperationGate { + try await ensureCurrentVirtualMachineIsRunning(virtualMachine) + return try await detachAllForSnapshotLocked() + } + } + + private func detachAllForSnapshotLocked() async throws -> [UTMRegistryEntry.AppleUSBDevice] { + try await setSuspended(true) + let connections = await connectionSnapshots() + let persistentConnections = await persistentIdentities() + do { + for connection in connections { + _ = try await detach(registryID: connection.registryID, retainingOwnership: true) + } + await notifyConnectedDevicesChanged() + return persistentConnections + } catch { + try? await restoreConnectionsLocked(from: persistentConnections) + throw error + } + } + + func releaseSnapshotReservations(for virtualMachine: VZVirtualMachine) async { + await withOperationGate { + if await isCurrentVirtualMachine(virtualMachine) { + await releaseSnapshotReservationsLocked() + } + } + } + + private func releaseSnapshotReservationsLocked() async { + let claims: [UInt64: UUID] = await withCheckedContinuation { continuation in + virtualMachineQueue.async { + let claims = self.reservedClaims + self.reservedClaims.removeAll() + continuation.resume(returning: claims) + } + } + for (registryID, claimID) in claims { + await broker.release(registryID: registryID, ownerID: ownerID, claimID: claimID) + } + } + + func restoreConnections(from devices: [UTMRegistryEntry.AppleUSBDevice], + with virtualMachine: VZVirtualMachine) async throws { + try await withOperationGate { + try await ensureCurrentVirtualMachineIsRunning(virtualMachine) + try await restoreConnectionsLocked(from: devices) + } + } + + private func restoreConnectionsLocked(from devices: [UTMRegistryEntry.AppleUSBDevice]) async throws { + try await setSuspended(false) + guard !devices.isEmpty else { + await setPersistentConnections([]) + await notifyConnectedDevicesChanged() + return + } + await addObserver() + do { + try await broker.register() + } catch { + await releaseSnapshotReservationsLocked() + throw error + } + let availableDevices = await broker.devices(for: ownerID).filter { + if case .inUse = $0.state { + return false + } + return true + } + let resolvedDevices = devicesForRestore(devices, using: availableDevices) + let claimSessionID = await claimSessionIDSnapshot() + await setPersistentConnections(devices) + var claimedRegistryIDs = Set() + for (savedIdentity, resolvedIdentity) in zip(devices, resolvedDevices) { + guard let identity = resolvedIdentity else { + logger.debug("Saved USB device is not currently available or cannot be identified unambiguously: \(savedIdentity.displayName)") + continue + } + do { + guard let claim = try await broker.claim(resolved: identity, + ownerID: ownerID, + claimID: claimSessionID) else { + logger.debug("Saved USB device is not currently available: \(identity.displayName)") + continue + } + let registryID = claim.accessory.registryID + guard claimedRegistryIDs.insert(registryID).inserted else { + logger.debug("Skipping duplicate match for saved USB device: \(identity.displayName)") + continue + } + do { + _ = try await attach(claim, replacing: savedIdentity) + } catch { + claimedRegistryIDs.remove(registryID) + await releaseClaim(claim) + throw error + } + } catch { + logger.debug("Failed to restore USB device \(identity.displayName): \(error.localizedDescription)") + } + } + await notifyConnectedDevicesChanged() + } + + func persistentIdentities() async -> [UTMRegistryEntry.AppleUSBDevice] { + await withCheckedContinuation { continuation in + virtualMachineQueue.async { + let identities = self.persistentConnections.sorted { + ($0.registryID ?? 0) < ($1.registryID ?? 0) + } + continuation.resume(returning: identities) + } + } + } + + func usbController(_ usbController: VZUSBController, usbPassthroughDeviceDidDisconnect device: VZUSBPassthroughDevice) { + Task { [weak self] in + await self?.handleControllerDisconnect(usbController, device: device) + } + } + + private func handleControllerDisconnect(_ usbController: VZUSBController, + device: VZUSBPassthroughDevice) async { + await withOperationGate { + let disconnected: ConnectionSnapshot? = await withCheckedContinuation { continuation in + virtualMachineQueue.async { + guard self.virtualMachine?.usbControllers.first === usbController, + let registryID = self.connections.first(where: { $0.value.device === device })?.key, + let connection = self.connections[registryID], + self.pendingDetachClaims[registryID] != connection.claimID else { + continuation.resume(returning: nil) + return + } + self.connections.removeValue(forKey: registryID) + self.removePersistentConnection(registryID: registryID) + self.reservedClaims.removeValue(forKey: registryID) + continuation.resume(returning: ConnectionSnapshot(registryID: registryID, + identity: connection.identity, + claimID: connection.claimID)) + } + } + guard let disconnected = disconnected else { + return + } + await broker.release(registryID: disconnected.registryID, + ownerID: ownerID, + claimID: disconnected.claimID) + await notifyConnectedDevicesChanged() + } + } + + private func configure(virtualMachine: VZVirtualMachine) async throws -> [UInt64: UUID] { + try await withCheckedThrowingContinuation { continuation in + virtualMachineQueue.async { + guard virtualMachine.state == .running else { + continuation.resume(throwing: UTMAppleUSBManagerError.virtualMachineNotRunning) + return + } + guard let usbController = virtualMachine.usbControllers.first else { + continuation.resume(throwing: UTMAppleUSBManagerError.noUsbController) + return + } + if self.virtualMachine === virtualMachine { + usbController.delegate = self + continuation.resume(returning: [:]) + return + } + self.virtualMachine?.usbControllers.first?.delegate = nil + self.generation += 1 + self.claimSessionID = UUID() + var staleClaims = self.connections.mapValues(\.claimID) + staleClaims.merge(self.reservedClaims) { _, reservedClaimID in reservedClaimID } + staleClaims.merge(self.pendingClaims) { _, pendingClaimID in pendingClaimID } + self.connections.removeAll() + self.reservedClaims.removeAll() + self.pendingClaims.removeAll() + self.pendingDetachClaims.removeAll() + self.virtualMachine = virtualMachine + self.isSuspended = true + usbController.delegate = self + continuation.resume(returning: staleClaims) + } + } + } + + private func addObserver() async { + await broker.addObserver(ownerID: ownerID) { [weak self] event in + self?.enqueue(event: event) + } + } + + private func enqueue(event: UTMAppleUSBAccessoryEvent) { + eventTaskLock.lock() + let previousTask = eventTask + eventTask = Task { [weak self] in + await previousTask?.value + await self?.handle(event: event) + } + eventTaskLock.unlock() + } + + private func handle(event: UTMAppleUSBAccessoryEvent) async { + await withOperationGate { + await handleLocked(event: event) + } + } + + private func handleLocked(event: UTMAppleUSBAccessoryEvent) async { + let state: UTMAppleUSBDevice.State + if let previousOwnerID = event.previousOwnerID { + state = previousOwnerID == ownerID ? .connected : .inUse + } else { + state = .available + } + switch event.kind { + case .discovered: + break + case .removed: + if await forgetConnection(registryID: event.registryID) { + await notifyConnectedDevicesChanged() + } + } + let device = UTMAppleUSBDevice(registryID: event.registryID, + identity: event.identity, + name: event.identity.displayName, + state: state) + await MainActor.run { + switch event.kind { + case .discovered: + self.owner?.didDiscover(device) + case .removed: + self.owner?.didRemove(device) + } + } + } + + private func attach(_ claim: UTMAppleUSBAccessoryClaim, + replacing savedIdentity: UTMRegistryEntry.AppleUSBDevice? = nil) async throws -> Bool { + let registryID = claim.accessory.registryID + return try await withCheckedThrowingContinuation { continuation in + virtualMachineQueue.async { + if let connection = self.connections[registryID] { + self.recordPersistentConnection(connection.identity, replacing: savedIdentity) + self.reservedClaims.removeValue(forKey: registryID) + continuation.resume(returning: false) + return + } + guard self.pendingClaims[registryID] == nil else { + continuation.resume(returning: false) + return + } + guard let usbController = self.virtualMachine?.usbControllers.first else { + continuation.resume(throwing: UTMAppleUSBManagerError.noUsbController) + return + } + guard self.virtualMachine?.state == .running else { + continuation.resume(throwing: UTMAppleUSBManagerError.virtualMachineNotRunning) + return + } + let generation = self.generation + self.pendingClaims[registryID] = claim.claimID + do { + let configuration = VZUSBPassthroughDeviceConfiguration(device: claim.accessory) + let passthroughDevice = try VZUSBPassthroughDevice(configuration: configuration) + usbController.attach(device: passthroughDevice) { error in + guard self.pendingClaims[registryID] == claim.claimID else { + continuation.resume(throwing: UTMAppleUSBManagerError.deviceUnavailable) + return + } + self.pendingClaims.removeValue(forKey: registryID) + guard self.generation == generation else { + continuation.resume(throwing: UTMAppleUSBManagerError.deviceUnavailable) + return + } + if let error = error { + continuation.resume(throwing: error) + } else { + self.connections[registryID] = Connection(identity: claim.identity, + device: passthroughDevice, + claimID: claim.claimID) + self.recordPersistentConnection(claim.identity, replacing: savedIdentity) + self.reservedClaims.removeValue(forKey: registryID) + continuation.resume(returning: true) + } + } + } catch { + if self.pendingClaims[registryID] == claim.claimID { + self.pendingClaims.removeValue(forKey: registryID) + } + continuation.resume(throwing: error) + } + } + } + } + + private func detach(registryID: UInt64, retainingOwnership: Bool) async throws -> UTMRegistryEntry.AppleUSBDevice? { + let snapshot: ConnectionSnapshot? = try await withCheckedThrowingContinuation { continuation in + virtualMachineQueue.async { + guard let connection = self.connections[registryID] else { + continuation.resume(returning: nil) + return + } + guard self.pendingDetachClaims[registryID] == nil else { + continuation.resume(returning: nil) + return + } + guard let usbController = self.virtualMachine?.usbControllers.first else { + continuation.resume(throwing: UTMAppleUSBManagerError.noUsbController) + return + } + guard self.virtualMachine?.state == .running else { + continuation.resume(throwing: UTMAppleUSBManagerError.virtualMachineNotRunning) + return + } + let generation = self.generation + self.pendingDetachClaims[registryID] = connection.claimID + usbController.detach(device: connection.device) { error in + guard self.pendingDetachClaims[registryID] == connection.claimID else { + continuation.resume(throwing: UTMAppleUSBManagerError.deviceUnavailable) + return + } + self.pendingDetachClaims.removeValue(forKey: registryID) + guard self.generation == generation else { + continuation.resume(throwing: UTMAppleUSBManagerError.deviceUnavailable) + return + } + if let error = error { + continuation.resume(throwing: error) + } else { + self.connections.removeValue(forKey: registryID) + if retainingOwnership { + self.reservedClaims[registryID] = connection.claimID + } else { + self.removePersistentConnection(registryID: registryID) + } + continuation.resume(returning: ConnectionSnapshot(registryID: registryID, + identity: connection.identity, + claimID: connection.claimID)) + } + } + } + } + if !retainingOwnership, let snapshot = snapshot { + await broker.release(registryID: registryID, + ownerID: ownerID, + claimID: snapshot.claimID) + } + return snapshot?.identity + } + + private func connectionSnapshots() async -> [ConnectionSnapshot] { + await withCheckedContinuation { continuation in + virtualMachineQueue.async { + let snapshots = self.connections.map { registryID, connection in + ConnectionSnapshot(registryID: registryID, + identity: connection.identity, + claimID: connection.claimID) + }.sorted { $0.registryID < $1.registryID } + continuation.resume(returning: snapshots) + } + } + } + + private func releaseClaim(_ claim: UTMAppleUSBAccessoryClaim) async { + let shouldRelease: Bool = await withCheckedContinuation { continuation in + virtualMachineQueue.async { + let registryID = claim.accessory.registryID + if self.connections[registryID]?.claimID == claim.claimID || + self.pendingClaims[registryID] == claim.claimID { + continuation.resume(returning: false) + return + } + if self.reservedClaims[registryID] == claim.claimID { + self.reservedClaims.removeValue(forKey: registryID) + } + continuation.resume(returning: true) + } + } + if shouldRelease { + await broker.release(registryID: claim.accessory.registryID, + ownerID: ownerID, + claimID: claim.claimID) + } + } + + private func claimSessionIDSnapshot() async -> UUID { + await withCheckedContinuation { continuation in + virtualMachineQueue.async { + continuation.resume(returning: self.claimSessionID) + } + } + } + + private func ensureVirtualMachineIsRunning() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + virtualMachineQueue.async { + guard let virtualMachine = self.virtualMachine, + virtualMachine.state == .running, + !self.isSuspended else { + continuation.resume(throwing: UTMAppleUSBManagerError.virtualMachineNotRunning) + return + } + continuation.resume() + } + } + } + + private func ensureCurrentVirtualMachineIsRunning(_ virtualMachine: VZVirtualMachine) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + virtualMachineQueue.async { + guard self.virtualMachine === virtualMachine, virtualMachine.state == .running else { + continuation.resume(throwing: UTMAppleUSBManagerError.virtualMachineNotRunning) + return + } + continuation.resume() + } + } + } + + private func isCurrentVirtualMachine(_ virtualMachine: VZVirtualMachine) async -> Bool { + await withCheckedContinuation { continuation in + virtualMachineQueue.async { + continuation.resume(returning: self.virtualMachine === virtualMachine) + } + } + } + + private func setSuspended(_ isSuspended: Bool) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + virtualMachineQueue.async { + guard let virtualMachine = self.virtualMachine, + virtualMachine.state == .running else { + continuation.resume(throwing: UTMAppleUSBManagerError.virtualMachineNotRunning) + return + } + self.isSuspended = isSuspended + continuation.resume() + } + } + } + + private func withOperationGate(_ operation: () async throws -> T) async rethrows -> T { + await operationGate.acquire() + do { + let result = try await operation() + await operationGate.release() + return result + } catch { + await operationGate.release() + throw error + } + } + + private func connectedRegistryIDs() async -> Set { + await withCheckedContinuation { continuation in + virtualMachineQueue.async { + continuation.resume(returning: Set(self.connections.keys)) + } + } + } + + private func forgetConnection(registryID: UInt64) async -> Bool { + await withCheckedContinuation { continuation in + virtualMachineQueue.async { + self.connections.removeValue(forKey: registryID) + self.reservedClaims.removeValue(forKey: registryID) + self.pendingClaims.removeValue(forKey: registryID) + self.pendingDetachClaims.removeValue(forKey: registryID) + let wasPersistent = self.removePersistentConnection(registryID: registryID) + continuation.resume(returning: wasPersistent) + } + } + } + + private func setPersistentConnections(_ devices: [UTMRegistryEntry.AppleUSBDevice]) async { + await withCheckedContinuation { continuation in + virtualMachineQueue.async { + self.persistentConnections = devices + continuation.resume() + } + } + } + + private func recordPersistentConnection(_ identity: UTMRegistryEntry.AppleUSBDevice, + replacing savedIdentity: UTMRegistryEntry.AppleUSBDevice?) { + if let savedIdentity = savedIdentity, + let index = persistentConnections.firstIndex(of: savedIdentity) { + persistentConnections[index] = identity + } else if let registryID = identity.registryID, + let index = persistentConnections.firstIndex(where: { $0.registryID == registryID }) { + persistentConnections[index] = identity + } else if let index = persistentConnections.firstIndex(where: { + $0.deviceDescriptorData == identity.deviceDescriptorData && + ($0.registryID == nil || $0.hostBootSessionID != currentHostBootSessionID) + }) { + persistentConnections[index] = identity + } else { + persistentConnections.append(identity) + } + } + + @discardableResult + private func removePersistentConnection(registryID: UInt64) -> Bool { + let oldCount = persistentConnections.count + persistentConnections.removeAll { $0.registryID == registryID } + return persistentConnections.count != oldCount + } + + private func devicesForRestore(_ devices: [UTMRegistryEntry.AppleUSBDevice], + using availableDevices: [UTMAppleUSBDevice]) -> [UTMRegistryEntry.AppleUSBDevice?] { + var result = [UTMRegistryEntry.AppleUSBDevice?](repeating: nil, count: devices.count) + var candidates = availableDevices.map(\.identity) + var unresolvedIndices: [Int] = [] + for (index, device) in devices.enumerated() { + guard device.hostBootSessionID == currentHostBootSessionID, + let registryID = device.registryID, + let candidateIndex = candidates.firstIndex(where: { + $0.registryID == registryID && $0.deviceDescriptorData == device.deviceDescriptorData + }) else { + unresolvedIndices.append(index) + continue + } + result[index] = candidates.remove(at: candidateIndex) + } + let groups = Dictionary(grouping: unresolvedIndices) { devices[$0].deviceDescriptorData } + for (descriptor, indices) in groups { + let matchingCandidates = candidates.filter { $0.deviceDescriptorData == descriptor } + guard matchingCandidates.count == indices.count else { + continue + } + for (index, candidate) in zip(indices, matchingCandidates) { + result[index] = candidate + } + let registryIDs = Set(matchingCandidates.compactMap(\.registryID)) + candidates.removeAll { candidate in + candidate.registryID.map(registryIDs.contains) ?? false + } + } + return result + } + + private func notifyConnectedDevicesChanged() async { + let identities = (await persistentIdentities()).map(\.persisted) + await MainActor.run { + owner?.connectedDevicesDidChange(identities) + } + } +} + +@available(macOS 27, *) +private func makeIdentity(for accessory: AAUSBAccessory) throws -> UTMRegistryEntry.AppleUSBDevice { + let descriptor = accessory.deviceDescriptorData + guard descriptor.count >= 18 else { + throw UTMAppleUSBManagerError.invalidDeviceDescriptor + } + let bytes = [UInt8](descriptor) + let vendorID = UInt16(bytes[8]) | UInt16(bytes[9]) << 8 + let productID = UInt16(bytes[10]) | UInt16(bytes[11]) << 8 + return UTMRegistryEntry.AppleUSBDevice(vendorID: vendorID, + productID: productID, + deviceClass: bytes[4], + deviceSubclass: bytes[5], + deviceProtocol: bytes[6], + deviceDescriptorData: descriptor, + registryID: accessory.registryID, + hostBootSessionID: currentHostBootSessionID) +} + +#endif + +private extension UTMRegistryEntry.AppleUSBDevice { + var persisted: Self { + let canPersistRegistryID = hostBootSessionID != nil && hostBootSessionID == currentHostBootSessionID + return Self(vendorID: vendorID, + productID: productID, + deviceClass: deviceClass, + deviceSubclass: deviceSubclass, + deviceProtocol: deviceProtocol, + deviceDescriptorData: deviceDescriptorData, + registryID: canPersistRegistryID ? registryID : nil, + hostBootSessionID: canPersistRegistryID ? hostBootSessionID : nil) + } + + var displayName: String { + String(format: NSLocalizedString("USB Device (%04X:%04X)", comment: "UTMAppleUSBManager"), Int(vendorID), Int(productID)) + } +} + +private let currentHostBootSessionID: UUID? = { + var size = 0 + guard sysctlbyname("kern.bootsessionuuid", nil, &size, nil, 0) == 0, size > 0 else { + return nil + } + var buffer = [UInt8](repeating: 0, count: size) + let result = buffer.withUnsafeMutableBytes { bytes in + sysctlbyname("kern.bootsessionuuid", bytes.baseAddress, &size, nil, 0) + } + guard result == 0, + let value = String(bytes: buffer.prefix(while: { $0 != 0 }), encoding: .utf8) else { + return nil + } + return UUID(uuidString: value) +}() diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index 4917dc81a9..42417fb30d 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -118,6 +118,10 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { private var removableDrives: [String: Any] = [:] + private(set) var usbManager: UTMAppleUSBManager? + + private var snapshotUsbDevices: [UTMRegistryEntry.AppleUSBDevice]? + @MainActor var isHeadless: Bool { config.displays.isEmpty && config.serials.filter({ $0.mode == .builtin }).isEmpty } @@ -131,6 +135,18 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { self.registryEntry = UTMRegistryEntry.empty self.registryEntry = loadRegistry() self.screenshot = loadScreenshot() + #if arch(arm64) + if configuration.system.boot.operatingSystem == .macOS { + let usbManager = UTMAppleUSBManager(virtualMachineQueue: vmQueue) + self.usbManager = usbManager + usbManager.onConnectedDevicesChange = { [weak self] devices in + self?.registryEntry.connectedAppleUsbDevices = devices.isEmpty ? nil : devices + if self?.snapshotUsbDevices != nil { + self?.snapshotUsbDevices = devices + } + } + } + #endif } deinit { @@ -188,6 +204,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { let isSuspended = await registryEntry.isSuspended try await beginAccessingResources() try await createAppleVM() + guard let virtualMachine = await currentVirtualMachine() else { + return + } if isSuspended && !options.contains(.bootRecovery) { try await restoreSnapshot() } else { @@ -196,6 +215,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { if #available(macOS 15, *) { try await attachExternalDrives() } + await startUsbPassthrough(with: virtualMachine) if #available(macOS 12, *) { Task { @MainActor in let tag = config.shareDirectoryTag @@ -209,7 +229,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { } } } - state = .started + guard await transitionToStarted(ifRunning: virtualMachine) else { + return + } if screenshotTimer == nil { screenshotTimer = startScreenshotTimer() } @@ -222,18 +244,17 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { } @available(macOS 12, *) - private func _forceStop() async throws { + private func _forceStop(_ virtualMachine: VZVirtualMachine) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in vmQueue.async { - guard let apple = self.apple else { - continuation.resume() // already stopped + guard self.apple === virtualMachine else { + continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable) return } - apple.stop { error in + virtualMachine.stop { error in if let error = error { continuation.resume(throwing: error) } else { - self.guestDidStop(apple) continuation.resume() } } @@ -272,31 +293,42 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { guard #available(macOS 12, *) else { throw UTMAppleVirtualMachineError.operationNotAvailable } + let initialState = state + guard let virtualMachine = await currentVirtualMachine(), state == initialState else { + return + } state = .stopping do { - try await _forceStop() - state = .stopped + try await _forceStop(virtualMachine) + _ = await transitionToStopped(ifCurrent: virtualMachine) } catch { - state = .stopped + if await transitionToStarted(ifRunning: virtualMachine) { + // The stop failed while the VM was still running. + } else if await transitionToPaused(ifPaused: virtualMachine) { + // The stop failed while the VM was still paused. + } else { + _ = await transitionToStopped(ifCurrent: virtualMachine) + } throw error } } - private func _restart() async throws { + private func _restart(_ virtualMachine: VZVirtualMachine) async throws { guard #available(macOS 12, *) else { return } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in vmQueue.async { - guard let apple = self.apple else { + guard self.apple === virtualMachine, + virtualMachine.state == .running || virtualMachine.state == .paused else { continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable) return } - apple.stop { error in + virtualMachine.stop { error in if let error = error { continuation.resume(throwing: error) } else { - apple.start { result in + virtualMachine.start { result in continuation.resume(with: result) } } @@ -309,20 +341,68 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { guard state == .started || state == .paused else { return } - state = .stopping + let initialState = state + guard let virtualMachine = await currentVirtualMachine(), state == initialState else { + return + } + guard await prepareForRestart(virtualMachine, from: initialState) else { + return + } + let connectedUsbDevices: [UTMRegistryEntry.AppleUSBDevice] do { - try await _restart() - state = .started + if initialState == .paused { + connectedUsbDevices = snapshotUsbDevices ?? [] + } else { + snapshotUsbDevices = [] + let usbPassthroughIsAvailable = await isUsbPassthroughAvailable + if let usbManager = usbManager, usbPassthroughIsAvailable { + connectedUsbDevices = try await usbManager.detachAllForSnapshot(with: virtualMachine) + } else { + connectedUsbDevices = [] + } + } } catch { - state = .stopped + if initialState != .paused { + snapshotUsbDevices = nil + } + if initialState == .paused { + _ = await transitionToPaused(ifPaused: virtualMachine) + } else { + _ = await transitionToStarted(ifRunning: virtualMachine) + } + throw error + } + do { + try await _restart(virtualMachine) + let usbDevicesToRestore = snapshotUsbDevices ?? connectedUsbDevices + if await restoreUsbConnections(from: usbDevicesToRestore, with: virtualMachine) { + snapshotUsbDevices = nil + } + guard await transitionToStarted(ifRunning: virtualMachine) else { + return + } + } catch { + if await transitionToPaused(ifPaused: virtualMachine) { + // The stop failed before the paused VM changed state. + } else if await isCurrentVirtualMachineRunning(virtualMachine) { + let usbDevicesToRestore = snapshotUsbDevices ?? connectedUsbDevices + if await restoreUsbConnections(from: usbDevicesToRestore, with: virtualMachine) { + snapshotUsbDevices = nil + } + _ = await transitionToStarted(ifRunning: virtualMachine) + } else if await isCurrentVirtualMachine(virtualMachine) { + await usbManager?.releaseSnapshotReservations(for: virtualMachine) + _ = await transitionToStopped(ifCurrent: virtualMachine) + } throw error } } - private func _pause() async throws { + private func _pause(_ virtualMachine: VZVirtualMachine) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in vmQueue.async { - guard let apple = self.apple else { + guard self.apple === virtualMachine, + virtualMachine.state == .running else { continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable) return } @@ -332,7 +412,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { try? self.saveScreenshot() } } - apple.pause { result in + virtualMachine.pause { result in continuation.resume(with: result) } } @@ -344,25 +424,52 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { return } state = .pausing + guard let virtualMachine = await currentVirtualMachine() else { + return + } + snapshotUsbDevices = [] + let connectedUsbDevices: [UTMRegistryEntry.AppleUSBDevice] + let usbPassthroughIsAvailable = await isUsbPassthroughAvailable do { - try await _pause() - state = .paused + if let usbManager = usbManager, usbPassthroughIsAvailable { + connectedUsbDevices = try await usbManager.detachAllForSnapshot(with: virtualMachine) + } else { + connectedUsbDevices = [] + } } catch { - state = .stopped + snapshotUsbDevices = nil + _ = await transitionToStarted(ifRunning: virtualMachine) + throw error + } + do { + try await _pause(virtualMachine) + guard await transitionToPaused(ifPaused: virtualMachine) else { + return + } + } catch { + if !(await transitionToPaused(ifPaused: virtualMachine)) { + let usbDevicesToRestore = snapshotUsbDevices ?? connectedUsbDevices + snapshotUsbDevices = nil + _ = await restoreUsbConnections(from: usbDevicesToRestore, with: virtualMachine) + if !(await transitionToStarted(ifRunning: virtualMachine)) { + _ = await transitionToStopped(ifCurrent: virtualMachine) + } + } throw error } } #if arch(arm64) @available(macOS 14, *) - private func _saveSnapshot(url: URL) async throws { + private func _saveSnapshot(url: URL, virtualMachine: VZVirtualMachine) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in vmQueue.async { - guard let apple = self.apple else { + guard self.apple === virtualMachine, + virtualMachine.state == .paused else { continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable) return } - apple.saveMachineStateTo(url: url) { error in + virtualMachine.saveMachineStateTo(url: url) { error in if let error = error { continuation.resume(throwing: error) } else { @@ -392,12 +499,19 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { guard state == .paused else { return } + guard let virtualMachine = await currentVirtualMachine(), state == .paused else { + return + } state = .saving - defer { - state = .paused + do { + try await _saveSnapshot(url: vmSavedStateURL, virtualMachine: virtualMachine) + await registryEntry.setIsSuspended(true) + await usbManager?.releaseSnapshotReservations(for: virtualMachine) + _ = await transitionToPaused(ifPaused: virtualMachine) + } catch { + _ = await transitionToPaused(ifPaused: virtualMachine) + throw error } - try await _saveSnapshot(url: vmSavedStateURL) - await registryEntry.setIsSuspended(true) #endif } @@ -412,14 +526,14 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { #if arch(arm64) @available(macOS 14, *) - private func _restoreSnapshot(url: URL) async throws { + private func _restoreSnapshot(url: URL, virtualMachine: VZVirtualMachine) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in vmQueue.async { - guard let apple = self.apple else { + guard self.apple === virtualMachine, virtualMachine.state == .stopped else { continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable) return } - apple.restoreMachineStateFrom(url: url) { error in + virtualMachine.restoreMachineStateFrom(url: url) { error in if let error = error { continuation.resume(throwing: error) } else { @@ -445,29 +559,37 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { guard state == .stopped || state == .starting else { throw UTMAppleVirtualMachineError.operationNotAvailable } + guard let virtualMachine = await currentVirtualMachine() else { + throw UTMAppleVirtualMachineError.operationNotAvailable + } state = .restoring do { - try await _restoreSnapshot(url: vmSavedStateURL) - try await _resume() + try await _restoreSnapshot(url: vmSavedStateURL, virtualMachine: virtualMachine) + try await _resume(virtualMachine) } catch { - state = .stopped + if !(await transitionToPaused(ifPaused: virtualMachine)) { + _ = await transitionToStopped(ifCurrent: virtualMachine) + } throw error } - state = .started + guard await transitionToStarted(ifRunning: virtualMachine) else { + return + } try await deleteSnapshot(name: name) #else throw UTMAppleVirtualMachineError.operationNotAvailable #endif } - private func _resume() async throws { + private func _resume(_ virtualMachine: VZVirtualMachine) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in vmQueue.async { - guard let apple = self.apple else { + guard self.apple === virtualMachine, + virtualMachine.state == .paused else { continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable) return } - apple.resume { result in + virtualMachine.resume { result in continuation.resume(with: result) } } @@ -479,11 +601,23 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { return } state = .resuming + guard let virtualMachine = await currentVirtualMachine() else { + return + } do { - try await _resume() - state = .started + try await _resume(virtualMachine) + if let snapshotUsbDevices = snapshotUsbDevices { + if await restoreUsbConnections(from: snapshotUsbDevices, with: virtualMachine) { + self.snapshotUsbDevices = nil + } + } + guard await transitionToStarted(ifRunning: virtualMachine) else { + return + } } catch { - state = .stopped + if !(await transitionToPaused(ifPaused: virtualMachine)) { + _ = await transitionToStopped(ifCurrent: virtualMachine) + } throw error } } @@ -526,6 +660,179 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { #endif } } + + private func currentVirtualMachine() async -> VZVirtualMachine? { + await withCheckedContinuation { continuation in + vmQueue.async { + continuation.resume(returning: self.apple) + } + } + } + + private func prepareForRestart(_ virtualMachine: VZVirtualMachine, + from initialState: UTMVirtualMachineState) async -> Bool { + await withCheckedContinuation { continuation in + vmQueue.async { + guard self.apple === virtualMachine, + virtualMachine.state == .running || virtualMachine.state == .paused else { + continuation.resume(returning: false) + return + } + DispatchQueue.main.async { + let isCurrent = self.vmQueue.sync { + self.apple === virtualMachine && + (virtualMachine.state == .running || virtualMachine.state == .paused) + } + guard isCurrent, self.state == initialState else { + continuation.resume(returning: false) + return + } + self.state = .stopping + continuation.resume(returning: true) + } + } + } + } + + private func transitionToStarted(ifRunning virtualMachine: VZVirtualMachine) async -> Bool { + await withCheckedContinuation { continuation in + vmQueue.async { + guard self.apple === virtualMachine, virtualMachine.state == .running else { + continuation.resume(returning: false) + return + } + DispatchQueue.main.async { + guard self.vmQueue.sync(execute: { + self.apple === virtualMachine && virtualMachine.state == .running + }) else { + continuation.resume(returning: false) + return + } + self.state = .started + continuation.resume(returning: true) + } + } + } + } + + private func transitionToPaused(ifPaused virtualMachine: VZVirtualMachine) async -> Bool { + await withCheckedContinuation { continuation in + vmQueue.async { + guard self.apple === virtualMachine, virtualMachine.state == .paused else { + continuation.resume(returning: false) + return + } + DispatchQueue.main.async { + guard self.vmQueue.sync(execute: { + self.apple === virtualMachine && virtualMachine.state == .paused + }) else { + continuation.resume(returning: false) + return + } + self.state = .paused + continuation.resume(returning: true) + } + } + } + } + + private func transitionToStopped(ifCurrent virtualMachine: VZVirtualMachine, + error: Error? = nil) async -> Bool { + await withCheckedContinuation { continuation in + vmQueue.async { + guard self.apple === virtualMachine, + virtualMachine.state == .stopped || virtualMachine.state == .error else { + DispatchQueue.main.async { + continuation.resume(returning: false) + } + return + } + self.apple = nil + self.snapshotUnsupportedError = nil + self.usbManager?.stop(with: virtualMachine) + DispatchQueue.main.async { + self.removableDrives.removeAll() + self.sharedDirectoriesChanged = nil + self.stopAccesingResources() + for i in self.config.serials.indices { + if let serialPort = self.config.serials[i].interface { + serialPort.close() + self.config.serials[i].interface = nil + self.config.serials[i].fileHandleForReading = nil + self.config.serials[i].fileHandleForWriting = nil + } + } + try? self.saveScreenshot() + self.state = .stopped + if let error = error { + self.delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription) + } + continuation.resume(returning: true) + } + } + } + } + + private func isCurrentVirtualMachine(_ virtualMachine: VZVirtualMachine) async -> Bool { + await withCheckedContinuation { continuation in + vmQueue.async { + continuation.resume(returning: self.apple === virtualMachine) + } + } + } + + private func isCurrentVirtualMachineRunning(_ virtualMachine: VZVirtualMachine) async -> Bool { + await withCheckedContinuation { continuation in + vmQueue.async { + continuation.resume(returning: self.apple === virtualMachine && virtualMachine.state == .running) + } + } + } + + private func startUsbPassthrough(with virtualMachine: VZVirtualMachine) async { + let usbPassthroughIsAvailable = await isUsbPassthroughAvailable + guard let usbManager = usbManager, usbPassthroughIsAvailable else { + return + } + do { + let connectedUsbDevices = await usbDevicesForRestore() + try await usbManager.start(with: virtualMachine, restoring: connectedUsbDevices) + snapshotUsbDevices = nil + } catch { + logger.debug("Failed to initialize USB passthrough: \(error.localizedDescription)") + } + } + + private func restoreUsbConnections(from devices: [UTMRegistryEntry.AppleUSBDevice], + with virtualMachine: VZVirtualMachine) async -> Bool { + let usbPassthroughIsAvailable = await isUsbPassthroughAvailable + guard let usbManager = usbManager, usbPassthroughIsAvailable else { + return devices.isEmpty + } + do { + try await usbManager.restoreConnections(from: devices, with: virtualMachine) + return true + } catch { + logger.debug("Failed to restore USB passthrough devices: \(error.localizedDescription)") + return false + } + } + + private func usbDevicesForRestore() async -> [UTMRegistryEntry.AppleUSBDevice] { + if let snapshotUsbDevices = snapshotUsbDevices { + return snapshotUsbDevices + } + return await registryEntry.connectedAppleUsbDevices ?? [] + } + + private var isUsbPassthroughAvailable: Bool { + get async { + guard let usbManager = usbManager else { + return false + } + return await usbManager.isAvailable + } + } @available(macOS 12, *) private func updateSharedDirectories(with newShares: [UTMAppleConfigurationSharedDirectory], tag: String) { @@ -664,30 +971,15 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { @available(macOS 11, *) extension UTMAppleVirtualMachine: VZVirtualMachineDelegate { func guestDidStop(_ virtualMachine: VZVirtualMachine) { - vmQueue.async { [self] in - apple = nil - snapshotUnsupportedError = nil - } - removableDrives.removeAll() - sharedDirectoriesChanged = nil - Task { @MainActor in - stopAccesingResources() - for i in config.serials.indices { - if let serialPort = config.serials[i].interface { - serialPort.close() - config.serials[i].interface = nil - config.serials[i].fileHandleForReading = nil - config.serials[i].fileHandleForWriting = nil - } - } + Task { [weak self] in + _ = await self?.transitionToStopped(ifCurrent: virtualMachine) } - try? saveScreenshot() - state = .stopped } - + func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) { - guestDidStop(virtualMachine) - delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription) + Task { [weak self] in + _ = await self?.transitionToStopped(ifCurrent: virtualMachine, error: error) + } } // fake methods to adhere to NSObjectProtocol diff --git a/Services/UTMRegistryEntry.swift b/Services/UTMRegistryEntry.swift index 2d51d30b39..13c2847cb6 100644 --- a/Services/UTMRegistryEntry.swift +++ b/Services/UTMRegistryEntry.swift @@ -42,6 +42,8 @@ import Combine @Published private var _hasMigratedConfig: Bool @Published private var _macRecoveryIpsw: File? + + @Published private var _connectedAppleUsbDevices: [AppleUSBDevice]? private enum CodingKeys: String, CodingKey { case name = "Name" @@ -55,6 +57,7 @@ import Combine case resolutionSettings = "ResolutionSettings" case hasMigratedConfig = "MigratedConfig" case macRecoveryIpsw = "MacRecoveryIpsw" + case connectedAppleUsbDevices = "ConnectedAppleUsbDevices" } init(uuid: UUID, name: String, path: String, bookmark: Data? = nil) { @@ -74,6 +77,7 @@ import Combine _terminalSettings = [:] _resolutionSettings = [:] _hasMigratedConfig = false + _connectedAppleUsbDevices = nil } convenience init(newFrom vm: any UTMVirtualMachine) { @@ -96,6 +100,7 @@ import Combine _resolutionSettings = try container.decodeIfPresent([Int: Resolution].self, forKey: .resolutionSettings) ?? [:] _hasMigratedConfig = try container.decodeIfPresent(Bool.self, forKey: .hasMigratedConfig) ?? false _macRecoveryIpsw = try container.decodeIfPresent(File.self, forKey: .macRecoveryIpsw) + _connectedAppleUsbDevices = try container.decodeIfPresent([AppleUSBDevice].self, forKey: .connectedAppleUsbDevices) } func encode(to encoder: Encoder) throws { @@ -113,6 +118,7 @@ import Combine try container.encode(_hasMigratedConfig, forKey: .hasMigratedConfig) } try container.encodeIfPresent(_macRecoveryIpsw, forKey: .macRecoveryIpsw) + try container.encodeIfPresent(_connectedAppleUsbDevices, forKey: .connectedAppleUsbDevices) } func asDictionary() throws -> [String: Any] { @@ -237,6 +243,16 @@ extension UTMRegistryEntry: UTMRegistryEntryDecodable {} _macRecoveryIpsw = newValue } } + + var connectedAppleUsbDevices: [AppleUSBDevice]? { + get { + _connectedAppleUsbDevices + } + + set { + _connectedAppleUsbDevices = newValue + } + } func setExternalDrive(_ file: File, forId id: String) { externalDrives[id] = file @@ -276,6 +292,7 @@ extension UTMRegistryEntry: UTMRegistryEntryDecodable {} terminalSettings = other.terminalSettings resolutionSettings = other.resolutionSettings hasMigratedConfig = other.hasMigratedConfig + connectedAppleUsbDevices = other.connectedAppleUsbDevices } func setIsSuspended(_ isSuspended: Bool) { @@ -370,6 +387,24 @@ extension UTMRegistryEntry { } extension UTMRegistryEntry { + struct AppleUSBDevice: Codable, Hashable, Sendable { + let vendorID: UInt16 + + let productID: UInt16 + + let deviceClass: UInt8 + + let deviceSubclass: UInt8 + + let deviceProtocol: UInt8 + + let deviceDescriptorData: Data + + let registryID: UInt64? + + let hostBootSessionID: UUID? + } + struct File: Codable, Identifiable { var url: URL diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 1a37efbb0b..d168d64ee7 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -714,6 +714,7 @@ CE89CB102B8B1B6A006B2CC2 /* VisionKeyboardKit in Frameworks */ = {isa = PBXBuildFile; platformFilters = (xros, ); productRef = CE89CB0F2B8B1B6A006B2CC2 /* VisionKeyboardKit */; }; CE89CB122B8B1B7A006B2CC2 /* VisionKeyboardKit in Frameworks */ = {isa = PBXBuildFile; platformFilters = (xros, ); productRef = CE89CB112B8B1B7A006B2CC2 /* VisionKeyboardKit */; }; CE928C2A26ABE6690099F293 /* UTMAppleVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */; }; + 019F89159BEC7E21BF68C565 /* UTMAppleUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019F89169BEC7E21BF68C565 /* UTMAppleUSBManager.swift */; }; CE928C3126ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE928C3026ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift */; }; CE93758924B930270074066F /* BusyOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7D972B24B2B17D0080CB69 /* BusyOverlay.swift */; }; CE93759924BB821F0074066F /* IQKeyboardManagerSwift in Frameworks */ = {isa = PBXBuildFile; platformFilter = ios; productRef = CE93759824BB821F0074066F /* IQKeyboardManagerSwift */; }; @@ -2041,6 +2042,7 @@ CE88A1632E24E4C000EAA28E /* VMKeyboardMap.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VMKeyboardMap.h; sourceTree = ""; }; CE88A1642E24E4C000EAA28E /* VMKeyboardMap.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VMKeyboardMap.m; sourceTree = ""; }; CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleVirtualMachine.swift; sourceTree = ""; }; + 019F89169BEC7E21BF68C565 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = ""; }; CE928C3026ACCDEA0099F293 /* VMAppleRemovableDrivesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleRemovableDrivesView.swift; sourceTree = ""; }; CE9375A024BBDDD10074066F /* VMConfigDriveDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigDriveDetailsView.swift; sourceTree = ""; }; CE95877426D74C2A0086BDE8 /* iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOS.entitlements; sourceTree = ""; }; @@ -2956,6 +2958,7 @@ CE68047D2E493D71001671E9 /* UTMUSBManager.swift */, CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */, CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */, + 019F89169BEC7E21BF68C565 /* UTMAppleUSBManager.swift */, 841E999728AC817D003C6CB6 /* UTMQemuVirtualMachine.swift */, CEF01DB12B6724A300725A0F /* UTMSpiceVirtualMachine.swift */, ); @@ -3959,6 +3962,7 @@ CEF0300826A25A6900667B63 /* VMWizardView.swift in Sources */, CE88A15A2E247D0100EAA28E /* UTMActionIntent.swift in Sources */, CE928C2A26ABE6690099F293 /* UTMAppleVirtualMachine.swift in Sources */, + 019F89159BEC7E21BF68C565 /* UTMAppleUSBManager.swift in Sources */, CE0B6CF524AD568400FE012D /* UTMLegacyQemuConfiguration+Miscellaneous.m in Sources */, CE25125129C806AF000790AB /* UTMScriptingDeleteCommand.swift in Sources */, CE0B6CFB24AD568400FE012D /* UTMLegacyQemuConfiguration+Networking.m in Sources */,