Просмотр исходного кода

scripting: add guest suite

Support read/write files and execute commands via QEMU guest agent.
osy 2 лет назад
Родитель
Сommit
22e7e4284d

+ 213 - 1
Scripting/UTM.sdef

@@ -221,5 +221,217 @@
             description="Port number of the serial port (not used in some interface types)."/>
         </class>
     </suite>
-
+    
+    <suite name="UTM Guest Suite" code="UTMg" description="UTM virtual machine guest scripting suite. In order to use these commands, QEMU guest agent must be running.">
+        <access-group identifier="com.utmapp.UTM.vm-access" />
+        
+        <class-extension extends="virtual machine" description="Guest agent access.">
+            <element type="guest file" access="r"
+              description="Open files for this virtual machine from the guest agent.">
+              <cocoa key="openFiles"/>
+            </element>
+            
+            <element type="guest process" access="r"
+              description="Processe executed on this virtual machine from the guest agent.">
+              <cocoa key="processes"/>
+            </element>
+            
+            <responds-to command="open file">
+              <cocoa method="openFile:"/>
+            </responds-to>
+            <responds-to command="execute">
+              <cocoa method="execute:"/>
+            </responds-to>
+            <responds-to command="query ip">
+              <cocoa method="queryIp:"/>
+            </responds-to>
+        </class-extension>
+        
+        <enumeration name="open mode" code="OpMo" description="File open mode.">
+            <enumerator name="reading" code="OpRo" description="Open the file as read only. The file must exist."/>
+            <enumerator name="writing" code="OpWo" description="Open the file for writing. If the file does not exist, it will be created. If the file exists, it will be overwritten."/>
+            <enumerator name="appending" code="OpAp" description="Open the file for writing at the end. Offsets are ignored for writes. If the file does not exist, it will be created."/>
+        </enumeration>
+        
+        <command name="open file" code="UTMgOpEn" description="Open a file on the guest. You must close the file when you are done to prevent leaking guest resources.">
+          <direct-parameter description="Virtual machine of the guest." type="virtual machine"/>
+          <parameter name="at" code="OpPt" description="The guest path of the file to open." type="text">
+            <cocoa key="path"/>
+          </parameter>
+          <parameter name="for" code="OpMd" description="Open mode." type="open mode" optional="yes">
+            <cocoa key="mode"/>
+          </parameter>
+          <parameter name="updating" code="OpAp" description="If true, will open for both reading and writing. The file existance requirement and creation is still governed by the open mode. Default is false." type="boolean" optional="yes">
+            <cocoa key="isUpdate"/>
+          </parameter>
+          <result type="guest file" description="Guest file to operate on."/>
+        </command>
+        
+        <command name="execute" code="UTMgExEc" description="Execute a command or script on the guest.">
+          <direct-parameter description="Virtual machine of the guest." type="virtual machine"/>
+          <parameter name="at" code="ExPt" description="Either the full path of the executable to run or an executable found in the guest's PATH environment." type="text">
+            <cocoa key="path"/>
+          </parameter>
+          <parameter name="with arguments" code="ExAg" description="List of arguments to pass to the executable." optional="yes">
+            <cocoa key="argv"/>
+            <type type="text" list="yes"/>
+          </parameter>
+          <parameter name="with environment" code="ExEv" description="List of environment variables to pass to the executable. Each entry should be in the format NAME=VALUE." optional="yes">
+            <cocoa key="envp"/>
+            <type type="text" list="yes"/>
+          </parameter>
+          <parameter name="using input" code="ExIn" description="Data to feed into the process's standard input. If using base64 encoding, this should be a valid base64 string." type="text" optional="yes">
+            <cocoa key="input"/>
+          </parameter>
+          <parameter name="base64 encoding" code="Ex64" description="Input data is base64 encoded. The data will be decoded before being passed to the executable. Default is false." type="boolean" optional="yes">
+            <cocoa key="isBase64Encoded"/>
+          </parameter>
+          <parameter name="output capturing" code="ExOc" description="If true, the standard output and error will be captured and accessible in the returned object. You need to call update on the object to get the data. Default is false." type="boolean" optional="yes">
+            <cocoa key="isCaptureOutput"/>
+          </parameter>
+          <result type="guest process" description="Guest process that can be used to fetch the return value and outputs (if captured)."/>
+        </command>
+        
+        <command name="query ip" code="UTMgIpAd" description="Query the guest for all IP addresses on its network interfaces (excluding loopback).">
+          <direct-parameter description="Virtual machine of the guest." type="virtual machine"/>
+          <result description="List of IP addresses on all network interfaces (excluding loopback). Both IPv4 and IPv6 addresses can be returned. IPv4 addresses will show up before IPv6 addresses if any are available.">
+            <type type="text" list="yes"/>
+          </result>
+        </command>
+        
+        <class name="guest file" code="GuFi" description="A file that resides on the guest." plural="guest files">
+          <cocoa class="UTMScriptingGuestFileImpl"/>
+          <property name="id" code="ID  " type="integer" access="r"
+            description="The handle for the file."/>
+            
+          <responds-to command="read">
+            <cocoa method="read:"/>
+          </responds-to>
+          
+          <responds-to command="pull">
+            <cocoa method="pull:"/>
+          </responds-to>
+          
+          <responds-to command="write">
+            <cocoa method="write:"/>
+          </responds-to>
+          
+          <responds-to command="push">
+            <cocoa method="push:"/>
+          </responds-to>
+          
+          <responds-to command="close">
+            <cocoa method="close:"/>
+          </responds-to>
+        </class>
+        
+        <enumeration name="whence" code="WeCe" description="Where to offset from.">
+            <enumerator name="start position" code="StRt" description="The start of the file (only positive offsets)."/>
+            <enumerator name="current position" code="CuRr" description="The current pointer (both positive and negative offsets)."/>
+            <enumerator name="end position" code="UnAv" description="The end of the file (only negative offsets for reads, both for writes)."/>
+        </enumeration>
+        
+        <command name="read" code="GuFiReAd" description="Reads text data from a guest file.">
+          <direct-parameter description="Guest file to read." type="guest file"/>
+          <parameter name="at offset" code="RdOf" description="Specify the offset to start reading from. Default value is zero." type="integer" optional="yes">
+            <cocoa key="offset"/>
+          </parameter>
+          <parameter name="from" code="RdWh" description="Specify where the offset is from. Default value is from the current file pointer." type="whence" optional="yes">
+            <cocoa key="whence"/>
+          </parameter>
+          <parameter name="for length" code="RdLn" description="Amount of bytes to read. The limit is 48 MB. Default is to read until the end." type="integer" optional="yes">
+            <cocoa key="length"/>
+          </parameter>
+          <parameter name="base64 encoding" code="Rd64" description="If true, then the result will be base64 encoded. This is recommended if you are reading a binary file. Default is false." type="boolean" optional="yes">
+            <cocoa key="isBase64Encoded"/>
+          </parameter>
+          <parameter name="closing" code="RdCl" description="If true, the file will be closed after reading and must be opened again to perform more operations. If false, you can perform multiple reads on the same open file. The default is true." type="boolean" optional="yes">
+            <cocoa key="isClosing"/>
+          </parameter>
+          <result type="text" description="Data read from the guest file."/>
+        </command>
+        
+        <command name="pull" code="GuFiPuLl" description="Pulls a file from the guest to the host.">
+          <direct-parameter description="Guest file to pull." type="guest file"/>
+          <parameter name="to" code="kfil" description="The host file in which to save the guest file." type="file">
+            <cocoa key="file"/>
+          </parameter>
+          <parameter name="closing" code="PlCl" description="If true, the file will be closed after reading and must be opened again to perform more operations. If false, you can perform multiple reads on the same open file. The default is true." type="boolean" optional="yes">
+            <cocoa key="isClosing"/>
+          </parameter>
+        </command>
+        
+        <command name="write" code="GuFiWrIt" description="Writes text data to a guest file.">
+          <direct-parameter description="Guest file to write." type="guest file"/>
+          <parameter name="with data" code="WrDt" description="Data to write to the guest file. If base64 encoding is specified, this should be a valid base64 string which will be decoded before writing." type="text">
+            <cocoa key="data"/>
+          </parameter>
+          <parameter name="at offset" code="WrOf" description="Specify the offset to start writing to. Default value is zero." type="integer" optional="yes">
+            <cocoa key="offset"/>
+          </parameter>
+          <parameter name="from" code="WrWh" description="Specify where the offset is from. Default value is from the current file pointer." type="whence" optional="yes">
+            <cocoa key="whence"/>
+          </parameter>
+          <parameter name="base64 encoding" code="Wr64" description="If true, then the input data is base64 encoded. This is recommended if you are writing a binary file. Default is false." type="boolean" optional="yes">
+            <cocoa key="isBase64Encoded"/>
+          </parameter>
+          <parameter name="closing" code="WrCl" description="If true, the file will be closed after writing and must be opened again to perform more operations. If false, you can perform multiple reads on the same open file. The default is true." type="boolean" optional="yes">
+            <cocoa key="isClosing"/>
+          </parameter>
+        </command>
+        
+        <command name="push" code="GuFiPuSh" description="Pushes a file from the host to the guest and closes it.">
+          <direct-parameter description="Guest file to push." type="guest file"/>
+          <parameter name="from" code="kfil" description="The host file in which to send to the guest." type="file">
+            <cocoa key="file"/>
+          </parameter>
+          <parameter name="closing" code="PsCl" description="If true, the file will be closed after writing and must be opened again to perform more operations. If false, you can perform multiple reads on the same open file. The default is true." type="boolean" optional="yes">
+            <cocoa key="isClosing"/>
+          </parameter>
+        </command>
+        
+        <command name="close" code="GuFiClOs" description="Closes the file and prevent further operations.">
+          <direct-parameter description="Guest file to close." type="guest file"/>
+        </command>
+        
+        <class name="guest process" code="GuPr" description="A process on the guest." plural="guest processes">
+          <cocoa class="UTMScriptingGuestProcessImpl"/>
+          <property name="id" code="ID  " type="integer" access="r"
+            description="The PID of the process."/>
+          
+          <responds-to command="get result">
+            <cocoa method="getResult:"/>
+          </responds-to>
+        </class>
+        
+        <record-type name="execute result" code="ExRs" description="Process results after execution.">
+          <property name="exited" code="GuEx" type="boolean" access="r"
+            description="If true, the process has terminated.">
+            <cocoa key="hasExited" />
+          </property>
+            
+          <property name="exit code" code="GuEc" type="integer" access="r"
+            description="Exit code if it was normally terminated."/>
+            
+          <property name="signal code" code="GuSg" type="integer" access="r"
+            description="Signal number (Linux) or unhandled exception code (Windows) if the process was abnormally terminated."/>
+            
+          <property name="output text" code="GuOt" type="text" access="r"
+            description="If capture is enabled, the stdout of the process as text."/>
+            
+          <property name="error text" code="GuEr" type="text" access="r"
+            description="If capture is enabled, the stderr of the process as text."/>
+            
+          <property name="output data" code="GuOd" type="text" access="r"
+            description="If capture is enabled, the stdout of the process as base64 encoded data."/>
+            
+          <property name="error data" code="GuEd" type="text" access="r"
+            description="If capture is enabled, the stderr of the process as base64 encoded data."/>
+        </record-type>
+        
+        <command name="get result" code="GuPrGeRs" description="Fetch execution result from the guest.">
+          <direct-parameter description="Guest process to fetch result from." type="guest process"/>
+          <result type="execute result" description="Result from the guest."/>
+        </command>
+    </suite>
 </dictionary>

