소스 검색

remote: implement client side connection

osy 1 년 전
부모
커밋
545e36b383
5개의 변경된 파일177개의 추가작업 그리고 15개의 파일을 삭제
  1. 3 3
      Platform/iOS/UTMRemoteConnectView.swift
  2. 20 4
      Remote/UTMRemoteClient.swift
  3. 1 1
      Remote/UTMRemoteServer.swift
  4. 134 7
      Remote/UTMRemoteSpiceVirtualMachine.swift
  5. 19 0
      Services/UTMExtensions.swift

+ 3 - 3
Platform/iOS/UTMRemoteConnectView.swift

@@ -86,6 +86,9 @@ struct UTMRemoteConnectView: View {
                 }
                 }
             }.listStyle(.plain)
             }.listStyle(.plain)
         }.frame(maxWidth: idiom == .pad ? 600 : nil)
         }.frame(maxWidth: idiom == .pad ? 600 : nil)
+        .alert(item: $remoteClientState.alertMessage) { item in
+            Alert(title: Text(item.message))
+        }
         .sheet(item: $selectedServer) { server in
         .sheet(item: $selectedServer) { server in
             ServerConnectView(remoteClientState: remoteClientState, server: server, isAutoConnect: $isAutoConnect)
             ServerConnectView(remoteClientState: remoteClientState, server: server, isAutoConnect: $isAutoConnect)
         }
         }
@@ -175,9 +178,6 @@ private struct ServerConnectView: View {
                 }
                 }
             }
             }
         }
         }
-        .alert(item: $remoteClientState.alertMessage) { item in
-            Alert(title: Text(item.message))
-        }
         .onAppear {
         .onAppear {
             if isAutoConnect {
             if isAutoConnect {
                 connect()
                 connect()

+ 20 - 4
Remote/UTMRemoteClient.swift

@@ -84,9 +84,12 @@ actor UTMRemoteClient {
         let connection = try await Connection.init(endpoint: endpoint, identity: keyManager.identity) { certs in
         let connection = try await Connection.init(endpoint: endpoint, identity: keyManager.identity) { certs in
             return true
             return true
         }
         }
+        guard let host = connection.connection.currentPath?.remoteEndpoint?.hostname else {
+            throw ConnectionError.cannotDetermineHost
+        }
         try Task.checkCancellation()
         try Task.checkCancellation()
         let peer = Peer(connection: connection, localInterface: local)
         let peer = Peer(connection: connection, localInterface: local)
-        let remote = Remote(peer: peer)
+        let remote = Remote(peer: peer, host: host)
         do {
         do {
             try await remote.handshake()
             try await remote.handshake()
         } catch {
         } catch {
@@ -186,9 +189,9 @@ extension UTMRemoteClient {
             case .packageFileHasChanged:
             case .packageFileHasChanged:
                 return .init()
                 return .init()
             case .virtualMachineDidTransition:
             case .virtualMachineDidTransition:
-                return .init()
+                return try await _virtualMachineDidTransition(parameters: .decode(data)).encode()
             case .virtualMachineDidError:
             case .virtualMachineDidError:
-                return .init()
+                return try await _virtualMachineDidError(parameters: .decode(data)).encode()
             }
             }
         }
         }
 
 
@@ -201,6 +204,14 @@ extension UTMRemoteClient {
         private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
         private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
             return .init(version: UTMRemoteMessageClient.version)
             return .init(version: UTMRemoteMessageClient.version)
         }
         }
+
+        private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
+            return .init()
+        }
+
+        private func _virtualMachineDidError(parameters: M.VirtualMachineDidError.Request) async throws -> M.VirtualMachineDidError.Reply {
+            return .init()
+        }
     }
     }
 }
 }
 
 
@@ -208,9 +219,11 @@ extension UTMRemoteClient {
     class Remote {
     class Remote {
         typealias M = UTMRemoteMessageServer
         typealias M = UTMRemoteMessageServer
         private let peer: Peer<UTMRemoteMessageClient>
         private let peer: Peer<UTMRemoteMessageClient>
+        let host: String
 
 
-        init(peer: Peer<UTMRemoteMessageClient>) {
+        init(peer: Peer<UTMRemoteMessageClient>, host: String) {
             self.peer = peer
             self.peer = peer
+            self.host = host
         }
         }
 
 
         func close() {
         func close() {
@@ -256,6 +269,7 @@ extension UTMRemoteClient {
 extension UTMRemoteClient {
 extension UTMRemoteClient {
     enum ConnectionError: LocalizedError {
     enum ConnectionError: LocalizedError {
         case cannotFindEndpoint
         case cannotFindEndpoint
+        case cannotDetermineHost
         case passwordRequired
         case passwordRequired
         case passwordInvalid
         case passwordInvalid
 
 
@@ -263,6 +277,8 @@ extension UTMRemoteClient {
             switch self {
             switch self {
             case .cannotFindEndpoint:
             case .cannotFindEndpoint:
                 return NSLocalizedString("The server has disappeared.", comment: "UTMRemoteClient")
                 return NSLocalizedString("The server has disappeared.", comment: "UTMRemoteClient")
+            case .cannotDetermineHost:
+                return NSLocalizedString("Failed to determine host name.", comment: "UTMRemoteClient")
             case .passwordRequired:
             case .passwordRequired:
                 return NSLocalizedString("Password is required.", comment: "UTMRemoteClient")
                 return NSLocalizedString("Password is required.", comment: "UTMRemoteClient")
             case .passwordInvalid:
             case .passwordInvalid:

+ 1 - 1
Remote/UTMRemoteServer.swift

@@ -139,7 +139,7 @@ actor UTMRemoteServer {
     }
     }
 
 
     private func newRemoteConnection(_ connection: Connection) async {
     private func newRemoteConnection(_ connection: Connection) async {
-        let remoteAddress = connection.connection.endpoint.debugDescription
+        let remoteAddress = connection.connection.endpoint.hostname ?? "\(connection.connection.endpoint)"
         guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else {
         guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else {
             connection.close()
             connection.close()
             return
             return

+ 134 - 7
Remote/UTMRemoteSpiceVirtualMachine.swift

@@ -45,6 +45,38 @@ final class UTMRemoteSpiceVirtualMachine: UTMSpiceVirtualMachine {
 
 
     static let capabilities = Capabilities()
     static let capabilities = Capabilities()
 
 
+    actor State {
+        let vm: UTMRemoteSpiceVirtualMachine
+        private(set) var state: UTMVirtualMachineState = .stopped {
+            didSet {
+                vm.state = state
+            }
+        }
+
+        init(vm: UTMRemoteSpiceVirtualMachine) {
+            self.vm = vm
+        }
+
+        func operation(before: UTMVirtualMachineState, during: UTMVirtualMachineState, after: UTMVirtualMachineState, body: () async throws -> Void) async throws {
+            try await operation(before: [before], during: during, after: after, body: body)
+        }
+
+        func operation(before: Set<UTMVirtualMachineState>, during: UTMVirtualMachineState, after: UTMVirtualMachineState, body: () async throws -> Void) async throws {
+            guard before.contains(state) else {
+                throw VMError.operationInProgress
+            }
+            let previous = state
+            state = during
+            do {
+                try await body()
+            } catch {
+                state = previous
+                throw error
+            }
+            state = after
+        }
+    }
+
     private let server: UTMRemoteClient.Remote
     private let server: UTMRemoteClient.Remote
 
 
     init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool) throws {
     init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool) throws {
@@ -56,6 +88,7 @@ final class UTMRemoteSpiceVirtualMachine: UTMSpiceVirtualMachine {
         self.config = config
         self.config = config
         self.registryEntry = entry
         self.registryEntry = entry
         self.server = server
         self.server = server
+        _state = State(vm: self)
     }
     }
 
 
     private(set) var pathUrl: URL
     private(set) var pathUrl: URL
@@ -64,19 +97,41 @@ final class UTMRemoteSpiceVirtualMachine: UTMSpiceVirtualMachine {
 
 
     private(set) var isRunningAsDisposible: Bool = false
     private(set) var isRunningAsDisposible: Bool = false
 
 
-    var delegate: (UTMVirtualMachineDelegate)?
-    
+    weak var delegate: (UTMVirtualMachineDelegate)?
+
     var onConfigurationChange: (() -> Void)?
     var onConfigurationChange: (() -> Void)?
     
     
     var onStateChange: (() -> Void)?
     var onStateChange: (() -> Void)?
 
 
-    private(set) var config: UTMQemuConfiguration
+    private(set) var config: UTMQemuConfiguration {
+        willSet {
+            onConfigurationChange?()
+        }
+    }
+
+    private(set) var registryEntry: UTMRegistryEntry {
+        willSet {
+            onConfigurationChange?()
+        }
+    }
+
+    private var _state: State!
 
 
-    private(set) var registryEntry: UTMRegistryEntry
+    private(set) var state: UTMVirtualMachineState = .stopped {
+        willSet {
+            onStateChange?()
+        }
 
 
-    private(set) var state: UTMVirtualMachineState = .stopped
+        didSet {
+            delegate?.virtualMachine(self, didTransitionToState: state)
+        }
+    }
 
 
-    var screenshot: PlatformImage?
+    var screenshot: PlatformImage? {
+        willSet {
+            onStateChange?()
+        }
+    }
 
 
     private(set) var snapshotUnsupportedError: Error?
     private(set) var snapshotUnsupportedError: Error?
 
 
@@ -111,8 +166,63 @@ final class UTMRemoteSpiceVirtualMachine: UTMSpiceVirtualMachine {
 }
 }
 
 
 extension UTMRemoteSpiceVirtualMachine {
 extension UTMRemoteSpiceVirtualMachine {
-    func start(options: UTMVirtualMachineStartOptions) async throws {
+    private class ConnectCoordinator: NSObject, UTMRemoteConnectDelegate {
+        var continuation: CheckedContinuation<Void, Error>?
+
+        func remoteInterface(_ remoteInterface: UTMRemoteConnectInterface, didErrorWithMessage message: String) {
+            remoteInterface.connectDelegate = nil
+            continuation?.resume(throwing: VMError.spiceConnectError(message))
+            continuation = nil
+        }
+
+        func remoteInterfaceDidConnect(_ remoteInterface: UTMRemoteConnectInterface) {
+            remoteInterface.connectDelegate = nil
+            continuation?.resume()
+            continuation = nil
+        }
+    }
+}
 
 
+extension UTMRemoteSpiceVirtualMachine {
+    func start(options: UTMVirtualMachineStartOptions) async throws {
+        try await _state.operation(before: .stopped, during: .starting, after: .started) {
+            let port = try await server.startVirtualMachine(id: id, options: options)
+            var options = UTMSpiceIOOptions()
+            if await !config.sound.isEmpty {
+                options.insert(.hasAudio)
+            }
+            if await config.sharing.hasClipboardSharing {
+                options.insert(.hasClipboardSharing)
+            }
+            if await config.sharing.isDirectoryShareReadOnly {
+                options.insert(.isShareReadOnly)
+            }
+            #if false // FIXME: verbose logging is broken on iOS
+            if hasDebugLog {
+                options.insert(.hasDebugLog)
+            }
+            #endif
+            let ioService = UTMSpiceIO(host: server.host, port: Int(port), options: options)
+            ioService.logHandler = { (line: String) -> Void in
+                guard !line.contains("spice_make_scancode") else {
+                    return // do not log key presses for privacy reasons
+                }
+                NSLog("%@", line) // FIXME: log to file
+            }
+            try ioService.start()
+            let coordinator = ConnectCoordinator()
+            try await withCheckedThrowingContinuation { continuation in
+                coordinator.continuation = continuation
+                ioService.connectDelegate = coordinator
+                do {
+                    try ioService.connect()
+                } catch {
+                    ioService.connectDelegate = nil
+                    continuation.resume(throwing: error)
+                }
+            }
+            self.ioService = ioService
+        }
     }
     }
 
 
     func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
     func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
@@ -132,6 +242,12 @@ extension UTMRemoteSpiceVirtualMachine {
     }
     }
 }
 }
 
 
+extension UTMRemoteSpiceVirtualMachine {
+    static func isSupported(systemArchitecture: QEMUArchitecture) -> Bool {
+        true // FIXME: somehow determine which architectures are supported
+    }
+}
+
 extension UTMRemoteSpiceVirtualMachine {
 extension UTMRemoteSpiceVirtualMachine {
     func requestInputTablet(_ tablet: Bool) {
     func requestInputTablet(_ tablet: Bool) {
 
 
@@ -161,5 +277,16 @@ extension UTMRemoteSpiceVirtualMachine {
 
 
 extension UTMRemoteSpiceVirtualMachine {
 extension UTMRemoteSpiceVirtualMachine {
     enum VMError: LocalizedError {
     enum VMError: LocalizedError {
+        case spiceConnectError(String)
+        case operationInProgress
+
+        var errorDescription: String? {
+            switch self {
+            case .spiceConnectError(let message):
+                return String.localizedStringWithFormat(NSLocalizedString("Failed to connect to SPICE: %@", comment: "UTMRemoteSpiceVirtualMachine"), message)
+            case .operationInProgress:
+                return NSLocalizedString("An operation is already in progress.", comment: "UTMRemoteSpiceVirtualMachine")
+            }
+        }
     }
     }
 }
 }

+ 19 - 0
Services/UTMExtensions.swift

@@ -16,6 +16,7 @@
 
 
 import SwiftUI
 import SwiftUI
 import UniformTypeIdentifiers
 import UniformTypeIdentifiers
+import Network
 
 
 extension Optional where Wrapped == String {
 extension Optional where Wrapped == String {
     var _bound: String? {
     var _bound: String? {
@@ -401,3 +402,21 @@ extension Decodable {
         self = try decoder.decode(Self.self, from: data)
         self = try decoder.decode(Self.self, from: data)
     }
     }
 }
 }
+
+extension NWEndpoint {
+    var hostname: String? {
+        if case .hostPort(let host, _) = self {
+            switch host {
+            case .name(let hostname, _):
+                return hostname
+            case .ipv4(let address):
+                return "\(address)"
+            case .ipv6(let address):
+                return "\(address)"
+            @unknown default:
+                break
+            }
+        }
+        return nil
+    }
+}