Browse Source

scripting: add configuration suite

osy 2 years ago
parent
commit
4b4b33d10a

+ 1 - 1
Configuration/UTMAppleConfigurationDrive.swift

@@ -57,7 +57,7 @@ struct UTMAppleConfigurationDrive: UTMConfigurationDrive {
         isExternal = false
         isExternal = false
     }
     }
     
     
-    init(existingURL url: URL, isExternal: Bool = false) {
+    init(existingURL url: URL?, isExternal: Bool = false) {
         self.imageURL = url
         self.imageURL = url
         self.isReadOnly = isExternal
         self.isReadOnly = isExternal
         self.isExternal = isExternal
         self.isExternal = isExternal

+ 1 - 1
Configuration/UTMAppleConfigurationSharedDirectory.swift

@@ -31,7 +31,7 @@ struct UTMAppleConfigurationSharedDirectory: Codable, Hashable, Identifiable {
         case isReadOnly = "ReadOnly"
         case isReadOnly = "ReadOnly"
     }
     }
     
     
-    init(directoryURL: URL, isReadOnly: Bool = false) {
+    init(directoryURL: URL?, isReadOnly: Bool = false) {
         self.directoryURL = directoryURL
         self.directoryURL = directoryURL
         self.isReadOnly = isReadOnly
         self.isReadOnly = isReadOnly
     }
     }

+ 295 - 0
Scripting/UTM.sdef

@@ -434,4 +434,299 @@
           <result type="execute result" description="Result from the guest."/>
           <result type="execute result" description="Result from the guest."/>
         </command>
         </command>
     </suite>
     </suite>
+
+    <suite name="UTM Configuration Suite" code="UTMc" description="UTM virtual machine configuration suite. Use this to create and configurate virtual machines.">
+        <access-group identifier="com.utmapp.UTM.vm-access" />
+        
+        <class-extension extends="virtual machine" description="Virtual machine configuration.">
+            <property name="configuration" code="VmCg" access="r"
+              description="The configuration of the virtual machine.">
+              <type type="qemu configuration"/>
+              <type type="apple configuration"/>
+            </property>
+            
+            <responds-to command="update configuration">
+              <cocoa method="updateConfiguration:"/>
+            </responds-to>
+        </class-extension>
+        
+        <command name="update configuration" code="UTMcUpDt" description="Update the configuration of the virtual machine. The VM must be in the stopped state.">
+          <direct-parameter description="Virtual machine to configure." type="virtual machine"/>
+          <parameter name="with" code="UpCf" description="The configuration to update the virtual machine. You cannot change the backend with this!">
+            <cocoa key="newConfiguration"/>
+            <type type="qemu configuration"/>
+            <type type="apple configuration"/>
+          </parameter>
+        </command>
+
+        <record-type name="qemu configuration" code="QeCf" description="QEMU virtual machine configuration.">
+          <property name="name" code="pnam" type="text"
+            description="Virtual machine name."/>
+            
+          <property name="notes" code="QcAr" type="text"
+            description="User-specified notes."/>
+            
+          <property name="architecture" code="QcAr" type="text"
+            description="QEMU system architecture."/>
+            
+          <property name="machine" code="QcMa" type="text"
+            description="QEMU target machine (if empty, the default will be used)."/>
+            
+          <property name="memory" code="QcMe" type="integer"
+            description="RAM size (in mebibytes)."/>
+            
+          <property name="cpu cores" code="QcCc" type="integer"
+            description="Number of CPU cores (0 is the default for this host)."/>
+            
+          <property name="hypervisor" code="QcHv" type="boolean"
+            description="Use the hypervisor (if supported)?"/>
+            
+          <property name="uefi" code="QcUe" type="boolean"
+            description="Use UEFI boot?"/>
+            
+          <property name="directory share mode" code="QcDs" type="qemu directory share mode"
+            description="Mode for directory sharing."/>
+            
+          <property name="drives" code="QcDr"
+            description="List of drive configuration.">
+            <type type="qemu drive existing configuration" list="yes"/>
+            <type type="qemu drive new configuration" list="yes"/>
+            <type type="qemu drive import configuration" list="yes"/>
+          </property>
+          
+          <property name="network interfaces" code="QcNi"
+            description="List of network configuration.">
+            <type type="qemu network configuration" list="yes"/>
+          </property>
+          
+          <property name="serial ports" code="QcSr"
+            description="List of serial configuration.">
+            <type type="qemu serial configuration" list="yes"/>
+          </property>
+        </record-type>
+        
+        <enumeration name="qemu directory share mode" code="QeSm" description="Method for sharing directory in QEMU.">
+            <enumerator name="none" code="SmOf" description="Do not enable directory sharing."/>
+            <enumerator name="WebDAV" code="SmWv" description="Use SPICE WebDav (SPICE guest tools required)."/>
+            <enumerator name="VirtFS" code="SmVs" description="Use VirtFS mount tagged 'share' (VirtFS guest drivers required)."/>
+        </enumeration>
+        
+        <record-type name="qemu drive existing configuration" code="QdEc" description="QEMU virtual existing drive configuration.">
+          <property name="id" code="ID  " type="text" access="r"
+            description="The unique identifier for this drive."/>
+            
+          <property name="removable" code="QdRm" type="boolean" access="r"
+            description="Is this drive removable?"/>
+            
+          <property name="interface" code="QdIf" type="qemu drive interface"
+            description="The hardware interface this drive is attached to (if empty, the default will be used)."/>
+            
+          <property name="host size" code="QdHs" type="integer" access="r"
+            description="The size of this drive as seen by the host (in MiB)."/>
+        </record-type>
+        
+        <record-type name="qemu drive new configuration" code="QdNc" description="QEMU virtual new drive configuration.">
+          <property name="interface" code="QdIf" type="qemu drive interface"
+            description="The hardware interface this drive is attached to (if empty, the default will be used)."/>
+            
+          <property name="removable" code="QdRm" type="boolean"
+            description="Is this drive removable?"/>
+            
+          <property name="guest size" code="QdHs" type="integer"
+            description="The size of this drive as seen by the guest (in MiB)."/>
+            
+          <property name="raw" code="QdRw" type="boolean"
+            description="Is this disk image raw format?"/>
+        </record-type>
+        
+        <record-type name="qemu drive import configuration" code="QdIc" description="QEMU virtual import drive configuration.">
+          <property name="interface" code="QdIf" type="qemu drive interface"
+            description="The hardware interface this drive is attached to (if empty, the default will be used)."/>
+            
+          <property name="removable" code="QdRm" type="boolean"
+            description="Is this drive removable?"/>
+            
+          <property name="source" code="QdSs" type="file"
+            description="The drive image to import into the virtual machine."/>
+            
+          <property name="raw" code="QdRw" type="boolean"
+            description="Is this disk image raw format?"/>
+        </record-type>
+        
+        <enumeration name="qemu drive interface" code="QeDi" description="QEMU drive interfaces.">
+            <enumerator name="none" code="QdIN"/>
+            <enumerator name="IDE" code="QdIi"/>
+            <enumerator name="SCSI" code="QdIs"/>
+            <enumerator name="SD" code="QdId"/>
+            <enumerator name="MTD" code="QdIm"/>
+            <enumerator name="Floppy" code="QdIf"/>
+            <enumerator name="PFlash" code="QdIp"/>
+            <enumerator name="VirtIO" code="QdIv"/>
+            <enumerator name="NVMe" code="QdIn"/>
+            <enumerator name="USB" code="QdIu"/>
+        </enumeration>
+        
+        <record-type name="qemu network configuration" code="QeCn" description="QEMU virtual network configuration.">
+          <property name="index" code="pidx" type="integer"
+            description="The position of the configuration to update. It can be empty to create a new device. Index is invalid after updating the configuration and must be reset to the current position."/>
+            
+          <property name="hardware" code="QnHw" type="text"
+            description="Name of the emulated network card (if empty, the default will be used)."/>
+            
+          <property name="mode" code="QnMd" type="qemu network mode"
+            description="This determines how the network device is attached to the host."/>
+            
+          <property name="address" code="QnAd" type="text"
+            description="MAC address (formatted as XX:XX:XX:XX:XX:XX, if empty a random address will be genertaed)"/>
+            
+          <property name="host interface" code="QnHi" type="text"
+            description="Only used in bridged mode. Specify the interface name to bridge to."/>
+            
+          <property name="port forwards" code="QnPf"
+            description="Only used in emulated mode. Allows port forwarding from guest to host.">
+            <type type="qemu port forward" list="yes"/>
+          </property>
+        </record-type>
+        
+        <enumeration name="qemu network mode" code="QeNm" description="Mode for networking device.">
+            <enumerator name="emulated" code="QnEm" description="Emulate a VLAN."/>
+            <enumerator name="shared" code="QnSh" description="NAT based sharing with the host."/>
+            <enumerator name="host" code="QnHs" description="NAT based sharing with no WAN routing."/>
+            <enumerator name="bridged" code="QnBr" description="Bridged to a host interface."/>
+        </enumeration>
+        
+        <record-type name="qemu port forward" code="QePf" description="QEMU port forward configuration.">
+          <property name="protocol" code="QpPr" type="network protocol"
+            description="Protocol of the port that will be forwarded."/>
+            
+          <property name="host address" code="QpHa" type="text"
+            description="The host interface IP address to forward to (if empty, it will forward to any interface)."/>
+            
+          <property name="host port" code="QpHp" type="integer"
+            description="Port number on the host."/>
+            
+          <property name="guest address" code="QpGa" type="text"
+            description="The IP address on the guest subnet to forward from (if empty, any guest IP will be accepted)."/>
+            
+          <property name="guest port" code="QpGp" type="integer"
+            description="Port number on the guest."/>
+        </record-type>
+        
+        <enumeration name="network protocol" code="NtPr" description="Supported network protocols.">
+            <enumerator name="TCP" code="NtTp"/>
+            <enumerator name="UDP" code="NtUp"/>
+        </enumeration>
+        
+        <record-type name="qemu serial configuration" code="QeSn" description="QEMU virtual serial configuration.">
+            <property name="index" code="pidx" type="integer"
+              description="The position of the configuration to update. It can be empty to create a new device. Index is invalid after updating the configuration and must be reset to the current position."/>
+              
+            <property name="hardware" code="QsHw" type="text"
+              description="Name of the emulated serial device (if empty, the default will be used)."/>
+              
+            <property name="interface" code="QsIf" type="serial interface"
+              description="The type of serial interface on the host."/>
+              
+            <property name="port" code="QsPt" type="integer"
+              description="The port number to listen on when the interface is a TCP server."/>
+        </record-type>
+        
+        <record-type name="apple configuration" code="ApCf" description="Apple virtual machine configuration.">
+          <property name="name" code="pnam" type="text"
+            description="Virtual machine name."/>
+            
+          <property name="notes" code="ApAr" type="text"
+            description="User-specified notes."/>
+            
+          <property name="memory" code="ApMe" type="integer"
+            description="RAM size (in mebibytes)."/>
+            
+          <property name="cpu cores" code="ApCc" type="integer"
+            description="Number of CPU cores (0 is the default for this host)."/>
+            
+          <property name="directory shares" code="ApDs"
+            description="List of directory share configuration.">
+            <type type="apple directory share configuration" list="yes"/>
+          </property>
+            
+          <property name="drives" code="ApDr"
+            description="List of drive configuration.">
+            <type type="apple drive existing configuration" list="yes"/>
+            <type type="apple drive new configuration" list="yes"/>
+            <type type="apple drive import configuration" list="yes"/>
+          </property>
+          
+          <property name="network interfaces" code="ApNi"
+            description="List of network configuration.">
+            <type type="apple network configuration" list="yes"/>
+          </property>
+          
+          <property name="serial ports" code="ApSr"
+            description="List of serial configuration.">
+            <type type="apple serial configuration" list="yes"/>
+          </property>
+        </record-type>
+        
+        <record-type name="apple directory share configuration" code="ApDs" description="Apple directory share configuration.">
+          <property name="index" code="pidx" type="integer"
+            description="The position of the configuration to update. It can be empty to create a new device. Index is invalid after updating the configuration and must be reset to the current position."/>
+            
+          <property name="read only" code="AdRo" type="boolean" access="r"
+            description="Is this directory read-only?"/>
+        </record-type>
+        
+        <record-type name="apple drive existing configuration" code="ApEc" description="Apple virtual existing drive configuration.">
+          <property name="id" code="ID  " type="text" access="r"
+            description="The unique identifier for this drive."/>
+            
+          <property name="removable" code="ApRm" type="boolean" access="r"
+            description="Is this drive removable?"/>
+            
+          <property name="host size" code="ApHs" type="integer" access="r"
+            description="The size of this drive as seen by the host (in MiB)."/>
+        </record-type>
+        
+        <record-type name="apple drive new configuration" code="ApNc" description="Apple virtual new drive configuration.">
+          <property name="guest size" code="ApGs" type="integer"
+            description="The size of this drive (in MiB)."/>
+            
+          <property name="removable" code="ApRm" type="boolean"
+            description="Is this drive removable?"/>
+        </record-type>
+        
+        <record-type name="apple drive import configuration" code="ApIc" description="Apple virtual import drive configuration.">
+          <property name="source" code="ApSs" type="file"
+            description="The drive image to import into the virtual machine."/>
+            
+          <property name="removable" code="ApRm" type="boolean"
+            description="Is this drive removable?"/>
+        </record-type>
+        
+        <record-type name="apple network configuration" code="ApCn" description="Apple virtual network configuration.">
+          <property name="index" code="pidx" type="integer"
+            description="The position of the configuration to update. It can be empty to create a new device. Index is invalid after updating the configuration and must be reset to the current position."/>
+            
+          <property name="mode" code="ApMd" type="apple network mode"
+            description="This determines how the network device is attached to the host."/>
+            
+          <property name="address" code="AnAd" type="text"
+            description="MAC address (formatted as XX:XX:XX:XX:XX:XX, if empty a random address will be genertaed)"/>
+            
+          <property name="host interface" code="AnHi" type="text"
+            description="Only used in bridged mode. Specify the interface name to bridge to."/>
+        </record-type>
+        
+        <enumeration name="apple network mode" code="ApNm" description="Mode for networking device.">
+            <enumerator name="shared" code="AnSh" description="NAT based sharing with the host."/>
+            <enumerator name="bridged" code="AnBr" description="Bridged to a host interface."/>
+        </enumeration>
+        
+        <record-type name="apple serial configuration" code="ApSn" description="Apple virtual serial configuration.">
+            <property name="index" code="pidx" type="integer"
+              description="The position of the configuration to update. It can be empty to create a new device. Index is invalid after updating the configuration and must be reset to the current position."/>
+              
+            <property name="interface" code="AsIf" type="serial interface"
+              description="The type of serial interface on the host (only PTTY is supported)."/>
+        </record-type>
+    </suite>
 </dictionary>
 </dictionary>

+ 43 - 0
Scripting/UTMScripting.swift

@@ -90,6 +90,47 @@ import ScriptingBridge
     case endPosition = 0x556e4176 /* 'UnAv' */
     case endPosition = 0x556e4176 /* 'UnAv' */
 }
 }
 
 
