Skip to content
Open
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
145 changes: 145 additions & 0 deletions Sources/Containerization/USBPassthrough.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// 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
//
// https://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.
//===----------------------------------------------------------------------===//

// AccessoryAccess and `VZUSBPassthroughDevice` need the macOS 27 SDK; older SDKs skip this file.
#if os(macOS) && canImport(AccessoryAccess)
import AccessoryAccess
import ContainerizationError
import ContainerizationExtras
import Foundation
@preconcurrency import Virtualization

/// Passes host USB devices through to the guest over an emulated XHCI controller.
///
/// Add to `VMConfiguration.extensions` or `LinuxPod.Configuration.extensions`.
/// The controller is always added, so `accessories` can be empty and devices
/// attached later with ``VZVirtualMachineInstance/attachUSBDevice(_:)``.
///
/// Accessories come from `AAUSBAccessoryManager`, which requires the
/// `com.apple.developer.accessory-access.usb` entitlement and an app in the Dock.
/// A helper can receive them over XPC via `AAUSBAccessory(xpcRepresentation:)`.
///
/// Requires a guest kernel with `CONFIG_USB_XHCI_PCI`. Devices appear under
/// `/dev/bus/usb` in the guest and in `vmexec` containers, but not in containers
/// using `ociRuntimePath`, which get a tmpfs `/dev`.
@available(macOS 27, *)
public struct USBPassthrough: VZInstanceExtension {
/// Accessories to capture at VM start.
public var accessories: [AAUSBAccessory]

public init(accessories: [AAUSBAccessory] = []) {
self.accessories = accessories
}

public func configureVZ(
_ config: inout VZVirtualMachineConfiguration,
allocator: any AddressAllocator<Character>,
storageDeviceCount: Int,
mountsByID: [String: [Mount]]
) throws {
let devices = accessories.map { VZUSBPassthroughDeviceConfiguration(device: $0) }
if let controller = config.usbControllers.first(where: { $0 is VZXHCIControllerConfiguration }) {
controller.usbDevices.append(contentsOf: devices)
return
}
let controller = VZXHCIControllerConfiguration()
controller.usbDevices = devices
config.usbControllers.append(controller)
}
}

@available(macOS 27, *)
extension VZVirtualMachineInstance {
/// Captures `accessory` and attaches it to the running VM, which must use ``USBPassthrough``.
/// - Returns: The device UUID, for ``detachUSBDevice(_:)``.
public func attachUSBDevice(_ accessory: AAUSBAccessory) async throws -> UUID {
try await withInstanceLock {
guard self.state == .running else {
throw ContainerizationError(.invalidState, message: "vm is not running")
}
return try await self.vzVirtualMachine.attachUSB(queue: self.vmQueue, accessory: accessory)
}
}

/// Detaches a device attached at boot or with ``attachUSBDevice(_:)``.
public func detachUSBDevice(_ uuid: UUID) async throws {
try await withInstanceLock {
try await self.vzVirtualMachine.detachUSB(queue: self.vmQueue, uuid: uuid)
}
}
}

extension VZVirtualMachine {
@available(macOS 27, *)
func attachUSB(queue: DispatchQueue, accessory: AAUSBAccessory) async throws -> UUID {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<UUID, Error>) in
queue.sync {
guard let controller = self.usbControllers.first else {
cont.resume(
throwing: ContainerizationError(
.invalidState,
message: "vm has no USB controller; add USBPassthrough to the VM extensions"
)
)
return
}
let device: VZUSBPassthroughDevice
do {
device = try VZUSBPassthroughDevice(configuration: VZUSBPassthroughDeviceConfiguration(device: accessory))
} catch {
cont.resume(
throwing: ContainerizationError(.internalError, message: "failed to capture USB accessory", cause: error)
)
return
}
let uuid = device.uuid
controller.attach(device: device) { error in
if let error {
cont.resume(
throwing: ContainerizationError(.internalError, message: "failed to attach USB device", cause: error)
)
return
}
cont.resume(returning: uuid)
}
}
}
}

func detachUSB(queue: DispatchQueue, uuid: UUID) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
queue.sync {
for controller in self.usbControllers {
guard let device = controller.usbDevices.first(where: { $0.uuid == uuid }) else {
continue
}
controller.detach(device: device) { error in
if let error {
cont.resume(
throwing: ContainerizationError(.internalError, message: "failed to detach USB device", cause: error)
)
return
}
cont.resume()
}
return
}
cont.resume(throwing: ContainerizationError(.notFound, message: "no USB device \(uuid) attached"))
}
}
}
}
#endif
62 changes: 62 additions & 0 deletions Tests/ContainerizationTests/USBPassthroughTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// 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
//
// https://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.
//===----------------------------------------------------------------------===//

