From e25397a431835b0907d31c6624526816816fa5d4 Mon Sep 17 00:00:00 2001 From: Marc Eisenbarth Date: Fri, 11 Sep 2026 16:57:32 -0500 Subject: [PATCH] scripting: allow creating macOS VMs with the Apple backend createAppleVirtualMachine hardcodes the guest OS: config.system.boot = try UTMAppleConfigurationBoot(for: .linux) Every other field in the record is already honoured via updateConfiguration(from:). The OS is the only thing a script cannot ask for, and UTMAppleConfigurationBoot.init(for:) already accepts one. This reads `os` from the record (as an AEKeyword, the same as `backend`; sdef enumerations do not arrive as strings), defaulting to .linux so existing scripts are unaffected. When the OS is macOS it requires and sets `macRecoveryIpsw`, since without it the VM has nothing to restore from. It also derives macPlatform from the caller's IPSW. fillVZConfiguration throws platformUnsupported for a macOS guest with no platform, so setting the OS and IPSW alone produces a VM that is created and then fails to start with "This virtual machine cannot run on the current host machine." The hardware model comes from the caller's own image via VZMacOSRestoreImage.image(from:) rather than fetchLatestSupported: the caller named a specific IPSW and the model must match it. An IPSW this Mac cannot run fails with the same error the wizard shows. And it gives a macOS guest a display, audio, keyboard and pointer by default. The scripted create clears `displays` (the headless-by-default convention that suits a Linux guest with a serial console), and fillVZConfiguration only builds a graphics device when `displays` is non-empty. For macOS that produced a guest with no framebuffer: it booted to 0% CPU and UTMData.run chose a headless session because there was nothing to show. The defaults mirror VMWizardState for a new macOS VM (1920x1200, audio, generic keyboard, trackpad on Ventura+). A caller may still override `displays`, but the input and audio devices cannot be set from a script, so they are always enabled for a macOS guest. The sdef gains an `operating system` enumeration (linux / macOS) and a `macRecoveryIpsw` file property. The property name matches the existing macRecoveryIpswURL and the "IPSW Install Image" label in VMConfigAppleBootView. UTMScripting.swift: the UTMScriptingOperatingSystem enum matches the output of bridge-gen.sh for this sdef change. Verified against main on Xcode 26.6 / Apple Silicon: a macOS 26.6.2 VM created from AppleScript with OperatingSystem=macOS, a HardwareModel, MachineIdentifier, AuxiliaryStoragePath and a 1920x1200 Display in the resulting config.plist, and booted to Setup Assistant in a UTM display window. Refs #6092 Assisted-by: Claude:claude-opus-5 --- Scripting/UTM.sdef | 11 +++++ Scripting/UTMScripting.swift | 6 +++ Scripting/UTMScriptingCreateCommand.swift | 50 +++++++++++++++++++++-- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Scripting/UTM.sdef b/Scripting/UTM.sdef index 64a2a0c6..eb9742dc 100644 --- a/Scripting/UTM.sdef +++ b/Scripting/UTM.sdef @@ -31,6 +31,11 @@ + + + + + @@ -713,6 +718,12 @@ description="List of display configuration."> + + + + diff --git a/Scripting/UTMScripting.swift b/Scripting/UTMScripting.swift index 909d3084..a19fdab2 100644 --- a/Scripting/UTMScripting.swift +++ b/Scripting/UTMScripting.swift @@ -51,6 +51,12 @@ import ScriptingBridge case detailed = 0x6c776474 /* 'lwdt' */ } +// MARK: UTMScriptingOperatingSystem +@objc public enum UTMScriptingOperatingSystem : AEKeyword { + case linux = 0x41704f6c /* 'ApOl' */ + case macOS = 0x41704f6d /* 'ApOm' */ +} + // MARK: UTMScriptingBackend @objc public enum UTMScriptingBackend : AEKeyword { case apple = 0x4170506c /* 'ApPl' */ diff --git a/Scripting/UTMScriptingCreateCommand.swift b/Scripting/UTMScriptingCreateCommand.swift index fb6fbdf8..53faf2e0 100644 --- a/Scripting/UTMScriptingCreateCommand.swift +++ b/Scripting/UTMScriptingCreateCommand.swift @@ -15,6 +15,7 @@ // import Foundation +import Virtualization @MainActor @objc(UTMScriptingCreateCommand) @@ -103,12 +104,49 @@ class UTMScriptingCreateCommand: NSCreateCommand, UTMScriptable { throw ScriptingError.nameNotSpecified } let config = UTMAppleConfiguration() - config.system.boot = try UTMAppleConfigurationBoot(for: .linux) + // enumerations arrive as an AEKeyword and not a string + var bootOS = UTMAppleConfigurationBoot.OperatingSystem.linux + if let osKeyword = record["os"] as? AEKeyword { + guard let scriptingOS = UTMScriptingOperatingSystem(rawValue: osKeyword) else { + throw ScriptingError.operatingSystemNotSupported + } + switch scriptingOS { + case .linux: bootOS = .linux + case .macOS: bootOS = .macOS + } + } + config.system.boot = try UTMAppleConfigurationBoot(for: bootOS) + var macGuestMajorVersion = 0 + if bootOS == .macOS { + guard let ipsw = record["macRecoveryIpsw"] as? URL else { + throw ScriptingError.macRecoveryIpswNotSpecified + } + config.system.boot.macRecoveryIpswURL = ipsw + #if arch(arm64) + // the hardware model must match the IPSW the caller chose + let image = try await VZMacOSRestoreImage.image(from: ipsw) + guard let model = image.mostFeaturefulSupportedConfiguration?.hardwareModel else { + throw ScriptingError.ipswNotSupported + } + config.system.macPlatform = UTMAppleConfigurationMacPlatform(newHardware: model) + macGuestMajorVersion = image.operatingSystemVersion.majorVersion + #else + throw ScriptingError.operatingSystemNotSupported + #endif + } config.virtualization.hasBalloon = true config.virtualization.hasEntropy = true config.networks = [UTMAppleConfigurationNetwork()] - // remove any display devices - config.displays = [] + if bootOS == .macOS { + // macOS is not usable headless so it gets the same devices as the wizard + config.displays = [UTMAppleConfigurationDisplay(width: 1920, height: 1200)] + config.virtualization.hasAudio = true + config.virtualization.keyboard = .generic + config.virtualization.pointer = macGuestMajorVersion >= 13 ? .trackpad : .mouse + } else { + // remove any display devices + config.displays = [] + } // add a default serial device var serial = UTMAppleConfigurationSerial() serial.mode = .ptty @@ -131,6 +169,9 @@ class UTMScriptingCreateCommand: NSCreateCommand, UTMScriptable { case configurationNotFound case nameNotSpecified case architectureNotSpecified + case operatingSystemNotSupported + case macRecoveryIpswNotSpecified + case ipswNotSupported var errorDescription: String? { switch self { @@ -140,6 +181,9 @@ class UTMScriptingCreateCommand: NSCreateCommand, UTMScriptable { case .configurationNotFound: return NSLocalizedString("A valid configuration must be specified.", comment: "UTMScriptingAppDelegate") case .nameNotSpecified: return NSLocalizedString("No name specified in the configuration.", comment: "UTMScriptingAppDelegate") case .architectureNotSpecified: return NSLocalizedString("No architecture specified in the configuration.", comment: "UTMScriptingAppDelegate") + case .operatingSystemNotSupported: return NSLocalizedString("The specified operating system is not supported on your machine.", comment: "UTMScriptingAppDelegate") + case .macRecoveryIpswNotSpecified: return NSLocalizedString("An IPSW recovery image must be specified to create a macOS virtual machine.", comment: "UTMScriptingAppDelegate") + case .ipswNotSupported: return NSLocalizedString("Your machine does not support running this IPSW.", comment: "UTMScriptingAppDelegate") } } }