+// MARK: UTMScriptingQemuDirectoryShareMode
+@objc public enum UTMScriptingQemuDirectoryShareMode : AEKeyword {
+    case none = 0x536d4f66 /* 'SmOf' */
+    case webDAV = 0x536d5776 /* 'SmWv' */
+    case virtFS = 0x536d5673 /* 'SmVs' */
+}
+
+// MARK: UTMScriptingQemuDriveInterface
+@objc public enum UTMScriptingQemuDriveInterface : AEKeyword {
+    case none = 0x5164494e /* 'QdIN' */
+    case ide = 0x51644969 /* 'QdIi' */
+    case scsi = 0x51644973 /* 'QdIs' */
+    case sd = 0x51644964 /* 'QdId' */
+    case mtd = 0x5164496d /* 'QdIm' */
+    case floppy = 0x51644966 /* 'QdIf' */
+    case pFlash = 0x51644970 /* 'QdIp' */
+    case virtIO = 0x51644976 /* 'QdIv' */
+    case nvMe = 0x5164496e /* 'QdIn' */
+    case usb = 0x51644975 /* 'QdIu' */
+}
+
+// MARK: UTMScriptingQemuNetworkMode
+@objc public enum UTMScriptingQemuNetworkMode : AEKeyword {
+    case emulated = 0x516e456d /* 'QnEm' */
+    case shared = 0x516e5368 /* 'QnSh' */
+    case host = 0x516e4873 /* 'QnHs' */
+    case bridged = 0x516e4272 /* 'QnBr' */
+}
+
+// MARK: UTMScriptingNetworkProtocol
+@objc public enum UTMScriptingNetworkProtocol : AEKeyword {
+    case tcp = 0x4e745470 /* 'NtTp' */
+    case udp = 0x4e745570 /* 'NtUp' */
+}
+
+// MARK: UTMScriptingAppleNetworkMode
+@objc public enum UTMScriptingAppleNetworkMode : AEKeyword {
+    case shared = 0x416e5368 /* 'AnSh' */
+    case bridged = 0x416e4272 /* 'AnBr' */
+}
+
 // MARK: UTMScriptingGenericMethods
 // MARK: UTMScriptingGenericMethods
 @objc public protocol UTMScriptingGenericMethods {
 @objc public protocol UTMScriptingGenericMethods {
     @objc optional func close() // Close a document.
     @objc optional func close() // Close a document.
@@ -147,8 +188,10 @@ extension SBObject: UTMScriptingWindow {}
     @objc optional func openFileAt(_ at: String!, for for_: UTMScriptingOpenMode, updating: Bool) -> UTMScriptingGuestFile // Open a file on the guest. You must close the file when you are done to prevent leaking guest resources.
     @objc optional func openFileAt(_ at: String!, for for_: UTMScriptingOpenMode, updating: Bool) -> UTMScriptingGuestFile // Open a file on the guest. You must close the file when you are done to prevent leaking guest resources.
     @objc optional func executeAt(_ at: String!, withArguments: [String]!, withEnvironment: [String]!, usingInput: String!, base64Encoding: Bool, outputCapturing: Bool) -> UTMScriptingGuestProcess // Execute a command or script on the guest.
     @objc optional func executeAt(_ at: String!, withArguments: [String]!, withEnvironment: [String]!, usingInput: String!, base64Encoding: Bool, outputCapturing: Bool) -> UTMScriptingGuestProcess // Execute a command or script on the guest.
     @objc optional func queryIp() -> [Any] // Query the guest for all IP addresses on its network interfaces (excluding loopback).
     @objc optional func queryIp() -> [Any] // Query the guest for all IP addresses on its network interfaces (excluding loopback).
+    @objc optional func updateConfigurationWith(_ with: Any!) // Update the configuration of the virtual machine. The VM must be in the stopped state.
     @objc optional func guestFiles() -> SBElementArray
     @objc optional func guestFiles() -> SBElementArray
     @objc optional func guestProcesses() -> SBElementArray
     @objc optional func guestProcesses() -> SBElementArray
+    @objc optional var configuration: Any { get } // The configuration of the virtual machine.
 }
 }
 extension SBObject: UTMScriptingVirtualMachine {}
 extension SBObject: UTMScriptingVirtualMachine {}
 
 

