Skip to content

Commit 9d34c59

Browse files
osyGemini
andcommitted
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 <gemini@google.com>
1 parent baf362d commit 9d34c59

10 files changed

Lines changed: 1125 additions & 4 deletions

Platform/macOS/Display/VMDisplayAppleWindowController.swift

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
4646
// MARK: - User preferences
4747

4848
@Setting("SharePathAlertShown") private var isSharePathAlertShownPersistent: Bool = false
49+
@Setting("NoUsbPrompt") private var isNoUsbPrompt: Bool = false
50+
51+
private var allUsbDevices: [Any] = []
4952

5053
override func windowDidLoad() {
5154
mainView!.translatesAutoresizingMaskIntoConstraints = false
@@ -94,11 +97,22 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
9497
setControl([.restart, .sharedFolder], isEnabled: false)
9598
}
9699
if #available(macOS 15, *) {
97-
setControl(.drives, isEnabled: true)
100+
setControl([.drives, .usb], isEnabled: true)
101+
}
102+
if #available(macOS 15, *), !isSecondary, let usbManager = appleVM.usbManager {
103+
usbManager.delegate = self
98104
}
99105
}
100106

101107
override func enterSuspended(isBusy busy: Bool) {
108+
if #available(macOS 15, *), let usbManager = appleVM.usbManager {
109+
usbManager.delegate = nil
110+
}
111+
if vm.state == .stopped {
112+
if #available(macOS 15, *) {
113+
allUsbDevices.removeAll()
114+
}
115+
}
102116
super.enterSuspended(isBusy: busy)
103117
}
104118

