Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 159 additions & 1 deletion Platform/macOS/Display/VMDisplayAppleWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
}
}
}
}
3 changes: 3 additions & 0 deletions Services/Swift-Bridging-Header.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 69 additions & 3 deletions Services/UTMAppleVirtualMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
}

69 changes: 69 additions & 0 deletions Services/UTMIOUSBHostDevice.h
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

API_AVAILABLE(macos(15.0))
@interface UTMIOUSBHostDevice : NSObject <NSSecureCoding, NSCopying>

/// 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
Loading
Loading