remote: support takeover of existing session and auto-pause of orphaned sessions

This commit is contained in:
osy 2024-02-24 18:00:41 -08:00
parent b762149671
commit 51a7969b09
11 changed files with 68 additions and 17 deletions

View File

@ -162,7 +162,7 @@ struct Screenshot: View {
.blendMode(.hardLight) .blendMode(.hardLight)
#if os(visionOS) #if os(visionOS)
.overlay { .overlay {
if vm.isStopped { if vm.isStopped || vm.isTakeoverAllowed {
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.resizable() .resizable()
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
@ -175,7 +175,7 @@ struct Screenshot: View {
#endif #endif
if vm.isBusy { if vm.isBusy {
Spinner(size: .large) Spinner(size: .large)
} else if vm.isStopped { } else if vm.isStopped || vm.isTakeoverAllowed {
#if !os(visionOS) #if !os(visionOS)
Button(action: { data.run(vm: vm) }, label: { Button(action: { data.run(vm: vm) }, label: {
Label("Run", systemImage: "play.circle.fill") Label("Run", systemImage: "play.circle.fill")

View File

@ -829,8 +829,9 @@ struct AlertMessage: Identifiable {
}) })
observers.insert(vm.$state.sink { state in observers.insert(vm.$state.sink { state in
Task { Task {
let isTakeoverAllowed = self.vmWindows[vm] is VMRemoteSessionState && (state == .started || state == .paused)
await self.remoteServer.broadcast { remote in await self.remoteServer.broadcast { remote in
try await remote.virtualMachine(id: vm.id, didTransitionToState: state) try await remote.virtualMachine(id: vm.id, didTransitionToState: state, isTakeoverAllowed: isTakeoverAllowed)
} }
} }
}) })
@ -1271,12 +1272,13 @@ class UTMRemoteData: UTMData {
vm.updateMountedDrives(mountedDrives) vm.updateMountedDrives(mountedDrives)
} }
func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState) async { func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async {
guard let vm = virtualMachines.first(where: { $0.id == id }) else { guard let vm = virtualMachines.first(where: { $0.id == id }) else {
return return
} }
let remoteVM = vm as! VMRemoteData let remoteVM = vm as! VMRemoteData
let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine
remoteVM.isTakeoverAllowed = isTakeoverAllowed
await wrapped.updateRemoteState(state) await wrapped.updateRemoteState(state)
} }

View File

@ -68,7 +68,10 @@ import SwiftUI
/// Copy from wrapped VM /// Copy from wrapped VM
@Published var screenshot: UTMVirtualMachineScreenshot? @Published var screenshot: UTMVirtualMachineScreenshot?
/// If true, it is possible to hijack the session.
@Published var isTakeoverAllowed: Bool = false
/// Allows changes in the config, registry, and VM to be reflected /// Allows changes in the config, registry, and VM to be reflected
private var observers: [AnyCancellable] = [] private var observers: [AnyCancellable] = []
@ -450,6 +453,7 @@ class VMRemoteData: VMData {
self._isShortcut = item.isShortcut self._isShortcut = item.isShortcut
self.initialState = item.state self.initialState = item.state
super.init() super.init()
self.isTakeoverAllowed = item.isTakeoverAllowed
self.registryEntryWrapped = UTMRegistry.shared.entry(uuid: item.id, name: item.name, path: item.path) self.registryEntryWrapped = UTMRegistry.shared.entry(uuid: item.id, name: item.name, path: item.path)
self.registryEntryWrapped!.isSuspended = item.isSuspended self.registryEntryWrapped!.isSuspended = item.isSuspended
self.registryEntryWrapped!.externalDrives = item.mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) }) self.registryEntryWrapped!.externalDrives = item.mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })

View File

@ -30,9 +30,9 @@ extension UTMData {
} }
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) { if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) {
session.showWindow() session.showWindow()
} else if vm.state == .stopped { } else if vm.isStopped || vm.isTakeoverAllowed {
let session = VMSessionState(for: wrapped as! (any UTMSpiceVirtualMachine)) let session = VMSessionState(for: wrapped as! (any UTMSpiceVirtualMachine))
session.start() session.start(options: options)
} else { } else {
showErrorAlert(message: NSLocalizedString("This virtual machine is already running. In order to run it from this device, you must stop it first.", comment: "UTMDataExtension")) showErrorAlert(message: NSLocalizedString("This virtual machine is already running. In order to run it from this device, you must stop it first.", comment: "UTMDataExtension"))
} }