@@ -494,3 +508,147 @@ fileprivate extension NSView {
494508
return NSImage(cgImage: imageRepresentation.cgImage!, size: bounds.size)
495509
}
496510
}
511+
512+
// MARK: - USB capture
513+
514+
@available(macOS 15, *)
515+
extension VMDisplayAppleWindowController: UTMIOUSBHostManagerDelegate {
516+
func ioUsbHostManager(_ ioUsbHostManager: UTMIOUSBHostManager, deviceAttached device: UTMIOUSBHostDevice) {
517+
logger.debug("USB device attached: \(device.name ?? "")")
518+
if !isNoUsbPrompt {
519+
Task { @MainActor in
520+
if self.window?.isKeyWindow == true && self.vm.state == .started {
521+
self.showConnectPrompt(for: device)
522+
}
523+
}
524+
}
525+
}
526+
527+
func ioUsbHostManager(_ ioUsbHostManager: UTMIOUSBHostManager, deviceRemoved device: UTMIOUSBHostDevice) {
528+
logger.debug("USB device removed: \(device.name ?? "")")
529+
}
530+
531+
func showConnectPrompt(for usbDevice: UTMIOUSBHostDevice) {
532+
let alert = NSAlert()
533+
alert.alertStyle = .informational
534+
alert.messageText = NSLocalizedString("USB Device", comment: "VMDisplayAppleWindowController")
535+
alert.informativeText = String.localizedStringWithFormat(NSLocalizedString("Would you like to connect '%@' to this virtual machine?", comment: "VMDisplayAppleWindowController"), usbDevice.name ?? "")
536+
alert.showsSuppressionButton = true
537+
alert.addButton(withTitle: NSLocalizedString("Confirm", comment: "VMDisplayAppleWindowController"))
538+
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayAppleWindowController"))
539+
alert.beginSheetModal(for: window!) { response in
540+
if let suppressionButton = alert.suppressionButton,
541+
suppressionButton.state == .on {
542+
self.isNoUsbPrompt = true
543+
}
544+
guard response == .alertFirstButtonReturn else {
545+
return
546+
}
547+
guard let apple = self.appleVM.apple else { return }
548+
guard let usbManager = self.appleVM.usbManager else { return }
549+
usbManager.connectUsbDevice(usbDevice, to: apple) { error in
550+
if let error = error {
551+
Task { @MainActor in
552+
self.showErrorAlert(error.localizedDescription)
553+
}
554+
}
555+
}
556+
}
557+
}
558+
}
559+
560+
extension VMDisplayAppleWindowController {
561+
override func updateUsbMenu(_ menu: NSMenu) {
562+
guard #available(macOS 15, *), let usbManager = appleVM.usbManager else {
563+
return
564+
}
565+
menu.autoenablesItems = false
566+
let item = NSMenuItem()
567+
item.title = NSLocalizedString("Querying USB devices...", comment: "VMDisplayAppleWindowController")
568+
item.isEnabled = false
569+
menu.addItem(item)
570+
usbManager.usbDevices { devices, error in
571+
if let error = error {
572+
logger.error("Failed to query USB devices: \(error)")
573+
return
574+
}
575+
self.updateUsbDevicesMenu(menu, devices: devices)
576+
}
577+
}
578+
579+
@available(macOS 15, *)
580+
func updateUsbDevicesMenu(_ menu: NSMenu, devices: [UTMIOUSBHostDevice]) {
581+
allUsbDevices = devices
582+
menu.removeAllItems()
583+
if devices.count == 0 {
584+
let item = NSMenuItem()
585+
item.title = NSLocalizedString("No USB devices detected.", comment: "VMDisplayAppleWindowController")
586+
item.isEnabled = false
587+
menu.addItem(item)
588+
}
589+
guard let usbManager = appleVM.usbManager else {
590+
return
591+
}
592+
let connectedDevices = usbManager.connectedDevices
593+
for (i, device) in devices.enumerated() {
594+
let item = NSMenuItem()
595+
let isConnected = device.isCaptured
596+
let isConnectedToSelf = connectedDevices.contains(device)
597+
item.title = device.name ?? ""
598+
item.isEnabled = (isConnectedToSelf || !isConnected)
599+
item.state = isConnectedToSelf ? .on : .off
600+
item.tag = i
601+
602+
let submenu = NSMenu()
603+
let connectItem = NSMenuItem()
604+
connectItem.title = isConnectedToSelf ? NSLocalizedString("Disconnect…", comment: "VMDisplayAppleWindowController") : NSLocalizedString("Connect…", comment: "VMDisplayAppleWindowController")
605+
connectItem.isEnabled = (isConnectedToSelf || !isConnected)
606+
connectItem.tag = i
607+
connectItem.target = self
608+
connectItem.action = isConnectedToSelf ? #selector(disconnectUsbDevice) : #selector(connectUsbDevice)
609+
submenu.addItem(connectItem)
610+
611+
item.submenu = submenu
612+
menu.addItem(item)
613+
}
614+
menu.update()
615+
}
616+
617+
@available(macOS 15, *)
618+
@objc func connectUsbDevice(sender: AnyObject) {
619+
guard let menu = sender as? NSMenuItem else {
620+
logger.error("wrong sender for connectUsbDevice")
621+
return
622+
}
623+
guard let usbManager = appleVM.usbManager else {
624+
return
625+
}
626+
let device = allUsbDevices[menu.tag] as! UTMIOUSBHostDevice
627+
usbManager.connectUsbDevice(device, to: appleVM.apple!) { error in
628+
if let error = error {
629+
Task { @MainActor in
630+
self.showErrorAlert(error.localizedDescription)
631+
}
632+
}
633+
}
634+
}
635+
636+
@available(macOS 15, *)
637+
@objc func disconnectUsbDevice(sender: AnyObject) {
638+
guard let menu = sender as? NSMenuItem else {
639+
logger.error("wrong sender for disconnectUsbDevice")
640+
return
641+
}
642+
guard let usbManager = appleVM.usbManager else {
643+
return
644+
}
645+
let device = allUsbDevices[menu.tag] as! UTMIOUSBHostDevice
646+
usbManager.disconnectUsbDevice(device, to: appleVM.apple!) { error in
647+
if let error = error {
648+
Task { @MainActor in
649+
self.showErrorAlert(error.localizedDescription)
650+
}
651+
}
652+
}
653+
}
654+
}

