diff --git a/Sources/Containerization/USBPassthrough.swift b/Sources/Containerization/USBPassthrough.swift new file mode 100644 index 000000000..7e8ffc4a5 --- /dev/null +++ b/Sources/Containerization/USBPassthrough.swift @@ -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, + 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) 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) 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 diff --git a/Tests/ContainerizationTests/USBPassthroughTests.swift b/Tests/ContainerizationTests/USBPassthroughTests.swift new file mode 100644 index 000000000..2d980f902 --- /dev/null +++ b/Tests/ContainerizationTests/USBPassthroughTests.swift @@ -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 diff --git a/examples/README.md b/examples/README.md index bc18a2a4f..132357123 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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). diff --git a/examples/usb-passthrough/Info.plist b/examples/usb-passthrough/Info.plist new file mode 100644 index 000000000..b137966f8 --- /dev/null +++ b/examples/usb-passthrough/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleExecutable + usb-passthrough + CFBundleIdentifier + BUNDLE_ID + CFBundleName + USB Passthrough + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 27.0 + + diff --git a/examples/usb-passthrough/Makefile b/examples/usb-passthrough/Makefile new file mode 100644 index 000000000..d69ee5ba7 --- /dev/null +++ b/examples/usb-passthrough/Makefile @@ -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/ diff --git a/examples/usb-passthrough/Package.resolved b/examples/usb-passthrough/Package.resolved new file mode 100644 index 000000000..922b5c3f3 --- /dev/null +++ b/examples/usb-passthrough/Package.resolved @@ -0,0 +1,258 @@ +{ + "originHash" : "9eacee03eb49c2b5da826e7e449faf28fcf024c2b9ba7ddced9473441074dc68", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "f95c908967e98c68c5ce3fd61a7974e7e869e303", + "version" : "1.36.1" + } + }, + { + "identity" : "grpc-swift-2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-2.git", + "state" : { + "revision" : "ac33066eb6edb1a21a6ca172ea8184a9b06f3cc7", + "version" : "2.4.3" + } + }, + { + "identity" : "grpc-swift-nio-transport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", + "state" : { + "revision" : "ff4420d7c33cc998a590b0761630d67f76bc291e", + "version" : "2.10.0" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "176c5a434fd76f6f479848d1a8f7d44967534168", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "789dcf1f3d3f00251482f40a432ab7144c181987", + "version" : "1.1.6" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a66de878e87ef5a3d5d390e0f6d9002aa5541a43", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "3533f65d3e36dcdffc91ce34ef4d3c9c1887fd4b", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", + "version" : "4.5.2" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "cc504a45f6ce73ce6067837d7ac19fa67b229a56", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", + "version" : "1.35.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", + "version" : "1.46.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", + "version" : "2.12.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + }, + { + "identity" : "zstd", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/zstd.git", + "state" : { + "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99", + "version" : "1.5.7" + } + } + ], + "version" : 3 +} diff --git a/examples/usb-passthrough/Package.swift b/examples/usb-passthrough/Package.swift new file mode 100644 index 000000000..8425cfc0a --- /dev/null +++ b/examples/usb-passthrough/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.2 +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +import PackageDescription + +let package = Package( + name: "usb-passthrough", + platforms: [ + .macOS("27.0") + ], + products: [ + .executable( + name: "usb-passthrough", + targets: ["usb-passthrough"] + ) + ], + dependencies: [ + // USBPassthrough is unreleased; use this checkout. + .package(path: "../.."), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + ], + targets: [ + .executableTarget( + name: "usb-passthrough", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ) + ] +) diff --git a/examples/usb-passthrough/README.md b/examples/usb-passthrough/README.md new file mode 100644 index 000000000..55e9ea17f --- /dev/null +++ b/examples/usb-passthrough/README.md @@ -0,0 +1,82 @@ +# USB Passthrough Example + +Boots a VM with `USBPassthrough`, attaches each USB device you grant it through Accessory Access, and checks that the guest enumerates it. + +## Requirements + +- macOS 27 and the macOS 27 SDK. +- A guest kernel built from `kernel/config-arm64` (`make -C ../../kernel`). The kernel from `make fetch-default-kernel` has no USB support. +- `bin/initfs.ext4` from `make init` at the repo root. The example builds against this checkout, so it needs a matching `vminitd`. +- An Apple Developer Program team and a provisioning profile (see below). +- A USB device to pass through. + +## Signing Setup + +Accessory Access requires the `com.apple.developer.accessory-access.usb` entitlement, which macOS only accepts when a provisioning profile allows it. Signed ad hoc or without a profile, the app is killed at launch. This is a one-time setup; a development profile lasts about a year. + +1. **Find your signing identity and Team ID.** + + ```bash + security find-identity -v -p codesigning + security find-certificate -c "Apple Development: Your Name" -p | openssl x509 -noout -subject + ``` + + Use the Apple Development identity as `SIGN_IDENTITY`. The Team ID is the `OU` in the certificate's subject, which isn't always the value in parentheses in the identity name. If no identity is listed, create one in Xcode under Settings → Accounts → Manage Certificates. + +2. **Register an App ID.** At [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list), add an explicit App ID with a bundle ID you control, such as `com.yourcompany.containerization.usb-passthrough`, and enable **Claim USB Accessory**. + +3. **Register this Mac.** Under Devices, add this Mac's Provisioning UDID: + + ```bash + system_profiler SPHardwareDataType | grep "Provisioning UDID" + ``` + +4. **Create the profile.** Under Profiles, create a **macOS App Development** profile with the App ID, your certificate, and this Mac, then download it. + +## Build and Run + +```bash +make run SIGN_IDENTITY="Apple Development: Your Name (XXXXXXXXXX)" \ + PROVISIONING_PROFILE=path/to/profile.provisionprofile \ + TEAM_ID=YOURTEAMID BUNDLE_ID=com.yourcompany.containerization.usb-passthrough +``` + +This builds `bin/USBPassthrough.app`, embeds the profile, signs it with `usb-passthrough.entitlements` plus the identifier entitlements `TEAM_ID` adds, and runs the bundle's executable directly so output stays in the terminal. Override the guest paths with `KERNEL=...` and `INITFS=...`. + +Once the VM is running, plug in a device and attach it to the app from the Accessory Access menu bar item. Output looks like this: + +``` +VM running. Boot log: /var/folders/.../usb-passthrough-example/boot.log +Accessory Access listener registered. +Attach a USB device to this app from the Accessory Access menu bar item. Ctrl-C to quit. +2341:0043: connected, attaching +2341:0043: attached as 8D1C... + sysfs: /sys/bus/usb/devices/1-1 (Arduino Uno) + node: /dev/bus/usb/001/002 +2341:0043: PASS, visible in guest +``` + +While attached, the device is unavailable to macOS. Detaching it from the menu bar item, or quitting with Ctrl-C, releases it. + +## Troubleshooting + +| What happens | Cause | +|---|---| +| `Killed: 9` at launch | The entitlement isn't allowed by an embedded profile: no profile, or one that doesn't match the signature | +| `Accessory Access refused this process` | The app was signed without the entitlement, e.g. `ENTITLEMENTS=../../signing/vz.entitlements` | +| `not found; build it with ...` | The kernel or initfs is missing | + +To compare the signature with the profile: + +```bash +make entitlements +security cms -D -i path/to/profile.provisionprofile | grep -A1 -E 'accessory-access|application-identifier' +log show --last 5m --predicate 'sender == "AppleMobileFileIntegrity" OR process == "amfid"' +``` + +The embedded entitlements must be allowed by the profile, and `com.apple.application-identifier` must match exactly. + +## Notes + +- `LinuxContainer` doesn't pass extensions to its VM, so this example uses `LinuxPod`. +- Accessory Access only works from an app that appears in the Dock, so the example runs as an app with a Dock icon. diff --git a/examples/usb-passthrough/Sources/usb-passthrough/USBPassthroughExample.swift b/examples/usb-passthrough/Sources/usb-passthrough/USBPassthroughExample.swift new file mode 100644 index 000000000..9b4caba80 --- /dev/null +++ b/examples/usb-passthrough/Sources/usb-passthrough/USBPassthroughExample.swift @@ -0,0 +1,262 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +import AccessoryAccess +import AppKit +import ArgumentParser +import Containerization +import ContainerizationError +import ContainerizationOCI +import ContainerizationOS +import Foundation +import Synchronization + +struct Options: ParsableArguments { + @Option(name: [.customLong("kernel"), .customShort("k")], help: "Kernel binary path, built from kernel/config-arm64", completion: .file(), transform: absolutePath) + var kernel: String = absolutePath("../../bin/vmlinux-arm64") + + @Option(name: .long, help: "initfs.ext4 path containing vminitd", completion: .file(), transform: absolutePath) + var initfs: String = absolutePath("../../bin/initfs.ext4") + + @Option(name: [.customLong("image"), .customShort("i")], help: "Image reference to base the container on") + var imageReference: String = "docker.io/library/alpine:3.20" +} + +private func absolutePath(_ path: String) -> String { + URL(fileURLWithPath: path, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) +} + +@main +struct USBPassthroughExample { + @MainActor + static func main() { + let options = Options.parseOrExit() + + // Accessory Access only works from apps that appear in the Dock. + let app = NSApplication.shared + app.setActivationPolicy(.regular) + + Task { + do { + try await run(options) + exit(0) + } catch { + print("error: \(error)") + exit(1) + } + } + app.run() + } + + static func run(_ options: Options) async throws { + for (path, hint) in [(options.kernel, "make -C kernel"), (options.initfs, "make init")] { + guard FileManager.default.fileExists(atPath: path) else { + throw ContainerizationError(.notFound, message: "\(path) not found; build it with `\(hint)` from the repo root") + } + } + + let workDir = FileManager.default.temporaryDirectory.appendingPathComponent("usb-passthrough-example") + try? FileManager.default.removeItem(at: workDir) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + + print("Pulling \(options.imageReference)...") + let image = try await ImageStore.default.get(reference: options.imageReference, pull: true) + let rootfs = try await EXT4Unpacker(capacityInBytes: 512.mib()) + .unpack(image, for: .current, at: workDir.appendingPathComponent("rootfs.ext4")) + + let vmm = VZVirtualMachineManager( + kernel: Kernel(path: URL(fileURLWithPath: options.kernel), platform: .linuxArm), + initialFilesystem: .block( + format: "ext4", + source: options.initfs, + destination: "/", + options: ["ro"] + ) + ) + let bootLog = workDir.appendingPathComponent("boot.log") + // LinuxContainer doesn't forward extensions, so use a pod. + let pod = try LinuxPod("usb-passthrough", vmm: vmm) { config in + config.extensions = [USBPassthrough()] + config.bootLog = .file(path: bootLog) + } + try await pod.addContainer("probe", rootfs: rootfs) { config in + config.process.arguments = ["/bin/sh", "-c", "while :; do sleep 3600; done"] + } + + print("Starting VM...") + try await pod.create() + do { + try await pod.startContainer("probe") + print("VM running. Boot log: \(bootLog.path)") + try await listen(pod) + } catch { + try? await pod.stop() + throw error + } + try await pod.stop() + } + + static func listen(_ pod: LinuxPod) async throws { + let listener = Listener(pod: pod) + let existing: [AAUSBAccessory] + do { + existing = try await AAUSBAccessoryManager.shared.registerListener(listener, matchingCriteria: []) + } catch let error as AAError where error.code == .internalError { + throw ContainerizationError( + .internalError, + message: """ + Accessory Access refused this process. The com.apple.developer.accessory-access.usb \ + entitlement is missing or wasn't honored for this signature. Run `make entitlements` \ + to see what was embedded. + """, + cause: error + ) + } + print("Accessory Access listener registered.") + for accessory in existing { + listener.usbAccessoryDidConnect(accessory) + } + + print("Attach a USB device to this app from the Accessory Access menu bar item. Ctrl-C to quit.") + let signals = AsyncSignalHandler.create(notify: [SIGINT]) + for await _ in signals.signals { + break + } + signals.cancel() + // A second Ctrl-C kills the process. + signal(SIGINT, SIG_DFL) + + print("Shutting down...") + await AAUSBAccessoryManager.shared.unregisterListener(listener) + } +} + +/// Attaches granted accessories to the VM and checks that the guest sees them. +final class Listener: NSObject, AAUSBAccessoryListener, Sendable { + private let pod: LinuxPod + private let attached = Mutex<[UInt64: UUID]>([:]) + private let probeCount = Atomic(0) + + init(pod: LinuxPod) { + self.pod = pod + } + + func usbAccessoryDidConnect(_ accessory: AAUSBAccessory) { + Task { await self.attach(accessory) } + } + + func usbAccessoryDidDisconnect(_ accessory: AAUSBAccessory) { + Task { await self.detach(accessory) } + } + + private func attach(_ accessory: AAUSBAccessory) async { + let id = DeviceID(accessory) + print("\(id): connected, attaching") + do { + let uuid = try await self.pod.withVirtualMachineInstance { vm in + guard let vz = vm as? VZVirtualMachineInstance else { + throw ContainerizationError(.unsupported, message: "USB passthrough requires the Virtualization.framework backend") + } + return try await vz.attachUSBDevice(accessory) + } + self.attached.withLock { $0[accessory.registryID] = uuid } + print("\(id): attached as \(uuid)") + try await self.probe(id) + } catch { + print("\(id): \(error)") + } + } + + private func detach(_ accessory: AAUSBAccessory) async { + let id = DeviceID(accessory) + guard let uuid = self.attached.withLock({ $0.removeValue(forKey: accessory.registryID) }) else { + return + } + do { + try await self.pod.withVirtualMachineInstance { vm in + try await (vm as? VZVirtualMachineInstance)?.detachUSBDevice(uuid) + } + print("\(id): detached") + } catch { + print("\(id): detach: \(error)") + } + } + + /// Waits up to 10s for the device in the guest's sysfs and /dev. + private static let probeScript = """ + for _ in $(seq 1 10); do + for d in /sys/bus/usb/devices/*; do + [ "$(cat "$d/idVendor" 2>/dev/null)" = "$1" ] || continue + [ "$(cat "$d/idProduct" 2>/dev/null)" = "$2" ] || continue + node=$(printf /dev/bus/usb/%03d/%03d "$(cat "$d/busnum")" "$(cat "$d/devnum")") + echo " sysfs: $d ($(cat "$d/product" 2>/dev/null))" + [ -e "$node" ] || { echo " missing $node"; exit 1; } + echo " node: $node" + exit 0 + done + sleep 1 + done + exit 1 + """ + + private func probe(_ id: DeviceID) async throws { + let n = self.probeCount.add(1, ordering: .relaxed).newValue + let process = try await self.pod.execInContainer("probe", processID: "probe-\(n)") { config in + config.arguments = ["/bin/sh", "-c", Self.probeScript, "probe", id.vendor, id.product] + config.stdout = StdoutWriter() + config.stderr = StdoutWriter() + } + try await process.start() + let status = try await process.wait() + try await process.delete() + print(status.exitCode == 0 ? "\(id): PASS, visible in guest" : "\(id): FAIL, not visible in guest") + } +} + +/// Vendor and product IDs from the device descriptor, formatted like sysfs. +struct DeviceID: Sendable, CustomStringConvertible { + let vendor: String + let product: String + + init(_ accessory: AAUSBAccessory) { + let data = accessory.deviceDescriptorData + // idVendor and idProduct are little-endian at offsets 8 and 10. + func word(_ offset: Int) -> String { + guard data.count >= offset + 2 else { + return "????" + } + let lo = UInt16(data[data.startIndex + offset]) + let hi = UInt16(data[data.startIndex + offset + 1]) + return String(format: "%04x", hi << 8 | lo) + } + self.vendor = word(8) + self.product = word(10) + } + + var description: String { + "\(self.vendor):\(self.product)" + } +} + +struct StdoutWriter: Writer { + func write(_ data: Data) throws { + try FileHandle.standardOutput.write(contentsOf: data) + } + + func close() throws { + return + } +} diff --git a/examples/usb-passthrough/usb-passthrough.entitlements b/examples/usb-passthrough/usb-passthrough.entitlements new file mode 100644 index 000000000..0f4c01a89 --- /dev/null +++ b/examples/usb-passthrough/usb-passthrough.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.virtualization + + com.apple.developer.accessory-access.usb + + + diff --git a/kernel/config-arm64 b/kernel/config-arm64 index 429535b9f..349bea865 100644 --- a/kernel/config-arm64 +++ b/kernel/config-arm64 @@ -2859,7 +2859,12 @@ CONFIG_HID_REDRAGON=y # end of HID support CONFIG_USB_OHCI_LITTLE_ENDIAN=y -# CONFIG_USB_SUPPORT is not set +CONFIG_USB_SUPPORT=y +CONFIG_USB=y +CONFIG_USB_PCI=y +CONFIG_USB_XHCI_HCD=y +CONFIG_USB_XHCI_PCI=y +CONFIG_USB_ACM=y # CONFIG_MMC is not set # CONFIG_MEMSTICK is not set # CONFIG_NEW_LEDS is not set