+ 88 - 0
Scripting/UTMScriptable.swift

@@ -0,0 +1,88 @@
+//
+// 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
+
+protocol UTMScriptable: Equatable {}
+
+extension UTMScriptable {
+    /// Run a script command asynchronously
+    /// - Parameters:
+    ///   - command: Script command to run
+    ///   - body: What to do
+    @MainActor
+    func withScriptCommand<Result>(_ command: NSScriptCommand, body: @MainActor @escaping () async throws -> Result) {
+        guard command.evaluatedReceivers as? Self == self else {
+            return
+        }
+        command.suspendExecution()
+        // we need to run this in next event loop due to the need to return before calling resume
+        DispatchQueue.main.async {
+            Task {
+                do {
+                    let result = try await body()
+                    await MainActor.run {
+                        if result is Void {
+                            command.resumeExecution(withResult: nil)
+                        } else {
+                            command.resumeExecution(withResult: result)
+                        }
+                    }
+                } catch {
+                    await MainActor.run {
+                        command.scriptErrorNumber = errOSAGeneralError
+                        command.scriptErrorString = error.localizedDescription
+                        command.resumeExecution(withResult: nil)
+                    }
+                }
+            }
+        }
+    }
+    
+    /// Convert text to data either as a UTF-8 string or as binary encoded in base64
+    /// - Parameters:
+    ///   - text: Text input
+    ///   - isBase64Encoded: If true, the data will be decoded from base64
+    /// - Returns: Data or nil on error (or if text was nil)
+    func dataFromText(_ text: String?, isBase64Encoded: Bool = false) -> Data? {
+        if let text = text {
+            if isBase64Encoded {
+                return Data(base64Encoded: text)
+            } else {
+                return text.data(using: .utf8)
+            }
+        } else {
+            return nil
+        }
+    }
+    
+    /// Convert data to either UTF-8 string or as binary encoded in base64
+    /// - Parameters:
+    ///   - data: Data input
+    ///   - isBase64Encoded: If true, the text will be encoded to base64
+    /// - Returns: Text or nil on error (or if data was nil)
+    func textFromData(_ data: Data?, isBase64Encoded: Bool = false) -> String? {
+        if let data = data {
+            if isBase64Encoded {
+                return data.base64EncodedString()
+            } else {
+                return String(data: data, encoding: .utf8)
+            }
+        } else {
+            return nil
+        }
+    }
+}