View File

@ -434,7 +434,11 @@ extension VMSessionState {
} }
Self.allActiveSessions[id] = self Self.allActiveSessions[id] = self
showWindow() showWindow()
vm.requestVmStart(options: options) if vm.state == .paused {
vm.requestVmResume()
} else {
vm.requestVmStart(options: options)
}
} }
func showWindow() { func showWindow() {

View File

@ -86,6 +86,13 @@ extension UTMData {
guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else { guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else {
throw UTMDataError.unsupportedBackend throw UTMDataError.unsupportedBackend
} }
if let existingSession = vmWindows[vm] as? VMRemoteSessionState, let spiceServerInfo = wrapped.spiceServerInfo {
if wrapped.state == .paused {
try await wrapped.resume()
}
existingSession.client = client
return spiceServerInfo
}
guard vmWindows[vm] == nil else { guard vmWindows[vm] == nil else {
throw UTMDataError.virtualMachineUnavailable throw UTMDataError.virtualMachineUnavailable
} }

View File

@ -19,7 +19,7 @@ import IOKit.pwr_mgt
/// Represents the UI state for a single headless VM session. /// Represents the UI state for a single headless VM session.
class VMRemoteSessionState: VMHeadlessSessionState { class VMRemoteSessionState: VMHeadlessSessionState {
let client: UTMRemoteServer.Remote public weak var client: UTMRemoteServer.Remote?
init(for vm: any UTMVirtualMachine, client: UTMRemoteServer.Remote, onStop: (() -> Void)?) { init(for vm: any UTMVirtualMachine, client: UTMRemoteServer.Remote, onStop: (() -> Void)?) {
self.client = client self.client = client
@ -28,7 +28,7 @@ class VMRemoteSessionState: VMHeadlessSessionState {
override func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) { override func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
Task { Task {
try? await client.virtualMachine(id: vm.id, didErrorWithMessage: message) try? await client?.virtualMachine(id: vm.id, didErrorWithMessage: message)
super.virtualMachine(vm, didErrorWithMessage: message) super.virtualMachine(vm, didErrorWithMessage: message)
} }
} }

View File

@ -313,7 +313,7 @@ extension UTMRemoteClient {
} }
private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply { private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
await data.remoteVirtualMachineDidTransition(id: parameters.id, state: parameters.state) await data.remoteVirtualMachineDidTransition(id: parameters.id, state: parameters.state, isTakeoverAllowed: parameters.isTakeoverAllowed)
return .init() return .init()
} }

View File

@ -74,6 +74,7 @@ extension UTMRemoteMessageServer {
let path: String let path: String
let isShortcut: Bool let isShortcut: Bool
let isSuspended: Bool let isSuspended: Bool
let isTakeoverAllowed: Bool
let backend: UTMBackend let backend: UTMBackend
let state: UTMVirtualMachineState let state: UTMVirtualMachineState
let mountedDrives: [String: String] let mountedDrives: [String: String]
@ -360,6 +361,7 @@ extension UTMRemoteMessageClient {
struct Request: Serializable, Codable { struct Request: Serializable, Codable {
let id: UUID let id: UUID
let state: UTMVirtualMachineState let state: UTMVirtualMachineState
let isTakeoverAllowed: Bool
} }
struct Reply: Serializable, Codable {} struct Reply: Serializable, Codable {}

View File

@ -228,11 +228,36 @@ actor UTMRemoteServer {
if !connectedClients.contains(client) { if !connectedClients.contains(client) {
if let remote = establishedConnections.removeValue(forKey: client) { if let remote = establishedConnections.removeValue(forKey: client) {
remote.close() remote.close()
Task { @MainActor in
await suspendSessions(for: remote)
}
} }
} }
} }
} }
@MainActor
private func suspendSessions(for remote: Remote) async {
let sessions = data.vmWindows.compactMap {
if let session = $0.value as? VMRemoteSessionState {
return ($0.key, session)
} else {
return nil
}
}
await withTaskGroup(of: Void.self) { group in
for (vm, session) in sessions {
if session.client?.id == remote.id {
session.client = nil
}
group.addTask {
try? await vm.wrapped?.pause()
}
}
await group.waitForAll()
}
}
private func establishConnection(_ connection: Connection) async { private func establishConnection(_ connection: Connection) async {
guard let fingerprint = connection.fingerprint else { guard let fingerprint = connection.fingerprint else {
connection.close() connection.close()
@ -712,11 +737,13 @@ extension UTMRemoteServer {
try parameters.ids.map { id in try parameters.ids.map { id in
let vm = try findVM(withId: id) let vm = try findVM(withId: id)
let mountedDrives = vm.registryEntry?.externalDrives.mapValues({ $0.path }) ?? [:] let mountedDrives = vm.registryEntry?.externalDrives.mapValues({ $0.path }) ?? [:]
let isTakeoverAllowed = data.vmWindows[vm] is VMRemoteSessionState && (vm.state == .started || vm.state == .paused)
return M.VirtualMachineInformation(id: vm.id, return M.VirtualMachineInformation(id: vm.id,
name: vm.detailsTitleLabel, name: vm.detailsTitleLabel,
path: vm.pathUrl.path, path: vm.pathUrl.path,
isShortcut: vm.isShortcut, isShortcut: vm.isShortcut,
isSuspended: vm.registryEntry?.isSuspended ?? false, isSuspended: vm.registryEntry?.isSuspended ?? false,
isTakeoverAllowed: isTakeoverAllowed,
backend: vm.wrapped is UTMQemuVirtualMachine ? .qemu : .unknown, backend: vm.wrapped is UTMQemuVirtualMachine ? .qemu : .unknown,
state: vm.wrapped?.state ?? .stopped, state: vm.wrapped?.state ?? .stopped,
mountedDrives: mountedDrives) mountedDrives: mountedDrives)
@ -850,9 +877,10 @@ extension UTMRemoteServer {
} }
extension UTMRemoteServer { extension UTMRemoteServer {
class Remote { class Remote: Identifiable {
typealias M = UTMRemoteMessageClient typealias M = UTMRemoteMessageClient
fileprivate(set) var peer: Peer<UTMRemoteMessageServer>! fileprivate(set) var peer: Peer<UTMRemoteMessageServer>!
let id = UUID()
func close() { func close() {
peer.close() peer.close()
@ -876,8 +904,8 @@ extension UTMRemoteServer {
try await _mountedDrivesHasChanged(parameters: .init(id: id, mountedDrives: mountedDrives)) try await _mountedDrivesHasChanged(parameters: .init(id: id, mountedDrives: mountedDrives))
} }
func virtualMachine(id: UUID, didTransitionToState state: UTMVirtualMachineState) async throws { func virtualMachine(id: UUID, didTransitionToState state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async throws {
try await _virtualMachineDidTransition(parameters: .init(id: id, state: state)) try await _virtualMachineDidTransition(parameters: .init(id: id, state: state, isTakeoverAllowed: isTakeoverAllowed))
} }
func virtualMachine(id: UUID, didErrorWithMessage message: String) async throws { func virtualMachine(id: UUID, didErrorWithMessage message: String) async throws {

View File

@ -197,7 +197,7 @@ extension UTMRemoteSpiceVirtualMachine {
} }
func start(options: UTMVirtualMachineStartOptions) async throws { func start(options: UTMVirtualMachineStartOptions) async throws {
try await _state.operation(before: .stopped, during: .starting, after: .started) { try await _state.operation(before: [.stopped, .started, .paused], during: .starting, after: .started) {
let spiceServer = try await server.startVirtualMachine(id: id, options: options) let spiceServer = try await server.startVirtualMachine(id: id, options: options)
var options = UTMSpiceIOOptions() var options = UTMSpiceIOOptions()
if await !config.sound.isEmpty { if await !config.sound.isEmpty {
@ -250,8 +250,12 @@ extension UTMRemoteSpiceVirtualMachine {
} }
func resume() async throws { func resume() async throws {
try await _state.operation(before: .paused, during: .resuming, after: .started) { if ioService == nil {
try await server.resumeVirtualMachine(id: id) return try await start(options: [])
} else {
try await _state.operation(before: .paused, during: .resuming, after: .started) {
try await server.resumeVirtualMachine(id: id)
}
} }
} }