This commit is contained in:
Randolph Voorhies 2026-08-11 18:13:30 +02:00 committed by GitHub
commit 6169502404
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 141 additions and 4 deletions

View File

@ -66,10 +66,19 @@ struct Clone: AsyncParsableCommand {
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
let sourceState = try sourceVM.state()
let sourceConfig = try VMConfig(fromURL: sourceVM.configURL)
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceVM.state() != .Suspended
&& sourceState != .Suspended
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
if sourceState != .Suspended,
let linux = sourceConfig.platform as? Linux,
linux.machineIdentifier == nil {
try tmpVMDir.initializeLinuxMachineIdentifier()
}
try localStorage.move(newName, from: tmpVMDir)
try lock.unlock()

View File

@ -271,7 +271,9 @@ struct Run: AsyncParsableCommand {
var rootDiskOpts: String = ""
#if arch(arm64)
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
@Flag(
help: ArgumentHelp("Disables or replaces devices that do not support VM suspension, such as audio, entropy and some input devices.",
discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
#endif
var suspendable: Bool = false
@ -359,7 +361,11 @@ struct Run: AsyncParsableCommand {
if suspendable {
let config = try VMConfig.init(fromURL: vmDir.configURL)
if !(config.platform is PlatformSuspendable) {
throw ValidationError("You can only suspend macOS VMs")
throw ValidationError("This platform is not suspendable")
}
if let linux = config.platform as? Linux, linux.machineIdentifier == nil {
throw ValidationError("Linux VMs without a machine identifier cannot be suspended or resumed")
}
if noTrackpad {

View File

@ -1,7 +1,42 @@
import Virtualization
@available(macOS 13, *)
struct Linux: Platform {
struct Linux: PlatformSuspendable {
var machineIdentifier: VZGenericMachineIdentifier?
init(machineIdentifier: VZGenericMachineIdentifier = VZGenericMachineIdentifier()) {
self.machineIdentifier = machineIdentifier
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard let encodedMachineIdentifier = try container.decodeIfPresent(
String.self, forKey: .machineIdentifier
) else {
self.machineIdentifier = nil
return
}
guard let data = Data.init(base64Encoded: encodedMachineIdentifier) else {
throw DecodingError.dataCorruptedError(forKey: .machineIdentifier,
in: container,
debugDescription: "failed to initialize Data using the provided value")
}
guard let machineIdentifier = VZGenericMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .machineIdentifier,
in: container,
debugDescription: "failed to initialize VZGenericMachineIdentifier using the provided value")
}
self.machineIdentifier = machineIdentifier
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(machineIdentifier?.dataRepresentation.base64EncodedString(), forKey: .machineIdentifier)
}
func os() -> OS {
.linux
}
@ -16,6 +51,11 @@ struct Linux: Platform {
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
let config = VZGenericPlatformConfiguration()
if let machineIdentifier {
config.machineIdentifier = machineIdentifier
}
if #available(macOS 15, *) {
config.isNestedVirtualizationEnabled = needsNestedVirtualization
}
@ -47,4 +87,14 @@ struct Linux: Platform {
// Linux doesn't support trackpad, so just return the regular pointing devices
return pointingDevices()
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
// VZUSBScreenCoordinatePointingDeviceConfiguration passes save/restore
// validation, but causes restoring a Linux VM to fail with "invalid argument".
[]
}
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
keyboards()
}
}

View File

@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey {
// macOS-specific keys
case ecid
case hardwareModel
// Linux-specific keys
case machineIdentifier
}
struct VMDisplayConfig: Codable, Equatable {

View File

@ -198,6 +198,23 @@ struct VMDirectory: Prunable {
try vmConfig.save(toURL: configURL)
}
func initializeLinuxMachineIdentifier() throws {
var vmConfig = try VMConfig(fromURL: configURL)
guard var vmLinux = vmConfig.platform as? Linux else {
throw RuntimeError.VMConfigurationError("cannot initialize a Linux machine identifier on a non-Linux VM")
}
guard vmLinux.machineIdentifier == nil else {
throw RuntimeError.VMConfigurationError(
"cannot initialize a Linux machine identifier when one already exists"
)
}
vmLinux.machineIdentifier = VZGenericMachineIdentifier()
vmConfig.platform = vmLinux
try vmConfig.save(toURL: configURL)
}
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)

View File

@ -1,6 +1,9 @@
import XCTest
@testable import tart
import Foundation
import Virtualization
final class VMConfigTests: XCTestCase {
func testVMDisplayConfig() throws {
// Defaults units (points)
@ -15,4 +18,53 @@ final class VMConfigTests: XCTestCase {
vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px")
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig)
}
func testLinuxMachineIdentifierSerialization() throws {
let originalIdentifier = VZGenericMachineIdentifier()
let originalConfig = VMConfig(
platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024
)
let encodedConfigData = try originalConfig.toJSON()
let decodedConfig = try VMConfig(fromJSON: encodedConfigData)
let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux)
let decodedMachineIdentifier = try XCTUnwrap(decodedLinux.machineIdentifier)
XCTAssertEqual(
decodedMachineIdentifier.dataRepresentation,
originalIdentifier.dataRepresentation,
"decoded machine identifier should match original identifier"
)
let platformConfiguration = try decodedLinux.platform(
nvramURL: URL(fileURLWithPath: "/dev/null"),
needsNestedVirtualization: false
)
let decodedPlatformConfiguration = try XCTUnwrap(
platformConfiguration as? VZGenericPlatformConfiguration
)
XCTAssertEqual(
decodedPlatformConfiguration.machineIdentifier.dataRepresentation,
originalIdentifier.dataRepresentation,
"platform configuration should reuse decoded machine identifier"
)
}
func testLegacyLinuxConfigWithoutMachineIdentifier() throws {
let originalIdentifier = VZGenericMachineIdentifier()
let originalConfig = VMConfig(
platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024
)
let encodedConfigData = try originalConfig.toJSON()
var configJSONObject = try XCTUnwrap(JSONSerialization.jsonObject(with: encodedConfigData) as? [String: Any])
configJSONObject.removeValue(forKey: "machineIdentifier")
let legacyConfigData = try JSONSerialization.data(withJSONObject: configJSONObject)
let decodedConfig = try VMConfig(fromJSON: legacyConfigData)
let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux)
XCTAssertNil(decodedLinux.machineIdentifier, "missing machineIdentifier should be decoded as nil")
}
}