|
|
// // 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 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, "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) }), "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) }), ] } 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 serializeQemuAdditionalArgument(_ argument: QEMUArgument) -> [AnyHashable: Any] { var serializedArgument: [AnyHashable: Any] = [ "argumentString": argument.string ] return serializedArgument } private func serializeAppleConfiguration(_ config: UTMAppleConfiguration) -> [AnyHashable : Any] { [ "name": config.information.name, "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) }), ] } 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 .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, ] } }
@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 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 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) }) } } 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 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) 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 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) } } 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 = UTMScriptingQemuNetworkMode(rawValue: value) else { return Optional.none } switch parsed { case .shared: return .shared case .bridged: return .bridged default: return Optional.none } } 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 } } enum ConfigurationError: Error, LocalizedError { case identifierNotFound(id: any Hashable) case invalidDriveDescription case indexNotFound(index: Int) case deviceNotSupported 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") } } } }
|