From 9d34c595f7e275d8093c84589ae67649befc94fa Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:54:25 -0800 Subject: [PATCH] vm(apple): implement USB passthrough for Apple Virtualization This change implements USB device passthrough for macOS 15+ using the Virtualization framework private APIs. Changes include: - `UTMIOUSBHostManager`: Uses IOKit to dynamically manage connected USB devices. Instead of relying on private framework headers, it uses Objective-C reflection to safely instantiate `_VZIOUSBHostPassthroughDeviceConfiguration` and `_VZIOUSBHostPassthroughDevice`, and configures delegates for `VZUSBController`. - `UTMIOUSBHostDevice`: Represents an IOKit USB device, conforming to `NSSecureCoding` and `NSCopying`. It safely reads properties such as location ID and port directly from the IORegistry. - `UTMAppleVirtualMachine`: Now initializes `UTMIOUSBHostManager` and restores captured USB devices asynchronously before VM startup. State is archived directly into the registry. - `VMDisplayAppleWindowController`: Added a new USB menu populated with current devices to allow interactively connecting and disconnecting USB devices on the fly. - `UTMRegistryEntry`: Extended to support archiving and unarchiving of connected USB devices. Co-authored-by: Gemini --- .../VMDisplayAppleWindowController.swift | 160 ++++++- Services/Swift-Bridging-Header.h | 3 + Services/UTMAppleVirtualMachine.swift | 72 ++- Services/UTMIOUSBHostDevice.h | 69 +++ Services/UTMIOUSBHostDevice.m | 237 +++++++++ Services/UTMIOUSBHostManager.h | 67 +++ Services/UTMIOUSBHostManager.m | 449 ++++++++++++++++++ Services/UTMIOUSBHostManagerDelegate.h | 41 ++ Services/UTMRegistryEntry.swift | 17 + UTM.xcodeproj/project.pbxproj | 14 + 10 files changed, 1125 insertions(+), 4 deletions(-) create mode 100644 Services/UTMIOUSBHostDevice.h create mode 100644 Services/UTMIOUSBHostDevice.m create mode 100644 Services/UTMIOUSBHostManager.h create mode 100644 Services/UTMIOUSBHostManager.m create mode 100644 Services/UTMIOUSBHostManagerDelegate.h diff --git a/Platform/macOS/Display/VMDisplayAppleWindowController.swift b/Platform/macOS/Display/VMDisplayAppleWindowController.swift index 36d359608..8d634d49c 100644 --- a/Platform/macOS/Display/VMDisplayAppleWindowController.swift +++ b/Platform/macOS/Display/VMDisplayAppleWindowController.swift @@ -46,6 +46,9 @@ class VMDisplayAppleWindowController: VMDisplayWindowController { // MARK: - User preferences @Setting("SharePathAlertShown") private var isSharePathAlertShownPersistent: Bool = false + @Setting("NoUsbPrompt") private var isNoUsbPrompt: Bool = false + + private var allUsbDevices: [Any] = [] override func windowDidLoad() { mainView!.translatesAutoresizingMaskIntoConstraints = false @@ -94,11 +97,22 @@ class VMDisplayAppleWindowController: VMDisplayWindowController { setControl([.restart, .sharedFolder], isEnabled: false) } if #available(macOS 15, *) { - setControl(.drives, isEnabled: true) + setControl([.drives, .usb], isEnabled: true) + } + if #available(macOS 15, *), !isSecondary, let usbManager = appleVM.usbManager { + usbManager.delegate = self } } override func enterSuspended(isBusy busy: Bool) { + if #available(macOS 15, *), let usbManager = appleVM.usbManager { + usbManager.delegate = nil + } + if vm.state == .stopped { + if #available(macOS 15, *) { + allUsbDevices.removeAll() + } + } super.enterSuspended(isBusy: busy) } @@ -494,3 +508,147 @@ fileprivate extension NSView { return NSImage(cgImage: imageRepresentation.cgImage!, size: bounds.size) } } + +// MARK: - USB capture + +@available(macOS 15, *) +extension VMDisplayAppleWindowController: UTMIOUSBHostManagerDelegate { + func ioUsbHostManager(_ ioUsbHostManager: UTMIOUSBHostManager, deviceAttached device: UTMIOUSBHostDevice) { + logger.debug("USB device attached: \(device.name ?? "")") + if !isNoUsbPrompt { + Task { @MainActor in + if self.window?.isKeyWindow == true && self.vm.state == .started { + self.showConnectPrompt(for: device) + } + } + } + } + + func ioUsbHostManager(_ ioUsbHostManager: UTMIOUSBHostManager, deviceRemoved device: UTMIOUSBHostDevice) { + logger.debug("USB device removed: \(device.name ?? "")") + } + + func showConnectPrompt(for usbDevice: UTMIOUSBHostDevice) { + 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"), usbDevice.name ?? "") + alert.showsSuppressionButton = true + alert.addButton(withTitle: NSLocalizedString("Confirm", comment: "VMDisplayAppleWindowController")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayAppleWindowController")) + alert.beginSheetModal(for: window!) { response in + if let suppressionButton = alert.suppressionButton, + suppressionButton.state == .on { + self.isNoUsbPrompt = true + } + guard response == .alertFirstButtonReturn else { + return + } + guard let apple = self.appleVM.apple else { return } + guard let usbManager = self.appleVM.usbManager else { return } + usbManager.connectUsbDevice(usbDevice, to: apple) { error in + if let error = error { + Task { @MainActor in + self.showErrorAlert(error.localizedDescription) + } + } + } + } + } +} + +extension VMDisplayAppleWindowController { + override func updateUsbMenu(_ menu: NSMenu) { + guard #available(macOS 15, *), let usbManager = appleVM.usbManager else { + return + } + menu.autoenablesItems = false + let item = NSMenuItem() + item.title = NSLocalizedString("Querying USB devices...", comment: "VMDisplayAppleWindowController") + item.isEnabled = false + menu.addItem(item) + usbManager.usbDevices { devices, error in + if let error = error { + logger.error("Failed to query USB devices: \(error)") + return + } + self.updateUsbDevicesMenu(menu, devices: devices) + } + } + + @available(macOS 15, *) + func updateUsbDevicesMenu(_ menu: NSMenu, devices: [UTMIOUSBHostDevice]) { + allUsbDevices = devices + menu.removeAllItems() + if devices.count == 0 { + let item = NSMenuItem() + item.title = NSLocalizedString("No USB devices detected.", comment: "VMDisplayAppleWindowController") + item.isEnabled = false + menu.addItem(item) + } + guard let usbManager = appleVM.usbManager else { + return + } + let connectedDevices = usbManager.connectedDevices + for (i, device) in devices.enumerated() { + let item = NSMenuItem() + let isConnected = device.isCaptured + let isConnectedToSelf = connectedDevices.contains(device) + item.title = device.name ?? "" + item.isEnabled = (isConnectedToSelf || !isConnected) + item.state = isConnectedToSelf ? .on : .off + item.tag = i + + let submenu = NSMenu() + let connectItem = NSMenuItem() + connectItem.title = isConnectedToSelf ? NSLocalizedString("Disconnect…", comment: "VMDisplayAppleWindowController") : NSLocalizedString("Connect…", comment: "VMDisplayAppleWindowController") + connectItem.isEnabled = (isConnectedToSelf || !isConnected) + connectItem.tag = i + connectItem.target = self + connectItem.action = isConnectedToSelf ? #selector(disconnectUsbDevice) : #selector(connectUsbDevice) + submenu.addItem(connectItem) + + item.submenu = submenu + menu.addItem(item) + } + menu.update() + } + + @available(macOS 15, *) + @objc func connectUsbDevice(sender: AnyObject) { + guard let menu = sender as? NSMenuItem else { + logger.error("wrong sender for connectUsbDevice") + return + } + guard let usbManager = appleVM.usbManager else { + return + } + let device = allUsbDevices[menu.tag] as! UTMIOUSBHostDevice + usbManager.connectUsbDevice(device, to: appleVM.apple!) { error in + if let error = error { + Task { @MainActor in + self.showErrorAlert(error.localizedDescription) + } + } + } + } + + @available(macOS 15, *) + @objc func disconnectUsbDevice(sender: AnyObject) { + guard let menu = sender as? NSMenuItem else { + logger.error("wrong sender for disconnectUsbDevice") + return + } + guard let usbManager = appleVM.usbManager else { + return + } + let device = allUsbDevices[menu.tag] as! UTMIOUSBHostDevice + usbManager.disconnectUsbDevice(device, to: appleVM.apple!) { error in + if let error = error { + Task { @MainActor in + self.showErrorAlert(error.localizedDescription) + } + } + } + } +} diff --git a/Services/Swift-Bridging-Header.h b/Services/Swift-Bridging-Header.h index 7c5207eea..9d38153fc 100644 --- a/Services/Swift-Bridging-Header.h +++ b/Services/Swift-Bridging-Header.h @@ -50,6 +50,9 @@ #include "VMKeyboardButton.h" #include "VMKeyboardView.h" #elif TARGET_OS_OSX +#include "UTMIOUSBHostDevice.h" +#include "UTMIOUSBHostManager.h" +#include "UTMIOUSBHostManagerDelegate.h" typedef uint32_t CGSConnectionID; typedef CF_ENUM(uint32_t, CGSGlobalHotKeyOperatingMode) { kCGSGlobalHotKeyOperatingModeEnable = 0, diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index 4917dc81a..ed2df3fcd 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -118,6 +118,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { private var removableDrives: [String: Any] = [:] + private(set) var usbManager: UTMIOUSBHostManager? + @MainActor var isHeadless: Bool { config.displays.isEmpty && config.serials.filter({ $0.mode == .builtin }).isEmpty } @@ -131,6 +133,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { self.registryEntry = UTMRegistryEntry.empty self.registryEntry = loadRegistry() self.screenshot = loadScreenshot() + if #available(macOS 15, *) { + usbManager = UTMIOUSBHostManager(virtualMachineQueue: vmQueue) + } } deinit { @@ -187,14 +192,15 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { do { let isSuspended = await registryEntry.isSuspended try await beginAccessingResources() - try await createAppleVM() + try await createAppleVM(ignoringUsbErrors: !isSuspended) if isSuspended && !options.contains(.bootRecovery) { try await restoreSnapshot() } else { try await _start(options: options) } - if #available(macOS 15, *) { + if #available(macOS 15, *), let usbManager = usbManager { try await attachExternalDrives() + usbManager.synchronize(with: apple!) } if #available(macOS 12, *) { Task { @MainActor in @@ -397,6 +403,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { state = .paused } try await _saveSnapshot(url: vmSavedStateURL) + if #available(macOS 15, *) { + await saveUsbDevices() + } await registryEntry.setIsSuspended(true) #endif } @@ -498,7 +507,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { screenshot = loadScreenshot() } - @MainActor private func createAppleVM() throws { + @MainActor private func createAppleVM(ignoringUsbErrors: Bool = true) async throws { for i in config.serials.indices { let (fd, sfd, name) = try createPty() let terminalTtyHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false) @@ -509,6 +518,15 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { config.serials[i].interface = serialPort } let vzConfig = try config.appleVZConfiguration() + if #available(macOS 15, *) { + do { + try await restoreUsbDevices(to: vzConfig) + } catch { + if !ignoringUsbErrors { + throw error + } + } + } vmQueue.async { [self] in apple = VZVirtualMachine(configuration: vzConfig, queue: vmQueue) apple!.delegate = self @@ -667,6 +685,12 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate { vmQueue.async { [self] in apple = nil snapshotUnsupportedError = nil + Task { @MainActor in + if #available(macOS 15, *), let usbManager = usbManager { + saveUsbDevices() + usbManager.synchronize() + } + } } removableDrives.removeAll() sharedDirectoriesChanged = nil @@ -1017,3 +1041,45 @@ extension UTMAppleVirtualMachine { } } } + +// MARK: - USB device passthrough + +@available(macOS 15, *) +extension UTMAppleVirtualMachine { + @MainActor func saveUsbDevices() { + guard let usbManager = usbManager else { + return + } + let connectedDevices = usbManager.connectedDevices + if connectedDevices.isEmpty { + registryEntry.connectedUsbDevices = nil + } else { + do { + registryEntry.connectedUsbDevices = try NSKeyedArchiver.archivedData(withRootObject: connectedDevices, requiringSecureCoding: true) + } catch { + logger.error("Failed to archive USB devices: \(error)") + } + } + } + + @MainActor func restoreUsbDevices(to config: VZVirtualMachineConfiguration) async throws { + guard let usbManager = usbManager else { + return + } + guard let data = registryEntry.connectedUsbDevices else { + return + } + // delete the list from registryEntry to prevent reuse + registryEntry.connectedUsbDevices = nil + let classes = [NSArray.self, NSUUID.self, UTMIOUSBHostDevice.self] + if let connectedDevices = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: classes, from: data) as? [UTMIOUSBHostDevice] { + for device in connectedDevices { + guard let uuid = device.uuid else { + continue + } + try await usbManager.restoreUsbDevice(device, to: config) + } + } + } +} + diff --git a/Services/UTMIOUSBHostDevice.h b/Services/UTMIOUSBHostDevice.h new file mode 100644 index 000000000..526eb4aa1 --- /dev/null +++ b/Services/UTMIOUSBHostDevice.h @@ -0,0 +1,69 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 + +NS_ASSUME_NONNULL_BEGIN + +API_AVAILABLE(macos(15.0)) +@interface UTMIOUSBHostDevice : NSObject + +/// A user-readable description of the device +@property (nonatomic, nullable, readonly) NSString *name; + +/// USB manufacturer if available +@property (nonatomic, nullable, readonly) NSString *usbManufacturerName; + +/// USB product if available +@property (nonatomic, nullable, readonly) NSString *usbProductName; + +/// USB device serial if available +@property (nonatomic, nullable, readonly) NSString *usbSerial; + +/// USB vendor ID +@property (nonatomic, readonly) NSInteger usbVendorId; + +/// USB product ID +@property (nonatomic, readonly) NSInteger usbProductId; + +/// USB bus number +@property (nonatomic, readonly) NSInteger usbBusNumber; + +/// USB port number +@property (nonatomic, readonly) NSInteger usbPortNumber; + +/// USB device signature +@property (nonatomic, nullable, readonly) NSData *usbSignature; + +/// Unique identifier for this device (used for restoring) +@property (nonatomic, nullable, readonly) NSUUID *uuid; + +/// Is the device currently connected to a guest? +@property (nonatomic, readonly) BOOL isCaptured; + +/// IOService corrosponding to this device +@property (nonatomic, readonly) io_service_t ioService; + ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +/// Create a new USB device from an IOService handle +/// - Parameter service: IOService handle +- (instancetype)initWithService:(io_service_t)service NS_SWIFT_UNAVAILABLE("Create from UTMIOUSBHostManager."); + +@end + +NS_ASSUME_NONNULL_END diff --git a/Services/UTMIOUSBHostDevice.m b/Services/UTMIOUSBHostDevice.m new file mode 100644 index 000000000..970e361c7 --- /dev/null +++ b/Services/UTMIOUSBHostDevice.m @@ -0,0 +1,237 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 "UTMIOUSBHostDevice.h" +#import + +API_AVAILABLE(macos(15.0)) +@interface UTMIOUSBHostDevice () + +@property (nonatomic, nullable, readwrite) NSString *usbManufacturerName; +@property (nonatomic, nullable, readwrite) NSString *usbProductName; +@property (nonatomic, nullable, readwrite) NSString *usbSerial; +@property (nonatomic, readwrite) NSInteger usbVendorId; +@property (nonatomic, readwrite) NSInteger usbProductId; +@property (nonatomic, readwrite) NSInteger usbBusNumber; +@property (nonatomic, readwrite) NSInteger usbPortNumber; +@property (nonatomic, readwrite) io_service_t ioService; +@property (nonatomic, nullable, readwrite) NSUUID *uuid; + +@end + +@implementation UTMIOUSBHostDevice + +static NSString * _Nullable get_ioregistry_value_string(io_service_t service, CFStringRef property) { + CFTypeRef cfProperty = IORegistryEntryCreateCFProperty(service, property, kCFAllocatorDefault, 0); + if (cfProperty) { + if (CFGetTypeID(cfProperty) == CFStringGetTypeID()) { + return CFBridgingRelease(cfProperty); + } + CFRelease(cfProperty); + } + return nil; +} + +static NSData * _Nullable get_ioregistry_value_data(io_service_t service, CFStringRef property) { + CFTypeRef cfProperty = IORegistryEntryCreateCFProperty(service, property, kCFAllocatorDefault, 0); + if (cfProperty) { + if (CFGetTypeID(cfProperty) == CFDataGetTypeID()) { + return CFBridgingRelease(cfProperty); + } + CFRelease(cfProperty); + } + return nil; +} + +static BOOL get_ioregistry_value_number(io_service_t service, CFStringRef property, CFNumberType type, void *value) { + BOOL ret = NO; + CFTypeRef cfProperty = IORegistryEntryCreateCFProperty(service, property, kCFAllocatorDefault, 0); + if (cfProperty) { + if (CFGetTypeID(cfProperty) == CFNumberGetTypeID()) { + ret = CFNumberGetValue((CFNumberRef)cfProperty, type, value); + } + CFRelease(cfProperty); + } + return ret; +} + +static BOOL get_ioregistry_value_data_range(io_service_t service, CFStringRef property, CFIndex length, UInt8 *value) { + BOOL ret = NO; + CFTypeRef cfProperty = IORegistryEntryCreateCFProperty(service, property, kCFAllocatorDefault, 0); + if (cfProperty) { + if (CFGetTypeID(cfProperty) == CFDataGetTypeID() && CFDataGetLength((CFDataRef)cfProperty) >= length) { + CFDataGetBytes((CFDataRef)cfProperty, CFRangeMake(0, length), value); + ret = YES; + } + CFRelease(cfProperty); + } + return ret; +} + +static BOOL get_device_port(io_service_t service, UInt8 *port) { + io_service_t parent; + BOOL ret = NO; + + if (get_ioregistry_value_number(service, CFSTR("PortNum"), kCFNumberSInt8Type, port)) { + return YES; + } + + if (IORegistryEntryGetParentEntry(service, kIOServicePlane, &parent) == kIOReturnSuccess) { + ret = get_ioregistry_value_data_range(parent, CFSTR("port"), 1, port); + IOObjectRelease(parent); + } + + return ret; +} + ++ (BOOL)supportsSecureCoding { + return YES; +} + +- (void)encodeWithCoder:(NSCoder *)coder { + [coder encodeObject:self.usbManufacturerName forKey:@"usbManufacturerName"]; + [coder encodeObject:self.usbProductName forKey:@"usbProductName"]; + [coder encodeObject:self.usbSerial forKey:@"usbSerial"]; + [coder encodeInteger:self.usbVendorId forKey:@"usbVendorId"]; + [coder encodeInteger:self.usbProductId forKey:@"usbProductId"]; + [coder encodeInteger:self.usbBusNumber forKey:@"usbBusNumber"]; + [coder encodeInteger:self.usbPortNumber forKey:@"usbPortNumber"]; + [coder encodeObject:self.usbSignature forKey:@"usbSignature"]; + [coder encodeObject:self.uuid forKey:@"uuid"]; +} + +- (instancetype)initWithCoder:(NSCoder *)coder { + self = [super init]; + if (self) { + _usbManufacturerName = [coder decodeObjectOfClass:[NSString class] forKey:@"usbManufacturerName"]; + _usbProductName = [coder decodeObjectOfClass:[NSString class] forKey:@"usbProductName"]; + _usbSerial = [coder decodeObjectOfClass:[NSString class] forKey:@"usbSerial"]; + _usbVendorId = [coder decodeIntegerForKey:@"usbVendorId"]; + _usbProductId = [coder decodeIntegerForKey:@"usbProductId"]; + _usbBusNumber = [coder decodeIntegerForKey:@"usbBusNumber"]; + _usbPortNumber = [coder decodeIntegerForKey:@"usbPortNumber"]; + _usbSignature = [coder decodeObjectOfClass:[NSData class] forKey:@"usbSignature"]; + _uuid = [coder decodeObjectForKey:@"uuid"]; + } + return self; +} + +- (instancetype)initWithService:(io_service_t)service { + self = [super init]; + if (self) { + _ioService = service; + IOObjectRetain(service); + + _usbManufacturerName = get_ioregistry_value_string(service, CFSTR(kUSBVendorString)); + _usbProductName = get_ioregistry_value_string(service, CFSTR(kUSBProductString)); + _usbSerial = get_ioregistry_value_string(service, CFSTR(kUSBSerialNumberString)); + _usbSignature = get_ioregistry_value_data(service, CFSTR(kUSBHostDevicePropertySignature)); + + UInt32 vendorId; + if (get_ioregistry_value_number(service, CFSTR(kUSBVendorID), kCFNumberSInt32Type, &vendorId)) { + _usbVendorId = vendorId; + } + + UInt32 productId; + if (get_ioregistry_value_number(service, CFSTR(kUSBProductID), kCFNumberSInt32Type, &productId)) { + _usbProductId = productId; + } + + UInt32 locationId; + if (get_ioregistry_value_number(service, CFSTR(kUSBDevicePropertyLocationID), kCFNumberSInt32Type, &locationId)) { + _usbBusNumber = locationId >> 24; + } + + UInt8 port; + if (get_device_port(service, &port)) { + _usbPortNumber = port; + } + } + return self; +} + +- (id)copyWithZone:(nullable NSZone *)zone { + UTMIOUSBHostDevice *copy = [[[self class] allocWithZone:zone] init]; + if (copy) { + copy->_usbManufacturerName = [self.usbManufacturerName copyWithZone:zone]; + copy->_usbProductName = [self.usbProductName copyWithZone:zone]; + copy->_usbSerial = [self.usbSerial copyWithZone:zone]; + copy->_usbVendorId = self.usbVendorId; + copy->_usbProductId = self.usbProductId; + copy->_usbBusNumber = self.usbBusNumber; + copy->_usbPortNumber = self.usbPortNumber; + copy->_usbSignature = [self.usbSignature copyWithZone:zone]; + copy->_ioService = self.ioService; + if (copy->_ioService) { + IOObjectRetain(copy->_ioService); + } + copy->_uuid = [self.uuid copyWithZone:zone]; + } + return copy; +} + +- (void)dealloc { + if (_ioService) { + IOObjectRelease(_ioService); + } +} + +- (NSString *)name { + if (self.usbProductName) { + return [NSString stringWithFormat:@"%@ (%ld:%ld)", self.usbProductName, (long)self.usbBusNumber, (long)self.usbPortNumber]; + } else { + return nil; + } +} + +- (BOOL)isCaptured { + return self.uuid != nil; +} + +- (BOOL)isEqual:(id)other { + if (self == other) return YES; + if (![other isKindOfClass:[UTMIOUSBHostDevice class]]) return NO; + UTMIOUSBHostDevice *device = (UTMIOUSBHostDevice *)other; + // if both have UUID, compare that + if (self.uuid != nil && device.uuid != nil) { + return [self.uuid isEqual:device.uuid]; + } + // next if both have a signature, compare that + if (self.usbSignature != nil && device.usbSignature != nil) { + return [self.usbSignature isEqualToData:device.usbSignature]; + } + // otherwise, compare all the string values + BOOL namesEqual = (self.usbManufacturerName == device.usbManufacturerName) || [self.usbManufacturerName isEqualToString:device.usbManufacturerName]; + BOOL productsEqual = (self.usbProductName == device.usbProductName) || [self.usbProductName isEqualToString:device.usbProductName]; + BOOL serialsEqual = (self.usbSerial == device.usbSerial) || [self.usbSerial isEqualToString:device.usbSerial]; + return namesEqual && productsEqual && serialsEqual && + self.usbVendorId == device.usbVendorId && + self.usbProductId == device.usbProductId && + self.usbBusNumber == device.usbBusNumber && + self.usbPortNumber == device.usbPortNumber; +} + +- (NSUInteger)hash { + if (self.uuid != nil) { + return self.uuid.hash; + } + if (self.usbSignature != nil) { + return self.usbSignature.hash; + } + return self.usbManufacturerName.hash ^ self.usbProductName.hash ^ self.usbSerial.hash ^ self.usbVendorId ^ self.usbProductId ^ self.usbBusNumber ^ self.usbPortNumber; +} + +@end diff --git a/Services/UTMIOUSBHostManager.h b/Services/UTMIOUSBHostManager.h new file mode 100644 index 000000000..6f3165859 --- /dev/null +++ b/Services/UTMIOUSBHostManager.h @@ -0,0 +1,67 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 +#import +#import "UTMIOUSBHostManagerDelegate.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface UTMIOUSBHostManager : NSObject + +/// Delegate to handle USB connect/disconnect events +@property (nonatomic, weak) id delegate API_AVAILABLE(macos(15.0)); + +@property (nonatomic, readonly) NSArray *connectedDevices API_AVAILABLE(macos(15.0)); + +- (instancetype)init NS_UNAVAILABLE; + +- (instancetype)initWithVirtualMachineQueue:(dispatch_queue_t)virtualMachineQueue NS_DESIGNATED_INITIALIZER API_AVAILABLE(macos(15.0)); + +/// Enumerate all currently connected USB devices +- (void)usbDevicesWithCompletion:(void (^)(NSArray *devices, NSError * _Nullable error))completion API_AVAILABLE(macos(15.0)); + +/// Connect a USB device to a running VZVirtualMachine +/// - Parameters: +/// - usbDevice: USB device to connect +/// - virtualMachine: Virtual machine to connect to +/// - completion: Return error +- (void)connectUsbDevice:(UTMIOUSBHostDevice *)usbDevice toVirtualMachine:(VZVirtualMachine *)virtualMachine withCompletion:(void (^)(NSError * _Nullable error))completion API_AVAILABLE(macos(15.0)); + +/// Disconnect a USB device from a running VZVirtualMachine +/// - Parameters: +/// - usbDevice: USB device to disconnect +/// - virtualMachine: Virtual machine to disconnect from +/// - completion: Return error +- (void)disconnectUsbDevice:(UTMIOUSBHostDevice *)usbDevice toVirtualMachine:(VZVirtualMachine *)virtualMachine withCompletion:(void (^)(NSError * _Nullable error))completion API_AVAILABLE(macos(15.0)); + +/// Restore connected devices to a virtual machine before it is started +/// - Parameters: +/// - usbDevice: USB device to restore +/// - virtualMachineConfiguration: Virtual machine configuration to restore to +/// - completion: Return error +- (void)restoreUsbDevice:(UTMIOUSBHostDevice *)usbDevices toVirtualMachineConfiguration:(VZVirtualMachineConfiguration *)virtualMachineConfiguration withCompletion:(void (^)(NSError * _Nullable error))completion API_AVAILABLE(macos(15.0)); + +/// Called when the virtual machine stops to make sure internal state matches +- (void)synchronize API_AVAILABLE(macos(15.0)); + +/// Called when the virtual machine starts to make sure internal state matches already captured devices +/// - Parameter virtualMachine: Virtual machine to synchronize with +- (void)synchronizeWithVirtualMachine:(nullable VZVirtualMachine *)virtualMachine API_AVAILABLE(macos(15.0)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/Services/UTMIOUSBHostManager.m b/Services/UTMIOUSBHostManager.m new file mode 100644 index 000000000..9aaf02960 --- /dev/null +++ b/Services/UTMIOUSBHostManager.m @@ -0,0 +1,449 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 "UTMIOUSBHostManager.h" +#import "UTMIOUSBHostDevice.h" +#import "UTMIOUSBHostManagerDelegate.h" +#import +#import + +extern NSString *const kUTMErrorDomain; +static const int kCooldownClearLastSeenDisconnectSecs = 1; + +API_AVAILABLE(macos(15.0)) +@interface UTMIOUSBHostManager () + +/// Devices connected to this instance +@property (nonatomic, readonly) NSMutableDictionary> *connectedDevicesMap; + +@property (nonatomic, readonly) NSMutableDictionary *pendingDevicesMap; + +/// Queue to dispatch VM operations +@property (nonatomic, readonly) dispatch_queue_t vmQueue; + +@end + +API_AVAILABLE(macos(15.0)) +@interface UTMIOUSBHostDevice (Private) + +@property (nonatomic, nullable, readwrite) NSUUID *uuid; + +@end + +API_AVAILABLE(macos(15.0)) +static NSMutableArray *gUsbDevices; +static NSPointerArray *gManagers; +static dispatch_queue_t gUsbHostManagerQueue; +static IONotificationPortRef gNotifyPort; +static io_iterator_t gAddedIter; +static io_iterator_t gRemovedIter; +static NSData *gLastRemovedSignature; + +static Class ClassVZIOUSBHostPassthroughDeviceConfiguration; +static Class ClassVZIOUSBHostPassthroughDevice; +static BOOL gPassthroughSupported = NO; + +static BOOL InitPassthrough(void) API_AVAILABLE(macos(15.0)) { + ClassVZIOUSBHostPassthroughDeviceConfiguration = NSClassFromString(@"_VZIOUSBHostPassthroughDeviceConfiguration"); + ClassVZIOUSBHostPassthroughDevice = NSClassFromString(@"_VZIOUSBHostPassthroughDevice"); + if (!ClassVZIOUSBHostPassthroughDeviceConfiguration || !ClassVZIOUSBHostPassthroughDevice) { + return NO; + } + if (![ClassVZIOUSBHostPassthroughDeviceConfiguration instancesRespondToSelector:NSSelectorFromString(@"initWithService:error:")]) { + return NO; + } + if (![ClassVZIOUSBHostPassthroughDevice instancesRespondToSelector:NSSelectorFromString(@"initWithConfiguration:error:")]) { + return NO; + } + if (![VZUSBController instancesRespondToSelector:NSSelectorFromString(@"setDelegate:")]) { + return NO; + } + return YES; +} + +static void DeviceAdded(void *refCon, io_iterator_t iterator) API_AVAILABLE(macos(15.0)) { + io_service_t usbDevice; + while ((usbDevice = IOIteratorNext(iterator))) { + UTMIOUSBHostDevice *device = [[UTMIOUSBHostDevice alloc] initWithService:usbDevice]; + [gUsbDevices addObject:device]; + // if this was the device we just removed, it can be from capture release + if (gLastRemovedSignature && [device.usbSignature isEqualToData:gLastRemovedSignature]) { + gLastRemovedSignature = nil; + continue; // do not alert delegates + } + for (UTMIOUSBHostManager *manager in gManagers) { + if ([manager.delegate respondsToSelector:@selector(ioUsbHostManager:deviceAttached:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [manager.delegate ioUsbHostManager:manager deviceAttached:device]; + }); + } + } + } +} + +static void DeviceRemoved(void *refCon, io_iterator_t iterator) API_AVAILABLE(macos(15.0)) { + io_service_t usbDevice; + while ((usbDevice = IOIteratorNext(iterator))) { + UTMIOUSBHostDevice *removedDevice = nil; + for (UTMIOUSBHostDevice *device in gUsbDevices) { + if (device.ioService == usbDevice || IOObjectIsEqualTo(device.ioService, usbDevice)) { + removedDevice = device; + break; + } + } + if (removedDevice) { + [gUsbDevices removeObject:removedDevice]; + gLastRemovedSignature = removedDevice.usbSignature; + // cooldown to clear last removed + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC*kCooldownClearLastSeenDisconnectSecs), gUsbHostManagerQueue, ^{ + gLastRemovedSignature = nil; + }); + } + IOObjectRelease(usbDevice); + } +} + +static void InitUsbNotify(void) API_AVAILABLE(macos(15.0)) { + if (gNotifyPort != NULL) { + return; + } + gUsbDevices = [[NSMutableArray alloc] init]; + gManagers = [NSPointerArray weakObjectsPointerArray]; + + CFMutableDictionaryRef matchingDict = IOServiceMatching(kIOUSBDeviceClassName); + gNotifyPort = IONotificationPortCreate(kIOMasterPortDefault); + IONotificationPortSetDispatchQueue(gNotifyPort, gUsbHostManagerQueue); + + CFRetain(matchingDict); // Need another reference for the second call + + IOServiceAddMatchingNotification(gNotifyPort, kIOFirstMatchNotification, matchingDict, DeviceAdded, NULL, &gAddedIter); + DeviceAdded(NULL, gAddedIter); // Iterate already existing devices + + IOServiceAddMatchingNotification(gNotifyPort, kIOTerminatedNotification, matchingDict, DeviceRemoved, NULL, &gRemovedIter); + DeviceRemoved(NULL, gRemovedIter); // Clear any already removed devices (unlikely) +} + +static void CleanupUsbNotify(void) API_AVAILABLE(macos(15.0)) { + if (gAddedIter) { + IOObjectRelease(gAddedIter); + gAddedIter = 0; + } + if (gRemovedIter) { + IOObjectRelease(gRemovedIter); + gRemovedIter = 0; + } + if (gNotifyPort != NULL) { + IONotificationPortDestroy(gNotifyPort); + gNotifyPort = NULL; + } + gUsbDevices = nil; + gManagers = nil; +} + +@implementation UTMIOUSBHostManager + +- (instancetype)initWithVirtualMachineQueue:(dispatch_queue_t)virtualMachineQueue { + if (@available(macOS 15, *)) { + self = [super init]; + if (self) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + gPassthroughSupported = InitPassthrough(); + gUsbHostManagerQueue = dispatch_queue_create("com.utmapp.UTM.USBHostManagerQueue", DISPATCH_QUEUE_SERIAL); + }); + + if (!gPassthroughSupported) { + return nil; + } + + _connectedDevicesMap = [[NSMutableDictionary alloc] init]; + _pendingDevicesMap = [[NSMutableDictionary alloc] init]; + _vmQueue = virtualMachineQueue; + + dispatch_async(gUsbHostManagerQueue, ^{ + InitUsbNotify(); + [gManagers addPointer:(__bridge void *)self]; + [gManagers compact]; + }); + } + } else { + self = nil; + } + return self; +} + +- (void)dealloc { + dispatch_async(gUsbHostManagerQueue, ^{ + for (NSUInteger i = 0; i < gManagers.count; i++) { + if ([gManagers pointerAtIndex:i] == (__bridge void *)self) { + [gManagers removePointerAtIndex:i]; + break; + } + } + [gManagers compact]; + + if (@available(macOS 15, *)) { + if (gManagers.count == 0) { + CleanupUsbNotify(); + } + } + }); +} + +- (NSError *)errorWithMessage:(nullable NSString *)message { + return [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +- (nullable id)createVzUsbDeviceConfigFromUsbDevice:(UTMIOUSBHostDevice *)usbDevice uuid:(nullable NSUUID *)uuid error:(NSError **)error API_AVAILABLE(macos(15.0)){ + io_service_t ioService = usbDevice.ioService; + + assert(ioService); + SEL initSel = NSSelectorFromString(@"initWithService:error:"); + id (*initWithServiceError)(id, SEL, io_service_t, NSError **) = (void *)[ClassVZIOUSBHostPassthroughDeviceConfiguration instanceMethodForSelector:initSel]; + id config = [ClassVZIOUSBHostPassthroughDeviceConfiguration alloc]; + config = initWithServiceError(config, initSel, ioService, error); + + if (!config) { + return nil; + } + if (uuid) { + config.uuid = uuid; + } + + return config; +} + +- (nullable id)createVzUsbDeviceFromUsbDevice:(UTMIOUSBHostDevice *)usbDevice uuid:(nullable NSUUID *)uuid error:(NSError **)error API_AVAILABLE(macos(15.0)){ + id config = [self createVzUsbDeviceConfigFromUsbDevice:usbDevice uuid:uuid error:error]; + + if (!config) { + return nil; + } + + SEL initSel = NSSelectorFromString(@"initWithConfiguration:error:"); + id (*initWithConfigurationError)(id, SEL, id, NSError **) = (void *)[ClassVZIOUSBHostPassthroughDevice instanceMethodForSelector:initSel]; + id device = [ClassVZIOUSBHostPassthroughDevice alloc]; + device = initWithConfigurationError(device, initSel, config, error); + + return device; +} + +- (void)usbController:(VZUSBController *)usbController setDelegate:(id)delegate API_AVAILABLE(macos(15.0)) { + SEL setDelegateSel = NSSelectorFromString(@"setDelegate:"); + void (*setDelegate)(id, SEL, id) = (void *)[VZUSBController instanceMethodForSelector:setDelegateSel]; + setDelegate(usbController, setDelegateSel, delegate); +} + +- (void)usbController:(VZUSBController *)usbController passthroughDeviceDidDisconnect:(id)device API_AVAILABLE(macos(15.0)) { + dispatch_async(gUsbHostManagerQueue, ^{ + UTMIOUSBHostDevice *disconnectedDevice = nil; + for (UTMIOUSBHostDevice *usbDevice in self.connectedDevicesMap) { + if (self.connectedDevicesMap[usbDevice] == device) { + disconnectedDevice = usbDevice; + break; + } + } + if (disconnectedDevice) { + disconnectedDevice.uuid = nil; + [self.connectedDevicesMap removeObjectForKey:disconnectedDevice]; + if ([self.delegate respondsToSelector:@selector(ioUsbHostManager:deviceRemoved:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate ioUsbHostManager:self deviceRemoved:disconnectedDevice]; + }); + } + } + }); +} + +- (void)usbDevicesWithCompletion:(void (^)(NSArray *devices, NSError * _Nullable error))completion { + dispatch_async(gUsbHostManagerQueue, ^{ + NSArray *devicesCopy = [gUsbDevices copy]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(devicesCopy, nil); + }); + }); +} + +- (void)connectUsbDevice:(UTMIOUSBHostDevice *)usbDevice toVirtualMachine:(VZVirtualMachine *)virtualMachine withCompletion:(void (^)(NSError * _Nullable error))completion { + VZUSBController *firstController = virtualMachine.usbControllers.firstObject; + if (!firstController) { + completion([self errorWithMessage:NSLocalizedString(@"This virtual machine does not have any USB controllers.", "UTMIOUSBHostManager")]); + return; + } + [self usbController:firstController setDelegate:self]; + dispatch_async(gUsbHostManagerQueue, ^{ + if (usbDevice.uuid != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion([self errorWithMessage:NSLocalizedString(@"This USB device is already connected to a virtual machine.", "UTMIOUSBHostManager")]); + }); + return; + } + NSError *error = nil; + id vzDevice = [self createVzUsbDeviceFromUsbDevice:usbDevice uuid:nil error:&error]; + if (!vzDevice) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(error); + }); + return; + } + usbDevice.uuid = vzDevice.uuid; + dispatch_async(self.vmQueue, ^{ + [firstController attachDevice:vzDevice completionHandler:^(NSError * _Nullable attachError) { + dispatch_async(gUsbHostManagerQueue, ^{ + if (!attachError) { + self.connectedDevicesMap[usbDevice] = vzDevice; + } else { + usbDevice.uuid = nil; + } + dispatch_async(dispatch_get_main_queue(), ^{ + completion(attachError); + }); + }); + }]; + }); + }); +} + +- (void)disconnectUsbDevice:(UTMIOUSBHostDevice *)usbDevice toVirtualMachine:(VZVirtualMachine *)virtualMachine withCompletion:(void (^)(NSError * _Nullable error))completion { + VZUSBController *firstController = virtualMachine.usbControllers.firstObject; + if (!firstController) { + completion([self errorWithMessage:NSLocalizedString(@"This virtual machine does not have any USB controllers.", "UTMIOUSBHostManager")]); + return; + } + dispatch_async(gUsbHostManagerQueue, ^{ + if (usbDevice.uuid == nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion([self errorWithMessage:NSLocalizedString(@"This USB device is not connected to a virtual machine.", "UTMIOUSBHostManager")]); + }); + return; + } + id vzDevice = self.connectedDevicesMap[usbDevice]; + if (!vzDevice) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion([self errorWithMessage:NSLocalizedString(@"This USB device is connected to another virtual machine.", "UTMIOUSBHostManager")]); + }); + return; + } + [self.connectedDevicesMap removeObjectForKey:usbDevice]; + usbDevice.uuid = nil; + dispatch_async(self.vmQueue, ^{ + [firstController detachDevice:vzDevice completionHandler:^(NSError * _Nullable detachError) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(detachError); + }); + }]; + }); + }); +} + +- (void)restoreUsbDevice:(UTMIOUSBHostDevice *)usbDevice toVirtualMachineConfiguration:(VZVirtualMachineConfiguration *)virtualMachineConfiguration withCompletion:(void (^)(NSError * _Nullable error))completion { + VZUSBControllerConfiguration *firstControllerConfig = virtualMachineConfiguration.usbControllers.firstObject; + if (!firstControllerConfig) { + completion([self errorWithMessage:NSLocalizedString(@"This virtual machine does not have any USB controllers.", "UTMIOUSBHostManager")]); + return; + } + + if (!usbDevice.uuid) { + completion([self errorWithMessage:NSLocalizedString(@"Internal error: no identifier found for USB device.", "UTMIOUSBHostManager")]); + return; + } + + dispatch_async(gUsbHostManagerQueue, ^{ + UTMIOUSBHostDevice *matchedDevice = nil; + for (UTMIOUSBHostDevice *device in gUsbDevices) { + if ([usbDevice isEqual:device]) { + matchedDevice = device; + break; + } + } + if (!matchedDevice) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion([self errorWithMessage:[NSString localizedStringWithFormat:NSLocalizedString(@"USB device not found or already in use: %@", "UTMIOUSBHostManager"), usbDevice.name]]); + }); + return; + } + NSError *error = nil; + id vzDeviceConfig = [self createVzUsbDeviceConfigFromUsbDevice:matchedDevice uuid:usbDevice.uuid error:&error]; + if (!vzDeviceConfig) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(error); + }); + return; + } + + if (IOServiceAuthorize(matchedDevice.ioService, kIOServiceInteractionAllowed) != kIOReturnSuccess) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion([self errorWithMessage:[NSString localizedStringWithFormat:NSLocalizedString(@"Failed to authorize USB device: %@", "UTMIOUSBHostManager"), usbDevice.name]]); + }); + return; + } + + NSArray *usbDevices = [firstControllerConfig.usbDevices arrayByAddingObject:vzDeviceConfig]; + firstControllerConfig.usbDevices = usbDevices; + + self.pendingDevicesMap[usbDevice.uuid] = matchedDevice; + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil); + }); + }); +} + +- (NSArray *)connectedDevices { + return self.connectedDevicesMap.allKeys; +} + +- (void)synchronize { + [self synchronizeWithVirtualMachine:nil]; +} + +- (void)synchronizeWithVirtualMachine:(nullable VZVirtualMachine *)virtualMachine { + VZUSBController *firstController = virtualMachine.usbControllers.firstObject; + NSArray> *usbDevices = firstController.usbDevices; + if (firstController) { + [self usbController:firstController setDelegate:self]; + } + dispatch_async(gUsbHostManagerQueue, ^{ + for (id vzDevice in usbDevices) { + UTMIOUSBHostDevice *device = self.pendingDevicesMap[vzDevice.uuid]; + if (device) { + device.uuid = vzDevice.uuid; + self.connectedDevicesMap[device] = vzDevice; + } + } + [self.pendingDevicesMap removeAllObjects]; + + NSMutableArray *toRemove = [NSMutableArray array]; + for (UTMIOUSBHostDevice *device in self.connectedDevicesMap) { + id mappedVzDevice = self.connectedDevicesMap[device]; + if (![usbDevices containsObject:mappedVzDevice]) { + [toRemove addObject:device]; + } + } + + for (UTMIOUSBHostDevice *device in toRemove) { + [self.connectedDevicesMap removeObjectForKey:device]; + device.uuid = nil; + if ([self.delegate respondsToSelector:@selector(ioUsbHostManager:deviceRemoved:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate ioUsbHostManager:self deviceRemoved:device]; + }); + } + } + }); +} + +@end diff --git a/Services/UTMIOUSBHostManagerDelegate.h b/Services/UTMIOUSBHostManagerDelegate.h new file mode 100644 index 000000000..0f2383559 --- /dev/null +++ b/Services/UTMIOUSBHostManagerDelegate.h @@ -0,0 +1,41 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 + +@class UTMIOUSBHostDevice; +@class UTMIOUSBHostManager; + +NS_ASSUME_NONNULL_BEGIN + +API_AVAILABLE(macos(15.0)) +@protocol UTMIOUSBHostManagerDelegate + +/// Called when a new USB device is attached to the host +/// - Parameters: +/// - ioUsbHostManager: USB manager instance +/// - device: Device that is attached +- (void)ioUsbHostManager:(UTMIOUSBHostManager *)ioUsbHostManager deviceAttached:(UTMIOUSBHostDevice *)device; + +/// Called when a USB device is removed from the host +/// - Parameters: +/// - ioUsbHostManager: USB manager instance +/// - device: Device that is removed +- (void)ioUsbHostManager:(UTMIOUSBHostManager *)ioUsbHostManager deviceRemoved:(UTMIOUSBHostDevice *)device; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Services/UTMRegistryEntry.swift b/Services/UTMRegistryEntry.swift index 982acc682..7f2e611dc 100644 --- a/Services/UTMRegistryEntry.swift +++ b/Services/UTMRegistryEntry.swift @@ -43,6 +43,8 @@ import Combine @Published private var _macRecoveryIpsw: File? + @Published private var _connectedUsbDevices: Data? + private enum CodingKeys: String, CodingKey { case name = "Name" case package = "Package" @@ -55,6 +57,7 @@ import Combine case resolutionSettings = "ResolutionSettings" case hasMigratedConfig = "MigratedConfig" case macRecoveryIpsw = "MacRecoveryIpsw" + case connectedUsbDevices = "ConnectedUsbDevices" } init(uuid: UUID, name: String, path: String, bookmark: Data? = nil) { @@ -74,6 +77,7 @@ import Combine _terminalSettings = [:] _resolutionSettings = [:] _hasMigratedConfig = false + _connectedUsbDevices = 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) + _connectedUsbDevices = try container.decodeIfPresent(Data.self, forKey: .connectedUsbDevices) } 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(_connectedUsbDevices, forKey: .connectedUsbDevices) } func asDictionary() throws -> [String: Any] { @@ -238,6 +244,16 @@ extension UTMRegistryEntry: UTMRegistryEntryDecodable {} } } + var connectedUsbDevices: Data? { + get { + _connectedUsbDevices + } + + set { + _connectedUsbDevices = 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 + connectedUsbDevices = other.connectedUsbDevices } func setIsSuspended(_ isSuspended: Bool) { diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 1a37efbb0..fef32abd3 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -91,6 +91,8 @@ 841E999928AC817D003C6CB6 /* UTMQemuVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841E999728AC817D003C6CB6 /* UTMQemuVirtualMachine.swift */; }; 841E999A28AC817D003C6CB6 /* UTMQemuVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841E999728AC817D003C6CB6 /* UTMQemuVirtualMachine.swift */; }; 84258C42288F806400C66366 /* VMToolbarUSBMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84258C41288F806400C66366 /* VMToolbarUSBMenuView.swift */; }; + 842855FB2F4E620500192F8E /* UTMIOUSBHostManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 842855F92F4E620500192F8E /* UTMIOUSBHostManager.m */; }; + 842856032F4E680300192F8E /* UTMIOUSBHostDevice.m in Sources */ = {isa = PBXBuildFile; fileRef = 842856002F4E680300192F8E /* UTMIOUSBHostDevice.m */; }; 842B9F8D28CC58B700031EE7 /* UTMPatches.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B9F8C28CC58B700031EE7 /* UTMPatches.swift */; }; 842B9F8E28CC58B700031EE7 /* UTMPatches.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B9F8C28CC58B700031EE7 /* UTMPatches.swift */; }; 8432329028C2CDAD00CFBC97 /* VMNavigationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8432328F28C2CDAD00CFBC97 /* VMNavigationListView.swift */; }; @@ -1735,6 +1737,11 @@ 841E997828AA119B003C6CB6 /* UTMRegistryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMRegistryEntry.swift; sourceTree = ""; }; 841E999728AC817D003C6CB6 /* UTMQemuVirtualMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMQemuVirtualMachine.swift; sourceTree = ""; }; 84258C41288F806400C66366 /* VMToolbarUSBMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMToolbarUSBMenuView.swift; sourceTree = ""; }; + 842855F82F4E620500192F8E /* UTMIOUSBHostManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMIOUSBHostManager.h; sourceTree = ""; }; + 842855F92F4E620500192F8E /* UTMIOUSBHostManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UTMIOUSBHostManager.m; sourceTree = ""; }; + 842855FE2F4E661900192F8E /* UTMIOUSBHostManagerDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMIOUSBHostManagerDelegate.h; sourceTree = ""; }; + 842855FF2F4E680300192F8E /* UTMIOUSBHostDevice.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMIOUSBHostDevice.h; sourceTree = ""; }; + 842856002F4E680300192F8E /* UTMIOUSBHostDevice.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UTMIOUSBHostDevice.m; sourceTree = ""; }; 842B9F8C28CC58B700031EE7 /* UTMPatches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMPatches.swift; sourceTree = ""; }; 8432328F28C2CDAD00CFBC97 /* VMNavigationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMNavigationListView.swift; sourceTree = ""; }; 8432329328C2ED9000CFBC97 /* FileBrowseField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileBrowseField.swift; sourceTree = ""; }; @@ -2928,6 +2935,11 @@ CE88A09B2E1DDB4200EAA28E /* UTMASIFImage.h */, CE88A09C2E1DDB4200EAA28E /* UTMASIFImage.m */, CE2D954624AD4F980059923A /* UTMExtensions.swift */, + 842855FF2F4E680300192F8E /* UTMIOUSBHostDevice.h */, + 842856002F4E680300192F8E /* UTMIOUSBHostDevice.m */, + 842855F82F4E620500192F8E /* UTMIOUSBHostManager.h */, + 842855F92F4E620500192F8E /* UTMIOUSBHostManager.m */, + 842855FE2F4E661900192F8E /* UTMIOUSBHostManagerDelegate.h */, CEB63A7924F469E300CAF323 /* UTMJailbreak.m */, CEB63A7824F468BA00CAF323 /* UTMJailbreak.h */, 846F8D592E3891FE0037162B /* UTMKeyboardShortcuts.swift */, @@ -3891,6 +3903,7 @@ CED779E82C79062500EB82AE /* UTMTips.swift in Sources */, CE9B154A2B12A87E003A32DD /* GenerateKey.c in Sources */, CE020BAC24AEE00000B44AB6 /* UTMLoggingSwift.swift in Sources */, + 842856032F4E680300192F8E /* UTMIOUSBHostDevice.m in Sources */, 848D99BA28630A780055C215 /* VMConfigSerialView.swift in Sources */, 8401FDA2269D3E2500265F0D /* VMConfigAppleNetworkingView.swift in Sources */, CE9375A224BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */, @@ -3931,6 +3944,7 @@ 845F95E52A57628400A016D7 /* UTMSWTPM.swift in Sources */, 844EC0FB2773EE49003C104A /* UTMDownloadIPSWTask.swift in Sources */, 8432329228C2CDAD00CFBC97 /* VMNavigationListView.swift in Sources */, + 842855FB2F4E620500192F8E /* UTMIOUSBHostManager.m in Sources */, CE0B6CF324AD568400FE012D /* UTMLegacyQemuConfiguration.m in Sources */, 8401FDA8269D4A4100265F0D /* VMConfigAppleSharingView.swift in Sources */, CE0B6CFC24AD568400FE012D /* UTMLegacyQemuConfigurationPortForward.m in Sources */,