mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
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
191 lines
8.8 KiB
Swift
191 lines
8.8 KiB
Swift
//
|
|
// Copyright © 2023 osy. All rights reserved.
|
|
//
|
|
// 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
|
|
//
|
|
// http://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 Foundation
|
|
import Virtualization
|
|
|
|
@MainActor
|
|
@objc(UTMScriptingCreateCommand)
|
|
class UTMScriptingCreateCommand: NSCreateCommand, UTMScriptable {
|
|
private var bytesInMib: Int {
|
|
1048576
|
|
}
|
|
|
|
private var bytesInGib: Int {
|
|
1073741824
|
|
}
|
|
|
|
private var data: UTMData? {
|
|
(NSApp.scriptingDelegate as? AppDelegate)?.data
|
|
}
|
|
|
|
@objc override func performDefaultImplementation() -> Any? {
|
|
if createClassDescription.implementationClassName == "UTMScriptingVirtualMachineImpl" {
|
|
withScriptCommand(self) { [self] in
|
|
guard let backend = resolvedKeyDictionary["backend"] as? AEKeyword, let backend = UTMScriptingBackend(rawValue: backend) else {
|
|
throw ScriptingError.backendNotFound
|
|
}
|
|
guard let configuration = resolvedKeyDictionary["configuration"] as? [AnyHashable : Any] else {
|
|
throw ScriptingError.configurationNotFound
|
|
}
|
|
if backend == .qemu {
|
|
return try await createQemuVirtualMachine(from: configuration).objectSpecifier
|
|
} else if backend == .apple {
|
|
return try await createAppleVirtualMachine(from: configuration).objectSpecifier
|
|
} else {
|
|
throw ScriptingError.backendNotFound
|
|
}
|
|
}
|
|
return nil
|
|
} else {
|
|
return super.performDefaultImplementation()
|
|
}
|
|
}
|
|
|
|
private func createQemuVirtualMachine(from record: [AnyHashable : Any]) async throws -> UTMScriptingVirtualMachineImpl {
|
|
guard let data = data else {
|
|
throw ScriptingError.notReady
|
|
}
|
|
guard record["name"] as? String != nil else {
|
|
throw ScriptingError.nameNotSpecified
|
|
}
|
|
guard let architecture = record["architecture"] as? String, let architecture = QEMUArchitecture(rawValue: architecture) else {
|
|
throw ScriptingError.architectureNotSpecified
|
|
}
|
|
let machine = record["machine"] as? String
|
|
let target = architecture.targetType.init(rawValue: machine ?? "") ?? architecture.targetType.default
|
|
let config = UTMQemuConfiguration()
|
|
config.system.architecture = architecture
|
|
config.system.target = target
|
|
config.reset(forArchitecture: architecture, target: target)
|
|
config.qemu.hasHypervisor = true
|
|
config.qemu.hasUefiBoot = true
|
|
// add default drives
|
|
config.drives.append(UTMQemuConfigurationDrive(forArchitecture: architecture, target: target, isExternal: true))
|
|
var fixed = UTMQemuConfigurationDrive(forArchitecture: architecture, target: target)
|
|
fixed.sizeMib = 64 * bytesInGib / bytesInMib
|
|
config.drives.append(fixed)
|
|
// add a default serial device
|
|
var serial = UTMQemuConfigurationSerial()
|
|
serial.mode = .ptty
|
|
config.serials = [serial]
|
|
// remove GUI devices
|
|
config.displays = []
|
|
config.sound = []
|
|
// parse the remaining config
|
|
let wrapper = UTMScriptingConfigImpl(config)
|
|
try wrapper.updateConfiguration(from: record)
|
|
// create the vm
|
|
let vm = try await data.create(config: config)
|
|
return UTMScriptingVirtualMachineImpl(for: vm, data: data)
|
|
}
|
|
|
|
private func createAppleVirtualMachine(from record: [AnyHashable : Any]) async throws -> UTMScriptingVirtualMachineImpl {
|
|
guard let data = data else {
|
|
throw ScriptingError.notReady
|
|
}
|
|
guard #available(macOS 13, *) else {
|
|
throw ScriptingError.backendNotSupported
|
|
}
|
|
guard record["name"] as? String != nil else {
|
|
throw ScriptingError.nameNotSpecified
|
|
}
|
|
let config = UTMAppleConfiguration()
|
|
// 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()]
|
|
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
|
|
config.serials = [serial]
|
|
// add default drives
|
|
config.drives.append(UTMAppleConfigurationDrive(existingURL: nil, isExternal: true))
|
|
config.drives.append(UTMAppleConfigurationDrive(newSize: 64 * bytesInGib / bytesInMib))
|
|
// parse the remaining config
|
|
let wrapper = UTMScriptingConfigImpl(config)
|
|
try wrapper.updateConfiguration(from: record)
|
|
// create the vm
|
|
let vm = try await data.create(config: config)
|
|
return UTMScriptingVirtualMachineImpl(for: vm, data: data)
|
|
}
|
|
|
|
enum ScriptingError: Error, LocalizedError {
|
|
case notReady
|
|
case backendNotFound
|
|
case backendNotSupported
|
|
case configurationNotFound
|
|
case nameNotSpecified
|
|
case architectureNotSpecified
|
|
case operatingSystemNotSupported
|
|
case macRecoveryIpswNotSpecified
|
|
case ipswNotSupported
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notReady: return NSLocalizedString("UTM is not ready to accept commands.", comment: "UTMScriptingAppDelegate")
|
|
case .backendNotFound: return NSLocalizedString("A valid backend must be specified.", comment: "UTMScriptingAppDelegate")
|
|
case .backendNotSupported: return NSLocalizedString("This backend is not supported on your machine.", comment: "UTMScriptingAppDelegate")
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
}
|