#if os(macOS) && canImport(AccessoryAccess)
import ContainerizationExtras
import Testing
import Virtualization

@testable import Containerization

// `AAUSBAccessory` needs a physical device, so these only cover controller setup.
struct USBPassthroughTests {
@available(macOS 27, *)
private func configure(_ config: inout VZVirtualMachineConfiguration, with ext: USBPassthrough) throws {
try ext.configureVZ(&config, allocator: Character.blockDeviceTagAllocator(), storageDeviceCount: 0, mountsByID: [:])
}

@Test func addsXHCIController() throws {
guard #available(macOS 27, *) else { return }
var config = VZVirtualMachineConfiguration()
try configure(&config, with: USBPassthrough())

#expect(config.usbControllers.count == 1)
let controller = try #require(config.usbControllers.first as? VZXHCIControllerConfiguration)
#expect(controller.usbDevices.isEmpty)
}

@Test func reusesExistingXHCIController() throws {
guard #available(macOS 27, *) else { return }
var config = VZVirtualMachineConfiguration()
let existing = VZXHCIControllerConfiguration()
config.usbControllers = [existing]

try configure(&config, with: USBPassthrough())

#expect(config.usbControllers.count == 1)
#expect(config.usbControllers.first === existing)
}

@Test func multipleExtensionsShareOneController() throws {
guard #available(macOS 27, *) else { return }
var config = VZVirtualMachineConfiguration()
try configure(&config, with: USBPassthrough())
try configure(&config, with: USBPassthrough())

#expect(config.usbControllers.count == 1)
}
}
#endif
6 changes: 6 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ A basic example of launching a Linux container using Containerization. This exam
- Basic container management operations

See the [ctr-example README](ctr-example/README.md) for detailed build and run instructions.

### [usb-passthrough](usb-passthrough/)

Passes host USB devices through to a Linux guest with `USBPassthrough` and checks that the guest sees them. Requires macOS 27, a physical USB device, and an Apple Developer provisioning profile.

See the [usb-passthrough README](usb-passthrough/README.md).
20 changes: 20 additions & 0 deletions examples/usb-passthrough/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>usb-passthrough</string>
<key>CFBundleIdentifier</key>
<string>BUNDLE_ID</string>
<key>CFBundleName</key>
<string>USB Passthrough</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>27.0</string>
</dict>
</plist>
65 changes: 65 additions & 0 deletions examples/usb-passthrough/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright © 2026 Apple Inc. and the Containerization project authors.
#
# 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
#
# https://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.

BUILD_CONFIGURATION ?= debug
SWIFT = /usr/bin/swift
BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path)

# `-` signs ad hoc. The USB entitlement needs SIGN_IDENTITY, PROVISIONING_PROFILE and TEAM_ID.
SIGN_IDENTITY ?= -
PROVISIONING_PROFILE ?=
# Adds the application-identifier and team-identifier entitlements the profile requires.
TEAM_ID ?=
BUNDLE_ID ?= com.example.containerization.usb-passthrough
ENTITLEMENTS ?= usb-passthrough.entitlements

# Needs a USB-enabled kernel from `make -C ../../kernel`; the kata kernel won't work.
KERNEL ?= ../../bin/vmlinux-arm64
INITFS ?= ../../bin/initfs.ext4

APP = bin/USBPassthrough.app

.PHONY: all build run entitlements clean fmt

all: build

build:
$(SWIFT) build -c $(BUILD_CONFIGURATION)
@rm -rf $(APP)
@mkdir -p $(APP)/Contents/MacOS
@install "$(BUILD_BIN_DIR)/usb-passthrough" $(APP)/Contents/MacOS/
@sed 's/BUNDLE_ID/$(BUNDLE_ID)/' Info.plist > $(APP)/Contents/Info.plist
$(if $(PROVISIONING_PROFILE),@cp "$(PROVISIONING_PROFILE)" $(APP)/Contents/embedded.provisionprofile)
@cp $(ENTITLEMENTS) bin/entitlements.plist
$(if $(TEAM_ID),@/usr/libexec/PlistBuddy \
-c "Add :com.apple.application-identifier string $(TEAM_ID).$(BUNDLE_ID)" \
-c "Add :com.apple.developer.team-identifier string $(TEAM_ID)" \
bin/entitlements.plist)
codesign --force --sign "$(SIGN_IDENTITY)" --entitlements bin/entitlements.plist $(APP)

# Run the executable directly to keep output in this terminal.
run: build
$(APP)/Contents/MacOS/usb-passthrough --kernel "$(KERNEL)" --initfs "$(INITFS)"

# Show the signature and embedded entitlements.
entitlements:
codesign -dvv $(APP)
codesign -d --entitlements - $(APP)

clean:
$(SWIFT) package clean
rm -rf bin

fmt:
$(SWIFT) format --in-place --recursive Sources/
Loading