From f8ce0f9acb2ec31da54fac019f614ed5d3aa70ec Mon Sep 17 00:00:00 2001 From: Yibo Zhuang Date: Wed, 23 Sep 2026 12:14:39 -0700 Subject: [PATCH] Add an option to disable USB accessories (#1338) * Add an option to disable USB accessories * Select USB accessories in platform input factories --- Sources/tart/Commands/Run.swift | 4 + Sources/tart/Platform/Darwin.swift | 26 +-- Sources/tart/Platform/Linux.swift | 12 +- Sources/tart/Platform/Platform.swift | 10 +- Sources/tart/VM.swift | 56 ++++--- Tests/TartTests/CommandBehaviorTests.swift | 16 ++ .../InputDeviceConfigurationTests.swift | 152 ++++++++++++++++++ 7 files changed, 234 insertions(+), 42 deletions(-) create mode 100644 Tests/TartTests/InputDeviceConfigurationTests.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 8fb8249..13535b3 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -90,6 +90,9 @@ struct Run: AsyncParsableCommand { @Flag(help: "Disable audio pass-through to host.") var noAudio: Bool = false + @Flag(help: "Disable USB accessories.") + var noUSBAccessories: Bool = false + @Flag(help: ArgumentHelp( "Disable clipboard sharing between host and guest.", discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS.")) @@ -470,6 +473,7 @@ struct Run: AsyncParsableCommand { nested: nested, audio: !noAudio, clipboard: !noClipboard, + noUSBAccessories: noUSBAccessories, sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw), caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw), noTrackpad: noTrackpad, diff --git a/Sources/tart/Platform/Darwin.swift b/Sources/tart/Platform/Darwin.swift index dcdca09..2a3d87c 100644 --- a/Sources/tart/Platform/Darwin.swift +++ b/Sources/tart/Platform/Darwin.swift @@ -104,40 +104,42 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible { return result } - func keyboards() -> [VZKeyboardConfiguration] { + func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] { + var devices: [VZKeyboardConfiguration] = noUSB ? [] : [VZUSBKeyboardConfiguration()] if #available(macOS 14, *) { // Mac keyboard is only supported by guests starting with macOS Ventura - return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()] - } else { - return [VZUSBKeyboardConfiguration()] + devices.append(VZMacKeyboardConfiguration()) } + return devices } - func keyboardsSuspendable() -> [VZKeyboardConfiguration] { + func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] { if #available(macOS 14, *) { return [VZMacKeyboardConfiguration()] } else { // fallback to the regular configuration - return keyboards() + return keyboards(noUSB: noUSB) } } - func pointingDevices() -> [VZPointingDeviceConfiguration] { + func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] { // Trackpad is only supported by guests starting with macOS Ventura - [VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()] + var devices: [VZPointingDeviceConfiguration] = noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()] + devices.append(VZMacTrackpadConfiguration()) + return devices } - func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] { + func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] { // Only include the USB pointing device, not the trackpad - return [VZUSBScreenCoordinatePointingDeviceConfiguration()] + return noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()] } - func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] { + func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] { if #available(macOS 14, *) { return [VZMacTrackpadConfiguration()] } else { // fallback to the regular configuration - return pointingDevices() + return pointingDevices(noUSB: noUSB) } } } diff --git a/Sources/tart/Platform/Linux.swift b/Sources/tart/Platform/Linux.swift index 4636710..10772c9 100644 --- a/Sources/tart/Platform/Linux.swift +++ b/Sources/tart/Platform/Linux.swift @@ -35,16 +35,16 @@ struct Linux: Platform { return result } - func keyboards() -> [VZKeyboardConfiguration] { - [VZUSBKeyboardConfiguration()] + func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] { + noUSB ? [] : [VZUSBKeyboardConfiguration()] } - func pointingDevices() -> [VZPointingDeviceConfiguration] { - [VZUSBScreenCoordinatePointingDeviceConfiguration()] + func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] { + noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()] } - func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] { + func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] { // Linux doesn't support trackpad, so just return the regular pointing devices - return pointingDevices() + return pointingDevices(noUSB: noUSB) } } diff --git a/Sources/tart/Platform/Platform.swift b/Sources/tart/Platform/Platform.swift index f610fe2..c863596 100644 --- a/Sources/tart/Platform/Platform.swift +++ b/Sources/tart/Platform/Platform.swift @@ -5,12 +5,12 @@ protocol Platform: Codable { func bootLoader(nvramURL: URL) throws -> VZBootLoader func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration - func keyboards() -> [VZKeyboardConfiguration] - func pointingDevices() -> [VZPointingDeviceConfiguration] - func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] + func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] + func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] + func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] } protocol PlatformSuspendable: Platform { - func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] - func keyboardsSuspendable() -> [VZKeyboardConfiguration] + func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] + func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] } diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index a2fb948..91a952e 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -49,6 +49,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { nested: Bool = false, audio: Bool = true, clipboard: Bool = true, + noUSBAccessories: Bool = false, sync: VZDiskImageSynchronizationMode = .full, caching: VZDiskImageCachingMode? = nil, noTrackpad: Bool = false, @@ -73,6 +74,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { nested: nested, audio: audio, clipboard: clipboard, + noUSBAccessories: noUSBAccessories, sync: sync, caching: caching, noTrackpad: noTrackpad, @@ -324,6 +326,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { nested: Bool = false, audio: Bool = true, clipboard: Bool = true, + noUSBAccessories: Bool = false, sync: VZDiskImageSynchronizationMode = .full, caching: VZDiskImageCachingMode? = nil, noTrackpad: Bool = false, @@ -364,25 +367,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { configuration.audioDevices = [soundDeviceConfiguration] // Keyboard and mouse - if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable { - configuration.keyboards = platformSuspendable.keyboardsSuspendable() - configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable() - } else { - - if noKeyboard { - configuration.keyboards = [] - } else { - configuration.keyboards = vmConfig.platform.keyboards() - } - - if noPointer { - configuration.pointingDevices = [] - } else if noTrackpad { - configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified() - } else { - configuration.pointingDevices = vmConfig.platform.pointingDevices() - } - } + configureInputDevices( + configuration, + platform: vmConfig.platform, + suspendable: suspendable, + noUSBAccessories: noUSBAccessories, + noTrackpad: noTrackpad, + noPointer: noPointer, + noKeyboard: noKeyboard + ) // Networking configuration.networkDevices = network.attachments().map { @@ -460,6 +453,31 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { return configuration } + static func configureInputDevices( + _ configuration: VZVirtualMachineConfiguration, + platform: Platform, + suspendable: Bool = false, + noUSBAccessories: Bool = false, + noTrackpad: Bool = false, + noPointer: Bool = false, + noKeyboard: Bool = false + ) { + if suspendable, let platformSuspendable = platform as? PlatformSuspendable { + configuration.keyboards = platformSuspendable.keyboardsSuspendable(noUSB: noUSBAccessories) + configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable(noUSB: noUSBAccessories) + } else { + configuration.keyboards = noKeyboard ? [] : platform.keyboards(noUSB: noUSBAccessories) + + if noPointer { + configuration.pointingDevices = [] + } else if noTrackpad { + configuration.pointingDevices = platform.pointingDevicesSimplified(noUSB: noUSBAccessories) + } else { + configuration.pointingDevices = platform.pointingDevices(noUSB: noUSBAccessories) + } + } + } + func guestDidStop(_ virtualMachine: VZVirtualMachine) { print("guest has stopped the virtual machine") sema.signal() diff --git a/Tests/TartTests/CommandBehaviorTests.swift b/Tests/TartTests/CommandBehaviorTests.swift index 8c31362..da2ce98 100644 --- a/Tests/TartTests/CommandBehaviorTests.swift +++ b/Tests/TartTests/CommandBehaviorTests.swift @@ -4,6 +4,22 @@ import XCTest @testable import tart final class CommandBehaviorTests: XCTestCase { + func testNoUSBAccessoriesDoesNotEnableSuspendable() throws { + try withTemporaryTartHome { + let vmDir = try VMStorageLocal().create("no-usb-accessories") + try config().save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data())) + + let command = try Run.parseAsRoot(["no-usb-accessories", "--no-usb-accessories"]) as! Run + + XCTAssertTrue(command.noUSBAccessories) + XCTAssertFalse(command.suspendable) + XCTAssertFalse(command.noAudio) + XCTAssertFalse(command.noGraphics) + } + } + func testStandaloneDeleteDoesNotInitializeContentStore() throws { try withTemporaryTartHome { let vmDir = try VMStorageLocal().create("standalone") diff --git a/Tests/TartTests/InputDeviceConfigurationTests.swift b/Tests/TartTests/InputDeviceConfigurationTests.swift new file mode 100644 index 0000000..94a2486 --- /dev/null +++ b/Tests/TartTests/InputDeviceConfigurationTests.swift @@ -0,0 +1,152 @@ +import Virtualization +import XCTest +@testable import tart + +final class InputDeviceConfigurationTests: XCTestCase { + func testLinuxUSBInputsCanBeDisabled() { + let configuration = VZVirtualMachineConfiguration() + + VM.configureInputDevices(configuration, platform: Linux()) + XCTAssertEqual(configuration.keyboards.count, 1) + XCTAssertTrue(configuration.keyboards.contains { $0 is VZUSBKeyboardConfiguration }) + XCTAssertEqual(configuration.pointingDevices.count, 1) + XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZUSBScreenCoordinatePointingDeviceConfiguration }) + + VM.configureInputDevices(configuration, platform: Linux(), noUSBAccessories: true) + XCTAssertTrue(configuration.keyboards.isEmpty) + XCTAssertTrue(configuration.pointingDevices.isEmpty) + + VM.configureInputDevices(configuration, platform: Linux(), noUSBAccessories: true, noTrackpad: true) + XCTAssertTrue(configuration.keyboards.isEmpty) + XCTAssertTrue(configuration.pointingDevices.isEmpty) + } + + #if arch(arm64) + func testMacOS13RetainsItsNativeTrackpad() { + let platform = MacInputPlatform(nativeKeyboard: false) + let configuration = VZVirtualMachineConfiguration() + + VM.configureInputDevices(configuration, platform: platform) + XCTAssertEqual(configuration.keyboards.count, 1) + XCTAssertEqual(configuration.pointingDevices.count, 2) + + VM.configureInputDevices(configuration, platform: platform, noUSBAccessories: true) + XCTAssertTrue(configuration.keyboards.isEmpty) + XCTAssertEqual(configuration.pointingDevices.count, 1) + XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration }) + } + + func testMacOS14RetainsBothNativeInputs() throws { + guard #available(macOS 14, *) else { + throw XCTSkip("Mac keyboards require macOS 14") + } + + let configuration = VZVirtualMachineConfiguration() + VM.configureInputDevices(configuration, platform: MacInputPlatform(nativeKeyboard: true), noUSBAccessories: true) + + XCTAssertEqual(configuration.keyboards.count, 1) + XCTAssertTrue(configuration.keyboards.contains { $0 is VZMacKeyboardConfiguration }) + XCTAssertEqual(configuration.pointingDevices.count, 1) + XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration }) + } + + func testInputFlagsStillSelectTheExpectedDevices() throws { + guard #available(macOS 14, *) else { + throw XCTSkip("Mac keyboards require macOS 14") + } + + let platform = MacInputPlatform(nativeKeyboard: true) + for noUSBAccessories in [false, true] { + for noKeyboard in [false, true] { + for noPointer in [false, true] { + for noTrackpad in [false, true] { + let configuration = VZVirtualMachineConfiguration() + VM.configureInputDevices( + configuration, + platform: platform, + noUSBAccessories: noUSBAccessories, + noTrackpad: noTrackpad, + noPointer: noPointer, + noKeyboard: noKeyboard + ) + + XCTAssertEqual(configuration.keyboards.contains { $0 is VZUSBKeyboardConfiguration }, !noUSBAccessories && !noKeyboard) + XCTAssertEqual(configuration.keyboards.contains { $0 is VZMacKeyboardConfiguration }, !noKeyboard) + XCTAssertEqual(configuration.pointingDevices.contains { $0 is VZUSBScreenCoordinatePointingDeviceConfiguration }, !noUSBAccessories && !noPointer) + XCTAssertEqual(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration }, !noPointer && !noTrackpad) + } + } + } + } + } + + func testSuspendableFallbackCannotReintroduceUSBInputs() { + let configuration = VZVirtualMachineConfiguration() + let platform = MacInputPlatform(nativeKeyboard: false) + VM.configureInputDevices(configuration, platform: platform, suspendable: true) + XCTAssertEqual(configuration.keyboards.count, 1) + XCTAssertEqual(configuration.pointingDevices.count, 2) + + VM.configureInputDevices( + configuration, + platform: platform, + suspendable: true, + noUSBAccessories: true + ) + + XCTAssertTrue(configuration.keyboards.isEmpty) + XCTAssertEqual(configuration.pointingDevices.count, 1) + XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration }) + } + #endif +} + +#if arch(arm64) + // Model macOS 13 and 14 input availability without requiring a second host. + private struct MacInputPlatform: PlatformSuspendable { + var nativeKeyboard: Bool + + func os() -> OS { .darwin } + + func bootLoader(nvramURL: URL) throws -> VZBootLoader { + try Linux().bootLoader(nvramURL: nvramURL) + } + + func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration { + try Linux().platform(nvramURL: nvramURL, needsNestedVirtualization: needsNestedVirtualization) + } + + func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration { + Linux().graphicsDevice(vmConfig: vmConfig) + } + + func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] { + var devices: [VZKeyboardConfiguration] = noUSB ? [] : [VZUSBKeyboardConfiguration()] + if nativeKeyboard, #available(macOS 14, *) { + devices.append(VZMacKeyboardConfiguration()) + } + return devices + } + + func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] { + var devices: [VZPointingDeviceConfiguration] = noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()] + devices.append(VZMacTrackpadConfiguration()) + return devices + } + + func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] { + noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()] + } + + func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] { + if nativeKeyboard, #available(macOS 14, *) { + return [VZMacKeyboardConfiguration()] + } + return keyboards(noUSB: noUSB) + } + + func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] { + nativeKeyboard ? [VZMacTrackpadConfiguration()] : pointingDevices(noUSB: noUSB) + } + } +#endif