784 lines
33 KiB
Swift
784 lines
33 KiB
Swift
//
|
|
// 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
|
|
import Combine
|
|
import Network
|
|
import SwiftConnect
|
|
import UserNotifications
|
|
|
|
let service = "_utm_server._tcp"
|
|
|
|
actor UTMRemoteServer {
|
|
fileprivate let data: UTMData
|
|
private let keyManager = UTMRemoteKeyManager(forClient: false)
|
|
private let center = UNUserNotificationCenter.current()
|
|
let state: State
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var notificationDelegate: NotificationDelegate?
|
|
private var listener: Task<Void, Error>?
|
|
private var pendingConnections: [State.ClientFingerprint: Connection] = [:]
|
|
private var establishedConnections: [State.ClientFingerprint: Remote] = [:]
|
|
|
|
private func _replaceCancellables(with set: Set<AnyCancellable>) {
|
|
cancellables = set
|
|
}
|
|
|
|
@MainActor
|
|
init(data: UTMData) {
|
|
let _state = State()
|
|
var _cancellables = Set<AnyCancellable>()
|
|
self.data = data
|
|
self.state = _state
|
|
|
|
_cancellables.insert(_state.$approvedClients.sink { approved in
|
|
Task {
|
|
await self.approvedClientsHasChanged(approved)
|
|
}
|
|
})
|
|
_cancellables.insert(_state.$blockedClients.sink { blocked in
|
|
Task {
|
|
await self.blockedClientsHasChanged(blocked)
|
|
}
|
|
})
|
|
_cancellables.insert(_state.$connectedClients.sink { connected in
|
|
Task {
|
|
await self.connectedClientsHasChanged(connected)
|
|
}
|
|
})
|
|
_cancellables.insert(_state.$serverAction.sink { action in
|
|
guard action != .none else {
|
|
return
|
|
}
|
|
Task {
|
|
switch action {
|
|
case .stop:
|
|
await self.stop()
|
|
break
|
|
case .start:
|
|
await self.start()
|
|
break
|
|
case .reset:
|
|
await self.resetServer()
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
self.state.requestServerAction(.none)
|
|
}
|
|
})
|
|
// this is a really ugly way to make sure that we keep a reference to the AnyCancellables even though
|
|
// we cannot access self._cancellables from init() due to it being associated with @MainActor.
|
|
// it should be fine because we only need to make sure the references are not dropped, we will never
|
|
// actually read from _cancellables
|
|
Task {
|
|
await self._replaceCancellables(with: _cancellables)
|
|
}
|
|
}
|
|
|
|
private func withErrorNotification(_ body: () async throws -> Void) async {
|
|
do {
|
|
try await body()
|
|
} catch {
|
|
if case .silentError(let error) = error as? ServerError {
|
|
logger.error("Error message inhibited: \(error)")
|
|
} else {
|
|
await notifyError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var metadata: NWTXTRecord {
|
|
NWTXTRecord(["Model": MacDevice.current.model])
|
|
}
|
|
|
|
func start() async {
|
|
do {
|
|
try await center.requestAuthorization(options: .alert)
|
|
} catch {
|
|
logger.error("Failed to authorize notifications.")
|
|
}
|
|
await withErrorNotification {
|
|
guard await !state.isServerActive else {
|
|
return
|
|
}
|
|
try await keyManager.load()
|
|
await state.setServerFingerprint(keyManager.fingerprint!)
|
|
registerNotifications()
|
|
listener = Task {
|
|
await withErrorNotification {
|
|
for try await connection in Connection.advertise(forServiceType: service, txtRecord: metadata, identity: keyManager.identity) {
|
|
if let connection = try? await Connection(connection: connection) {
|
|
await newRemoteConnection(connection)
|
|
}
|
|
}
|
|
}
|
|
await stop()
|
|
}
|
|
await state.setServerActive(true)
|
|
}
|
|
}
|
|
|
|
func stop() async {
|
|
await state.disconnectAll()
|
|
unregisterNotifications()
|
|
if let listener = listener {
|
|
self.listener = nil
|
|
listener.cancel()
|
|
_ = await listener.result
|
|
}
|
|
await state.setServerActive(false)
|
|
}
|
|
|
|
private func newRemoteConnection(_ connection: Connection) async {
|
|
let remoteAddress = connection.connection.endpoint.hostname ?? "\(connection.connection.endpoint)"
|
|
guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else {
|
|
connection.close()
|
|
return
|
|
}
|
|
guard await !state.isBlocked(fingerprint) else {
|
|
connection.close()
|
|
return
|
|
}
|
|
await state.seen(fingerprint, name: remoteAddress)
|
|
if await state.isApproved(fingerprint) {
|
|
await notifyNewConnection(remoteAddress: remoteAddress, fingerprint: fingerprint)
|
|
await establishConnection(connection)
|
|
} else {
|
|
pendingConnections[fingerprint] = connection
|
|
await notifyNewConnection(remoteAddress: remoteAddress, fingerprint: fingerprint, isUnknown: true)
|
|
}
|
|
}
|
|
|
|
private func approvedClientsHasChanged(_ approvedClients: Set<State.Client>) async {
|
|
for approvedClient in approvedClients {
|
|
if let connection = pendingConnections.removeValue(forKey: approvedClient.fingerprint) {
|
|
await establishConnection(connection)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func blockedClientsHasChanged(_ blockedClients: Set<State.Client>) {
|
|
for blockedClient in blockedClients {
|
|
if let connection = pendingConnections.removeValue(forKey: blockedClient.fingerprint) {
|
|
connection.close()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func connectedClientsHasChanged(_ connectedClients: Set<State.ClientFingerprint>) {
|
|
for client in establishedConnections.keys {
|
|
if !connectedClients.contains(client) {
|
|
if let remote = establishedConnections.removeValue(forKey: client) {
|
|
remote.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func establishConnection(_ connection: Connection) async {
|
|
guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else {
|
|
connection.close()
|
|
return
|
|
}
|
|
await withErrorNotification {
|
|
let remote = Remote()
|
|
let local = Local(server: self, client: remote)
|
|
let peer = Peer(connection: connection, localInterface: local)
|
|
remote.peer = peer
|
|
do {
|
|
try await remote.handshake()
|
|
} catch {
|
|
if let error = error as? NWError, case .posix(let code) = error, code == .ECONNRESET {
|
|
// if the user canceled the connection, we don't do anything
|
|
throw ServerError.silentError(error)
|
|
}
|
|
peer.close()
|
|
throw error
|
|
}
|
|
establishedConnections.updateValue(remote, forKey: fingerprint)
|
|
await state.connect(fingerprint)
|
|
}
|
|
}
|
|
|
|
private func resetServer() async {
|
|
await withErrorNotification {
|
|
try await keyManager.reset()
|
|
await state.setServerFingerprint(keyManager.fingerprint!)
|
|
}
|
|
}
|
|
|
|
/// Send message to every connected remote client.
|
|
///
|
|
/// If any are disconnected, we will gracefully handle the disconnect.
|
|
/// If `body` throws an error for any remote client (excluding NWError), then we ignore it.
|
|
/// - Parameter body: What to broadcast
|
|
func broadcast(_ body: @escaping (Remote) async throws -> Void) async {
|
|
enum BroadcastError: Error {
|
|
case connectionError(NWError, State.ClientFingerprint)
|
|
}
|
|
await withThrowingTaskGroup(of: Void.self) { group in
|
|
for (fingerprint, remote) in establishedConnections {
|
|
if Task.isCancelled {
|
|
break
|
|
}
|
|
group.addTask {
|
|
do {
|
|
try await body(remote)
|
|
} catch {
|
|
if let error = error as? NWError {
|
|
throw BroadcastError.connectionError(error, fingerprint)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
while !group.isEmpty {
|
|
switch await group.nextResult() {
|
|
case .failure(let error):
|
|
if case BroadcastError.connectionError(let error, let fingerprint) = error {
|
|
// disconnect any clients who failed to respond
|
|
await notifyError(error)
|
|
await state.disconnect(fingerprint)
|
|
} else {
|
|
logger.error("client returned error on broadcast: \(error)")
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteServer {
|
|
private class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|
private let state: UTMRemoteServer.State
|
|
|
|
init(state: UTMRemoteServer.State) {
|
|
self.state = state
|
|
}
|
|
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
|
.banner
|
|
}
|
|
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
|
Task {
|
|
let userInfo = response.notification.request.content.userInfo
|
|
guard let hexString = userInfo["FINGERPRINT"] as? String, let fingerprint = State.ClientFingerprint(hexString: hexString) else {
|
|
return
|
|
}
|
|
switch response.actionIdentifier {
|
|
case "ALLOW_ACTION":
|
|
await state.approve(fingerprint)
|
|
case "DENY_ACTION":
|
|
await state.block(fingerprint)
|
|
case "DISCONNECT_ACTION":
|
|
await state.disconnect(fingerprint)
|
|
default:
|
|
break
|
|
}
|
|
completionHandler()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func registerNotifications() {
|
|
let allowAction = UNNotificationAction(identifier: "ALLOW_ACTION",
|
|
title: NSString.localizedUserNotificationString(forKey: "Allow", arguments: nil),
|
|
options: [])
|
|
let denyAction = UNNotificationAction(identifier: "DENY_ACTION",
|
|
title: NSString.localizedUserNotificationString(forKey: "Deny", arguments: nil),
|
|
options: [])
|
|
let disconnectAction = UNNotificationAction(identifier: "DISCONNECT_ACTION",
|
|
title: NSString.localizedUserNotificationString(forKey: "Disconnect", arguments: nil),
|
|
options: [])
|
|
let unknownRemoteCategory = UNNotificationCategory(identifier: "UNKNOWN_REMOTE_CLIENT",
|
|
actions: [denyAction, allowAction],
|
|
intentIdentifiers: [],
|
|
hiddenPreviewsBodyPlaceholder: NSString.localizedUserNotificationString(forKey: "New unknown remote client connection.", arguments: nil),
|
|
options: .customDismissAction)
|
|
let trustedRemoteCategory = UNNotificationCategory(identifier: "TRUSTED_REMOTE_CLIENT",
|
|
actions: [disconnectAction],
|
|
intentIdentifiers: [],
|
|
hiddenPreviewsBodyPlaceholder: NSString.localizedUserNotificationString(forKey: "New trusted remote client connection.", arguments: nil),
|
|
options: [])
|
|
center.setNotificationCategories([unknownRemoteCategory, trustedRemoteCategory])
|
|
notificationDelegate = NotificationDelegate(state: state)
|
|
center.delegate = notificationDelegate
|
|
}
|
|
|
|
private func unregisterNotifications() {
|
|
center.setNotificationCategories([])
|
|
notificationDelegate = nil
|
|
center.delegate = nil
|
|
}
|
|
|
|
private func notifyNewConnection(remoteAddress: String, fingerprint: State.ClientFingerprint, isUnknown: Bool = false) async {
|
|
let settings = await center.notificationSettings()
|
|
let combinedFingerprint = (fingerprint ^ keyManager.fingerprint!).hexString()
|
|
guard settings.authorizationStatus == .authorized else {
|
|
logger.info("Notifications disabled, ignoring connection request from '\(remoteAddress)' with fingerprint '\(combinedFingerprint)'")
|
|
return
|
|
}
|
|
let content = UNMutableNotificationContent()
|
|
if isUnknown {
|
|
content.title = NSString.localizedUserNotificationString(forKey: "Unknown Remote Client", arguments: nil)
|
|
content.body = NSString.localizedUserNotificationString(forKey: "A client with fingerprint '%@' is attempting to connect.", arguments: [combinedFingerprint])
|
|
content.categoryIdentifier = "UNKNOWN_REMOTE_CLIENT"
|
|
} else {
|
|
content.title = NSString.localizedUserNotificationString(forKey: "Remote Client Connected", arguments: nil)
|
|
content.body = NSString.localizedUserNotificationString(forKey: "Established connection from %@.", arguments: [remoteAddress])
|
|
content.categoryIdentifier = "TRUSTED_REMOTE_CLIENT"
|
|
}
|
|
let clientFingerprint = fingerprint.hexString()
|
|
content.userInfo = ["FINGERPRINT": clientFingerprint]
|
|
let request = UNNotificationRequest(identifier: clientFingerprint,
|
|
content: content,
|
|
trigger: nil)
|
|
do {
|
|
try await center.add(request)
|
|
if !isUnknown {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(15)) {
|
|
self.center.removeDeliveredNotifications(withIdentifiers: [clientFingerprint])
|
|
}
|
|
}
|
|
} catch {
|
|
logger.error("Error sending remote connection request: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
fileprivate func notifyError(_ error: Error) async {
|
|
logger.error("UTM Remote Server error: '\(error)'")
|
|
let settings = await center.notificationSettings()
|
|
guard settings.authorizationStatus == .authorized else {
|
|
return
|
|
}
|
|
let content = UNMutableNotificationContent()
|
|
content.title = NSString.localizedUserNotificationString(forKey: "UTM Remote Server Error", arguments: nil)
|
|
content.body = error.localizedDescription
|
|
let request = UNNotificationRequest(identifier: UUID().uuidString,
|
|
content: content,
|
|
trigger: nil)
|
|
do {
|
|
try await center.add(request)
|
|
} catch {
|
|
logger.error("Error sending error notification: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteServer {
|
|
@MainActor
|
|
class State: ObservableObject {
|
|
typealias ClientFingerprint = [UInt8]
|
|
typealias ServerFingerprint = [UInt8]
|
|
struct Client: Codable, Identifiable, Hashable {
|
|
let fingerprint: ClientFingerprint
|
|
var name: String
|
|
var lastSeen: Date
|
|
|
|
var id: ClientFingerprint {
|
|
fingerprint
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(fingerprint)
|
|
}
|
|
|
|
static func == (lhs: Client, rhs: Client) -> Bool {
|
|
lhs.hashValue == rhs.hashValue
|
|
}
|
|
}
|
|
|
|
enum ServerAction {
|
|
case none
|
|
case stop
|
|
case start
|
|
case reset
|
|
}
|
|
|
|
@Published var allClients: [Client] {
|
|
didSet {
|
|
let all = Set(allClients)
|
|
approvedClients.subtract(approvedClients.subtracting(all))
|
|
blockedClients.subtract(blockedClients.subtracting(all))
|
|
connectedClients.subtract(connectedClients.subtracting(all.map({ $0.fingerprint })))
|
|
}
|
|
}
|
|
|
|
@Published var approvedClients: Set<Client> {
|
|
didSet {
|
|
UserDefaults.standard.setValue(try! approvedClients.propertyList(), forKey: "TrustedClients")
|
|
}
|
|
}
|
|
|
|
@Published var blockedClients: Set<Client> {
|
|
didSet {
|
|
UserDefaults.standard.setValue(try! blockedClients.propertyList(), forKey: "BlockedClients")
|
|
}
|
|
}
|
|
|
|
@Published var connectedClients = Set<ClientFingerprint>()
|
|
|
|
@Published var serverAction: ServerAction = .none
|
|
|
|
var isBusy: Bool {
|
|
serverAction != .none
|
|
}
|
|
|
|
@Published private(set) var isServerActive = false
|
|
|
|
@Published private(set) var serverFingerprint: ServerFingerprint = [] {
|
|
didSet {
|
|
UserDefaults.standard.setValue(serverFingerprint.hexString(), forKey: "ServerFingerprint")
|
|
}
|
|
}
|
|
|
|
init() {
|
|
var _approvedClients = Set<Client>()
|
|
if let array = UserDefaults.standard.array(forKey: "TrustedClients") {
|
|
if let clients = try? Set<Client>(fromPropertyList: array) {
|
|
_approvedClients = clients
|
|
}
|
|
}
|
|
self.approvedClients = _approvedClients
|
|
var _blockedClients = Set<Client>()
|
|
if let array = UserDefaults.standard.array(forKey: "BlockedClients") {
|
|
if let clients = try? Set<Client>(fromPropertyList: array) {
|
|
_blockedClients = clients
|
|
}
|
|
}
|
|
self.blockedClients = _blockedClients
|
|
self.allClients = Array(_approvedClients) + Array(_blockedClients)
|
|
if let value = UserDefaults.standard.string(forKey: "ServerFingerprint"), let serverFingerprint = ServerFingerprint(hexString: value) {
|
|
self.serverFingerprint = serverFingerprint
|
|
}
|
|
}
|
|
|
|
func isConnected(_ fingerprint: ClientFingerprint) -> Bool {
|
|
connectedClients.contains(fingerprint)
|
|
}
|
|
|
|
func isApproved(_ fingerprint: ClientFingerprint) -> Bool {
|
|
approvedClients.contains(where: { $0.fingerprint == fingerprint }) && !isBlocked(fingerprint)
|
|
}
|
|
|
|
func isBlocked(_ fingerprint: ClientFingerprint) -> Bool {
|
|
blockedClients.contains(where: { $0.fingerprint == fingerprint })
|
|
}
|
|
|
|
fileprivate func setServerActive(_ isActive: Bool) {
|
|
isServerActive = isActive
|
|
}
|
|
|
|
func requestServerAction(_ action: ServerAction) {
|
|
serverAction = action
|
|
}
|
|
|
|
private func client(forFingerprint fingerprint: ClientFingerprint, name: String? = nil) -> (Int?, Client) {
|
|
if let idx = allClients.firstIndex(where: { $0.fingerprint == fingerprint }) {
|
|
if let name = name {
|
|
allClients[idx].name = name
|
|
}
|
|
return (idx, allClients[idx])
|
|
} else {
|
|
return (nil, Client(fingerprint: fingerprint, name: name ?? "", lastSeen: Date()))
|
|
}
|
|
}
|
|
|
|
func seen(_ fingerprint: ClientFingerprint, name: String? = nil) {
|
|
var (idx, client) = client(forFingerprint: fingerprint, name: name)
|
|
client.lastSeen = Date()
|
|
if let idx = idx {
|
|
allClients[idx] = client
|
|
} else {
|
|
allClients.append(client)
|
|
}
|
|
}
|
|
|
|
fileprivate func connect(_ fingerprint: ClientFingerprint, name: String? = nil) {
|
|
connectedClients.insert(fingerprint)
|
|
}
|
|
|
|
func disconnect(_ fingerprint: ClientFingerprint) {
|
|
connectedClients.remove(fingerprint)
|
|
}
|
|
|
|
func disconnectAll() {
|
|
connectedClients.removeAll()
|
|
}
|
|
|
|
func approve(_ fingerprint: ClientFingerprint) {
|
|
let (_, client) = client(forFingerprint: fingerprint)
|
|
approvedClients.insert(client)
|
|
blockedClients.remove(client)
|
|
}
|
|
|
|
func block(_ fingerprint: ClientFingerprint) {
|
|
let (_, client) = client(forFingerprint: fingerprint)
|
|
approvedClients.remove(client)
|
|
blockedClients.insert(client)
|
|
}
|
|
|
|
fileprivate func setServerFingerprint(_ fingerprint: ServerFingerprint) {
|
|
serverFingerprint = fingerprint
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteServer {
|
|
class Local: LocalInterface {
|
|
typealias M = UTMRemoteMessageServer
|
|
|
|
private let server: UTMRemoteServer
|
|
private let client: UTMRemoteServer.Remote
|
|
|
|
private var data: UTMData {
|
|
server.data
|
|
}
|
|
|
|
init(server: UTMRemoteServer, client: UTMRemoteServer.Remote) {
|
|
self.server = server
|
|
self.client = client
|
|
}
|
|
|
|
func handle(message: M, data: Data) async throws -> Data {
|
|
switch message {
|
|
case .serverHandshake:
|
|
return try await _handshake(parameters: .decode(data)).encode()
|
|
case .listVirtualMachines:
|
|
return try await _listVirtualMachines(parameters: .decode(data)).encode()
|
|
case .getQEMUConfiguration:
|
|
return try await _getQEMUConfiguration(parameters: .decode(data)).encode()
|
|
case .updateQEMUConfiguration:
|
|
return try await _updateQEMUConfiguration(parameters: .decode(data)).encode()
|
|
case .getPackageDataFile:
|
|
return try await _getPackageDataFile(parameters: .decode(data)).encode()
|
|
case .startVirtualMachine:
|
|
return try await _startVirtualMachine(parameters: .decode(data)).encode()
|
|
case .stopVirtualMachine:
|
|
return try await _stopVirtualMachine(parameters: .decode(data)).encode()
|
|
case .restartVirtualMachine:
|
|
return try await _restartVirtualMachine(parameters: .decode(data)).encode()
|
|
case .pauseVirtualMachine:
|
|
return try await _pauseVirtualMachine(parameters: .decode(data)).encode()
|
|
case .resumeVirtualMachine:
|
|
return try await _resumeVirtualMachine(parameters: .decode(data)).encode()
|
|
case .saveSnapshotVirtualMachine:
|
|
return try await _saveSnapshotVirtualMachine(parameters: .decode(data)).encode()
|
|
case .deleteSnapshotVirtualMachine:
|
|
return try await _deleteSnapshotVirtualMachine(parameters: .decode(data)).encode()
|
|
case .restoreSnapshotVirtualMachine:
|
|
return try await _restoreSnapshotVirtualMachine(parameters: .decode(data)).encode()
|
|
case .changePointerTypeVirtualMachine:
|
|
return try await _changePointerTypeVirtualMachine(parameters: .decode(data)).encode()
|
|
}
|
|
}
|
|
|
|
func handle(error: Error) {
|
|
Task {
|
|
await server.notifyError(error)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func findVM(withId id: UUID) async throws -> VMData {
|
|
let vm = data.virtualMachines.first(where: { $0.id == id })
|
|
if let vm = vm, let _ = vm.wrapped {
|
|
return vm
|
|
} else {
|
|
throw UTMRemoteServer.ServerError.notFound(id)
|
|
}
|
|
}
|
|
|
|
private func _handshake(parameters: M.ServerHandshake.Request) async throws -> M.ServerHandshake.Reply {
|
|
return .init(version: UTMRemoteMessageServer.version, capabilities: .current, model: MacDevice.current.model)
|
|
}
|
|
|
|
private func _listVirtualMachines(parameters: M.ListVirtualMachines.Request) async throws -> M.ListVirtualMachines.Reply {
|
|
let vms = await data.virtualMachines
|
|
let items = await Task { @MainActor in
|
|
vms.map { vmdata in
|
|
M.ListVirtualMachines.Information(id: vmdata.id,
|
|
name: vmdata.detailsTitleLabel,
|
|
path: vmdata.pathUrl.path,
|
|
isShortcut: vmdata.isShortcut,
|
|
isSuspended: vmdata.registryEntry?.isSuspended ?? false,
|
|
backend: vmdata.wrapped is UTMQemuVirtualMachine ? .qemu : .unknown,
|
|
state: vmdata.wrapped?.state ?? .stopped)
|
|
}
|
|
}.value
|
|
return .init(items: items)
|
|
}
|
|
|
|
private func _getQEMUConfiguration(parameters: M.GetQEMUConfiguration.Request) async throws -> M.GetQEMUConfiguration.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
if let config = await vm.config as? UTMQemuConfiguration {
|
|
return .init(configuration: config)
|
|
} else {
|
|
throw ServerError.invalidBackend
|
|
}
|
|
}
|
|
|
|
private func _updateQEMUConfiguration(parameters: M.UpdateQEMUConfiguration.Request) async throws -> M.UpdateQEMUConfiguration.Reply {
|
|
return .init()
|
|
}
|
|
|
|
private func _getPackageDataFile(parameters: M.GetPackageDataFile.Request) async throws -> M.GetPackageDataFile.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
let fm = FileManager.default
|
|
let fileUrl = await vm.pathUrl.appendingPathComponent(UTMQemuConfiguration.dataDirectoryName).appendingPathComponent(parameters.name)
|
|
guard let lastModified = try fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date else {
|
|
throw ServerError.failedToAccessFile
|
|
}
|
|
if let requestLastModified = parameters.lastModified {
|
|
if lastModified.distance(to: requestLastModified).rounded(.towardZero) == 0 {
|
|
return .init(data: nil, lastModified: lastModified)
|
|
}
|
|
}
|
|
guard let data = fm.contents(atPath: fileUrl.path) else {
|
|
throw ServerError.failedToAccessFile
|
|
}
|
|
return .init(data: data, lastModified: lastModified)
|
|
}
|
|
|
|
private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
let (port, publicKey, password) = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
|
|
return .init(spiceServerPort: port, spiceServerPublicKey: publicKey, spiceServerPassword: password)
|
|
}
|
|
|
|
private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
try await vm.wrapped!.stop(usingMethod: parameters.method)
|
|
return .init()
|
|
}
|
|
|
|
private func _restartVirtualMachine(parameters: M.RestartVirtualMachine.Request) async throws -> M.RestartVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
try await vm.wrapped!.restart()
|
|
return .init()
|
|
}
|
|
|
|
private func _pauseVirtualMachine(parameters: M.PauseVirtualMachine.Request) async throws -> M.PauseVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
try await vm.wrapped!.pause()
|
|
return .init()
|
|
}
|
|
|
|
private func _resumeVirtualMachine(parameters: M.ResumeVirtualMachine.Request) async throws -> M.ResumeVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
try await vm.wrapped!.resume()
|
|
return .init()
|
|
}
|
|
|
|
private func _saveSnapshotVirtualMachine(parameters: M.SaveSnapshotVirtualMachine.Request) async throws -> M.SaveSnapshotVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
try await vm.wrapped!.saveSnapshot(name: parameters.name)
|
|
return .init()
|
|
}
|
|
|
|
private func _deleteSnapshotVirtualMachine(parameters: M.DeleteSnapshotVirtualMachine.Request) async throws -> M.DeleteSnapshotVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
try await vm.wrapped!.deleteSnapshot(name: parameters.name)
|
|
return .init()
|
|
}
|
|
|
|
private func _restoreSnapshotVirtualMachine(parameters: M.RestoreSnapshotVirtualMachine.Request) async throws -> M.RestoreSnapshotVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
try await vm.wrapped!.restoreSnapshot(name: parameters.name)
|
|
return .init()
|
|
}
|
|
|
|
private func _changePointerTypeVirtualMachine(parameters: M.ChangePointerTypeVirtualMachine.Request) async throws -> M.ChangePointerTypeVirtualMachine.Reply {
|
|
let vm = try await findVM(withId: parameters.id)
|
|
guard let wrapped = await vm.wrapped as? UTMQemuVirtualMachine else {
|
|
throw ServerError.invalidBackend
|
|
}
|
|
try await wrapped.changeInputTablet(parameters.isTabletMode)
|
|
return .init()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteServer {
|
|
class Remote {
|
|
typealias M = UTMRemoteMessageClient
|
|
fileprivate(set) var peer: Peer<UTMRemoteMessageServer>!
|
|
|
|
func close() {
|
|
peer.close()
|
|
}
|
|
|
|
func handshake() async throws {
|
|
guard try await _handshake(parameters: .init(version: UTMRemoteMessageClient.version)).version == UTMRemoteMessageClient.version else {
|
|
throw ServerError.versionMismatch
|
|
}
|
|
}
|
|
|
|
private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
|
|
try await M.ClientHandshake.send(parameters, to: peer)
|
|
}
|
|
|
|
func virtualMachine(id: UUID, didTransitionToState state: UTMVirtualMachineState) async throws {
|
|
try await _virtualMachineDidTransition(parameters: .init(id: id, state: state))
|
|
}
|
|
|
|
@discardableResult
|
|
private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
|
|
try await M.VirtualMachineDidTransition.send(parameters, to: peer)
|
|
}
|
|
|
|
func virtualMachine(id: UUID, didErrorWithMessage message: String) async throws {
|
|
try await _virtualMachineDidError(parameters: .init(id: id, errorMessage: message))
|
|
}
|
|
|
|
@discardableResult
|
|
private func _virtualMachineDidError(parameters: M.VirtualMachineDidError.Request) async throws -> M.VirtualMachineDidError.Reply {
|
|
try await M.VirtualMachineDidError.send(parameters, to: peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteServer {
|
|
enum ServerError: LocalizedError {
|
|
case silentError(Error)
|
|
case versionMismatch
|
|
case notFound(UUID)
|
|
case invalidBackend
|
|
case failedToAccessFile
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .silentError(let error):
|
|
return error.localizedDescription
|
|
case .versionMismatch:
|
|
return NSLocalizedString("The client interface version does not match the server.", comment: "UTMRemoteServer")
|
|
case .notFound(let id):
|
|
return String.localizedStringWithFormat(NSLocalizedString("Cannot find VM with ID: %@", comment: "UTMRemoteServer"), id.uuidString)
|
|
case .invalidBackend:
|
|
return NSLocalizedString("Invalid backend.", comment: "UTMRemoteServer")
|
|
case .failedToAccessFile:
|
|
return NSLocalizedString("Failed to access file.", comment: "UTMRemoteServer")
|
|
}
|
|
}
|
|
}
|
|
}
|