Files
UTM/Scripting/UTMScriptingConfigImpl.swift
T
osy 5d395abfc8 scripting: return from install before the Apple Event times out
A macOS install takes several minutes, well past the default two minute
Apple Event timeout. A plain `install` therefore failed with -1712 while
the installation kept running, and a script reacting to that error by
retrying or starting the VM would race the install.

`install` now returns as soon as the installation starts, and the new
`query install` verb reports whether it is still running and how far it
has progressed, or raises the error if it failed. The installation only
counts as finished once the VM has stopped by itself, so the VM can be
started right away.

Other verbs fail with a clear error while an installation started from
a script is in progress. This includes `start`, which would otherwise
open a window that asks to install macOS again. As in the GUI, `stop`
cancels the installation.

Assisted-by: Claude:claude-opus-5
2026-09-19 09:49:15 -05:00

805 lines
34 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
@objc extension UTMScriptingVirtualMachineImpl {
@objc var configuration: [AnyHashable : Any] {
let wrapper = UTMScriptingConfigImpl(vm.config, data: data)
return wrapper.serializeConfiguration()
}
@objc func updateConfiguration(_ command: NSScriptCommand) {
let newConfiguration = command.evaluatedArguments?["newConfiguration"] as? [AnyHashable : Any]
withScriptCommand(command) { [self] in
guard let newConfiguration = newConfiguration else {
throw ScriptingError.invalidParameter
}
guard !isInstalling else {
throw ScriptingError.installInProgress
}
guard vm.state == .stopped else {
throw ScriptingError.notStopped
}
let wrapper = UTMScriptingConfigImpl(vm.config)
try wrapper.updateConfiguration(from: newConfiguration)
try await data.save(vm: box)
}
}
}
@MainActor
class UTMScriptingConfigImpl {
private var bytesInMib: Int64 {
1048576
}
private(set) var config: any UTMConfiguration
private weak var data: UTMData?
init(_ config: any UTMConfiguration, data: UTMData? = nil) {
self.config = config
self.data = data
}
func serializeConfiguration() -> [AnyHashable : Any] {
if let qemuConfig = config as? UTMQemuConfiguration {
return serializeQemuConfiguration(qemuConfig)
} else if let appleConfig = config as? UTMAppleConfiguration {
return serializeAppleConfiguration(appleConfig)
} else {
fatalError()
}
}
func updateConfiguration(from record: [AnyHashable : Any]) throws {
if let _ = config as? UTMQemuConfiguration {
try updateQemuConfiguration(from: record)
} else if let _ = config as? UTMAppleConfiguration {
try updateAppleConfiguration(from: record)
} else {
fatalError()
}
}
private func size(of drive: any UTMConfigurationDrive) -> Int {
guard let data = data else {
return 0
}
guard let url = drive.imageURL else {
return 0
}
return Int(data.computeSize(for: url) / bytesInMib)
}
}
@MainActor
extension UTMScriptingConfigImpl {
private func qemuDirectoryShareMode(from mode: QEMUFileShareMode) -> UTMScriptingQemuDirectoryShareMode {
switch mode {
case .none: return .none
case .webdav: return .webDAV
case .virtfs: return .virtFS
}
}
private func serializeQemuConfiguration(_ config: UTMQemuConfiguration) -> [AnyHashable : Any] {
[
"name": config.information.name,
"icon": config.information.iconURL?.deletingPathExtension().lastPathComponent ?? "",
"notes": config.information.notes ?? "",
"architecture": config.system.architecture.rawValue,
"machine": config.system.target.rawValue,
"memory": config.system.memorySize,
"cpuCores": config.system.cpuCount,
"hypervisor": config.qemu.hasHypervisor,
"uefi": config.qemu.hasUefiBoot,
"directoryShareMode": qemuDirectoryShareMode(from: config.sharing.directoryShareMode).rawValue,
"drives": config.drives.map({ serializeQemuDriveExisting($0) }),
"networkInterfaces": config.networks.enumerated().map({ serializeQemuNetwork($1, index: $0) }),
"serialPorts": config.serials.enumerated().map({ serializeQemuSerial($1, index: $0) }),
"displays": config.displays.enumerated().map({ serializeQemuDisplay($1, index: $0)}),
"qemuAdditionalArguments": config.qemu.additionalArguments.map({ serializeQemuAdditionalArgument($0)}),
]
}
private func qemuDriveInterface(from interface: QEMUDriveInterface) -> UTMScriptingQemuDriveInterface {
switch interface {
case .none: return .none
case .ide: return .ide
case .scsi: return .scsi
case .sd: return .sd
case .mtd: return .mtd
case .floppy: return .floppy
case .pflash: return .pFlash
case .virtio: return .virtIO
case .nvme: return .nvMe
case .usb: return .usb
}
}
private func serializeQemuDriveExisting(_ config: UTMQemuConfigurationDrive) -> [AnyHashable : Any] {
[
"id": config.id,
"removable": config.isExternal,
"interface": qemuDriveInterface(from: config.interface).rawValue,
"hostSize": size(of: config),
]
}
private func qemuNetworkMode(from mode: QEMUNetworkMode) -> UTMScriptingQemuNetworkMode {
switch mode {
case .emulated: return .emulated
case .shared: return .shared
case .host: return .host
case .bridged: return .bridged
}
}
private func serializeQemuNetwork(_ config: UTMQemuConfigurationNetwork, index: Int) -> [AnyHashable : Any] {
[
"index": index,
"hardware": config.hardware.rawValue,
"mode": qemuNetworkMode(from: config.mode).rawValue,
"address": config.macAddress,
"hostInterface": config.bridgeInterface ?? "",
"portForwards": config.portForward.map({ serializeQemuPortForward($0) }),
"vlanGuestAddress": config.vlanGuestAddress ?? "",
"vlanGuestAddressIpv6": config.vlanGuestAddressIPv6 ?? "",
"vlanDhcpStartAddress": config.vlanDhcpStartAddress ?? "",
"vlanDhcpEndAddress": config.vlanDhcpEndAddress ?? "",
"isolateFromHost": config.isIsolateFromHost,
]
}
private func networkProtocol(from protc: QEMUNetworkProtocol) -> UTMScriptingNetworkProtocol {
switch protc {
case .tcp: return .tcp
case .udp: return .udp
}
}
private func serializeQemuPortForward(_ config: UTMQemuConfigurationPortForward) -> [AnyHashable : Any] {
[
"protocol": networkProtocol(from: config.protocol).rawValue,
"hostAddress": config.hostAddress ?? "",
"hostPort": config.hostPort,
"guestAddress": config.guestAddress ?? "",
"guestPort": config.guestPort,
]
}
private func qemuSerialInterface(from mode: QEMUSerialMode) -> UTMScriptingSerialInterface {
switch mode {
case .ptty: return .ptty
case .tcpServer: return .tcp
default: return .unavailable
}
}
private func serializeQemuSerial(_ config: UTMQemuConfigurationSerial, index: Int) -> [AnyHashable : Any] {
[
"index": index,
"hardware": config.hardware?.rawValue ?? "",
"interface": qemuSerialInterface(from: config.mode).rawValue,
"port": config.tcpPort ?? 0,
]
}
private func qemuScaler(from filter: QEMUScaler) -> UTMScriptingQemuScaler {
switch filter {
case .linear: return .linear
case .nearest: return .nearest
}
}
private func serializeQemuDisplay(_ config: UTMQemuConfigurationDisplay, index: Int) -> [AnyHashable : Any] {
[
"index": index,
"hardware": config.hardware.rawValue,
"dynamicResolution": config.isDynamicResolution,
"nativeResolution": config.isNativeResolution,
"upscalingFilter": qemuScaler(from: config.upscalingFilter).rawValue,
"downscalingFilter": qemuScaler(from: config.downscalingFilter).rawValue,
]
}
private func serializeQemuAdditionalArgument(_ argument: QEMUArgument) -> [AnyHashable: Any] {
var serializedArgument: [AnyHashable: Any] = [
"argumentString": argument.string
]
// Only add fileUrls if it is not nil and contains URLs
if let fileUrls = argument.fileUrls, !fileUrls.isEmpty {
serializedArgument["fileUrls"] = fileUrls.map({ $0 as AnyHashable })
}
return serializedArgument
}
private func serializeAppleConfiguration(_ config: UTMAppleConfiguration) -> [AnyHashable : Any] {
[
"name": config.information.name,
"icon": config.information.iconURL?.deletingPathExtension().lastPathComponent ?? "",
"notes": config.information.notes ?? "",
"memory": config.system.memorySize,
"cpuCores": config.system.cpuCount,
"directoryShares": config.sharedDirectories.enumerated().map({ serializeAppleDirectoryShare($1, index: $0) }),
"drives": config.drives.map({ serializeAppleDriveExisting($0) }),
"networkInterfaces": config.networks.enumerated().map({ serializeAppleNetwork($1, index: $0) }),
"serialPorts": config.serials.enumerated().map({ serializeAppleSerial($1, index: $0) }),
"displays": config.displays.enumerated().map({ serializeAppleDisplay($1, index: $0)}),
]
}
private func serializeAppleDirectoryShare(_ config: UTMAppleConfigurationSharedDirectory, index: Int) -> [AnyHashable : Any] {
[
"index": index,
"readOnly": config.isReadOnly
]
}
private func serializeAppleDriveExisting(_ config: UTMAppleConfigurationDrive) -> [AnyHashable : Any] {
[
"id": config.id,
"removable": config.isExternal,
"hostSize": size(of: config),
]
}
private func appleNetworkMode(from mode: UTMAppleConfigurationNetwork.NetworkMode) -> UTMScriptingAppleNetworkMode {
switch mode {
case .shared: return .shared
case .natNetwork: return .natNetwork
case .host: return .host
case .bridged: return .bridged
}
}
private func serializeAppleNetwork(_ config: UTMAppleConfigurationNetwork, index: Int) -> [AnyHashable : Any] {
[
"index": index,
"mode": appleNetworkMode(from: config.mode).rawValue,
"address": config.macAddress,
"hostInterface": config.bridgeInterface ?? "",
]
}
private func appleSerialInterface(from mode: UTMAppleConfigurationSerial.SerialMode) -> UTMScriptingSerialInterface {
switch mode {
case .ptty: return .ptty
default: return .unavailable
}
}
private func serializeAppleSerial(_ config: UTMAppleConfigurationSerial, index: Int) -> [AnyHashable : Any] {
[
"index": index,
"interface": appleSerialInterface(from: config.mode).rawValue,
]
}
private func serializeAppleDisplay(_ config: UTMAppleConfigurationDisplay, index: Int) -> [AnyHashable : Any] {
[
"index": index,
"dynamicResolution": config.isDynamicResolution,
]
}
}
@MainActor
extension UTMScriptingConfigImpl {
private func updateElements<T>(_ array: inout [T], with records: [[AnyHashable : Any]], onExisting: @MainActor (inout T, [AnyHashable : Any]) throws -> Void, onNew: @MainActor ([AnyHashable : Any]) throws -> T) throws {
var unseenIndicies = IndexSet(integersIn: array.indices)
for record in records {
if let index = record["index"] as? Int {
guard array.indices.contains(index) else {
throw ConfigurationError.indexNotFound(index: index)
}
try onExisting(&array[index], record)
unseenIndicies.remove(index)
} else {
array.append(try onNew(record))
}
}
array.remove(atOffsets: unseenIndicies)
}
private func updateIdentifiedElements<T: Identifiable>(_ array: inout [T], with records: [[AnyHashable : Any]], onExisting: @MainActor (inout T, [AnyHashable : Any]) throws -> Void, onNew: @MainActor ([AnyHashable : Any]) throws -> T) throws {
var unseenIndicies = IndexSet(integersIn: array.indices)
for record in records {
if let id = record["id"] as? T.ID {
guard let index = array.enumerated().first(where: { $1.id == id })?.offset else {
throw ConfigurationError.identifierNotFound(id: id)
}
try onExisting(&array[index], record)
unseenIndicies.remove(index)
} else {
array.append(try onNew(record))
}
}
array.remove(atOffsets: unseenIndicies)
}
private func parseQemuDirectoryShareMode(_ value: AEKeyword?) -> QEMUFileShareMode? {
guard let value = value, let parsed = UTMScriptingQemuDirectoryShareMode(rawValue: value) else {
return Optional.none
}
switch parsed {
case .none: return QEMUFileShareMode.none
case .webDAV: return .webdav
case .virtFS: return .virtfs
default: return Optional.none
}
}
private func updateQemuConfiguration(from record: [AnyHashable : Any]) throws {
let config = config as! UTMQemuConfiguration
if let name = record["name"] as? String, !name.isEmpty {
config.information.name = name
}
if let icon = record["icon"] as? String, !icon.isEmpty {
if let url = UTMConfigurationInfo.builtinIcon(named: icon) {
config.information.iconURL = url
} else {
throw ConfigurationError.iconNotFound(icon: icon)
}
}
if let notes = record["notes"] as? String, !notes.isEmpty {
config.information.notes = notes
}
let architecture = record["architecture"] as? String
let arch = QEMUArchitecture(rawValue: architecture ?? "")
let machine = record["machine"] as? String
let target = arch?.targetType.init(rawValue: machine ?? "")
if let arch = arch, arch != config.system.architecture {
let target = target ?? arch.targetType.default
config.system.architecture = arch
config.system.target = target
config.reset(forArchitecture: arch, target: target)
} else if let target = target, target.rawValue != config.system.target.rawValue {
config.system.target = target
config.reset(forArchitecture: config.system.architecture, target: target)
}
if let memory = record["memory"] as? Int, memory != 0 {
config.system.memorySize = memory
}
if let cpuCores = record["cpuCores"] as? Int {
config.system.cpuCount = cpuCores
}
if let hypervisor = record["hypervisor"] as? Bool {
config.qemu.hasHypervisor = hypervisor
}
if let uefi = record["uefi"] as? Bool {
config.qemu.hasUefiBoot = uefi
}
if let directoryShareMode = parseQemuDirectoryShareMode(record["directoryShareMode"] as? AEKeyword) {
config.sharing.directoryShareMode = directoryShareMode
}
if let drives = record["drives"] as? [[AnyHashable : Any]] {
try updateQemuDrives(from: drives)
}
if let networkInterfaces = record["networkInterfaces"] as? [[AnyHashable : Any]] {
try updateQemuNetworks(from: networkInterfaces)
}
if let serialPorts = record["serialPorts"] as? [[AnyHashable : Any]] {
try updateQemuSerials(from: serialPorts)
}
if let displays = record["displays"] as? [[AnyHashable : Any]] {
try updateQemuDisplays(from: displays)
}
if let qemuAdditionalArguments = record["qemuAdditionalArguments"] as? [[AnyHashable: Any]] {
try updateQemuAdditionalArguments(from: qemuAdditionalArguments)
}
}
private func parseQemuDriveInterface(_ value: AEKeyword?) -> QEMUDriveInterface? {
guard let value = value, let parsed = UTMScriptingQemuDriveInterface(rawValue: value) else {
return Optional.none
}
switch parsed {
case .none: return QEMUDriveInterface.none
case .ide: return .ide
case .scsi: return .scsi
case .sd: return .sd
case .mtd: return .mtd
case .floppy: return .floppy
case .pFlash: return .pflash
case .virtIO: return .virtio
case .nvMe: return .nvme
case .usb: return .usb
default: return Optional.none
}
}
private func updateQemuDrives(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMQemuConfiguration
try updateIdentifiedElements(&config.drives, with: records, onExisting: updateQemuExistingDrive, onNew: unserializeQemuDriveNew)
}
private func updateQemuExistingDrive(_ drive: inout UTMQemuConfigurationDrive, from record: [AnyHashable : Any]) throws {
if let interface = parseQemuDriveInterface(record["interface"] as? AEKeyword) {
drive.interface = interface
}
if let source = record["source"] as? URL {
drive.imageURL = source
}
}
private func unserializeQemuDriveNew(from record: [AnyHashable : Any]) throws -> UTMQemuConfigurationDrive {
let config = config as! UTMQemuConfiguration
let removable = record["removable"] as? Bool ?? false
var newDrive = UTMQemuConfigurationDrive(forArchitecture: config.system.architecture, target: config.system.target, isExternal: removable)
if let importUrl = record["source"] as? URL {
newDrive.imageURL = importUrl
} else if let size = record["guestSize"] as? Int {
newDrive.sizeMib = size
}
if let interface = parseQemuDriveInterface(record["interface"] as? AEKeyword) {
newDrive.interface = interface
}
if let raw = record["raw"] as? Bool {
newDrive.isRawImage = raw
}
return newDrive
}
private func updateQemuNetworks(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMQemuConfiguration
try updateElements(&config.networks, with: records, onExisting: updateQemuExistingNetwork, onNew: { record in
guard var newNetwork = UTMQemuConfigurationNetwork(forArchitecture: config.system.architecture, target: config.system.target) else {
throw ConfigurationError.deviceNotSupported
}
try updateQemuExistingNetwork(&newNetwork, from: record)
return newNetwork
})
}
private func parseQemuNetworkMode(_ value: AEKeyword?) -> QEMUNetworkMode? {
guard let value = value, let parsed = UTMScriptingQemuNetworkMode(rawValue: value) else {
return Optional.none
}
switch parsed {
case .emulated: return .emulated
case .shared: return .shared
case .host: return .host
case .bridged: return .bridged
default: return .none
}
}
private func updateQemuExistingNetwork(_ network: inout UTMQemuConfigurationNetwork, from record: [AnyHashable : Any]) throws {
let config = config as! UTMQemuConfiguration
if let hardware = record["hardware"] as? String, let hardware = config.system.architecture.networkDeviceType.init(rawValue: hardware) {
network.hardware = hardware
}
if let mode = parseQemuNetworkMode(record["mode"] as? AEKeyword) {
network.mode = mode
}
if let address = record["address"] as? String, !address.isEmpty {
network.macAddress = address
}
if let interface = record["hostInterface"] as? String, !interface.isEmpty {
network.bridgeInterface = interface
}
if let portForwards = record["portForwards"] as? [[AnyHashable : Any]] {
network.portForward = portForwards.map({ unserializeQemuPortForward(from: $0) })
}
if let value = record["vlanGuestAddress"] as? String, !value.isEmpty {
network.vlanGuestAddress = value
}
if let value = record["vlanGuestAddressIpv6"] as? String, !value.isEmpty {
network.vlanGuestAddressIPv6 = value
}
if let value = record["vlanDhcpStartAddress"] as? String, !value.isEmpty {
network.vlanDhcpStartAddress = value
}
if let value = record["vlanDhcpEndAddress"] as? String, !value.isEmpty {
network.vlanDhcpEndAddress = value
}
if let value = record["isolateFromHost"] as? Bool {
network.isIsolateFromHost = value
}
}
private func parseNetworkProtocol(_ value: AEKeyword?) -> QEMUNetworkProtocol? {
guard let value = value, let parsed = UTMScriptingNetworkProtocol(rawValue: value) else {
return Optional.none
}
switch parsed {
case .tcp: return .tcp
case .udp: return .udp
default: return Optional.none
}
}
private func unserializeQemuPortForward(from record: [AnyHashable : Any]) -> UTMQemuConfigurationPortForward {
var forward = UTMQemuConfigurationPortForward()
if let protoc = parseNetworkProtocol(record["protocol"] as? AEKeyword) {
forward.protocol = protoc
}
if let hostAddress = record["hostAddress"] as? String, !hostAddress.isEmpty {
forward.hostAddress = hostAddress
}
if let hostPort = record["hostPort"] as? Int {
forward.hostPort = hostPort
}
if let guestAddress = record["guestAddress"] as? String, !guestAddress.isEmpty {
forward.guestAddress = guestAddress
}
if let guestPort = record["guestPort"] as? Int {
forward.guestPort = guestPort
}
return forward
}
private func updateQemuSerials(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMQemuConfiguration
try updateElements(&config.serials, with: records, onExisting: updateQemuExistingSerial, onNew: { record in
guard var newSerial = UTMQemuConfigurationSerial(forArchitecture: config.system.architecture, target: config.system.target) else {
throw ConfigurationError.deviceNotSupported
}
try updateQemuExistingSerial(&newSerial, from: record)
return newSerial
})
}
private func parseQemuSerialInterface(_ value: AEKeyword?) -> QEMUSerialMode? {
guard let value = value, let parsed = UTMScriptingSerialInterface(rawValue: value) else {
return Optional.none
}
switch parsed {
case .ptty: return .ptty
case .tcp: return .tcpServer
default: return Optional.none
}
}
private func updateQemuExistingSerial(_ serial: inout UTMQemuConfigurationSerial, from record: [AnyHashable : Any]) throws {
let config = config as! UTMQemuConfiguration
if let hardware = record["hardware"] as? String, let hardware = config.system.architecture.serialDeviceType.init(rawValue: hardware) {
serial.hardware = hardware
}
if let interface = parseQemuSerialInterface(record["interface"] as? AEKeyword) {
serial.mode = interface
}
if let port = record["port"] as? Int {
serial.tcpPort = port
}
}
private func updateQemuDisplays(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMQemuConfiguration
try updateElements(&config.displays, with: records, onExisting: updateQemuExistingDisplay, onNew: { record in
guard var newDisplay = UTMQemuConfigurationDisplay(forArchitecture: config.system.architecture, target: config.system.target) else {
throw ConfigurationError.deviceNotSupported
}
try updateQemuExistingDisplay(&newDisplay, from: record)
return newDisplay
})
}
private func parseQemuScaler(_ value: AEKeyword?) -> QEMUScaler? {
guard let value = value, let parsed = UTMScriptingQemuScaler(rawValue: value) else {
return Optional.none
}
switch parsed {
case .linear: return .linear
case .nearest: return .nearest
default: return Optional.none
}
}
private func updateQemuExistingDisplay(_ display: inout UTMQemuConfigurationDisplay, from record: [AnyHashable : Any]) throws {
let config = config as! UTMQemuConfiguration
if let hardware = record["hardware"] as? String, let hardware = config.system.architecture.displayDeviceType.init(rawValue: hardware) {
display.hardware = hardware
}
if let dynamicResolution = record["dynamicResolution"] as? Bool {
display.isDynamicResolution = dynamicResolution
}
if let nativeResolution = record["nativeResolution"] as? Bool {
display.isNativeResolution = nativeResolution
}
if let upscalingFilter = parseQemuScaler(record["upscalingFilter"] as? AEKeyword) {
display.upscalingFilter = upscalingFilter
}
if let downscalingFilter = parseQemuScaler(record["downscalingFilter"] as? AEKeyword) {
display.downscalingFilter = downscalingFilter
}
}
private func updateQemuAdditionalArguments(from records: [[AnyHashable: Any]]) throws {
let config = config as! UTMQemuConfiguration
let additionalArguments = records.compactMap { record -> QEMUArgument? in
guard let argumentString = record["argumentString"] as? String else { return nil }
var argument = QEMUArgument(argumentString)
// fileUrls are used as required resources by QEMU.
if let fileUrls = record["fileUrls"] as? [URL] {
argument.fileUrls = fileUrls
}
return argument
}
// Update entire additional arguments with new one.
config.qemu.additionalArguments = additionalArguments
}
private func updateAppleConfiguration(from record: [AnyHashable : Any]) throws {
let config = config as! UTMAppleConfiguration
if let name = record["name"] as? String, !name.isEmpty {
config.information.name = name
}
if let icon = record["icon"] as? String, !icon.isEmpty {
if let url = UTMConfigurationInfo.builtinIcon(named: icon) {
config.information.iconURL = url
} else {
throw ConfigurationError.iconNotFound(icon: icon)
}
}
if let notes = record["notes"] as? String, !notes.isEmpty {
config.information.notes = notes
}
if let memory = record["memory"] as? Int, memory != 0 {
config.system.memorySize = memory
}
if let cpuCores = record["cpuCores"] as? Int {
config.system.cpuCount = cpuCores
}
if let directoryShares = record["directoryShares"] as? [[AnyHashable : Any]] {
try updateAppleDirectoryShares(from: directoryShares)
}
if let drives = record["drives"] as? [[AnyHashable : Any]] {
try updateAppleDrives(from: drives)
}
if let networkInterfaces = record["networkInterfaces"] as? [[AnyHashable : Any]] {
try updateAppleNetworks(from: networkInterfaces)
}
if let serialPorts = record["serialPorts"] as? [[AnyHashable : Any]] {
try updateAppleSerials(from: serialPorts)
}
if let displays = record["displays"] as? [[AnyHashable : Any]] {
try updateAppleDisplays(from: displays)
}
}
private func updateAppleDirectoryShares(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMAppleConfiguration
try updateElements(&config.sharedDirectories, with: records, onExisting: updateAppleExistingDirectoryShare, onNew: { record in
var newShare = UTMAppleConfigurationSharedDirectory(directoryURL: nil, isReadOnly: false)
try updateAppleExistingDirectoryShare(&newShare, from: record)
return newShare
})
}
private func updateAppleExistingDirectoryShare(_ share: inout UTMAppleConfigurationSharedDirectory, from record: [AnyHashable : Any]) throws {
if let readOnly = record["readOnly"] as? Bool {
share.isReadOnly = readOnly
}
}
private func updateAppleDrives(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMAppleConfiguration
try updateIdentifiedElements(&config.drives, with: records, onExisting: updateAppleExistingDrive, onNew: unserializeAppleNewDrive)
}
private func updateAppleExistingDrive(_ drive: inout UTMAppleConfigurationDrive, from record: [AnyHashable : Any]) throws {
if let source = record["source"] as? URL {
drive.imageURL = source
}
}
private func unserializeAppleNewDrive(from record: [AnyHashable : Any]) throws -> UTMAppleConfigurationDrive {
let removable = record["removable"] as? Bool ?? false
var newDrive: UTMAppleConfigurationDrive
if let size = record["guestSize"] as? Int {
newDrive = UTMAppleConfigurationDrive(newSize: size)
} else {
newDrive = UTMAppleConfigurationDrive(existingURL: record["source"] as? URL, isExternal: removable)
}
return newDrive
}
private func updateAppleNetworks(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMAppleConfiguration
try updateElements(&config.networks, with: records, onExisting: updateAppleExistingNetwork, onNew: { record in
var newNetwork = UTMAppleConfigurationNetwork()
try updateAppleExistingNetwork(&newNetwork, from: record)
return newNetwork
})
}
private func parseAppleNetworkMode(_ value: AEKeyword?) -> UTMAppleConfigurationNetwork.NetworkMode? {
guard let value = value, let parsed = UTMScriptingAppleNetworkMode(rawValue: value) else {
return Optional.none
}
switch parsed {
case .shared: return .shared
case .natNetwork: return .natNetwork
case .host: return .host
case .bridged: return .bridged
}
}
private func updateAppleExistingNetwork(_ network: inout UTMAppleConfigurationNetwork, from record: [AnyHashable : Any]) throws {
if let mode = parseAppleNetworkMode(record["mode"] as? AEKeyword) {
network.mode = mode
}
if let address = record["address"] as? String, !address.isEmpty {
network.macAddress = address
}
if let interface = record["hostInterface"] as? String, !interface.isEmpty {
network.bridgeInterface = interface
}
}
private func updateAppleSerials(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMAppleConfiguration
try updateElements(&config.serials, with: records, onExisting: updateAppleExistingSerial, onNew: { record in
var newSerial = UTMAppleConfigurationSerial()
try updateAppleExistingSerial(&newSerial, from: record)
return newSerial
})
}
private func parseAppleSerialInterface(_ value: AEKeyword?) -> UTMAppleConfigurationSerial.SerialMode? {
guard let value = value, let parsed = UTMScriptingSerialInterface(rawValue: value) else {
return Optional.none
}
switch parsed {
case .ptty: return .ptty
default: return Optional.none
}
}
private func updateAppleExistingSerial(_ serial: inout UTMAppleConfigurationSerial, from record: [AnyHashable : Any]) throws {
if let interface = parseAppleSerialInterface(record["interface"] as? AEKeyword) {
serial.mode = interface
}
}
private func updateAppleDisplays(from records: [[AnyHashable : Any]]) throws {
let config = config as! UTMAppleConfiguration
try updateElements(&config.displays, with: records, onExisting: updateAppleExistingDisplay, onNew: { record in
var newDisplay = UTMAppleConfigurationDisplay()
try updateAppleExistingDisplay(&newDisplay, from: record)
return newDisplay
})
}
private func updateAppleExistingDisplay(_ display: inout UTMAppleConfigurationDisplay, from record: [AnyHashable : Any]) throws {
if let dynamicResolution = record["dynamicResolution"] as? Bool {
display.isDynamicResolution = dynamicResolution
}
}
enum ConfigurationError: Error, LocalizedError {
case identifierNotFound(id: any Hashable)
case invalidDriveDescription
case indexNotFound(index: Int)
case deviceNotSupported
case iconNotFound(icon: String)
var errorDescription: String? {
switch self {
case .identifierNotFound(let id): return String.localizedStringWithFormat(NSLocalizedString("Identifier '%@' cannot be found.", comment: "UTMScriptingConfigImpl"), String(describing: id))
case .invalidDriveDescription: return NSLocalizedString("Drive description is invalid.", comment: "UTMScriptingConfigImpl")
case .indexNotFound(let index): return String.localizedStringWithFormat(NSLocalizedString("Index %lld cannot be found.", comment: "UTMScriptingConfigImpl"), index)
case .deviceNotSupported: return NSLocalizedString("This device is not supported by the target.", comment: "UTMScriptingConfigImpl")
case .iconNotFound(let icon): return String.localizedStringWithFormat(NSLocalizedString("The icon named '%@' cannot be found in the built-in icons.", comment: "UTMScriptingConfigImpl"), icon)
}
}
}
}