+ 42 - 3
Scripting/UTMScripting.swift

@@ -18,6 +18,8 @@
 
 public enum UTMScripting: String {
     case application = "application"
+    case guestFile = "guest file"
+    case guestProcess = "guest process"
     case serialPort = "serial port"
     case virtualMachine = "virtual machine"
     case window = "window"
@@ -74,6 +76,20 @@ import ScriptingBridge
     case unavailable = 0x49556e41 /* 'IUnA' */
 }
 
+// MARK: UTMScriptingOpenMode
+@objc public enum UTMScriptingOpenMode : AEKeyword {
+    case reading = 0x4f70526f /* 'OpRo' */
+    case writing = 0x4f70576f /* 'OpWo' */
+    case appending = 0x4f704170 /* 'OpAp' */
+}
+
+// MARK: UTMScriptingWhence
+@objc public enum UTMScriptingWhence : AEKeyword {
+    case startPosition = 0x53745274 /* 'StRt' */
+    case currentPosition = 0x43755272 /* 'CuRr' */
+    case endPosition = 0x556e4176 /* 'UnAv' */
+}
+
 // MARK: UTMScriptingGenericMethods
 @objc public protocol UTMScriptingGenericMethods {
     @objc optional func close() // Close a document.
@@ -125,9 +141,14 @@ extension SBObject: UTMScriptingWindow {}
     @objc optional var memory: String { get } // RAM size.
     @objc optional var backend: UTMScriptingBackend { get } // Emulation/virtualization engine used.
     @objc optional var status: UTMScriptingStatus { get } // Current running status.
-    @objc optional func startSaving(_ saving: Bool)
-    @objc optional func suspendSaving(_ saving: Bool)
-    @objc optional func stopBy(_ by: UTMScriptingStopMethod)
+    @objc optional func startSaving(_ saving: Bool) // Start a virtual machine or resume a suspended virtual machine.
+    @objc optional func suspendSaving(_ saving: Bool) // Suspend a running virtual machine to memory.
+    @objc optional func stopBy(_ by: UTMScriptingStopMethod) // Shuts down a running virtual machine.
+    @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 queryIp() -> [Any] // Query the guest for all IP addresses on its network interfaces (excluding loopback).
+    @objc optional func guestFiles() -> SBElementArray
+    @objc optional func guestProcesses() -> SBElementArray
 }
 extension SBObject: UTMScriptingVirtualMachine {}
 