Services/Swift-Bridging-Header.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
#include "VMKeyboardButton.h"
5151
#include "VMKeyboardView.h"
5252
#elif TARGET_OS_OSX
53+
#include "UTMIOUSBHostDevice.h"
54+
#include "UTMIOUSBHostManager.h"
55+
#include "UTMIOUSBHostManagerDelegate.h"
5356
typedef uint32_t CGSConnectionID;
5457
typedef CF_ENUM(uint32_t, CGSGlobalHotKeyOperatingMode) {
5558
kCGSGlobalHotKeyOperatingModeEnable = 0,

Services/UTMAppleVirtualMachine.swift

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
118118

119119
private var removableDrives: [String: Any] = [:]
120120

121+
private(set) var usbManager: UTMIOUSBHostManager?
122+
121123
@MainActor var isHeadless: Bool {
122124
config.displays.isEmpty && config.serials.filter({ $0.mode == .builtin }).isEmpty
123125
}
@@ -131,6 +133,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
131133
self.registryEntry = UTMRegistryEntry.empty
132134
self.registryEntry = loadRegistry()
133135
self.screenshot = loadScreenshot()
136+
if #available(macOS 15, *) {
137+
usbManager = UTMIOUSBHostManager(virtualMachineQueue: vmQueue)
138+
}
134139
}
135140

136141
deinit {
@@ -187,14 +192,15 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
187192
do {
188193
let isSuspended = await registryEntry.isSuspended
189194
try await beginAccessingResources()
190-
try await createAppleVM()
195+
try await createAppleVM(ignoringUsbErrors: !isSuspended)
191196
if isSuspended && !options.contains(.bootRecovery) {
192197
try await restoreSnapshot()
193198
} else {
194199
try await _start(options: options)
195200
}
196-
if #available(macOS 15, *) {
201+
if #available(macOS 15, *), let usbManager = usbManager {
197202
try await attachExternalDrives()
203+
usbManager.synchronize(with: apple!)
198204
}
199205
if #available(macOS 12, *) {
200206
Task { @MainActor in
@@ -397,6 +403,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
397403
state = .paused
398404
}
399405
try await _saveSnapshot(url: vmSavedStateURL)
406+
if #available(macOS 15, *) {
407+
await saveUsbDevices()
408+
}
400409
await registryEntry.setIsSuspended(true)
401410
#endif
402411
}
@@ -498,7 +507,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
498507
screenshot = loadScreenshot()
499508
}
500509

