From 8839b605eaba6caf330a9589c04eed136a1657b6 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Wed, 14 Dec 2022 22:13:22 -0800 Subject: [PATCH] utmctl: rewrote to use scripting bridge --- Platform/macOS/macOS-unsigned.entitlements | 2 - Platform/macOS/macOS.entitlements | 2 - .../UTMScriptingVirtualMachineImpl.swift | 6 +- utmctl/Info.plist | 4 - utmctl/UTMCtl.swift | 231 +++++++++--------- utmctl/utmctl-unsigned.entitlements | 7 + utmctl/utmctl.entitlements | 7 + 7 files changed, 127 insertions(+), 132 deletions(-) diff --git a/Platform/macOS/macOS-unsigned.entitlements b/Platform/macOS/macOS-unsigned.entitlements index 42385bba..216c2ec7 100644 --- a/Platform/macOS/macOS-unsigned.entitlements +++ b/Platform/macOS/macOS-unsigned.entitlements @@ -4,8 +4,6 @@ com.apple.security.app-sandbox - com.apple.security.automation.apple-events - com.apple.security.cs.disable-library-validation com.apple.security.device.audio-input diff --git a/Platform/macOS/macOS.entitlements b/Platform/macOS/macOS.entitlements index 8aa45b32..4b3bdf54 100644 --- a/Platform/macOS/macOS.entitlements +++ b/Platform/macOS/macOS.entitlements @@ -10,8 +10,6 @@ com.apple.security.device.audio-input - com.apple.security.automation.apple-events - com.apple.security.device.usb com.apple.security.files.user-selected.read-write diff --git a/Scripting/UTMScriptingVirtualMachineImpl.swift b/Scripting/UTMScriptingVirtualMachineImpl.swift index a5a57fd6..c334265b 100644 --- a/Scripting/UTMScriptingVirtualMachineImpl.swift +++ b/Scripting/UTMScriptingVirtualMachineImpl.swift @@ -120,7 +120,7 @@ class UTMScriptingVirtualMachineImpl: NSObject { } } - func start(_ command: NSScriptCommand) { + @objc func start(_ command: NSScriptCommand) { withScriptCommand(command) { [self] in if vm.state == .vmStopped { try await vm.vmStart() @@ -132,14 +132,14 @@ class UTMScriptingVirtualMachineImpl: NSObject { } } - func suspend(_ command: NSScriptCommand) { + @objc func suspend(_ command: NSScriptCommand) { let shouldSaveState = command.evaluatedArguments?["doneFlag"] as? Bool ?? false withScriptCommand(command) { [self] in try await vm.vmPause(save: shouldSaveState) } } - func stop(_ command: NSScriptCommand) { + @objc func stop(_ command: NSScriptCommand) { let stopMethod = command.evaluatedArguments?["stopBy"] as? UTMScriptingStopMethod ?? .force withScriptCommand(command) { [self] in switch stopMethod { diff --git a/utmctl/Info.plist b/utmctl/Info.plist index 4680e5f0..23dee146 100644 --- a/utmctl/Info.plist +++ b/utmctl/Info.plist @@ -24,9 +24,5 @@ NSHumanReadableCopyright Copyright © 2022 osy. All rights reserved. - AppGroupIdentifier - $(TeamIdentifierPrefix:default=invalid.)$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM - AppBundleIdentifier - $(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM diff --git a/utmctl/UTMCtl.swift b/utmctl/UTMCtl.swift index 812c7927..dedaee2b 100644 --- a/utmctl/UTMCtl.swift +++ b/utmctl/UTMCtl.swift @@ -18,13 +18,14 @@ import Foundation import AppKit import ArgumentParser import Logging +import ScriptingBridge var logger = Logger(label: "com.utmapp.utmctl") { label in StreamLogHandler.standardError(label: label) } @main -struct UTMCtl: AsyncParsableCommand { +struct UTMCtl: ParsableCommand { static var configuration = CommandConfiguration( commandName: "utmctl", abstract: "CLI tool for controlling UTM virtual machines.", @@ -33,63 +34,34 @@ struct UTMCtl: AsyncParsableCommand { } /// Common interface for all subcommands -protocol UTMAPICommand: AsyncParsableCommand { - associatedtype Request: UTMAPIRequest = UTMAPI.AnyRequest +protocol UTMAPICommand: ParsableCommand { var environment: UTMCtl.EnvironmentOptions { get } - func run(with client: UTMAPIClient) async throws - func createRequest() -> Request - func printResponse(_ response: Request.Response) + func run(with application: UTMScriptingApplication) throws } extension UTMAPICommand { /// Entry point for all subcommands - func run() async throws { + func run() throws { logger.logLevel = environment.debug ? .debug : .info - let socketUrl = environment.socketPath?.asFileURL ?? defaultSocketUrl - let client = UTMAPIClient(connectPathUrl: socketUrl) - try await connectClient(client) - try await run(with: client) - try await client.disconnect() + guard let utmApp = SBApplication(url: utmAppUrl) else { + throw UTMCtl.APIError.applicationNotFound + } + utmApp.delegate = UTMCtl.EventErrorHandler.shared + try run(with: utmApp) } - /// Attempt to connect to the server and spawn UTM if it is not running - /// - Parameter client: API client - private func connectClient(_ client: UTMAPIClient) async throws { - do { - try await client.connect() - } catch UTMAPI.APIError.serverNotFound { - logger.info("Launching UTM...") - let config = NSWorkspace.OpenConfiguration() - config.arguments = ["cli-request"] - try await NSWorkspace.shared.openApplication(at: utmAppUrl, configuration: config) - repeat { - do { - try await client.connect() - } catch UTMAPI.APIError.serverNotFound { - // try again after a cooldown - try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) - } - } while await !client.isConnected + /// Get a virtual machine from an identifier + /// - Parameters: + /// - identifier: Identifier + /// - application: Scripting bridge application + /// - Returns: Virtual machine for identifier + func virtualMachine(forIdentifier identifier: UTMCtl.VMIdentifier, in application: UTMScriptingApplication) throws -> UTMScriptingVirtualMachine { + let list = application.virtualMachines!() + guard let vm = list.object(withID: identifier.identifier) as? UTMScriptingVirtualMachine else { + throw UTMCtl.APIError.virtualMachineNotFound } - } - - /// Socket either in app group or app sandbox - private var defaultSocketUrl: URL { - let appGroup = Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String - let appBundle = Bundle.main.infoDictionary?["AppBundleIdentifier"] as? String - // default to unsigned sandbox path - var parentURL: URL = try! FileManager.default.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: false) - parentURL.appendPathComponent("Containers") - parentURL.appendPathComponent(appBundle ?? "com.utmapp.UTM") - parentURL.appendPathComponent("Data") - parentURL.appendPathComponent("tmp") - if let appGroup = appGroup, !appGroup.hasPrefix("invalid.") { - if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) { - parentURL = containerURL - } - } - return parentURL.appendingPathComponent("api.sock") + return vm } /// Find the path to UTM.app @@ -102,46 +74,49 @@ extension UTMAPICommand { } return URL(fileURLWithPath: "/Applications/UTM.app") } - - /// Default implementation of run command - /// - Parameter client: Connected client - func run(with client: UTMAPIClient) async throws { - let request = createRequest() - do { - let response = try await client.process(request: request) - if environment.machineReadable { - printJson(for: response) - } else { - printResponse(response) - } - } catch { - if environment.machineReadable { - let jsonError = UTMAPI.ErrorResponse(error.localizedDescription) - printJson(for: jsonError) - } else { - throw error +} + +extension UTMCtl { + @objc class EventErrorHandler: NSObject, SBApplicationDelegate { + static let shared = EventErrorHandler() + + /// Error handler for scripting events + /// - Parameters: + /// - event: Event that caused the error + /// - error: Error + /// - Returns: nil + func eventDidFail(_ event: UnsafePointer, withError error: Error) -> Any? { + logger.error("Error from event: \(error.localizedDescription)") + return nil + } + } +} + +extension UTMCtl { + enum APIError: Error, LocalizedError { + case applicationNotFound + case virtualMachineNotFound + + var localizedDescription: String { + switch self { + case .applicationNotFound: return "Application not found." + case .virtualMachineNotFound: return "Virtual machine not found." } } } - - /// Default placeholder for createRequest - /// - Returns: Nothing - func createRequest() -> Request { - fatalError("You must implement createRequest() if you use the default run(with:)!") - } - - /// Prints the JSON response - /// - Parameter response: Response from server - func printJson(for response: any UTMAPIResponse) { - let data = try! UTMAPI.encode(response) - let json = String(data: data, encoding: .utf8)! - print(json) - } - - /// Default placeholder for printResponse - /// - Parameter response: Response to print - func printResponse(_ response: Request.Response) { - // default no response is printed +} + +fileprivate extension UTMScriptingStatus { + var asString: String { + switch self { + case .stopped: return "stopped" + case .starting: return "starting" + case .started: return "started" + case .pausing: return "pausing" + case .paused: return "paused" + case .resuming: return "resuming" + case .stopping: return "stopping" + } } } @@ -153,15 +128,17 @@ extension UTMCtl { @OptionGroup var environment: EnvironmentOptions - func createRequest() -> UTMAPI.ListRequest { - UTMAPI.ListRequest() + func run(with application: UTMScriptingApplication) throws { + if let list = application.virtualMachines!() as? [UTMScriptingVirtualMachine] { + printResponse(list) + } } - func printResponse(_ response: UTMAPI.ListResponse) { + func printResponse(_ response: [UTMScriptingVirtualMachine]) { print("UUID Status Name") - for entry in response.entries { - let status = entry.status.rawValue.padding(toLength: 8, withPad: " ", startingAt: 0) - print("\(entry.uuid) \(status) \(entry.name)") + for entry in response { + let status = entry.status!.asString.padding(toLength: 8, withPad: " ", startingAt: 0) + print("\(entry.id!()) \(status) \(entry.name!)") } } } @@ -177,14 +154,14 @@ extension UTMCtl { @OptionGroup var identifer: VMIdentifier - func createRequest() -> UTMAPI.StatusRequest { - var request = UTMAPI.StatusRequest() - request.identifier = identifer.identifier - return request + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + printResponse(vm) + } - func printResponse(_ response: UTMAPI.StatusResponse) { - print(response.status) + func printResponse(_ vm: UTMScriptingVirtualMachine) { + print(vm.status!.asString) } } } @@ -202,15 +179,14 @@ extension UTMCtl { @Flag(name: .shortAndLong, help: "Attach to the first serial port after start.") var attach: Bool = false - func run(with client: UTMAPIClient) async throws { - var request = UTMAPI.StartRequest() - request.identifier = identifer.identifier - _ = try await client.process(request: request) + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + vm.start!() if attach { var attachCommand = Attach() attachCommand.environment = environment attachCommand.identifer = identifer - try await attachCommand.run(with: client) + try attachCommand.run(with: application) } } } @@ -229,11 +205,9 @@ extension UTMCtl { @Flag(name: .shortAndLong, help: "Save the VM state before suspending.") var saveState: Bool = false - func createRequest() -> UTMAPI.SuspendRequest { - var request = UTMAPI.SuspendRequest() - request.identifier = identifer.identifier - request.shouldSaveState = saveState - return request + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + vm.suspendSaving!(saveState) } } } @@ -277,17 +251,17 @@ extension UTMCtl { @OptionGroup var style: Style - func createRequest() -> UTMAPI.StopRequest { - var request = UTMAPI.StopRequest() - request.identifier = identifer.identifier + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + var stopMethod: UTMScriptingStopMethod = .force if style.request { - request.type = .request + stopMethod = .request } else if style.force { - request.type = .force + stopMethod = .force } else if style.kill { - request.type = .kill + stopMethod = .kill } - return request + vm.stopBy!(stopMethod) } } } @@ -311,16 +285,31 @@ extension UTMCtl { self.index = nil } - func createRequest() -> UTMAPI.SerialRequest { - var request = UTMAPI.SerialRequest() - request.identifier = identifer.identifier - request.index = index - return request + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + guard let serialPorts = vm.serialPorts!() as? [UTMScriptingSerialPort] else { + return + } + for serialPort in serialPorts { + if let index = index { + if index != serialPort.id!() { + continue + } + } + if let interface = serialPort.interface, interface != .unavailable { + printResponse(serialPort) + return + } + } } - func printResponse(_ response: UTMAPI.SerialResponse) { + func printResponse(_ serialPort: UTMScriptingSerialPort) { // TODO: spawn a terminal emulator - print(response.address) + if serialPort.interface == .ptty { + print("PTTY: \(serialPort.address!)") + } else if serialPort.interface == .tcp { + print("TCP: \(serialPort.address!):\(serialPort.port!)") + } } } } diff --git a/utmctl/utmctl-unsigned.entitlements b/utmctl/utmctl-unsigned.entitlements index 08ba3a3f..97d41010 100644 --- a/utmctl/utmctl-unsigned.entitlements +++ b/utmctl/utmctl-unsigned.entitlements @@ -6,5 +6,12 @@ com.apple.security.network.client + com.apple.security.scripting-targets + + $(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM + + com.utmapp.UTM.vm-access + + diff --git a/utmctl/utmctl.entitlements b/utmctl/utmctl.entitlements index 1037ebd2..8760fa97 100644 --- a/utmctl/utmctl.entitlements +++ b/utmctl/utmctl.entitlements @@ -10,5 +10,12 @@ com.apple.security.network.client + com.apple.security.scripting-targets + + $(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM + + com.utmapp.UTM.vm-access + +