@@ -140,3 +161,21 @@ extension SBObject: UTMScriptingVirtualMachine {}
 }
 extension SBObject: UTMScriptingSerialPort {}
 
+// MARK: UTMScriptingGuestFile
+@objc public protocol UTMScriptingGuestFile: SBObjectProtocol, UTMScriptingGenericMethods {
+    @objc optional func id() -> Int // The handle for the file.
+    @objc optional func readAtOffset(_ atOffset: Int, from: UTMScriptingWhence, forLength: Int, base64Encoding: Bool, closing: Bool) -> String // Reads text data from a guest file.
+    @objc optional func pullTo(_ to: URL!, closing: Bool) // Pulls a file from the guest to the host.
+    @objc optional func writeWithData(_ withData: String!, atOffset: Int, from: UTMScriptingWhence, base64Encoding: Bool, closing: Bool) // Writes text data to a guest file.
+    @objc optional func pushFrom(_ from: URL!, closing: Bool) // Pushes a file from the host to the guest and closes it.
+    @objc optional func close() // Closes the file and prevent further operations.
+}
+extension SBObject: UTMScriptingGuestFile {}
+
+// MARK: UTMScriptingGuestProcess
+@objc public protocol UTMScriptingGuestProcess: SBObjectProtocol, UTMScriptingGenericMethods {
+    @objc optional func id() -> Int // The PID of the process.
+    @objc optional func getResult() -> [AnyHashable : Any] // Fetch execution result from the guest.
+}
+extension SBObject: UTMScriptingGuestProcess {}
+

+ 191 - 0
Scripting/UTMScriptingGuestFileImpl.swift

