utmctl: rewrote to use scripting bridge

This commit is contained in:
osy 2022-12-14 22:13:22 -08:00
parent 7e71fefb1e
commit 8839b605ea
7 changed files with 127 additions and 132 deletions

View File

@ -4,8 +4,6 @@
<dict> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key> <key>com.apple.security.cs.disable-library-validation</key>
<true/> <true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>

View File

@ -10,8 +10,6 @@
</array> </array>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-write</key> <key>com.apple.security.files.user-selected.read-write</key>

View File

@ -120,7 +120,7 @@ class UTMScriptingVirtualMachineImpl: NSObject {
} }
} }
func start(_ command: NSScriptCommand) { @objc func start(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in withScriptCommand(command) { [self] in
if vm.state == .vmStopped { if vm.state == .vmStopped {
try await vm.vmStart() 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 let shouldSaveState = command.evaluatedArguments?["doneFlag"] as? Bool ?? false
withScriptCommand(command) { [self] in withScriptCommand(command) { [self] in
try await vm.vmPause(save: shouldSaveState) try await vm.vmPause(save: shouldSaveState)
} }
} }
func stop(_ command: NSScriptCommand) { @objc func stop(_ command: NSScriptCommand) {
let stopMethod = command.evaluatedArguments?["stopBy"] as? UTMScriptingStopMethod ?? .force let stopMethod = command.evaluatedArguments?["stopBy"] as? UTMScriptingStopMethod ?? .force
withScriptCommand(command) { [self] in withScriptCommand(command) { [self] in
switch stopMethod { switch stopMethod {

View File

@ -24,9 +24,5 @@
<false/> <false/>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>Copyright © 2022 osy. All rights reserved.</string> <string>Copyright © 2022 osy. All rights reserved.</string>
<key>AppGroupIdentifier</key>
<string>$(TeamIdentifierPrefix:default=invalid.)$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM</string>
<key>AppBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM</string>
</dict> </dict>
</plist> </plist>

View File

@ -18,13 +18,14 @@ import Foundation
import AppKit import AppKit
import ArgumentParser import ArgumentParser
import Logging import Logging
import ScriptingBridge
var logger = Logger(label: "com.utmapp.utmctl") { label in var logger = Logger(label: "com.utmapp.utmctl") { label in
StreamLogHandler.standardError(label: label) StreamLogHandler.standardError(label: label)
} }
@main @main
struct UTMCtl: AsyncParsableCommand { struct UTMCtl: ParsableCommand {
static var configuration = CommandConfiguration( static var configuration = CommandConfiguration(
commandName: "utmctl", commandName: "utmctl",
abstract: "CLI tool for controlling UTM virtual machines.", abstract: "CLI tool for controlling UTM virtual machines.",
@ -33,63 +34,34 @@ struct UTMCtl: AsyncParsableCommand {
} }
/// Common interface for all subcommands /// Common interface for all subcommands
protocol UTMAPICommand: AsyncParsableCommand { protocol UTMAPICommand: ParsableCommand {
associatedtype Request: UTMAPIRequest = UTMAPI.AnyRequest
var environment: UTMCtl.EnvironmentOptions { get } var environment: UTMCtl.EnvironmentOptions { get }
func run(with client: UTMAPIClient) async throws func run(with application: UTMScriptingApplication) throws
func createRequest() -> Request
func printResponse(_ response: Request.Response)
} }
extension UTMAPICommand { extension UTMAPICommand {
/// Entry point for all subcommands /// Entry point for all subcommands
func run() async throws { func run() throws {
logger.logLevel = environment.debug ? .debug : .info logger.logLevel = environment.debug ? .debug : .info
let socketUrl = environment.socketPath?.asFileURL ?? defaultSocketUrl guard let utmApp = SBApplication(url: utmAppUrl) else {
let client = UTMAPIClient(connectPathUrl: socketUrl) throw UTMCtl.APIError.applicationNotFound
try await connectClient(client) }
try await run(with: client) utmApp.delegate = UTMCtl.EventErrorHandler.shared
try await client.disconnect() try run(with: utmApp)
} }
/// Attempt to connect to the server and spawn UTM if it is not running /// Get a virtual machine from an identifier
/// - Parameter client: API client /// - Parameters:
private func connectClient(_ client: UTMAPIClient) async throws { /// - identifier: Identifier
do { /// - application: Scripting bridge application
try await client.connect() /// - Returns: Virtual machine for identifier
} catch UTMAPI.APIError.serverNotFound { func virtualMachine(forIdentifier identifier: UTMCtl.VMIdentifier, in application: UTMScriptingApplication) throws -> UTMScriptingVirtualMachine {
logger.info("Launching UTM...") let list = application.virtualMachines!()
let config = NSWorkspace.OpenConfiguration() guard let vm = list.object(withID: identifier.identifier) as? UTMScriptingVirtualMachine else {
config.arguments = ["cli-request"] throw UTMCtl.APIError.virtualMachineNotFound
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
} }
} return vm
/// 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")
} }
/// Find the path to UTM.app /// Find the path to UTM.app
@ -102,46 +74,49 @@ extension UTMAPICommand {
} }
return URL(fileURLWithPath: "/Applications/UTM.app") return URL(fileURLWithPath: "/Applications/UTM.app")
} }
}
/// Default implementation of run command
/// - Parameter client: Connected client extension UTMCtl {
func run(with client: UTMAPIClient) async throws { @objc class EventErrorHandler: NSObject, SBApplicationDelegate {
let request = createRequest() static let shared = EventErrorHandler()
do {
let response = try await client.process(request: request) /// Error handler for scripting events
if environment.machineReadable { /// - Parameters:
printJson(for: response) /// - event: Event that caused the error
} else { /// - error: Error
printResponse(response) /// - Returns: nil
} func eventDidFail(_ event: UnsafePointer<AppleEvent>, withError error: Error) -> Any? {
} catch { logger.error("Error from event: \(error.localizedDescription)")
if environment.machineReadable { return nil
let jsonError = UTMAPI.ErrorResponse(error.localizedDescription) }
printJson(for: jsonError) }
} else { }
throw error
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 fileprivate extension UTMScriptingStatus {
func createRequest() -> Request { var asString: String {
fatalError("You must implement createRequest() if you use the default run(with:)!") switch self {
} case .stopped: return "stopped"
case .starting: return "starting"
/// Prints the JSON response case .started: return "started"
/// - Parameter response: Response from server case .pausing: return "pausing"
func printJson(for response: any UTMAPIResponse) { case .paused: return "paused"
let data = try! UTMAPI.encode(response) case .resuming: return "resuming"
let json = String(data: data, encoding: .utf8)! case .stopping: return "stopping"
print(json) }
}
/// Default placeholder for printResponse
/// - Parameter response: Response to print
func printResponse(_ response: Request.Response) {
// default no response is printed
} }
} }
@ -153,15 +128,17 @@ extension UTMCtl {
@OptionGroup var environment: EnvironmentOptions @OptionGroup var environment: EnvironmentOptions
func createRequest() -> UTMAPI.ListRequest { func run(with application: UTMScriptingApplication) throws {
UTMAPI.ListRequest() if let list = application.virtualMachines!() as? [UTMScriptingVirtualMachine] {
printResponse(list)
}
} }
func printResponse(_ response: UTMAPI.ListResponse) { func printResponse(_ response: [UTMScriptingVirtualMachine]) {
print("UUID Status Name") print("UUID Status Name")
for entry in response.entries { for entry in response {
let status = entry.status.rawValue.padding(toLength: 8, withPad: " ", startingAt: 0) let status = entry.status!.asString.padding(toLength: 8, withPad: " ", startingAt: 0)
print("\(entry.uuid) \(status) \(entry.name)") print("\(entry.id!()) \(status) \(entry.name!)")
} }
} }
} }
@ -177,14 +154,14 @@ extension UTMCtl {
@OptionGroup var identifer: VMIdentifier @OptionGroup var identifer: VMIdentifier
func createRequest() -> UTMAPI.StatusRequest { func run(with application: UTMScriptingApplication) throws {
var request = UTMAPI.StatusRequest() let vm = try virtualMachine(forIdentifier: identifer, in: application)
request.identifier = identifer.identifier printResponse(vm)
return request
} }
func printResponse(_ response: UTMAPI.StatusResponse) { func printResponse(_ vm: UTMScriptingVirtualMachine) {
print(response.status) print(vm.status!.asString)
} }
} }
} }
@ -202,15 +179,14 @@ extension UTMCtl {
@Flag(name: .shortAndLong, help: "Attach to the first serial port after start.") @Flag(name: .shortAndLong, help: "Attach to the first serial port after start.")
var attach: Bool = false var attach: Bool = false
func run(with client: UTMAPIClient) async throws { func run(with application: UTMScriptingApplication) throws {
var request = UTMAPI.StartRequest() let vm = try virtualMachine(forIdentifier: identifer, in: application)
request.identifier = identifer.identifier vm.start!()
_ = try await client.process(request: request)
if attach { if attach {
var attachCommand = Attach() var attachCommand = Attach()
attachCommand.environment = environment attachCommand.environment = environment
attachCommand.identifer = identifer 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.") @Flag(name: .shortAndLong, help: "Save the VM state before suspending.")
var saveState: Bool = false var saveState: Bool = false
func createRequest() -> UTMAPI.SuspendRequest { func run(with application: UTMScriptingApplication) throws {
var request = UTMAPI.SuspendRequest() let vm = try virtualMachine(forIdentifier: identifer, in: application)
request.identifier = identifer.identifier vm.suspendSaving!(saveState)
request.shouldSaveState = saveState
return request
} }
} }
} }
@ -277,17 +251,17 @@ extension UTMCtl {
@OptionGroup var style: Style @OptionGroup var style: Style
func createRequest() -> UTMAPI.StopRequest { func run(with application: UTMScriptingApplication) throws {
var request = UTMAPI.StopRequest() let vm = try virtualMachine(forIdentifier: identifer, in: application)
request.identifier = identifer.identifier var stopMethod: UTMScriptingStopMethod = .force
if style.request { if style.request {
request.type = .request stopMethod = .request
} else if style.force { } else if style.force {
request.type = .force stopMethod = .force
} else if style.kill { } else if style.kill {
request.type = .kill stopMethod = .kill
} }
return request vm.stopBy!(stopMethod)
} }
} }
} }
@ -311,16 +285,31 @@ extension UTMCtl {
self.index = nil self.index = nil
} }
func createRequest() -> UTMAPI.SerialRequest { func run(with application: UTMScriptingApplication) throws {
var request = UTMAPI.SerialRequest() let vm = try virtualMachine(forIdentifier: identifer, in: application)
request.identifier = identifer.identifier guard let serialPorts = vm.serialPorts!() as? [UTMScriptingSerialPort] else {
request.index = index return
return request }
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 // 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!)")
}
} }
} }
} }

View File

@ -6,5 +6,12 @@
<false/> <false/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.scripting-targets</key>
<dict>
<key>$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM</key>
<array>
<string>com.utmapp.UTM.vm-access</string>
</array>
</dict>
</dict> </dict>
</plist> </plist>

View File

@ -10,5 +10,12 @@
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.scripting-targets</key>
<dict>
<key>$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM</key>
<array>
<string>com.utmapp.UTM.vm-access</string>
</array>
</dict>
</dict> </dict>
</plist> </plist>