+ 607 - 0
Scripting/UTMScriptingConfigImpl.swift

@@ -0,0 +1,607 @@
+//
+// 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] {
+        if let vm = vm as? UTMQemuVirtualMachine {
+            return serializeQemuConfiguration(vm.qemuConfig)
+        } else if let vm = vm as? UTMAppleVirtualMachine {
+            return serializeAppleConfiguration(vm.appleConfig)
+        } else {
+            fatalError()
+        }
+    }
+    
+    @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 == .vmStopped else {
+                throw ScriptingError.notStopped
+            }
+            if backend == .qemu {
+                try updateQemuConfiguration(from: newConfiguration)
+            } else if backend == .apple {
+                try updateAppleConfiguration(from: newConfiguration)
+            } else {
+                fatalError()
+            }
+            try await data.save(vm: vm)
+        }
+    }
+}
+
+@MainActor
+extension UTMScriptingVirtualMachineImpl {
+    private var bytesInMib: Int64 {
+        1048576
+    }
+    
+    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) }),
+        ]
+    }
+    
+    private func size(of drive: any UTMConfigurationDrive) -> Int {
+        guard let url = drive.imageURL else {
+            return 0
+        }
+        return Int(data.computeSize(for: url) / bytesInMib)
+    }
+    
+    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 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 UTMScriptingVirtualMachineImpl {
+    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 = (vm as! UTMQemuVirtualMachine).qemuConfig
+        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 {
+            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)
+        }
+    }
+    
+    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 = (vm as! UTMQemuVirtualMachine).qemuConfig
+        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
+        }
+    }
+    
+    private func unserializeQemuDriveNew(from record: [AnyHashable : Any]) throws -> UTMQemuConfigurationDrive {
+        let config = (vm as! UTMQemuVirtualMachine).qemuConfig
+        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 = (vm as! UTMQemuVirtualMachine).qemuConfig
+        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 = (vm as! UTMQemuVirtualMachine).qemuConfig
+        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 = (vm as! UTMQemuVirtualMachine).qemuConfig
+        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 = (vm as! UTMQemuVirtualMachine).qemuConfig
+        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 updateAppleConfiguration(from record: [AnyHashable : Any]) throws {
+        let config = (vm as! UTMAppleVirtualMachine).appleConfig
+        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 = (vm as! UTMAppleVirtualMachine).appleConfig
+        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 = (vm as! UTMAppleVirtualMachine).appleConfig
+        try updateIdentifiedElements(&config.drives, with: records, onExisting: { _, _  in }, onNew: unserializeAppleNewDrive)
+    }
+    
+    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 = (vm as! UTMAppleVirtualMachine).appleConfig
+        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 = (vm as! UTMAppleVirtualMachine).appleConfig
+        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 NSLocalizedString("Identifier '\(id)' cannot be found.", comment: "UTMScriptingConfigImpl")
+            case .invalidDriveDescription: return NSLocalizedString("Drive description is invalid.", comment: "UTMScriptingConfigImpl")
+            case .indexNotFound(let index): return NSLocalizedString("Index \(index) cannot be found.", comment: "UTMScriptingConfigImpl")
+            case .deviceNotSupported: return NSLocalizedString("This device is not supported by the target.", comment: "UTMScriptingConfigImpl")
+            }
+        }
+    }
+}

+ 4 - 2
Scripting/UTMScriptingVirtualMachineImpl.swift

@@ -19,8 +19,8 @@ import Foundation
 @MainActor
 @MainActor
 @objc(UTMScriptingVirtualMachineImpl)
 @objc(UTMScriptingVirtualMachineImpl)
 class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
 class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
-    private var vm: UTMVirtualMachine
-    private var data: UTMData
+    @nonobjc var vm: UTMVirtualMachine
+    @nonobjc var data: UTMData
     
     
     @objc var id: String {
     @objc var id: String {
         vm.id.uuidString
         vm.id.uuidString
@@ -248,6 +248,7 @@ extension UTMScriptingVirtualMachineImpl {
         case operationNotAvailable
         case operationNotAvailable
         case operationNotSupported
         case operationNotSupported
         case notRunning
         case notRunning
+        case notStopped
         case guestAgentNotRunning
         case guestAgentNotRunning
         case invalidParameter
         case invalidParameter
         
         
@@ -256,6 +257,7 @@ extension UTMScriptingVirtualMachineImpl {
             case .operationNotAvailable: return NSLocalizedString("Operation not available.", comment: "UTMScriptingVirtualMachineImpl")
             case .operationNotAvailable: return NSLocalizedString("Operation not available.", comment: "UTMScriptingVirtualMachineImpl")
             case .operationNotSupported: return NSLocalizedString("Operation not supported by the backend.", comment: "UTMScriptingVirtualMachineImpl")
             case .operationNotSupported: return NSLocalizedString("Operation not supported by the backend.", comment: "UTMScriptingVirtualMachineImpl")
             case .notRunning: return NSLocalizedString("The virtual machine is not running.", comment: "UTMScriptingVirtualMachineImpl")
             case .notRunning: return NSLocalizedString("The virtual machine is not running.", comment: "UTMScriptingVirtualMachineImpl")
+            case .notStopped: return NSLocalizedString("The virtual machine must be stopped before this operation can be performed.", comment: "UTMScriptingVirtualMachineImpl")
             case .guestAgentNotRunning: return NSLocalizedString("The QEMU guest agent is not running or not installed on the guest.", comment: "UTMScriptingVirtualMachineImpl")
             case .guestAgentNotRunning: return NSLocalizedString("The QEMU guest agent is not running or not installed on the guest.", comment: "UTMScriptingVirtualMachineImpl")
             case .invalidParameter: return NSLocalizedString("One or more required parameters are missing or invalid.", comment: "UTMScriptingVirtualMachineImpl")
             case .invalidParameter: return NSLocalizedString("One or more required parameters are missing or invalid.", comment: "UTMScriptingVirtualMachineImpl")
             }
             }

+ 4 - 0
UTM.xcodeproj/project.pbxproj

@@ -582,6 +582,7 @@
 		CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */; };
 		CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */; };
 		CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */; };
 		CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */; };
 		CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124A29BFE273000790AB /* UTMScriptable.swift */; };
 		CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124A29BFE273000790AB /* UTMScriptable.swift */; };
+		CE25124D29C55816000790AB /* UTMScriptingConfigImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124C29C55816000790AB /* UTMScriptingConfigImpl.swift */; };
 		CE2D926A24AD46670059923A /* VMDisplayMetalViewController+Pointer.h in Sources */ = {isa = PBXBuildFile; fileRef = 83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */; };
 		CE2D926A24AD46670059923A /* VMDisplayMetalViewController+Pointer.h in Sources */ = {isa = PBXBuildFile; fileRef = 83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */; };
 		CE2D926B24AD46670059923A /* qapi-types-rocker.c in Sources */ = {isa = PBXBuildFile; fileRef = CE23C14D23FCEC09001177D6 /* qapi-types-rocker.c */; };
 		CE2D926B24AD46670059923A /* qapi-types-rocker.c in Sources */ = {isa = PBXBuildFile; fileRef = CE23C14D23FCEC09001177D6 /* qapi-types-rocker.c */; };
 		CE2D926E24AD46670059923A /* qapi-commands-crypto.c in Sources */ = {isa = PBXBuildFile; fileRef = CE23C0AE23FCEC01001177D6 /* qapi-commands-crypto.c */; };
 		CE2D926E24AD46670059923A /* qapi-commands-crypto.c in Sources */ = {isa = PBXBuildFile; fileRef = CE23C0AE23FCEC01001177D6 /* qapi-commands-crypto.c */; };