@@ -0,0 +1,191 @@
+//
+// 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
+
+@MainActor
+@objc(UTMScriptingGuestFileImpl)
+class UTMScriptingGuestFileImpl: NSObject, UTMScriptable {
+    @objc private(set) var id: Int
+    
+    weak private var parent: UTMScriptingVirtualMachineImpl?
+    weak private var guestAgent: UTMQemuGuestAgent?
+    
+    init(from handle: Int, parent: UTMScriptingVirtualMachineImpl) {
+        self.id = handle
+        self.parent = parent
+        self.guestAgent = parent.guestAgent
+    }
+    
+    override var objectSpecifier: NSScriptObjectSpecifier? {
+        guard let parent = parent else {
+            return nil
+        }
+        guard let parentDescription = parent.classDescription as? NSScriptClassDescription else {
+            return nil
+        }
+        let parentSpecifier = parent.objectSpecifier
+        return NSUniqueIDSpecifier(containerClassDescription: parentDescription,
+                                   containerSpecifier: parentSpecifier,
+                                   key: "openFiles",
+                                   uniqueID: id)
+    }
+    
+    private func seek(to offset: Int, whence: AEKeyword?, using guestAgent: UTMQemuGuestAgent) async throws {
+        let seek: QGASeek
+        if let whence = whence {
+            switch UTMScriptingWhence(rawValue: whence) {
+            case .startPosition: seek = QGA_SEEK_SET
+            case .currentPosition: seek = QGA_SEEK_CUR
+            case .endPosition: seek = QGA_SEEK_END
+            default: seek = QGA_SEEK_SET
+            }
+        } else {
+            seek = QGA_SEEK_SET
+        }
+        try await guestAgent.guestFileSeek(id, offset: offset, whence: seek)
+    }
+    
+    @objc func read(_ command: NSScriptCommand) {
+        let id = self.id
+        let offset = command.evaluatedArguments?["offset"] as? Int
+        let whence = command.evaluatedArguments?["whence"] as? AEKeyword
+        let length = command.evaluatedArguments?["length"] as? Int
+        let isBase64Encoded = command.evaluatedArguments?["isBase64Encoded"] as? Bool ?? false
+        let isClosing = command.evaluatedArguments?["isClosing"] as? Bool ?? true
+        withScriptCommand(command) { [self] in
+            guard let guestAgent = guestAgent else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.guestAgentNotRunning
+            }
+            defer {
+                if isClosing {
+                    guestAgent.guestFileClose(id)
+                }
+            }
+            if let offset = offset {
+                try await seek(to: offset, whence: whence, using: guestAgent)
+            }
+            if let length = length {
+                let data = try await guestAgent.guestFileRead(id, count: length)
+                return textFromData(data, isBase64Encoded: isBase64Encoded)
+            }
+            var data: Data
+            var allData = Data()
+            repeat {
+                data = try await guestAgent.guestFileRead(id, count: 4096)
+                allData += data
+            } while data.count > 0
+            return textFromData(allData, isBase64Encoded: isBase64Encoded)
+        }
+    }
+    
+    @objc func pull(_ command: NSScriptCommand) {
+        let id = self.id
+        let file = command.evaluatedArguments?["file"] as? URL
+        let isClosing = command.evaluatedArguments?["isClosing"] as? Bool ?? true
+        withScriptCommand(command) { [self] in
+            guard let guestAgent = guestAgent else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.guestAgentNotRunning
+            }
+            defer {
+                if isClosing {
+                    guestAgent.guestFileClose(id)
+                }
+            }
+            guard let file = file else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.invalidParameter
+            }
+            try await guestAgent.guestFileSeek(id, offset: 0, whence: QGA_SEEK_SET)
+            _ = file.startAccessingSecurityScopedResource()
+            defer {
+                file.stopAccessingSecurityScopedResource()
+            }
+            let handle = try FileHandle(forWritingTo: file)
+            var data: Data
+            repeat {
+                data = try await guestAgent.guestFileRead(id, count: 4096)
+                try handle.write(contentsOf: data)
+            } while data.count > 0
+            try await guestAgent.guestFileClose(id)
+        }
+    }
+    
+    @objc func write(_ command: NSScriptCommand) {
+        let id = self.id
+        let data = command.evaluatedArguments?["data"] as? String
+        let offset = command.evaluatedArguments?["offset"] as? Int
+        let whence = command.evaluatedArguments?["whence"] as? AEKeyword
+        let isBase64Encoded = command.evaluatedArguments?["isBase64Encoded"] as? Bool ?? false
+        let isClosing = command.evaluatedArguments?["isClosing"] as? Bool ?? true
+        withScriptCommand(command) { [self] in
+            guard let guestAgent = guestAgent else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.guestAgentNotRunning
+            }
+            defer {
+                if isClosing {
+                    guestAgent.guestFileClose(id)
+                }
+            }
+            guard let data = dataFromText(data, isBase64Encoded: isBase64Encoded) else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.invalidParameter
+            }
+            if let offset = offset {
+                try await seek(to: offset, whence: whence, using: guestAgent)
+            }
+            try await guestAgent.guestFileWrite(id, data: data)
+            try await guestAgent.guestFileFlush(id)
+        }
+    }
+    
+    @objc func push(_ command: NSScriptCommand) {
+        let id = self.id
+        let file = command.evaluatedArguments?["file"] as? URL
+        let isClosing = command.evaluatedArguments?["isClosing"] as? Bool ?? true
+        withScriptCommand(command) { [self] in
+            guard let guestAgent = guestAgent else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.guestAgentNotRunning
+            }
+            defer {
+                if isClosing {
+                    guestAgent.guestFileClose(id)
+                }
+            }
+            guard let file = file else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.invalidParameter
+            }
+            try await guestAgent.guestFileSeek(id, offset: 0, whence: QGA_SEEK_SET)
+            _ = file.startAccessingSecurityScopedResource()
+            defer {
+                file.stopAccessingSecurityScopedResource()
+            }
+            let handle = try FileHandle(forReadingFrom: file)
+            var data: Data
+            repeat {
+                data = try handle.read(upToCount: 4096) ?? Data()
+                try await guestAgent.guestFileWrite(id, data: data)
+            } while data.count > 0
+        }
+    }
+    
+    @objc func close(_ command: NSScriptCommand) {
+        withScriptCommand(command) { [self] in
+            guard let guestAgent = guestAgent else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.guestAgentNotRunning
+            }
+            try await guestAgent.guestFileClose(id)
+        }
+    }
+}