501-
@MainActor private func createAppleVM() throws {
510+
@MainActor private func createAppleVM(ignoringUsbErrors: Bool = true) async throws {
502511
for i in config.serials.indices {
503512
let (fd, sfd, name) = try createPty()
504513
let terminalTtyHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
@@ -509,6 +518,15 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
509518
config.serials[i].interface = serialPort
510519
}
511520
let vzConfig = try config.appleVZConfiguration()
521+
if #available(macOS 15, *) {
522+
do {
523+
try await restoreUsbDevices(to: vzConfig)
524+
} catch {
525+
if !ignoringUsbErrors {
526+
throw error
527+
}
528+
}
529+
}
512530
vmQueue.async { [self] in
513531
apple = VZVirtualMachine(configuration: vzConfig, queue: vmQueue)
514532
apple!.delegate = self
@@ -667,6 +685,12 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
667685
vmQueue.async { [self] in
668686
apple = nil
669687
snapshotUnsupportedError = nil
688+
Task { @MainActor in
689+
if #available(macOS 15, *), let usbManager = usbManager {
690+
saveUsbDevices()
691+
usbManager.synchronize()
692+
}
693+
}
670694
}
671695
removableDrives.removeAll()
672696
sharedDirectoriesChanged = nil
@@ -1017,3 +1041,45 @@ extension UTMAppleVirtualMachine {
10171041
}
10181042
}
10191043
}
1044+
1045+
// MARK: - USB device passthrough
1046+
1047+
@available(macOS 15, *)
1048+
extension UTMAppleVirtualMachine {
1049+
@MainActor func saveUsbDevices() {
1050+
guard let usbManager = usbManager else {
1051+
return
1052+
}
1053+
let connectedDevices = usbManager.connectedDevices
1054+
if connectedDevices.isEmpty {
1055+
registryEntry.connectedUsbDevices = nil
1056+
} else {
1057+
do {
1058+
registryEntry.connectedUsbDevices = try NSKeyedArchiver.archivedData(withRootObject: connectedDevices, requiringSecureCoding: true)
1059+
} catch {
1060+
logger.error("Failed to archive USB devices: \(error)")
1061+
}
1062+
}
1063+
}
1064+
1065+
@MainActor func restoreUsbDevices(to config: VZVirtualMachineConfiguration) async throws {
1066+
guard let usbManager = usbManager else {
1067+
return
1068+
}
1069+
guard let data = registryEntry.connectedUsbDevices else {
1070+
return
1071+
}
1072+
// delete the list from registryEntry to prevent reuse
1073+
registryEntry.connectedUsbDevices = nil
1074+
let classes = [NSArray.self, NSUUID.self, UTMIOUSBHostDevice.self]
1075+
if let connectedDevices = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: classes, from: data) as? [UTMIOUSBHostDevice] {
1076+
for device in connectedDevices {
1077+
guard let uuid = device.uuid else {
1078+
continue
1079+
}
1080+
try await usbManager.restoreUsbDevice(device, to: config)
1081+
}
1082+
}
1083+
}
1084+
}
1085+

Services/UTMIOUSBHostDevice.h

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// Copyright © 2026 Turing Software, LLC. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
#import <Foundation/Foundation.h>
18+
19+
NS_ASSUME_NONNULL_BEGIN
20+
21+
API_AVAILABLE(macos(15.0))
22+
@interface UTMIOUSBHostDevice : NSObject <NSSecureCoding, NSCopying>
23+
24+
/// A user-readable description of the device
25+
@property (nonatomic, nullable, readonly) NSString *name;
26+
27+
/// USB manufacturer if available
28+
@property (nonatomic, nullable, readonly) NSString *usbManufacturerName;
29+
30+
/// USB product if available
31+
@property (nonatomic, nullable, readonly) NSString *usbProductName;
32+
33+
/// USB device serial if available
34+
@property (nonatomic, nullable, readonly) NSString *usbSerial;
35+
36+
/// USB vendor ID
37+
@property (nonatomic, readonly) NSInteger usbVendorId;
38+
39+
/// USB product ID
40+
@property (nonatomic, readonly) NSInteger usbProductId;
41+
42+
/// USB bus number
43+
@property (nonatomic, readonly) NSInteger usbBusNumber;
44+
45+
/// USB port number
46+
@property (nonatomic, readonly) NSInteger usbPortNumber;
47+
48+
/// USB device signature
49+
@property (nonatomic, nullable, readonly) NSData *usbSignature;
50+
51+
/// Unique identifier for this device (used for restoring)
52+
@property (nonatomic, nullable, readonly) NSUUID *uuid;
53+
54+
/// Is the device currently connected to a guest?
55+
@property (nonatomic, readonly) BOOL isCaptured;
56+
57+
/// IOService corrosponding to this device
58+
@property (nonatomic, readonly) io_service_t ioService;
59+
60+
+ (instancetype)new NS_UNAVAILABLE;
61+
- (instancetype)init NS_UNAVAILABLE;
62+
63+
/// Create a new USB device from an IOService handle
64+
/// - Parameter service: IOService handle
65+
- (instancetype)initWithService:(io_service_t)service NS_SWIFT_UNAVAILABLE("Create from UTMIOUSBHostManager.");
66+
67+
@end
68+
69+
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)