UTM/Remote/UTMRemoteClient.swift

402 lines
15 KiB
Swift

//
// Copyright © 2024 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 Network
import SwiftConnect
let service = "_utm_server._tcp"
actor UTMRemoteClient {
let state: State
private let keyManager = UTMRemoteKeyManager(forClient: true)
private var local: Local
private var scanTask: Task<Void, Error>?
private var endpoints: [String: NWEndpoint] = [:]
private(set) var server: Remote!
@MainActor
init(data: UTMRemoteData) {
self.state = State()
self.local = Local(data: data)
}
private func withErrorAlert(_ body: () async throws -> Void) async {
do {
try await body()
} catch {
await state.showErrorAlert(error.localizedDescription)
}
}
func startScanning() {
scanTask = Task {
await withErrorAlert {
for try await endpoints in Connection.endpoints(forServiceType: service) {
await self.didFindEndpoints(endpoints)
}
}
}
}
func stopScanning() {
scanTask?.cancel()
scanTask = nil
}
func didFindEndpoints(_ endpoints: [NWEndpoint]) async {
self.endpoints = endpoints.reduce(into: [String: NWEndpoint]()) { map, endpoint in
map[endpoint.debugDescription] = endpoint
}
let servers = endpoints.compactMap { endpoint in
switch endpoint {
case .hostPort(let host, _):
return State.Server(hostname: host.debugDescription, name: host.debugDescription, lastSeen: Date())
case .service(let name, _, _, _):
return State.Server(hostname: endpoint.debugDescription, name: name, lastSeen: Date())
default:
return nil
}
}
await state.updateFoundServers(servers)
}
func connect(_ server: State.Server, shouldSaveDetails: Bool = false) async throws {
guard let endpoint = endpoints[server.hostname] else {
throw ConnectionError.cannotFindEndpoint
}
try await keyManager.load()
let connection = try await Connection.init(endpoint: endpoint, identity: keyManager.identity) { certs in
return true
}
guard let host = connection.connection.currentPath?.remoteEndpoint?.hostname else {
throw ConnectionError.cannotDetermineHost
}
try Task.checkCancellation()
let peer = Peer(connection: connection, localInterface: local)
let remote = Remote(peer: peer, host: host)
do {
try await remote.handshake()
} catch {
peer.close()
throw error
}
self.server = remote
await state.setConnected(true)
}
}
extension UTMRemoteClient {
@MainActor
class State: ObservableObject {
typealias ServerFingerprint = String
struct Server: Codable, Identifiable, Hashable {
let hostname: String
var fingerprint: ServerFingerprint?
var name: String
var lastSeen: Date
var password: String?
var id: String {
hostname
}
func hash(into hasher: inout Hasher) {
hasher.combine(hostname)
}
static func == (lhs: Server, rhs: Server) -> Bool {
lhs.hashValue == rhs.hashValue
}
}
struct AlertMessage: Identifiable {
let id = UUID()
let message: String
}
@Published var savedServers: [Server] {
didSet {
UserDefaults.standard.setValue(try! savedServers.propertyList(), forKey: "TrustedServers")
}
}
@Published var foundServers: [Server] = []
@Published var isScanning: Bool = false
@Published private(set) var isConnected: Bool = false
@Published var alertMessage: AlertMessage?
init() {
var _savedServers = Array<Server>()
if let array = UserDefaults.standard.array(forKey: "TrustedServers") {
if let servers = try? Array<Server>(fromPropertyList: array) {
_savedServers = servers
}
}
self.savedServers = _savedServers
}
func showErrorAlert(_ message: String) {
alertMessage = AlertMessage(message: message)
}
func updateFoundServers(_ servers: [Server]) {
foundServers = servers
}
fileprivate func setConnected(_ connected: Bool) {
isConnected = connected
}
}
}
extension UTMRemoteClient {
class Local: LocalInterface {
typealias M = UTMRemoteMessageClient
private let data: UTMRemoteData
init(data: UTMRemoteData) {
self.data = data
}
func handle(message: M, data: Data) async throws -> Data {
switch message {
case .clientHandshake:
return try await _handshake(parameters: .decode(data)).encode()
case .listHasChangedOrder:
return .init()
case .QEMUConfigurationHasChanged:
return .init()
case .packageDataFileHasChanged:
return .init()
case .virtualMachineDidTransition:
return try await _virtualMachineDidTransition(parameters: .decode(data)).encode()
case .virtualMachineDidError:
return try await _virtualMachineDidError(parameters: .decode(data)).encode()
}
}
func handle(error: Error) {
Task {
await data.showErrorAlert(message: error.localizedDescription)
}
}
private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
return .init(version: UTMRemoteMessageClient.version, capabilities: .current)
}
private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
await data.remoteVirtualMachineDidTransition(id: parameters.id, state: parameters.state)
return .init()
}
private func _virtualMachineDidError(parameters: M.VirtualMachineDidError.Request) async throws -> M.VirtualMachineDidError.Reply {
await data.remoteVirtualMachineDidError(id: parameters.id, message: parameters.errorMessage)
return .init()
}
}
}
extension UTMRemoteClient {
class Remote {
typealias M = UTMRemoteMessageServer
private let peer: Peer<UTMRemoteMessageClient>
let host: String
private(set) var capabilities: UTMCapabilities?
init(peer: Peer<UTMRemoteMessageClient>, host: String) {
self.peer = peer
self.host = host
}
func close() {
peer.close()
}
func handshake() async throws {
let reply = try await _handshake(parameters: .init(version: UTMRemoteMessageServer.version))
guard reply.version == UTMRemoteMessageServer.version else {
throw ClientError.versionMismatch
}
capabilities = reply.capabilities
}
func listVirtualMachines() async throws -> [M.ListVirtualMachines.Information] {
try await _listVirtualMachines(parameters: .init()).items
}
func getQEMUConfiguration(for id: UUID) async throws -> UTMQemuConfiguration {
try await _getQEMUConfiguration(parameters: .init(id: id)).configuration
}
func getPackageDataFile(for id: UUID, name: String) async throws -> URL {
let fm = FileManager.default
let cacheUrl = try fm.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let packageUrl = cacheUrl.appendingPathComponent(id.uuidString)
if !fm.fileExists(atPath: packageUrl.path) {
try fm.createDirectory(at: packageUrl, withIntermediateDirectories: false)
}
let fileUrl = packageUrl.appendingPathComponent(name)
var lastModified: Date?
if fm.fileExists(atPath: fileUrl.path) {
lastModified = try? fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date
}
let reply = try await _getPackageDataFile(parameters: .init(id: id, name: name, lastModified: lastModified))
if let data = reply.data {
fm.createFile(atPath: fileUrl.path, contents: data, attributes: [.modificationDate: reply.lastModified])
}
return fileUrl
}
func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> (port: UInt16, publicKey: Data) {
let reply = try await _startVirtualMachine(parameters: .init(id: id, options: options))
return (reply.spiceServerPort, reply.spiceServerPublicKey)
}
func stopVirtualMachine(id: UUID, method: UTMVirtualMachineStopMethod) async throws {
try await _stopVirtualMachine(parameters: .init(id: id, method: method))
}
func restartVirtualMachine(id: UUID) async throws {
try await _restartVirtualMachine(parameters: .init(id: id))
}
func pauseVirtualMachine(id: UUID) async throws {
try await _pauseVirtualMachine(parameters: .init(id: id))
}
func resumeVirtualMachine(id: UUID) async throws {
try await _resumeVirtualMachine(parameters: .init(id: id))
}
func saveSnapshotVirtualMachine(id: UUID, name: String?) async throws {
try await _saveSnapshotVirtualMachine(parameters: .init(id: id, name: name))
}
func deleteSnapshotVirtualMachine(id: UUID, name: String?) async throws {
try await _deleteSnapshotVirtualMachine(parameters: .init(id: id, name: name))
}
func restoreSnapshotVirtualMachine(id: UUID, name: String?) async throws {
try await _restoreSnapshotVirtualMachine(parameters: .init(id: id, name: name))
}
func changePointerTypeVirtualMachine(id: UUID, toTabletMode tablet: Bool) async throws {
try await _changePointerTypeVirtualMachine(parameters: .init(id: id, isTabletMode: tablet))
}
private func _handshake(parameters: M.ServerHandshake.Request) async throws -> M.ServerHandshake.Reply {
try await M.ServerHandshake.send(parameters, to: peer)
}
private func _listVirtualMachines(parameters: M.ListVirtualMachines.Request) async throws -> M.ListVirtualMachines.Reply {
try await M.ListVirtualMachines.send(parameters, to: peer)
}
private func _getQEMUConfiguration(parameters: M.GetQEMUConfiguration.Request) async throws -> M.GetQEMUConfiguration.Reply {
try await M.GetQEMUConfiguration.send(parameters, to: peer)
}
private func _getPackageDataFile(parameters: M.GetPackageDataFile.Request) async throws -> M.GetPackageDataFile.Reply {
try await M.GetPackageDataFile.send(parameters, to: peer)
}
private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
try await M.StartVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply {
try await M.StopVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _restartVirtualMachine(parameters: M.RestartVirtualMachine.Request) async throws -> M.RestartVirtualMachine.Reply {
try await M.RestartVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _pauseVirtualMachine(parameters: M.PauseVirtualMachine.Request) async throws -> M.PauseVirtualMachine.Reply {
try await M.PauseVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _resumeVirtualMachine(parameters: M.ResumeVirtualMachine.Request) async throws -> M.ResumeVirtualMachine.Reply {
try await M.ResumeVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _saveSnapshotVirtualMachine(parameters: M.SaveSnapshotVirtualMachine.Request) async throws -> M.SaveSnapshotVirtualMachine.Reply {
try await M.SaveSnapshotVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _deleteSnapshotVirtualMachine(parameters: M.DeleteSnapshotVirtualMachine.Request) async throws -> M.DeleteSnapshotVirtualMachine.Reply {
try await M.DeleteSnapshotVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _restoreSnapshotVirtualMachine(parameters: M.RestoreSnapshotVirtualMachine.Request) async throws -> M.RestoreSnapshotVirtualMachine.Reply {
try await M.RestoreSnapshotVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _changePointerTypeVirtualMachine(parameters: M.ChangePointerTypeVirtualMachine.Request) async throws -> M.ChangePointerTypeVirtualMachine.Reply {
try await M.ChangePointerTypeVirtualMachine.send(parameters, to: peer)
}
}
}
extension UTMRemoteClient {
enum ConnectionError: LocalizedError {
case cannotFindEndpoint
case cannotDetermineHost
case passwordRequired
case passwordInvalid
var errorDescription: String? {
switch self {
case .cannotFindEndpoint:
return NSLocalizedString("The server has disappeared.", comment: "UTMRemoteClient")
case .cannotDetermineHost:
return NSLocalizedString("Failed to determine host name.", comment: "UTMRemoteClient")
case .passwordRequired:
return NSLocalizedString("Password is required.", comment: "UTMRemoteClient")
case .passwordInvalid:
return NSLocalizedString("Password is incorrect.", comment: "UTMRemoteClient")
}
}
}
enum ClientError: LocalizedError {
case versionMismatch
var errorDescription: String? {
switch self {
case .versionMismatch:
return NSLocalizedString("The server interface version does not match the client.", comment: "UTMRemoteClient")
}
}
}
}