+ 64 - 0
Scripting/UTMScriptingGuestProcessImpl.swift

@@ -0,0 +1,64 @@
+//
+// 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
+
+@MainActor
+@objc(UTMScriptingGuestProcessImpl)
+class UTMScriptingGuestProcessImpl: NSObject, UTMScriptable {
+    @objc private(set) var id: Int
+    
+    weak private var parent: UTMScriptingVirtualMachineImpl?
+    weak private var guestAgent: UTMQemuGuestAgent?
+    
+    init(from pid: Int, parent: UTMScriptingVirtualMachineImpl) {
+        self.id = pid
+        self.parent = parent
+        self.guestAgent = parent.guestAgent
+    }
+    
+    override var objectSpecifier: NSScriptObjectSpecifier? {
+        guard let parent = parent else {
+            return nil
+        }
+        guard let parentDescription = parent.classDescription as? NSScriptClassDescription else {
+            return nil
+        }
+        let parentSpecifier = parent.objectSpecifier
+        return NSUniqueIDSpecifier(containerClassDescription: parentDescription,
+                                   containerSpecifier: parentSpecifier,
+                                   key: "processes",
+                                   uniqueID: id)
+    }
+    
+    @objc func getResult(_ command: NSScriptCommand) {
+        withScriptCommand(command) { [self] in
+            guard let guestAgent = guestAgent else {
+                throw UTMScriptingVirtualMachineImpl.ScriptingError.guestAgentNotRunning
+            }
+            let status = try await guestAgent.guestExecStatus(id)
+            return [
+                "hasExited": status.hasExited,
+                "exitCode": status.exitCode,
+                "signalCode": status.signal,
+                "outputText": textFromData(status.outData) ?? "",
+                "outputData": textFromData(status.outData, isBase64Encoded: true) ?? "",
+                "errorText": textFromData(status.errData) ?? "",
+                "errorData": textFromData(status.errData, isBase64Encoded: true) ?? "",
+            ]
+        }
+    }
+}

+ 103 - 29
Scripting/UTMScriptingVirtualMachineImpl.swift

@@ -18,7 +18,7 @@ import Foundation
 
 @MainActor
 @objc(UTMScriptingVirtualMachineImpl)
-class UTMScriptingVirtualMachineImpl: NSObject {
+class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
     private var vm: UTMVirtualMachine
     private var data: UTMData
     
@@ -79,6 +79,10 @@ class UTMScriptingVirtualMachineImpl: NSObject {
         }
     }
     
+    var guestAgent: UTMQemuGuestAgent? {
+        (vm as? UTMQemuVirtualMachine)?.guestAgent
+    }
+    
     override var objectSpecifier: NSScriptObjectSpecifier? {
         let appDescription = NSApplication.classDescription() as! NSScriptClassDescription
         return NSUniqueIDSpecifier(containerClassDescription: appDescription,
@@ -92,34 +96,6 @@ class UTMScriptingVirtualMachineImpl: NSObject {
         self.data = data
     }
     
-    private func withScriptCommand<Result>(_ command: NSScriptCommand, body: @MainActor @escaping () async throws -> Result) {
-        guard command.evaluatedReceivers as? Self == self else {
-            return
-        }
-        command.suspendExecution()
-        // we need to run this in next event loop due to the need to return before calling resume
-        DispatchQueue.main.async {
-            Task {
-                do {
-                    let result = try await body()
-                    await MainActor.run {
-                        if result is Void {
-                            command.resumeExecution(withResult: nil)
-                        } else {
-                            command.resumeExecution(withResult: result)
-                        }
-                    }
-                } catch {
-                    await MainActor.run {
-                        command.scriptErrorNumber = errOSAGeneralError
-                        command.scriptErrorString = error.localizedDescription
-                        command.resumeExecution(withResult: nil)
-                    }
-                }
-            }
-        }
-    }
-    
     @objc func start(_ command: NSScriptCommand) {
         let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? true
         withScriptCommand(command) { [self] in
@@ -173,17 +149,115 @@ class UTMScriptingVirtualMachineImpl: NSObject {
     }
 }
 
+// MARK: - Guest agent suite
+@objc extension UTMScriptingVirtualMachineImpl {
+    @nonobjc private func withGuestAgent<Result>(_ block: (UTMQemuGuestAgent) async throws -> Result) async throws -> Result {
+        guard vm.state == .vmStarted else {
+            throw ScriptingError.notRunning
+        }
+        guard let vm = vm as? UTMQemuVirtualMachine else {
+            throw ScriptingError.operationNotSupported
+        }
+        guard let guestAgent = vm.guestAgent else {
+            throw ScriptingError.guestAgentNotRunning
+        }
+        return try await block(guestAgent)
+    }
+    
+    @objc func valueInOpenFilesWithUniqueID(_ id: Int) -> UTMScriptingGuestFileImpl {
+        UTMScriptingGuestFileImpl(from: id, parent: self)
+    }
+    
+    @objc func openFile(_ command: NSScriptCommand) {
+        let path = command.evaluatedArguments?["path"] as? String
+        let mode = command.evaluatedArguments?["mode"] as? AEKeyword
+        let isUpdate = command.evaluatedArguments?["isUpdate"] as? Bool ?? false
+        withScriptCommand(command) { [self] in
+            guard let path = path else {
+                throw ScriptingError.invalidParameter
+            }
+            let modeValue: String
+            if let mode = mode {
+                switch UTMScriptingOpenMode(rawValue: mode) {
+                case .reading: modeValue = "r"
+                case .writing: modeValue = "w"
+                case .appending: modeValue = "a"
+                default: modeValue = "r"
+                }
+            } else {
+                modeValue = "r"
+            }
+            return try await withGuestAgent { guestAgent in
+                let handle = try await guestAgent.guestFileOpen(path, mode: modeValue + (isUpdate ? "+" : ""))
+                return UTMScriptingGuestFileImpl(from: handle, parent: self)
+            }
+        }
+    }
+    
+    @objc func valueInProcessesWithUniqueID(_ id: Int) -> UTMScriptingGuestProcessImpl {
+        UTMScriptingGuestProcessImpl(from: id, parent: self)
+    }
+    
+    @objc func execute(_ command: NSScriptCommand) {
+        let path = command.evaluatedArguments?["path"] as? String
+        let argv = command.evaluatedArguments?["argv"] as? [String]
+        let envp = command.evaluatedArguments?["envp"] as? [String]
+        let input = command.evaluatedArguments?["input"] as? String
+        let isBase64Encoded = command.evaluatedArguments?["isBase64Encoded"] as? Bool ?? false
+        let isCaptureOutput = command.evaluatedArguments?["isCaptureOutput"] as? Bool ?? false
+        let inputData = dataFromText(input, isBase64Encoded: isBase64Encoded)
+        withScriptCommand(command) { [self] in
+            guard let path = path else {
+                throw ScriptingError.invalidParameter
+            }
+            return try await withGuestAgent { guestAgent in
+                let pid = try await guestAgent.guestExec(path, argv: argv, envp: envp, input: inputData, captureOutput: isCaptureOutput)
+                return UTMScriptingGuestProcessImpl(from: pid, parent: self)
+            }
+        }
+    }
+    
+    @objc func queryIp(_ command: NSScriptCommand) {
+        withScriptCommand(command) { [self] in
+            try await withGuestAgent { guestAgent in
+                let interfaces = try await guestAgent.guestNetworkGetInterfaces()
+                var ipv4: [String] = []
+                var ipv6: [String] = []
+                for interface in interfaces {
+                    for ip in interface.ipAddresses {
+                        if ip.isIpV6Address {
+                            if ip.ipAddress != "::1" && ip.ipAddress != "0:0:0:0:0:0:0:1" {
+                                ipv6.append(ip.ipAddress)
+                            }
+                        } else {
+                            if ip.ipAddress != "127.0.0.1" {
+                                ipv4.append(ip.ipAddress)
+                            }
+                        }
+                    }
+                }
+                return ipv4 + ipv6
+            }
+        }
+    }
+}
+
+// MARK: - Errors
 extension UTMScriptingVirtualMachineImpl {
     enum ScriptingError: Error, LocalizedError {
         case operationNotAvailable
         case operationNotSupported
         case notRunning
+        case guestAgentNotRunning
+        case invalidParameter
         
         var errorDescription: String? {
             switch self {
             case .operationNotAvailable: return NSLocalizedString("Operation not available.", 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 .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")
             }
         }
     }

+ 12 - 0
UTM.xcodeproj/project.pbxproj

@@ -579,6 +579,9 @@
 		CE25124329BD4F10000790AB /* UTMQemuGuestAgent.m in Sources */ = {isa = PBXBuildFile; fileRef = CE25124229BD4F10000790AB /* UTMQemuGuestAgent.m */; };
 		CE25124429BD4F10000790AB /* UTMQemuGuestAgent.m in Sources */ = {isa = PBXBuildFile; fileRef = CE25124229BD4F10000790AB /* UTMQemuGuestAgent.m */; };
 		CE25124529BD4F10000790AB /* UTMQemuGuestAgent.m in Sources */ = {isa = PBXBuildFile; fileRef = CE25124229BD4F10000790AB /* UTMQemuGuestAgent.m */; };
+		CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */; };
+		CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */; };
+		CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124A29BFE273000790AB /* UTMScriptable.swift */; };
 		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 */; };
 		CE2D926E24AD46670059923A /* qapi-commands-crypto.c in Sources */ = {isa = PBXBuildFile; fileRef = CE23C0AE23FCEC01001177D6 /* qapi-commands-crypto.c */; };
@@ -2095,6 +2098,9 @@
 		CE25123C29BD47D0000790AB /* UTMQemuManager-Protected.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UTMQemuManager-Protected.h"; sourceTree = "<group>"; };
 		CE25124129BD4F10000790AB /* UTMQemuGuestAgent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMQemuGuestAgent.h; sourceTree = "<group>"; };
 		CE25124229BD4F10000790AB /* UTMQemuGuestAgent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UTMQemuGuestAgent.m; 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>"; };
+		CE25124A29BFE273000790AB /* UTMScriptable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptable.swift; 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>"; };
 		CE2B89352262B2F600C6D9D8 /* UTMVirtualMachineDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMVirtualMachineDelegate.h; sourceTree = "<group>"; };
@@ -3447,9 +3453,12 @@
 			isa = PBXGroup;
 			children = (
 				CEFE98DE29485237007CB7A8 /* UTM.sdef */,
+				CE25124A29BFE273000790AB /* UTMScriptable.swift */,
 				CEC794BB2949663C00121A9F /* UTMScripting.swift */,
 				CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */,
 				CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */,
+				CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */,
+				CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */,
 			);
 			path = Scripting;
 			sourceTree = "<group>";
@@ -4333,6 +4342,7 @@
 				CE0B6D6624AD584D00FE012D /* qapi-visit-machine-target.c in Sources */,
 				8443EFF42845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */,
 				CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
+				CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */,
 				CE0B6D1D24AD57FC00FE012D /* qapi-commands-net.c in Sources */,
 				CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */,
 				CE0B6D4224AD584C00FE012D /* qapi-types-dump.c in Sources */,
@@ -4386,7 +4396,9 @@
 				84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
 				CE0B6D7324AD584D00FE012D /* qapi-events-migration.c in Sources */,
 				CE020BA424AEDC7C00B44AB6 /* UTMData.swift in Sources */,
+				CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */,
 				848A98C8287206AE006F0550 /* VMConfigAppleVirtualizationView.swift in Sources */,
+				CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */,
 				CE0B6D1A24AD57FC00FE012D /* qapi-commands-qdev.c in Sources */,
 				CE2D958824AD4F990059923A /* VMConfigPortForwardForm.swift in Sources */,
 				CE0B6D5024AD584C00FE012D /* qapi-events-rocker.c in Sources */,