utmctl: rewrote to use scripting bridge
This commit is contained in:
parent
7e71fefb1e
commit
8839b605ea
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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!)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue