remote: support takeover of existing session and auto-pause of orphaned sessions
This commit is contained in:
parent
b762149671
commit
51a7969b09
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) })
|
||||||
|
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue