420 lines
14 KiB
Swift
420 lines
14 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
|
|
|
|
final class UTMRemoteSpiceVirtualMachine: UTMSpiceVirtualMachine {
|
|
struct Capabilities: UTMVirtualMachineCapabilities {
|
|
var supportsProcessKill: Bool {
|
|
true
|
|
}
|
|
|
|
var supportsSnapshots: Bool {
|
|
true
|
|
}
|
|
|
|
var supportsScreenshots: Bool {
|
|
true
|
|
}
|
|
|
|
var supportsDisposibleMode: Bool {
|
|
true
|
|
}
|
|
|
|
var supportsRecoveryMode: Bool {
|
|
false
|
|
}
|
|
|
|
var supportsRemoteSession: Bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
static let capabilities = Capabilities()
|
|
|
|
private var server: UTMRemoteClient.Remote
|
|
|
|
init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool) throws {
|
|
throw UTMVirtualMachineError.notImplemented
|
|
}
|
|
|
|
init(forRemoteServer server: UTMRemoteClient.Remote, remotePath: String, entry: UTMRegistryEntry, config: UTMQemuConfiguration) {
|
|
self.pathUrl = URL(fileURLWithPath: remotePath)
|
|
self.config = config
|
|
self.registryEntry = entry
|
|
self.server = server
|
|
_state = State(vm: self)
|
|
}
|
|
|
|
private(set) var pathUrl: URL
|
|
|
|
private(set) var isShortcut: Bool = false
|
|
|
|
private(set) var isRunningAsDisposible: Bool = false
|
|
|
|
weak var delegate: (UTMVirtualMachineDelegate)?
|
|
|
|
var onConfigurationChange: (() -> Void)?
|
|
|
|
var onStateChange: (() -> Void)?
|
|
|
|
private(set) var config: UTMQemuConfiguration {
|
|
willSet {
|
|
onConfigurationChange?()
|
|
}
|
|
}
|
|
|
|
private(set) var registryEntry: UTMRegistryEntry {
|
|
willSet {
|
|
onConfigurationChange?()
|
|
}
|
|
}
|
|
|
|
private var _state: State!
|
|
|
|
private(set) var state: UTMVirtualMachineState = .stopped {
|
|
willSet {
|
|
onStateChange?()
|
|
}
|
|
|
|
didSet {
|
|
if state == .stopped {
|
|
virtualMachineDidStop()
|
|
}
|
|
delegate?.virtualMachine(self, didTransitionToState: state)
|
|
}
|
|
}
|
|
|
|
var screenshot: UTMVirtualMachineScreenshot? {
|
|
willSet {
|
|
onStateChange?()
|
|
}
|
|
}
|
|
|
|
private(set) var snapshotUnsupportedError: Error?
|
|
|
|
weak var ioServiceDelegate: UTMSpiceIODelegate? {
|
|
didSet {
|
|
if let ioService = ioService {
|
|
ioService.delegate = ioServiceDelegate
|
|
}
|
|
}
|
|
}
|
|
|
|
private(set) var ioService: UTMSpiceIO? {
|
|
didSet {
|
|
oldValue?.delegate = nil
|
|
ioService?.delegate = ioServiceDelegate
|
|
}
|
|
}
|
|
|
|
var changeCursorRequestInProgress: Bool = false
|
|
|
|
private weak var screenshotTimer: Timer?
|
|
|
|
func reload(from packageUrl: URL?) throws {
|
|
throw UTMVirtualMachineError.notImplemented
|
|
}
|
|
|
|
@MainActor
|
|
func reload(usingConfiguration config: UTMQemuConfiguration) {
|
|
self.config = config
|
|
updateConfigFromRegistry()
|
|
}
|
|
|
|
func updateConfigFromRegistry() {
|
|
// not needed
|
|
}
|
|
|
|
func changeUuid(to uuid: UUID, name: String?, copyingEntry entry: UTMRegistryEntry?) {
|
|
// not needed
|
|
}
|
|
|
|
func reconnectServer(_ body: () async throws -> UTMRemoteClient.Remote) async throws {
|
|
try await _state.operation(during: .resuming) {
|
|
self.server = try await body()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteSpiceVirtualMachine {
|
|
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 {
|
|
private func connect(_ serverInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation, options: UTMSpiceIOOptions, remoteConnection: Bool) async throws -> UTMSpiceIO {
|
|
let ioService = UTMSpiceIO(host: remoteConnection ? serverInfo.spiceHostExternal! : server.host,
|
|
tlsPort: Int(remoteConnection ? serverInfo.spicePortExternal! : serverInfo.spicePortInternal),
|
|
serverPublicKey: serverInfo.spicePublicKey,
|
|
password: serverInfo.spicePassword,
|
|
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)
|
|
}
|
|
}
|
|
return ioService
|
|
}
|
|
|
|
func start(options: UTMVirtualMachineStartOptions) async throws {
|
|
try await _state.operation(before: [.stopped, .started, .paused], during: .starting, after: .started) {
|
|
let spiceServer = 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
|
|
do {
|
|
self.ioService = try await connect(spiceServer, options: options, remoteConnection: false)
|
|
} catch {
|
|
if spiceServer.spiceHostExternal != nil && spiceServer.spicePortExternal != nil {
|
|
// retry with external port
|
|
self.ioService = try await connect(spiceServer, options: options, remoteConnection: true)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
if screenshotTimer == nil {
|
|
screenshotTimer = startScreenshotTimer()
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
|
|
try await _state.operation(before: [.started, .paused], during: .stopping, after: .stopped) {
|
|
await saveScreenshot()
|
|
try await server.stopVirtualMachine(id: id, method: method)
|
|
}
|
|
}
|
|
|
|
func restart() async throws {
|
|
try await _state.operation(before: [.started, .paused], during: .stopping, after: .started) {
|
|
try await server.restartVirtualMachine(id: id)
|
|
}
|
|
}
|
|
|
|
func pause() async throws {
|
|
try await _state.operation(before: .started, during: .pausing, after: .paused) {
|
|
try await server.pauseVirtualMachine(id: id)
|
|
}
|
|
}
|
|
|
|
func resume() async throws {
|
|
if ioService == nil {
|
|
return try await start(options: [])
|
|
} else {
|
|
try await _state.operation(before: .paused, during: .resuming, after: .started) {
|
|
try await server.resumeVirtualMachine(id: id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func saveSnapshot(name: String?) async throws {
|
|
try await _state.operation(before: [.started, .paused], during: .saving) {
|
|
await saveScreenshot()
|
|
try await server.saveSnapshotVirtualMachine(id: id, name: name)
|
|
}
|
|
}
|
|
|
|
func deleteSnapshot(name: String?) async throws {
|
|
try await server.deleteSnapshotVirtualMachine(id: id, name: name)
|
|
}
|
|
|
|
func restoreSnapshot(name: String?) async throws {
|
|
try await _state.operation(before: [.started, .paused], during: .saving) {
|
|
try await server.restoreSnapshotVirtualMachine(id: id, name: name)
|
|
}
|
|
}
|
|
|
|
func loadScreenshotFromServer() async {
|
|
if let url = try? await server.getPackageFile(for: id, relativePathComponents: [kUTMBundleScreenshotFilename]) {
|
|
loadScreenshot(from: url)
|
|
}
|
|
}
|
|
|
|
func loadScreenshot(from url: URL) {
|
|
screenshot = UTMVirtualMachineScreenshot(contentsOfURL: url)
|
|
}
|
|
|
|
func saveScreenshot() async {
|
|
if let data = screenshot?.pngData {
|
|
try? await server.sendPackageFile(for: id, relativePathComponents: [kUTMBundleScreenshotFilename], data: data)
|
|
}
|
|
}
|
|
|
|
private func virtualMachineDidStop() {
|
|
ioService = nil
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteSpiceVirtualMachine {
|
|
actor State {
|
|
private weak var vm: UTMRemoteSpiceVirtualMachine?
|
|
private var isInOperation: Bool = false
|
|
private(set) var state: UTMVirtualMachineState = .stopped {
|
|
didSet {
|
|
vm?.state = state
|
|
}
|
|
}
|
|
private var remoteState: UTMVirtualMachineState?
|
|
|
|
init(vm: UTMRemoteSpiceVirtualMachine) {
|
|
self.vm = vm
|
|
}
|
|
|
|
func operation(before: UTMVirtualMachineState, during: UTMVirtualMachineState, after: UTMVirtualMachineState? = nil, body: () async throws -> Void) async throws {
|
|
try await operation(before: [before], during: during, after: after, body: body)
|
|
}
|
|
|
|
func operation(before: Set<UTMVirtualMachineState>? = nil, during: UTMVirtualMachineState, after: UTMVirtualMachineState? = nil, body: () async throws -> Void) async throws {
|
|
while isInOperation {
|
|
await Task.yield()
|
|
}
|
|
if let before = before {
|
|
guard before.contains(state) else {
|
|
throw VMError.operationInProgress
|
|
}
|
|
}
|
|
isInOperation = true
|
|
remoteState = nil
|
|
defer {
|
|
isInOperation = false
|
|
if let remoteState = remoteState {
|
|
state = remoteState
|
|
}
|
|
}
|
|
let previous = state
|
|
state = during
|
|
do {
|
|
try await body()
|
|
} catch {
|
|
state = previous
|
|
throw error
|
|
}
|
|
state = after ?? previous
|
|
}
|
|
|
|
func updateRemoteState(_ state: UTMVirtualMachineState) {
|
|
self.remoteState = state
|
|
if !isInOperation && self.state != state {
|
|
self.state = state
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateRemoteState(_ state: UTMVirtualMachineState) async {
|
|
await _state.updateRemoteState(state)
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteSpiceVirtualMachine {
|
|
static func isSupported(systemArchitecture: QEMUArchitecture) -> Bool {
|
|
true // FIXME: somehow determine which architectures are supported
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteSpiceVirtualMachine {
|
|
func requestInputTablet(_ tablet: Bool) {
|
|
guard !changeCursorRequestInProgress else {
|
|
return
|
|
}
|
|
changeCursorRequestInProgress = true
|
|
Task {
|
|
defer {
|
|
changeCursorRequestInProgress = false
|
|
}
|
|
try await server.changePointerTypeVirtualMachine(id: id, toTabletMode: tablet)
|
|
ioService?.primaryInput?.requestMouseMode(!tablet)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteSpiceVirtualMachine {
|
|
func eject(_ drive: UTMQemuConfigurationDrive) async throws {
|
|
// FIXME: implement remote feature
|
|
throw UTMVirtualMachineError.notImplemented
|
|
}
|
|
|
|
func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL) async throws {
|
|
// FIXME: implement remote feature
|
|
throw UTMVirtualMachineError.notImplemented
|
|
}
|
|
|
|
}
|
|
|
|
extension UTMRemoteSpiceVirtualMachine {
|
|
func stopAccessingPath(_ path: String) async {
|
|
// not needed
|
|
}
|
|
|
|
func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
|
|
throw UTMVirtualMachineError.notImplemented
|
|
}
|
|
}
|
|
|
|
extension UTMRemoteSpiceVirtualMachine {
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
}
|