@@ -2101,6 +2102,7 @@
 		CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestProcessImpl.swift; sourceTree = "<group>"; };
 		CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestProcessImpl.swift; sourceTree = "<group>"; };
 		CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestFileImpl.swift; sourceTree = "<group>"; };
 		CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestFileImpl.swift; sourceTree = "<group>"; };
 		CE25124A29BFE273000790AB /* UTMScriptable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptable.swift; sourceTree = "<group>"; };
 		CE25124A29BFE273000790AB /* UTMScriptable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptable.swift; sourceTree = "<group>"; };
+		CE25124C29C55816000790AB /* UTMScriptingConfigImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingConfigImpl.swift; sourceTree = "<group>"; };
 		CE258ACC22715F8300E5A333 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
 		CE258ACC22715F8300E5A333 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
 		CE2B89332262A21E00C6D9D8 /* UTMVirtualMachine.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMVirtualMachine.h; sourceTree = "<group>"; };
 		CE2B89332262A21E00C6D9D8 /* UTMVirtualMachine.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMVirtualMachine.h; sourceTree = "<group>"; };
 		CE2B89352262B2F600C6D9D8 /* UTMVirtualMachineDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMVirtualMachineDelegate.h; sourceTree = "<group>"; };
 		CE2B89352262B2F600C6D9D8 /* UTMVirtualMachineDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMVirtualMachineDelegate.h; sourceTree = "<group>"; };
@@ -3459,6 +3461,7 @@
 				CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */,
 				CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */,
 				CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */,
 				CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */,
 				CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */,
 				CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */,
+				CE25124C29C55816000790AB /* UTMScriptingConfigImpl.swift */,
 			);
 			);
 			path = Scripting;
 			path = Scripting;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -4271,6 +4274,7 @@
 				CE0B6D6124AD584D00FE012D /* qapi-types-authz.c in Sources */,
 				CE0B6D6124AD584D00FE012D /* qapi-types-authz.c in Sources */,
 				848F71EA277A2A4E006A0240 /* UTMSerialPort.swift in Sources */,
 				848F71EA277A2A4E006A0240 /* UTMSerialPort.swift in Sources */,
 				CE0B6D6D24AD584D00FE012D /* qapi-visit-tpm.c in Sources */,
 				CE0B6D6D24AD584D00FE012D /* qapi-visit-tpm.c in Sources */,
+				CE25124D29C55816000790AB /* UTMScriptingConfigImpl.swift in Sources */,
 				CE0B6D0224AD56AE00FE012D /* UTMQemu.m in Sources */,
 				CE0B6D0224AD56AE00FE012D /* UTMQemu.m in Sources */,
 				CEF0306026A2AFDF00667B63 /* VMWizardState.swift in Sources */,
 				CEF0306026A2AFDF00667B63 /* VMWizardState.swift in Sources */,
 				CEF0300826A25A6900667B63 /* VMWizardView.swift in Sources */,
 				CEF0300826A25A6900667B63 /* VMWizardView.swift in Sources */,