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 */,