Browse Source

Merge pull request #6115 from utmapp/dev/remote

Introduce UTM Remote client for iOS/visionOS
osy 1 year ago
parent
commit
8a7a531a7e
90 changed files with 6317 additions and 548 deletions
  1. 27 23
      .github/workflows/build.yml
  2. 10 10
      Configuration/QEMUConstant.swift
  3. 1 1
      Configuration/UTMConfiguration.swift
  4. 5 2
      Configuration/UTMConfigurationDrive.swift
  5. 105 16
      Configuration/UTMQemuConfiguration+Arguments.swift
  6. 9 0
      Configuration/UTMQemuConfigurationQEMU.swift
  7. 1 1
      Platform/Main.swift
  8. 5 5
      Platform/Shared/BigButtonStyle.swift
  9. 14 5
      Platform/Shared/ContentView.swift
  10. 111 0
      Platform/Shared/MacDeviceLabel.swift
  11. 10 1
      Platform/Shared/NumberTextField.swift
  12. 27 1
      Platform/Shared/UTMUnavailableVMView.swift
  13. 2 0
      Platform/Shared/VMCommands.swift
  14. 1 1
      Platform/Shared/VMConfigInputView.swift
  15. 2 2
      Platform/Shared/VMConfigSystemView.swift
  16. 6 2
      Platform/Shared/VMContextMenuModifier.swift
  17. 19 8
      Platform/Shared/VMDetailsView.swift
  18. 8 2
      Platform/Shared/VMNavigationListView.swift
  19. 76 15
      Platform/Shared/VMPlaceholderView.swift
  20. 6 2
      Platform/Shared/VMRemovableDrivesView.swift
  21. 4 0
      Platform/Shared/VMToolbarModifier.swift
  22. 2 2
      Platform/Shared/VMWizardStartView.swift
  23. 365 20
      Platform/UTMData.swift
  24. 3 3
      Platform/UTMDownloadSupportToolsTask.swift
  25. 4 0
      Platform/UTMReleaseHelper.swift
  26. 118 17
      Platform/VMData.swift
  27. 6 0
      Platform/iOS/Display/VMDisplayMetalViewController+Pointer.m
  28. 5 1
      Platform/iOS/Display/VMDisplayMetalViewController+Touch.m
  29. 3 1
      Platform/iOS/Display/VMDisplayMetalViewController.h
  30. 92 33
      Platform/iOS/Display/VMDisplayMetalViewController.m
  31. 12 19
      Platform/iOS/Display/VMDisplayViewController.swift
  32. 7 7
      Platform/iOS/Display/VMKeyboardView.m
  33. 79 0
      Platform/iOS/Info-Remote.plist
  34. 32 0
      Platform/iOS/RemoteContentView.swift
  35. 40 0
      Platform/iOS/Settings.bundle/Root.plist
  36. 11 2
      Platform/iOS/UTMDataExtension.swift
  37. 300 0
      Platform/iOS/UTMRemoteConnectView.swift
  38. 9 1
      Platform/iOS/UTMSettingsView.swift
  39. 9 1
      Platform/iOS/UTMSingleWindowView.swift
  40. 15 9
      Platform/iOS/VMDisplayHostedView.swift
  41. 52 19
      Platform/iOS/VMSessionState.swift
  42. 7 0
      Platform/iOS/VMToolbarDriveMenuView.swift
  43. 7 3
      Platform/iOS/VMToolbarView.swift
  44. 4 2
      Platform/iOS/VMWindowState.swift
  45. 38 6
      Platform/iOS/VMWindowView.swift
  46. 1 1
      Platform/macOS/Display/VMDisplayAppleTerminalWindowController.swift
  47. 2 2
      Platform/macOS/Display/VMDisplayAppleWindowController.swift
  48. 1 1
      Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift
  49. 4 4
      Platform/macOS/Display/VMDisplayWindowController.swift
  50. 63 0
      Platform/macOS/SettingsView.swift
  51. 3 0
      Platform/macOS/UTMApp.swift
  52. 32 1
      Platform/macOS/UTMDataExtension.swift
  53. 173 0
      Platform/macOS/UTMServerView.swift
  54. 4 8
      Platform/macOS/VMHeadlessSessionState.swift
  55. 35 0
      Platform/macOS/VMRemoteSessionState.swift
  56. 6 0
      Platform/macOS/macOS-unsigned.entitlements
  57. 2 0
      Platform/macOS/macOS.entitlements
  58. 24 2
      Platform/visionOS/UTMApp.swift
  59. 56 4
      Platform/visionOS/VMToolbarOrnamentModifier.swift
  60. 276 0
      Remote/GenerateKey.c
  61. 33 0
      Remote/GenerateKey.h
  62. 588 0
      Remote/UTMRemoteClient.swift
  63. 39 0
      Remote/UTMRemoteConnectInterface.h
  64. 196 0
      Remote/UTMRemoteKeyManager.swift
  65. 380 0
      Remote/UTMRemoteMessage.swift
  66. 981 0
      Remote/UTMRemoteServer.swift
  67. 424 0
      Remote/UTMRemoteSpiceVirtualMachine.swift
  68. 8 1
      Services/Swift-Bridging-Header.h
  69. 11 3
      Services/UTMAppleVirtualMachine.swift
  70. 41 0
      Services/UTMExtensions.swift
  71. 5 5
      Services/UTMJailbreak.m
  72. 6 0
      Services/UTMLogging.m
  73. 1 1
      Services/UTMPasteboard.swift
  74. 152 0
      Services/UTMPipeInterface.swift
  75. 1 1
      Services/UTMQemuPort.swift
  76. 1 16
      Services/UTMQemuSystem.h
  77. 36 0
      Services/UTMQemuSystemBackends.h
  78. 139 141
      Services/UTMQemuVirtualMachine.swift
  79. 1 1
      Services/UTMRegistry.swift
  80. 10 16
      Services/UTMRegistryEntry.swift
  81. 11 2
      Services/UTMSpiceIO.h
  82. 56 17
      Services/UTMSpiceIO.m
  83. 2 1
      Services/UTMSpiceIODelegate.h
  84. 177 0
      Services/UTMSpiceVirtualMachine.swift
  85. 54 23
      Services/UTMVirtualMachine.swift
  86. 545 7
      UTM.xcodeproj/project.pbxproj
  87. 40 4
      UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  88. 2 0
      scripts/build_dependencies.sh
  89. 18 41
      scripts/build_utm.sh
  90. 8 2
      scripts/package.sh

+ 27 - 23
.github/workflows/build.yml

@@ -23,7 +23,7 @@ on:
         default: 'false'
         default: 'false'
 
 
 env:
 env:
-  BUILD_XCODE_PATH: /Applications/Xcode_15.1.app
+  BUILD_XCODE_PATH: /Applications/Xcode_15.2.app
   RUNNER_IMAGE: macos-13
   RUNNER_IMAGE: macos-13
 
 
 jobs:
 jobs:
@@ -53,7 +53,7 @@ jobs:
     strategy:
     strategy:
       matrix:
       matrix:
         arch: [arm64]
         arch: [arm64]
-        platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci]
+        platform: [ios, ios_simulator, ios-tci, ios_simulator-tci, macos, visionos, visionos_simulator, visionos-tci, visionos_simulator-tci]
         include:
         include:
           # x86_64 supported only for macOS and simulators
           # x86_64 supported only for macOS and simulators
           - arch: x86_64
           - arch: x86_64
@@ -91,7 +91,7 @@ jobs:
         if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event.inputs.rebuild_sysroot == 'true'
         if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event.inputs.rebuild_sysroot == 'true'
         run: ./scripts/build_dependencies.sh -p ${{ matrix.platform }} -a ${{ matrix.arch }}
         run: ./scripts/build_dependencies.sh -p ${{ matrix.platform }} -a ${{ matrix.arch }}
         env:
         env:
-          NCPU: ${{ matrix.platform == 'ios-tci' && '2' || '0' }} # limit 2 CPU for TCI build due to memory issues, 0 = unlimited for other builds
+          NCPU: ${{ endsWith(matrix.platform, '-tci') && '4' || '0' }} # limit 4 CPU for TCI build due to memory issues, 0 = unlimited for other builds
       - name: Compress Sysroot
       - name: Compress Sysroot
         if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event_name == 'release' || github.event.inputs.test_release == 'true'
         if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event_name == 'release' || github.event.inputs.test_release == 'true'
         run: tar -acf sysroot.tgz sysroot*
         run: tar -acf sysroot.tgz sysroot*
@@ -152,14 +152,16 @@ jobs:
     needs: [configuration, build-sysroot]
     needs: [configuration, build-sysroot]
     strategy:
     strategy:
       matrix:
       matrix:
-        arch: [arm64]
-        platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci]
-        include:
-          # x86_64 supported only for macOS and simulators
-          - arch: x86_64
-            platform: macos
-          - arch: x86_64
-            platform: ios_simulator
+        configuration: [
+          {arch: "arm64", sdk: "iphoneos", platform: "ios", scheme: "iOS"},
+          {arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-SE"},
+          {arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-Remote"},
+          {arch: "arm64", sdk: "xros", platform: "visionos", scheme: "iOS"},
+          {arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-SE"},
+          {arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-Remote"},
+          {arch: "arm64", sdk: "macosx", platform: "macos", scheme: "macOS"},
+          {arch: "x86_64", sdk: "macosx", platform: "macos", scheme: "macOS"},
+        ]
     steps:
     steps:
       - name: Checkout
       - name: Checkout
         uses: actions/checkout@v3
         uses: actions/checkout@v3
@@ -169,8 +171,8 @@ jobs:
         id: cache-sysroot
         id: cache-sysroot
         uses: osy/actions-cache@v3
         uses: osy/actions-cache@v3
         with:
         with:
-          path: sysroot-${{ matrix.platform }}-${{ matrix.arch }}
-          key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
+          path: sysroot-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
+          key: ${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
       - name: Check Cache
       - name: Check Cache
         if: steps.cache-sysroot.outputs.cache-hit != 'true'
         if: steps.cache-sysroot.outputs.cache-hit != 'true'
         uses: actions/github-script@v6
         uses: actions/github-script@v6
@@ -182,12 +184,12 @@ jobs:
           [[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
           [[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
       - name: Build UTM
       - name: Build UTM
         run: |
         run: |
-          ./scripts/build_utm.sh -p ${{ matrix.platform }} -a ${{ matrix.arch }} -o UTM
+          ./scripts/build_utm.sh -k ${{ matrix.configuration.sdk }} -s ${{ matrix.configuration.scheme }} -a ${{ matrix.configuration.arch }} -o UTM
           tar -acf UTM.xcarchive.tgz UTM.xcarchive
           tar -acf UTM.xcarchive.tgz UTM.xcarchive
       - name: Upload UTM
       - name: Upload UTM
         uses: actions/upload-artifact@v3
         uses: actions/upload-artifact@v3
         with:
         with:
-          name: UTM-${{ matrix.platform }}-${{ matrix.arch }}
+          name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
           path: UTM.xcarchive.tgz
           path: UTM.xcarchive.tgz
   build-universal:
   build-universal:
     name: Build UTM (Universal Mac)
     name: Build UTM (Universal Mac)
@@ -215,7 +217,7 @@ jobs:
           [[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
           [[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
       - name: Build UTM
       - name: Build UTM
         run: |
         run: |
-          ./scripts/build_utm.sh -t "$SIGNING_TEAM_ID" -p macos -a "arm64 x86_64" -o UTM
+          ./scripts/build_utm.sh -t "$SIGNING_TEAM_ID" -k macosx -s macOS -a "arm64 x86_64" -o UTM
           tar -acf UTM.xcarchive.tgz UTM.xcarchive
           tar -acf UTM.xcarchive.tgz UTM.xcarchive
         env:
         env:
           SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
           SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
@@ -231,12 +233,14 @@ jobs:
     strategy:
     strategy:
       matrix:
       matrix:
         configuration: [
         configuration: [
-          {platform: "ios", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
-          {platform: "ios-tci", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
-          {platform: "ios", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
-          {platform: "ios", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
-          {platform: "visionos", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
-          {platform: "visionos-tci", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"}
+          {platform: "ios", scheme: "iOS", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
+          {platform: "ios-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
+          {platform: "ios", scheme: "iOS", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
+          {platform: "ios", scheme: "iOS", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
+          {platform: "visionos", scheme: "iOS", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
+          {platform: "visionos-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"},
+          {platform: "ios-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote.ipa", path: "UTM Remote.ipa"},
+          {platform: "visionos-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote-visionOS.ipa", path: "UTM Remote.ipa"},
         ]
         ]
     if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
     if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
     steps:
     steps:
@@ -245,7 +249,7 @@ jobs:
       - name: Download Artifact
       - name: Download Artifact
         uses: actions/download-artifact@v3
         uses: actions/download-artifact@v3
         with:
         with:
-          name: UTM-${{ matrix.configuration.platform }}-arm64
+          name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-arm64
       - name: Install ldid + dpkg
       - name: Install ldid + dpkg
         run: brew install ldid dpkg
         run: brew install ldid dpkg
       - name: Fakesign IPA
       - name: Fakesign IPA

+ 10 - 10
Configuration/QEMUConstant.swift

@@ -424,20 +424,20 @@ extension QEMUArchitecture {
         default: return true
         default: return true
         }
         }
     }
     }
-    
+
     var hasHypervisorSupport: Bool {
     var hasHypervisorSupport: Bool {
-        guard jb_has_hypervisor() else {
+        guard UTMCapabilities.current.contains(.hasHypervisorSupport) else {
+            return false
+        }
+        if UTMCapabilities.current.contains(.isAarch64) {
+            return self == .aarch64
+        } else if UTMCapabilities.current.contains(.isX86_64) {
+            return self == .x86_64
+        } else {
             return false
             return false
         }
         }
-        #if arch(arm64)
-        return self == .aarch64
-        #elseif arch(x86_64)
-        return self == .x86_64
-        #else
-        return false
-        #endif
     }
     }
-    
+
     /// TSO is supported on jailbroken iOS devices with Hypervisor support
     /// TSO is supported on jailbroken iOS devices with Hypervisor support
     var hasTSOSupport: Bool {
     var hasTSOSupport: Bool {
         #if os(iOS) || os(visionOS)
         #if os(iOS) || os(visionOS)

+ 1 - 1
Configuration/UTMConfiguration.swift

@@ -120,7 +120,7 @@ extension UTMConfiguration {
             #endif
             #endif
             // is it a legacy QEMU config?
             // is it a legacy QEMU config?
             let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
             let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
-            let name = UTMQemuVirtualMachine.virtualMachineName(for: packageURL)
+            let name = ConcreteVirtualMachine.virtualMachineName(for: packageURL)
             let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
             let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
             return UTMQemuConfiguration(migrating: legacy)
             return UTMQemuConfiguration(migrating: legacy)
         } else if stub.backend == .qemu {
         } else if stub.backend == .qemu {

+ 5 - 2
Configuration/UTMConfigurationDrive.swift

@@ -15,7 +15,6 @@
 //
 //
 
 
 import Foundation
 import Foundation
-import QEMUKitInternal
 
 
 /// Settings for single disk device
 /// Settings for single disk device
 protocol UTMConfigurationDrive: Codable, Hashable, Identifiable {
 protocol UTMConfigurationDrive: Codable, Hashable, Identifiable {
@@ -101,13 +100,17 @@ extension UTMConfigurationDrive {
             try handle.close()
             try handle.close()
         }.value
         }.value
     }
     }
-    
+
     private func createQcow2Image(at newURL: URL, size sizeMib: Int) async throws {
     private func createQcow2Image(at newURL: URL, size sizeMib: Int) async throws {
+        #if WITH_REMOTE
+        fatalError("Not implemented")
+        #else
         try await Task.detached {
         try await Task.detached {
             if !QEMUGenerateDefaultQcow2File(newURL as CFURL, sizeMib) {
             if !QEMUGenerateDefaultQcow2File(newURL as CFURL, sizeMib) {
                 throw UTMConfigurationError.cannotCreateDiskImage
                 throw UTMConfigurationError.cannotCreateDiskImage
             }
             }
         }.value
         }.value
+        #endif
     }
     }
     
     
     #if os(macOS)
     #if os(macOS)

+ 105 - 16
Configuration/UTMQemuConfiguration+Arguments.swift

@@ -61,6 +61,26 @@ import Virtualization // for getting network interfaces
         socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("swtpm")
         socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("swtpm")
     }
     }
     
     
+    /// Used only if in remote sever mode.
+    var monitorPipeURL: URL {
+        socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qmp")
+    }
+
+    /// Used only if in remote sever mode.
+    var guestAgentPipeURL: URL {
+        socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qga")
+    }
+
+    /// Used only if in remote sever mode.
+    var spiceTlsKeyUrl: URL {
+        socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("pem")
+    }
+
+    /// Used only if in remote sever mode.
+    var spiceTlsCertUrl: URL {
+        socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("crt")
+    }
+
     /// Combined generated and user specified arguments.
     /// Combined generated and user specified arguments.
     @QEMUArgumentBuilder var allArguments: [QEMUArgument] {
     @QEMUArgumentBuilder var allArguments: [QEMUArgument] {
         generatedArguments
         generatedArguments
@@ -109,16 +129,48 @@ import Virtualization // for getting network interfaces
     
     
     @QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
     @QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
         f("-spice")
         f("-spice")
-        "unix=on"
-        "addr=\(spiceSocketURL.lastPathComponent)"
-        "disable-ticketing=on"
-        "image-compression=off"
-        "playback-compression=off"
-        "streaming-video=off"
-        "gl=\(isGLOn ? "on" : "off")"
+        if let port = qemu.spiceServerPort {
+            if qemu.isSpiceServerTlsEnabled {
+                "tls-port=\(port)"
+                "tls-channel=default"
+                "x509-key-file="
+                spiceTlsKeyUrl
+                "x509-cert-file="
+                spiceTlsCertUrl
+                "x509-cacert-file="
+                spiceTlsCertUrl
+            } else {
+                "port=\(port)"
+            }
+        } else {
+            "unix=on"
+            "addr=\(spiceSocketURL.lastPathComponent)"
+        }
+        if let _ = qemu.spiceServerPassword {
+            "password-secret=secspice0"
+        } else {
+            "disable-ticketing=on"
+        }
+        if !isRemoteSpice {
+            "image-compression=off"
+            "playback-compression=off"
+            "streaming-video=off"
+        } else {
+            "streaming-video=filter"
+        }
+        "gl=\(isGLSupported && !isRemoteSpice ? "on" : "off")"
         f()
         f()
         f("-chardev")
         f("-chardev")
-        f("spiceport,id=org.qemu.monitor.qmp,name=org.qemu.monitor.qmp.0")
+        if isRemoteSpice {
+            "pipe"
+            "path="
+            monitorPipeURL
+        } else {
+            "spiceport"
+            "name=org.qemu.monitor.qmp.0"
+        }
+        "id=org.qemu.monitor.qmp"
+        f()
         f("-mon")
         f("-mon")
         f("chardev=org.qemu.monitor.qmp,mode=control")
         f("chardev=org.qemu.monitor.qmp,mode=control")
         if !isSparc { // disable -vga and other default devices
         if !isSparc { // disable -vga and other default devices
@@ -128,8 +180,28 @@ import Virtualization // for getting network interfaces
             f("-vga")
             f("-vga")
             f("none")
             f("none")
         }
         }
+        if let password = qemu.spiceServerPassword {
+            // assume anyone who can read this is in our trust domain
+            f("-object")
+            f("secret,id=secspice0,data=\(password)")
+        }
     }
     }
-    
+
+    private func filterDisplayIfRemote(_ display: any QEMUDisplayDevice) -> any QEMUDisplayDevice {
+        if isRemoteSpice {
+            let rawValue = display.rawValue
+            if rawValue.hasSuffix("-gl") {
+                return AnyQEMUConstant(rawValue: String(rawValue.dropLast(3)))!
+            } else if rawValue.contains("-gl-") {
+                return AnyQEMUConstant(rawValue: String(rawValue.replacingOccurrences(of: "-gl-", with: "-")))!
+            } else {
+                return display
+            }
+        } else {
+            return display
+        }
+    }
+
     @QEMUArgumentBuilder private var displayArguments: [QEMUArgument] {
     @QEMUArgumentBuilder private var displayArguments: [QEMUArgument] {
         if displays.isEmpty {
         if displays.isEmpty {
             f("-nographic")
             f("-nographic")
@@ -143,7 +215,7 @@ import Virtualization // for getting network interfaces
         } else {
         } else {
             for display in displays {
             for display in displays {
                 f("-device")
                 f("-device")
-                display.hardware
+                filterDisplayIfRemote(display.hardware)
                 if let vgaRamSize = displays[0].vgaRamMib {
                 if let vgaRamSize = displays[0].vgaRamMib {
                     "vgamem_mb=\(vgaRamSize)"
                     "vgamem_mb=\(vgaRamSize)"
                 }
                 }
@@ -152,7 +224,7 @@ import Virtualization // for getting network interfaces
         }
         }
     }
     }
     
     
-    private var isGLOn: Bool {
+    private var isGLSupported: Bool {
         displays.contains { display in
         displays.contains { display in
             display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl")
             display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl")
         }
         }
@@ -161,7 +233,11 @@ import Virtualization // for getting network interfaces
     private var isSparc: Bool {
     private var isSparc: Bool {
         system.architecture == .sparc || system.architecture == .sparc64
         system.architecture == .sparc || system.architecture == .sparc64
     }
     }
-    
+
+    private var isRemoteSpice: Bool {
+        qemu.spiceServerPort != nil
+    }
+
     @QEMUArgumentBuilder private var serialArguments: [QEMUArgument] {
     @QEMUArgumentBuilder private var serialArguments: [QEMUArgument] {
         for i in serials.indices {
         for i in serials.indices {
             f("-chardev")
             f("-chardev")
@@ -318,9 +394,9 @@ import Virtualization // for getting network interfaces
             }
             }
             let tbSize = system.jitCacheSize > 0 ? system.jitCacheSize : system.memorySize / 4
             let tbSize = system.jitCacheSize > 0 ? system.jitCacheSize : system.memorySize / 4
             "tb-size=\(tbSize)"
             "tb-size=\(tbSize)"
-            #if !WITH_QEMU_TCI
+            #if WITH_JIT
             // use mirror mapping when we don't have JIT entitlements
             // use mirror mapping when we don't have JIT entitlements
-            if !jb_has_jit_entitlement() {
+            if !UTMCapabilities.current.contains(.hasJitEntitlements) {
                 "split-wx=on"
                 "split-wx=on"
             }
             }
             #endif
             #endif
@@ -433,6 +509,10 @@ import Virtualization // for getting network interfaces
         #if os(iOS) || os(visionOS)
         #if os(iOS) || os(visionOS)
         return false
         return false
         #else
         #else
+        // only support SPICE audio if we are running remotely
+        if isRemoteSpice {
+            return false
+        }
         // force CoreAudio backend for mac99 which only supports 44100 Hz
         // force CoreAudio backend for mac99 which only supports 44100 Hz
         // pcspk doesn't work with SPICE audio
         // pcspk doesn't work with SPICE audio
         if sound.contains(where: { $0.hardware.rawValue == "screamer" || $0.hardware.rawValue == "pcspk" }) {
         if sound.contains(where: { $0.hardware.rawValue == "screamer" || $0.hardware.rawValue == "pcspk" }) {
@@ -671,7 +751,7 @@ import Virtualization // for getting network interfaces
         f("usb-mouse,bus=usb-bus.0")
         f("usb-mouse,bus=usb-bus.0")
         f("-device")
         f("-device")
         f("usb-kbd,bus=usb-bus.0")
         f("usb-kbd,bus=usb-bus.0")
-        #if !WITH_QEMU_TCI
+        #if WITH_USB
         let maxDevices = input.maximumUsbShare
         let maxDevices = input.maximumUsbShare
         let buses = (maxDevices + 2) / 3
         let buses = (maxDevices + 2) / 3
         if input.usbBusSupport == .usb3_0 {
         if input.usbBusSupport == .usb3_0 {
@@ -859,7 +939,16 @@ import Virtualization // for getting network interfaces
             f("-device")
             f("-device")
             f("virtserialport,chardev=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
             f("virtserialport,chardev=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
             f("-chardev")
             f("-chardev")
-            f("spiceport,id=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
+            if isRemoteSpice {
+                "pipe"
+                "path="
+                guestAgentPipeURL
+            } else {
+                "spiceport"
+                "name=org.qemu.guest_agent.0"
+            }
+            "id=org.qemu.guest_agent"
+            f()
         }
         }
         if isSpiceAgentUsed {
         if isSpiceAgentUsed {
             f("-device")
             f("-device")

+ 9 - 0
Configuration/UTMQemuConfigurationQEMU.swift

@@ -69,6 +69,15 @@ struct UTMQemuConfigurationQEMU: Codable {
     /// Set to true to request UEFI variable reset. Not saved.
     /// Set to true to request UEFI variable reset. Not saved.
     var isUefiVariableResetRequested: Bool = false
     var isUefiVariableResetRequested: Bool = false
     
     
+    /// Set to open a port for remote SPICE session. Not saved.
+    var spiceServerPort: UInt16?
+
+    /// If true, all SPICE channels will be over TLS. Not saved.
+    var isSpiceServerTlsEnabled: Bool = false
+    
+    /// Set to a password shared with the client. Not saved.
+    var spiceServerPassword: String?
+
     enum CodingKeys: String, CodingKey {
     enum CodingKeys: String, CodingKey {
         case hasDebugLog = "DebugLog"
         case hasDebugLog = "DebugLog"
         case hasUefiBoot = "UEFIBoot"
         case hasUefiBoot = "UEFIBoot"

+ 1 - 1
Platform/Main.swift

@@ -34,7 +34,7 @@ class Main {
     static var jitAvailable = true
     static var jitAvailable = true
     
     
     static func main() {
     static func main() {
-        #if (os(iOS) || os(visionOS)) && !WITH_QEMU_TCI
+        #if (os(iOS) || os(visionOS)) && WITH_JIT
         // check if we have jailbreak
         // check if we have jailbreak
         if jb_spawn_ptrace_child(CommandLine.argc, CommandLine.unsafeArgv) {
         if jb_spawn_ptrace_child(CommandLine.argc, CommandLine.unsafeArgv) {
             logger.info("JIT: ptrace() child spawn trick")
             logger.info("JIT: ptrace() child spawn trick")

+ 5 - 5
Platform/Shared/BigButtonStyle.swift

@@ -17,12 +17,12 @@
 import SwiftUI
 import SwiftUI
 
 
 struct BigButtonStyle: ButtonStyle {
 struct BigButtonStyle: ButtonStyle {
-    let width: CGFloat
-    let height: CGFloat
-    
+    let width: CGFloat?
+    let height: CGFloat?
+
     fileprivate struct BigButtonView: View {
     fileprivate struct BigButtonView: View {
-        let width: CGFloat
-        let height: CGFloat
+        let width: CGFloat?
+        let height: CGFloat?
         let configuration: BigButtonStyle.Configuration
         let configuration: BigButtonStyle.Configuration
         @Environment(\.isEnabled) private var isEnabled: Bool
         @Environment(\.isEnabled) private var isEnabled: Bool
         
         

+ 14 - 5
Platform/Shared/ContentView.swift

@@ -20,8 +20,11 @@ import UniformTypeIdentifiers
 import IQKeyboardManagerSwift
 import IQKeyboardManagerSwift
 #endif
 #endif
 
 
-#if WITH_QEMU_TCI
+// on visionOS, there is no text to show more than UTM
+#if WITH_QEMU_TCI && !os(visionOS)
 let productName = "UTM SE"
 let productName = "UTM SE"
+#elseif WITH_REMOTE && !os(visionOS)
+let productName = "UTM Remote"
 #else
 #else
 let productName = "UTM"
 let productName = "UTM"
 #endif
 #endif
@@ -33,7 +36,8 @@ struct ContentView: View {
     @State private var newPopupPresented = false
     @State private var newPopupPresented = false
     @State private var openSheetPresented = false
     @State private var openSheetPresented = false
     @Environment(\.openURL) var openURL
     @Environment(\.openURL) var openURL
-    
+    @AppStorage("ServerAutostart") private var isServerAutostart: Bool = false
+
     var body: some View {
     var body: some View {
         VMNavigationListView()
         VMNavigationListView()
         .overlay(data.showSettingsModal ? AnyView(EmptyView()) : AnyView(BusyOverlay()))
         .overlay(data.showSettingsModal ? AnyView(EmptyView()) : AnyView(BusyOverlay()))
@@ -67,6 +71,11 @@ struct ContentView: View {
         .onAppear {
         .onAppear {
             Task {
             Task {
                 await data.listRefresh()
                 await data.listRefresh()
+                #if os(macOS)
+                if isServerAutostart {
+                    await data.remoteServer.start()
+                }
+                #endif
             }
             }
             Task {
             Task {
                 await releaseHelper.fetchReleaseNotes()
                 await releaseHelper.fetchReleaseNotes()
@@ -78,7 +87,7 @@ struct ContentView: View {
             #if !os(visionOS)
             #if !os(visionOS)
             IQKeyboardManager.shared.enable = true
             IQKeyboardManager.shared.enable = true
             #endif
             #endif
-            #if !WITH_QEMU_TCI
+            #if WITH_JIT
             if !Main.jitAvailable {
             if !Main.jitAvailable {
                 data.busyWorkAsync {
                 data.busyWorkAsync {
                     let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach")
                     let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach")
@@ -95,7 +104,7 @@ struct ContentView: View {
                     #endif
                     #endif
 
 
                     // ignore error when we are running on a HV only build
                     // ignore error when we are running on a HV only build
-                    if !jb_has_hypervisor() {
+                    if !UTMCapabilities.current.contains(.hasHypervisorSupport) {
                         throw NSLocalizedString("Your version of iOS does not support running VMs while unmodified. You must either run UTM while jailbroken or with a remote debugger attached. See https://getutm.app/install/ for more details.", comment: "ContentView")
                         throw NSLocalizedString("Your version of iOS does not support running VMs while unmodified. You must either run UTM while jailbroken or with a remote debugger attached. See https://getutm.app/install/ for more details.", comment: "ContentView")
                     }
                     }
                 }
                 }
@@ -163,7 +172,7 @@ struct ContentView: View {
             case "pause":
             case "pause":
                 if let vm = findVM(), vm.state == .started {
                 if let vm = findVM(), vm.state == .started {
                     let shouldSaveOnPause: Bool
                     let shouldSaveOnPause: Bool
-                    if let vm = vm.wrapped as? UTMQemuVirtualMachine {
+                    if let vm = vm.wrapped as? (any UTMSpiceVirtualMachine) {
                         shouldSaveOnPause = !vm.isRunningAsDisposible
                         shouldSaveOnPause = !vm.isRunningAsDisposible
                     } else {
                     } else {
                         shouldSaveOnPause = true
                         shouldSaveOnPause = true

+ 111 - 0
Platform/Shared/MacDeviceLabel.swift

@@ -0,0 +1,111 @@
+//
+// 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 SwiftUI
+import UniformTypeIdentifiers
+
+struct MacDeviceLabel<Title>: View where Title : StringProtocol {
+    let title: Title
+    let device: MacDevice
+
+    init(_ title: Title, device macDevice: MacDevice) {
+        self.title = title
+        self.device = macDevice
+    }
+
+    var body: some View {
+        Label(title, systemImage: device.symbolName)
+    }
+}
+
+// credits: https://adamdemasi.com/2023/04/15/mac-device-icon-by-device-class.html
+
+private extension UTTagClass {
+    static let deviceModelCode = UTTagClass(rawValue: "com.apple.device-model-code")
+}
+
+private extension UTType {
+    static let macBook          = UTType("com.apple.mac.laptop")
+    static let macBookWithNotch = UTType("com.apple.mac.notched-laptop")
+    static let macMini          = UTType("com.apple.macmini")
+    static let macStudio        = UTType("com.apple.macstudio")
+    static let iMac             = UTType("com.apple.imac")
+    static let macPro           = UTType("com.apple.macpro")
+    static let macPro2013       = UTType("com.apple.macpro-cylinder")
+    static let macPro2019       = UTType("com.apple.macpro-2019")
+}
+
+struct MacDevice {
+    let model: String
+    let symbolName: String
+
+    #if os(macOS)
+    static let current: Self = {
+        let key = "hw.model"
+        var size = size_t()
+        sysctlbyname(key, nil, &size, nil, 0)
+        let value = malloc(size)
+        defer {
+            value?.deallocate()
+        }
+        sysctlbyname(key, value, &size, nil, 0)
+        guard let cChar = value?.bindMemory(to: CChar.self, capacity: size) else {
+            return Self(model: "Unknown")
+        }
+        return Self(model: String(cString: cChar))
+    }()
+    #endif
+
+    init(model: String?) {
+        self.model = model ?? "Unknown"
+        self.symbolName = Self.symbolName(from: self.model)
+    }
+
+    private static func checkModel(_ model: String, conformsTo type: UTType?) -> Bool {
+        guard let type else {
+            return false
+        }
+        return UTType(tag: model, tagClass: .deviceModelCode, conformingTo: nil)?.conforms(to: type) ?? false
+    }
+
+    private static func symbolName(from model: String) -> String {
+        if checkModel(model, conformsTo: .macBookWithNotch),
+            #available(macOS 14, iOS 17, macCatalyst 17, tvOS 17, watchOS 10, *) {
+            // macbook.gen2 was added with SF Symbols 5.0 (macOS Sonoma, 2023), but MacBooks with a notch
+            // were released in 2021!
+            return "macbook.gen2"
+        } else if checkModel(model, conformsTo: .macBook) {
+            return "laptopcomputer"
+        } else if checkModel(model, conformsTo: .macMini) {
+            return "macmini"
+        } else if checkModel(model, conformsTo: .macStudio) {
+            return "macstudio"
+        } else if checkModel(model, conformsTo: .iMac) {
+            return "desktopcomputer"
+        } else if checkModel(model, conformsTo: .macPro2019) {
+            return "macpro.gen3"
+        } else if checkModel(model, conformsTo: .macPro2013) {
+            return "macpro.gen2"
+        } else if checkModel(model, conformsTo: .macPro) {
+            return "macpro"
+        }
+        return "display"
+    }
+}
+
+#Preview {
+    MacDeviceLabel("MacBook", device: MacDevice(model: "Mac14,6"))
+}

+ 10 - 1
Platform/Shared/NumberTextField.swift

@@ -107,7 +107,16 @@ struct NumberTextField: View {
         self.onEditingChanged = onEditingChanged
         self.onEditingChanged = onEditingChanged
         self.promptKey = prompt
         self.promptKey = prompt
     }
     }
-    
+
+    init(_ titleKey: LocalizedStringKey, number: Binding<Int?>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
+        let nsnumber = Binding<NSNumber?> {
+            return number.wrappedValue as NSNumber?
+        } set: { newValue in
+            number.wrappedValue = newValue?.intValue
+        }
+        self.init(titleKey, number: nsnumber, prompt: prompt, onEditingChanged: onEditingChanged)
+    }
+
     init(_ titleKey: LocalizedStringKey, number: Binding<Int>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
     init(_ titleKey: LocalizedStringKey, number: Binding<Int>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
         let nsnumber = Binding<NSNumber?> {
         let nsnumber = Binding<NSNumber?> {
             return number.wrappedValue as NSNumber
             return number.wrappedValue as NSNumber

+ 27 - 1
Platform/Shared/UTMUnavailableVMView.swift

@@ -25,7 +25,13 @@ struct UTMUnavailableVMView: View {
                              subtitle: vm.detailsSubtitleLabel,
                              subtitle: vm.detailsSubtitleLabel,
                              progress: nil,
                              progress: nil,
                              imageOverlaySystemName: "questionmark.circle.fill",
                              imageOverlaySystemName: "questionmark.circle.fill",
-                             popover: { WrappedVMDetailsView(path: vm.pathUrl.path, onRemove: remove) },
+                             popover: {
+                             #if WITH_REMOTE
+                                 UnsupportedVMDetailsView(vm: vm)
+                             #else
+                                 WrappedVMDetailsView(path: vm.pathUrl.path, onRemove: remove)
+                             #endif
+                             },
                              onRemove: remove)
                              onRemove: remove)
     }
     }
     
     
@@ -71,6 +77,26 @@ fileprivate struct WrappedVMDetailsView: View {
     }
     }
 }
 }
 
 
+#if WITH_REMOTE
+fileprivate struct UnsupportedVMDetailsView: View {
+    @ObservedObject var vm: VMData
+
+    var body: some View {
+        VStack(alignment: .center) {
+            if let remotevm = vm as? VMRemoteData, let reason = remotevm.unavailableReason {
+                Text(reason)
+                    .lineLimit(nil)
+            } else {
+                Text("This VM is unavailable.")
+            }
+        }
+        #if os(macOS)
+        .frame(width: 230)
+        #endif
+    }
+}
+#endif
+
 struct UTMUnavailableVMView_Previews: PreviewProvider {
 struct UTMUnavailableVMView_Previews: PreviewProvider {
     static var previews: some View {
     static var previews: some View {
         UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))
         UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))

+ 2 - 0
Platform/Shared/VMCommands.swift

@@ -21,6 +21,7 @@ struct VMCommands: Commands {
     
     
     @CommandsBuilder
     @CommandsBuilder
     var body: some Commands {
     var body: some Commands {
+        #if !WITH_REMOTE // FIXME: implement remote feature
         CommandGroup(replacing: .newItem) {
         CommandGroup(replacing: .newItem) {
             Button(action: { NotificationCenter.default.post(name: NSNotification.NewVirtualMachine, object: nil) }, label: {
             Button(action: { NotificationCenter.default.post(name: NSNotification.NewVirtualMachine, object: nil) }, label: {
                 Text("New…")
                 Text("New…")
@@ -29,6 +30,7 @@ struct VMCommands: Commands {
                 Text("Open…")
                 Text("Open…")
             }).keyboardShortcut(KeyEquivalent("o"))
             }).keyboardShortcut(KeyEquivalent("o"))
         }
         }
+        #endif
         SidebarCommands()
         SidebarCommands()
         ToolbarCommands()
         ToolbarCommands()
         CommandGroup(replacing: .help) {
         CommandGroup(replacing: .help) {

+ 1 - 1
Platform/Shared/VMConfigInputView.swift

@@ -26,7 +26,7 @@ struct VMConfigInputView: View {
                     VMConfigConstantPicker("USB Support", selection: $config.usbBusSupport)
                     VMConfigConstantPicker("USB Support", selection: $config.usbBusSupport)
                 }
                 }
                 
                 
-                #if !WITH_QEMU_TCI
+                #if WITH_USB
                 if config.usbBusSupport != .disabled {
                 if config.usbBusSupport != .disabled {
                     Section(header: Text("USB Sharing")) {
                     Section(header: Text("USB Sharing")) {
                         if !jb_has_usb_entitlement() {
                         if !jb_has_usb_entitlement() {

+ 2 - 2
Platform/Shared/VMConfigSystemView.swift

@@ -101,7 +101,7 @@ struct VMConfigSystemView: View {
         }
         }
         #endif
         #endif
         let actualJitSizeMib = jitSizeMib == 0 ? memorySizeMib / 4 : jitSizeMib
         let actualJitSizeMib = jitSizeMib == 0 ? memorySizeMib / 4 : jitSizeMib
-        let jitMirrorMultiplier = jb_has_jit_entitlement() ? 1 : 2;
+        let jitMirrorMultiplier = UTMCapabilities.current.contains(.hasJitEntitlements) ? 1 : 2;
         let estMemoryUsage = UInt64(memorySizeMib + jitMirrorMultiplier*actualJitSizeMib + baseUsageMib) * bytesInMib
         let estMemoryUsage = UInt64(memorySizeMib + jitMirrorMultiplier*actualJitSizeMib + baseUsageMib) * bytesInMib
         if Double(estMemoryUsage) > Double(totalDeviceMemory) * warningThreshold {
         if Double(estMemoryUsage) > Double(totalDeviceMemory) * warningThreshold {
             warningMessage = WarningMessage.overallocatedRam(totalMib: totalDeviceMemory / bytesInMib, estimatedMib: estMemoryUsage / bytesInMib)
             warningMessage = WarningMessage.overallocatedRam(totalMib: totalDeviceMemory / bytesInMib, estimatedMib: estMemoryUsage / bytesInMib)
@@ -177,7 +177,7 @@ private struct HardwareOptions: View {
                     }
                     }
                 }
                 }
                 .onChange(of: config.architecture) { newValue in
                 .onChange(of: config.architecture) { newValue in
-                    isArchitectureSupported = UTMQemuVirtualMachine.isSupported(systemArchitecture: newValue)
+                    isArchitectureSupported = ConcreteVirtualMachine.isSupported(systemArchitecture: newValue)
                     if newValue != architecture {
                     if newValue != architecture {
                         architecture = newValue
                         architecture = newValue
                     }
                     }

+ 6 - 2
Platform/Shared/VMContextMenuModifier.swift

@@ -61,6 +61,7 @@ struct VMContextMenuModifier: ViewModifier {
             }.help("Reveal where the VM is stored.")
             }.help("Reveal where the VM is stored.")
             Divider()
             Divider()
             #endif
             #endif
+            #if !WITH_REMOTE // FIXME: implement remote feature
             Button {
             Button {
                 data.close(vm: vm) // close window
                 data.close(vm: vm) // close window
                 data.edit(vm: vm)
                 data.edit(vm: vm)
@@ -68,6 +69,7 @@ struct VMContextMenuModifier: ViewModifier {
                 Label("Edit", systemImage: "slider.horizontal.3")
                 Label("Edit", systemImage: "slider.horizontal.3")
             }.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
             }.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
             .help("Modify settings for this VM.")
             .help("Modify settings for this VM.")
+            #endif
             if vm.hasSuspendState || !vm.isStopped {
             if vm.hasSuspendState || !vm.isStopped {
                 Button {
                 Button {
                     confirmAction = .confirmStopVM
                     confirmAction = .confirmStopVM
@@ -99,7 +101,7 @@ struct VMContextMenuModifier: ViewModifier {
                 }
                 }
                 #endif
                 #endif
                 
                 
-                if let _ = vm.wrapped as? UTMQemuVirtualMachine {
+                if let _ = vm.config as? UTMQemuConfiguration {
                     Button {
                     Button {
                         data.run(vm: vm, options: .bootDisposibleMode)
                         data.run(vm: vm, options: .bootDisposibleMode)
                     } label: {
                     } label: {
@@ -120,6 +122,7 @@ struct VMContextMenuModifier: ViewModifier {
                 
                 
                 Divider()
                 Divider()
             }
             }
+            #if !WITH_REMOTE // FIXME: implement remote feature
             Button {
             Button {
                 shareItem = .utmCopy(vm)
                 shareItem = .utmCopy(vm)
                 showSharePopup.toggle()
                 showSharePopup.toggle()
@@ -164,6 +167,7 @@ struct VMContextMenuModifier: ViewModifier {
                 }.disabled(!vm.isModifyAllowed)
                 }.disabled(!vm.isModifyAllowed)
                 .help("Delete this VM and all its data.")
                 .help("Delete this VM and all its data.")
             }
             }
+            #endif
         }
         }
         .modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
         .modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
         .modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
         .modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
@@ -175,7 +179,7 @@ struct VMContextMenuModifier: ViewModifier {
         .onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
         .onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
             if newValue == true {
             if newValue == true {
                 data.busyWorkAsync {
                 data.busyWorkAsync {
-                    try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)
+                    try await data.mountSupportTools(for: vm.wrapped!)
                 }
                 }
             }
             }
         }
         }

+ 19 - 8
Platform/Shared/VMDetailsView.swift

@@ -29,9 +29,10 @@ struct VMDetailsView: View {
     #else
     #else
     private let regularScreenSizeClass: Bool = true
     private let regularScreenSizeClass: Bool = true
     #endif
     #endif
-    
+
+    @State private var size: Int64 = 0
+
     private var sizeLabel: String {
     private var sizeLabel: String {
-        let size = data.computeSize(for: vm)
         return ByteCountFormatter.string(fromByteCount: size, countStyle: .binary)
         return ByteCountFormatter.string(fromByteCount: size, countStyle: .binary)
     }
     }
     
     
@@ -70,8 +71,8 @@ struct VMDetailsView: View {
                             .padding([.leading, .trailing, .bottom])
                             .padding([.leading, .trailing, .bottom])
                     }
                     }
                     #else
                     #else
-                    let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
-                    VMRemovableDrivesView(vm: vm, config: qemuVM.config)
+                    let qemuConfig = vm.config as! UTMQemuConfiguration
+                    VMRemovableDrivesView(vm: vm, config: qemuConfig)
                         .padding([.leading, .trailing, .bottom])
                         .padding([.leading, .trailing, .bottom])
                     #endif
                     #endif
                 } else {
                 } else {
@@ -89,8 +90,8 @@ struct VMDetailsView: View {
                             VMRemovableDrivesView(vm: vm, config: qemuVM.config)
                             VMRemovableDrivesView(vm: vm, config: qemuVM.config)
                         }
                         }
                         #else
                         #else
-                        let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
-                        VMRemovableDrivesView(vm: vm, config: qemuVM.config)
+                        let qemuConfig = vm.config as! UTMQemuConfiguration
+                        VMRemovableDrivesView(vm: vm, config: qemuConfig)
                         #endif
                         #endif
                     }.padding([.leading, .trailing, .bottom])
                     }.padding([.leading, .trailing, .bottom])
                 }
                 }
@@ -109,6 +110,16 @@ struct VMDetailsView: View {
                 }
                 }
                 #endif
                 #endif
             }
             }
+            .onAppear {
+                Task {
+                    size = await data.computeSize(for: vm)
+                    #if WITH_REMOTE
+                    if let vm = vm.wrapped as? UTMRemoteSpiceVirtualMachine {
+                        await vm.loadScreenshotFromServer()
+                    }
+                    #endif
+                }
+            }
         }
         }
     }
     }
 }
 }
@@ -151,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)
@@ -164,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")

+ 8 - 2
Platform/Shared/VMNavigationListView.swift

@@ -66,8 +66,10 @@ struct VMNavigationListView: View {
                 }
                 }
             }
             }
         }.onMove(perform: move)
         }.onMove(perform: move)
+        #if !WITH_REMOTE // FIXME: implement remote feature
         .onDelete(perform: delete)
         .onDelete(perform: delete)
-        
+        #endif
+
         if data.pendingVMs.count > 0 {
         if data.pendingVMs.count > 0 {
             Section(header: Text("Pending")) {
             Section(header: Text("Pending")) {
                 ForEach(data.pendingVMs, id: \.name) { vm in
                 ForEach(data.pendingVMs, id: \.name) { vm in
@@ -119,10 +121,12 @@ private struct VMListModifier: ViewModifier {
                 newButton
                 newButton
             }
             }
             #else
             #else
+            #if !WITH_REMOTE // FIXME: implement remote feature
             ToolbarItem(placement: .navigationBarLeading) {
             ToolbarItem(placement: .navigationBarLeading) {
                 newButton
                 newButton
             }
             }
-            #if !os(visionOS)
+            #endif
+            #if !os(visionOS) && !WITH_REMOTE
             ToolbarItem(placement: .navigationBarTrailing) {
             ToolbarItem(placement: .navigationBarTrailing) {
                 Button("Settings") {
                 Button("Settings") {
                     settingsPresented.toggle()
                     settingsPresented.toggle()
@@ -140,7 +144,9 @@ private struct VMListModifier: ViewModifier {
             if data.showNewVMSheet {
             if data.showNewVMSheet {
                 VMWizardView()
                 VMWizardView()
             } else if settingsPresented {
             } else if settingsPresented {
+                #if !WITH_REMOTE
                 UTMSettingsView()
                 UTMSettingsView()
+                #endif
             }
             }
         }
         }
         .onChange(of: data.showNewVMSheet) { newValue in
         .onChange(of: data.showNewVMSheet) { newValue in

+ 76 - 15
Platform/Shared/VMPlaceholderView.swift

@@ -17,39 +17,100 @@
 import SwiftUI
 import SwiftUI
 
 
 struct VMPlaceholderView: View {
 struct VMPlaceholderView: View {
-    @EnvironmentObject private var data: UTMData
-    @Environment(\.openURL) private var openURL
-    
+    var body: some View {
+        if #available(iOS 16, macOS 13, *) {
+            VMPlaceholderViewNew()
+        } else {
+            VMPlaceholderViewOld()
+        }
+    }
+}
+
+fileprivate struct VMPlaceholderViewOld: View {
     var body: some View {
     var body: some View {
         VStack {
         VStack {
+            Title()
             HStack {
             HStack {
-                Text("Welcome to UTM").font(.title)
+                FirstRow()
             }
             }
             HStack {
             HStack {
-                TileButton(Label(String.create, systemImage: "plus.circle")) {
-                    data.newVM()
-                }
-                TileButton(Label(String.browse, systemImage: "arrow.down.circle")) {
-                    openURL(URL(string: "https://mac.getutm.app/gallery/")!)
-                }
+                SecondRow()
             }
             }
-            HStack {
-                TileButton(Label(String.guide, systemImage: "book.circle")) {
-                    openURL(URL(string: "https://docs.getutm.app/basics/basics/")!)
+        }
+    }
+}
+
+@available(iOS 16, macOS 13, *)
+fileprivate struct VMPlaceholderViewNew: View {
+    @Environment(\.openWindow) private var openWindow
+
+    var body: some View {
+        VStack {
+            Title()
+            Grid {
+                GridRow {
+                    FirstRow()
+                }
+                GridRow {
+                    SecondRow()
                 }
                 }
-                TileButton(Label(String.support, systemImage: "questionmark.circle")) {
-                    openURL(URL(string: "https://docs.getutm.app/")!)
+                #if os(macOS)
+                GridRow {
+                    Button {
+                        openWindow(id: "server")
+                    } label: {
+                        Label(String.server, systemImage: "server.rack")
+                    }.buttonStyle(BigButtonStyle(width: nil, height: 50))
+                    .gridCellColumns(2)
+                    .gridCellUnsizedAxes(.horizontal)
                 }
                 }
+                #endif
             }
             }
         }
         }
     }
     }
 }
 }
 
 
+fileprivate struct Title: View {
+    var body: some View {
+        HStack {
+            Text("Welcome to UTM").font(.title)
+        }
+    }
+}
+
+fileprivate struct FirstRow: View {
+    @EnvironmentObject private var data: UTMData
+    @Environment(\.openURL) private var openURL
+
+    var body: some View {
+        TileButton(Label(String.create, systemImage: "plus.circle")) {
+            data.newVM()
+        }
+        TileButton(Label(String.browse, systemImage: "arrow.down.circle")) {
+            openURL(URL(string: "https://mac.getutm.app/gallery/")!)
+        }
+    }
+}
+
+fileprivate struct SecondRow: View {
+    @Environment(\.openURL) private var openURL
+
+    var body: some View {
+        TileButton(Label(String.guide, systemImage: "book.circle")) {
+            openURL(URL(string: "https://docs.getutm.app/basics/basics/")!)
+        }
+        TileButton(Label(String.support, systemImage: "questionmark.circle")) {
+            openURL(URL(string: "https://docs.getutm.app/")!)
+        }
+    }
+}
+
 fileprivate extension String {
 fileprivate extension String {
     static let create = NSLocalizedString("Create a New Virtual Machine", comment: "Welcome view")
     static let create = NSLocalizedString("Create a New Virtual Machine", comment: "Welcome view")
     static let browse = NSLocalizedString("Browse UTM Gallery", comment: "Welcome view")
     static let browse = NSLocalizedString("Browse UTM Gallery", comment: "Welcome view")
     static let guide = NSLocalizedString("User Guide", comment: "Welcome view")
     static let guide = NSLocalizedString("User Guide", comment: "Welcome view")
     static let support = NSLocalizedString("Support", comment: "Welcome view")
     static let support = NSLocalizedString("Support", comment: "Welcome view")
+    static let server = NSLocalizedString("Server", comment: "Server view")
 }
 }
 
 
 private struct TileButton: View {
 private struct TileButton: View {

+ 6 - 2
Platform/Shared/VMRemovableDrivesView.swift

@@ -26,8 +26,8 @@ struct VMRemovableDrivesView: View {
     @State private var workaroundFileImporterBug: Bool = false
     @State private var workaroundFileImporterBug: Bool = false
     @State private var currentDrive: UTMQemuConfigurationDrive?
     @State private var currentDrive: UTMQemuConfigurationDrive?
     
     
-    private var qemuVM: UTMQemuVirtualMachine! {
-        vm.wrapped as? UTMQemuVirtualMachine
+    private var qemuVM: (any UTMSpiceVirtualMachine)! {
+        vm.wrapped as? any UTMSpiceVirtualMachine
     }
     }
     
     
     var fileManager: FileManager {
     var fileManager: FileManager {
@@ -78,6 +78,7 @@ struct VMRemovableDrivesView: View {
             }
             }
             ForEach(config.drives.filter { $0.isExternal }) { drive in
             ForEach(config.drives.filter { $0.isExternal }) { drive in
                 HStack {
                 HStack {
+                    #if !WITH_REMOTE // FIXME: implement remote feature
                     // Drive menu
                     // Drive menu
                     Menu {
                     Menu {
                         // Browse button
                         // Browse button
@@ -118,6 +119,9 @@ struct VMRemovableDrivesView: View {
                     } label: {
                     } label: {
                         DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
                         DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
                     }.disabled(vm.hasSuspendState)
                     }.disabled(vm.hasSuspendState)
+                    #else
+                    DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
+                    #endif
                     Spacer()
                     Spacer()
                     // Disk image path, or (empty)
                     // Disk image path, or (empty)
                     Text(pathFor(drive))
                     Text(pathFor(drive))

+ 4 - 0
Platform/Shared/VMToolbarModifier.swift

@@ -51,6 +51,7 @@ struct VMToolbarModifier: ViewModifier {
             UTMPreferenceButtonToolbarContent()
             UTMPreferenceButtonToolbarContent()
             #endif
             #endif
             ToolbarItemGroup(placement: buttonPlacement) {
             ToolbarItemGroup(placement: buttonPlacement) {
+                #if !WITH_REMOTE // FIXME: implement remote feature
                 if vm.isShortcut {
                 if vm.isShortcut {
                     DestructiveButton {
                     DestructiveButton {
                         confirmAction = .confirmDeleteShortcut
                         confirmAction = .confirmDeleteShortcut
@@ -112,6 +113,7 @@ struct VMToolbarModifier: ViewModifier {
                     Spacer()
                     Spacer()
                 }
                 }
                 #endif
                 #endif
+                #endif
                 if vm.hasSuspendState || !vm.isStopped {
                 if vm.hasSuspendState || !vm.isStopped {
                     Button {
                     Button {
                         confirmAction = .confirmStopVM
                         confirmAction = .confirmStopVM
@@ -129,6 +131,7 @@ struct VMToolbarModifier: ViewModifier {
                     }.help("Run selected VM")
                     }.help("Run selected VM")
                     .padding(.leading, padding)
                     .padding(.leading, padding)
                 }
                 }
+                #if !WITH_REMOTE // FIXME: implement remote feature
                 #if !os(macOS)
                 #if !os(macOS)
                 if bottom {
                 if bottom {
                     Spacer()
                     Spacer()
@@ -143,6 +146,7 @@ struct VMToolbarModifier: ViewModifier {
                 }.help("Edit selected VM")
                 }.help("Edit selected VM")
                 .disabled(vm.hasSuspendState || !vm.isModifyAllowed)
                 .disabled(vm.hasSuspendState || !vm.isModifyAllowed)
                 .padding(.leading, padding)
                 .padding(.leading, padding)
+                #endif
             }
             }
         }
         }
         .modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
         .modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))

+ 2 - 2
Platform/Shared/VMWizardStartView.swift

@@ -26,12 +26,12 @@ struct VMWizardStartView: View {
         #if os(macOS)
         #if os(macOS)
         VZVirtualMachine.isSupported && !processIsTranslated()
         VZVirtualMachine.isSupported && !processIsTranslated()
         #else
         #else
-        jb_has_hypervisor()
+        UTMCapabilities.current.contains(.hasHypervisorSupport)
         #endif
         #endif
     }
     }
     
     
     var isEmulationSupported: Bool {
     var isEmulationSupported: Bool {
-        #if WITH_QEMU_TCI
+        #if !WITH_JIT
         true
         true
         #else
         #else
         Main.jitAvailable
         Main.jitAvailable

+ 365 - 20
Platform/UTMData.swift

@@ -21,9 +21,19 @@ import AppKit
 import UIKit
 import UIKit
 import SwiftUI
 import SwiftUI
 #endif
 #endif
-#if canImport(AltKit) && !WITH_QEMU_TCI
+#if canImport(AltKit) && WITH_JIT
 import AltKit
 import AltKit
 #endif
 #endif
+#if WITH_SERVER
+import Combine
+#endif
+
+#if WITH_REMOTE
+import CocoaSpiceNoUsb
+typealias ConcreteVirtualMachine = UTMRemoteSpiceVirtualMachine
+#else
+typealias ConcreteVirtualMachine = UTMQemuVirtualMachine
+#endif
 
 
 struct AlertMessage: Identifiable {
 struct AlertMessage: Identifiable {
     var message: String
     var message: String
@@ -88,7 +98,18 @@ struct AlertMessage: Identifiable {
     nonisolated private var documentsURL: URL {
     nonisolated private var documentsURL: URL {
         UTMData.defaultStorageUrl
         UTMData.defaultStorageUrl
     }
     }
-    
+
+    #if WITH_SERVER
+    /// Remote access server
+    private(set) var remoteServer: UTMRemoteServer!
+
+    /// Listeners for remote access
+    private var remoteChangeListeners: [VMData: Set<AnyCancellable>] = [:]
+
+    /// Listener for list changes
+    private var listChangedListener: AnyCancellable?
+    #endif
+
     /// Queue to run `busyWork` tasks
     /// Queue to run `busyWork` tasks
     private var busyQueue: DispatchQueue
     private var busyQueue: DispatchQueue
     
     
@@ -100,6 +121,10 @@ struct AlertMessage: Identifiable {
         self.virtualMachines = []
         self.virtualMachines = []
         self.pendingVMs = []
         self.pendingVMs = []
         self.selectedVM = nil
         self.selectedVM = nil
+        #if WITH_SERVER
+        self.remoteServer = UTMRemoteServer(data: self)
+        beginObservingChanges()
+        #endif
         listLoadFromDefaults()
         listLoadFromDefaults()
     }
     }
     
     
@@ -133,7 +158,7 @@ struct AlertMessage: Identifiable {
                 guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
                 guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
                     continue
                     continue
                 }
                 }
-                guard UTMQemuVirtualMachine.isVirtualMachine(url: file) else {
+                guard ConcreteVirtualMachine.isVirtualMachine(url: file) else {
                     continue
                     continue
                 }
                 }
                 await Task.yield()
                 await Task.yield()
@@ -168,7 +193,7 @@ struct AlertMessage: Identifiable {
     }
     }
     
     
     /// Load VM list (and order) from persistent storage
     /// Load VM list (and order) from persistent storage
-    private func listLoadFromDefaults() {
+    fileprivate func listLoadFromDefaults() {
         let defaults = UserDefaults.standard
         let defaults = UserDefaults.standard
         guard defaults.object(forKey: "VMList") == nil else {
         guard defaults.object(forKey: "VMList") == nil else {
             listLegacyLoadFromDefaults()
             listLegacyLoadFromDefaults()
@@ -186,7 +211,7 @@ struct AlertMessage: Identifiable {
         guard let list = defaults.stringArray(forKey: "VMEntryList") else {
         guard let list = defaults.stringArray(forKey: "VMEntryList") else {
             return
             return
         }
         }
-        virtualMachines = list.uniqued().compactMap { uuidString in
+        let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in
             guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
             guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
                 return nil
                 return nil
             }
             }
@@ -198,6 +223,7 @@ struct AlertMessage: Identifiable {
             }
             }
             return vm
             return vm
         }
         }
+        listReplace(with: virtualMachines)
     }
     }
     
     
     /// Load VM list (and order) from persistent storage (legacy)
     /// Load VM list (and order) from persistent storage (legacy)
@@ -205,7 +231,7 @@ struct AlertMessage: Identifiable {
         let defaults = UserDefaults.standard
         let defaults = UserDefaults.standard
         // legacy path list
         // legacy path list
         if let files = defaults.array(forKey: "VMList") as? [String] {
         if let files = defaults.array(forKey: "VMList") as? [String] {
-            virtualMachines = files.uniqued().compactMap({ file in
+            let virtualMachines = files.uniqued().compactMap({ file in
                 let url = documentsURL.appendingPathComponent(file, isDirectory: true)
                 let url = documentsURL.appendingPathComponent(file, isDirectory: true)
                 if let vm = try? VMData(url: url) {
                 if let vm = try? VMData(url: url) {
                     return vm
                     return vm
@@ -213,10 +239,11 @@ struct AlertMessage: Identifiable {
                     return nil
                     return nil
                 }
                 }
             })
             })
+            listReplace(with: virtualMachines)
         }
         }
         // bookmark list
         // bookmark list
         if let list = defaults.array(forKey: "VMList") {
         if let list = defaults.array(forKey: "VMList") {
-            virtualMachines = list.compactMap { item in
+            let virtualMachines = list.compactMap { item in
                 let vm: VMData?
                 let vm: VMData?
                 if let bookmark = item as? Data {
                 if let bookmark = item as? Data {
                     vm = VMData(bookmark: bookmark)
                     vm = VMData(bookmark: bookmark)
@@ -228,6 +255,7 @@ struct AlertMessage: Identifiable {
                 try? vm?.load()
                 try? vm?.load()
                 return vm
                 return vm
             }
             }
+            listReplace(with: virtualMachines)
         }
         }
     }
     }
     
     
@@ -238,8 +266,15 @@ struct AlertMessage: Identifiable {
         defaults.set(wrappedVMs, forKey: "VMEntryList")
         defaults.set(wrappedVMs, forKey: "VMEntryList")
     }
     }
     
     
-    private func listReplace(with vms: [VMData]) {
+    /// Replace current VM list with a new list
+    /// - Parameter vms: List to replace with
+    fileprivate func listReplace(with vms: [VMData]) {
+        virtualMachines.forEach({ endObservingChanges(for: $0) })
         virtualMachines = vms
         virtualMachines = vms
+        vms.forEach({ beginObservingChanges(for: $0) })
+        if let vm = selectedVM, !vms.contains(where: { $0 == vm }) {
+            selectedVM = nil
+        }
     }
     }
     
     
     /// Add VM to list
     /// Add VM to list
@@ -254,6 +289,7 @@ struct AlertMessage: Identifiable {
         } else {
         } else {
             virtualMachines.append(vm)
             virtualMachines.append(vm)
         }
         }
+        beginObservingChanges(for: vm)
     }
     }
     
     
     /// Select VM in list
     /// Select VM in list
@@ -267,6 +303,7 @@ struct AlertMessage: Identifiable {
     /// - Returns: Index of item removed or nil if already removed
     /// - Returns: Index of item removed or nil if already removed
     @discardableResult public func listRemove(vm: VMData) -> Int? {
     @discardableResult public func listRemove(vm: VMData) -> Int? {
         let index = virtualMachines.firstIndex(of: vm)
         let index = virtualMachines.firstIndex(of: vm)
+        endObservingChanges(for: vm)
         if let index = index {
         if let index = index {
             virtualMachines.remove(at: index)
             virtualMachines.remove(at: index)
         }
         }
@@ -316,7 +353,7 @@ struct AlertMessage: Identifiable {
         let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
         let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
         for i in 1..<1000 {
         for i in 1..<1000 {
             let name = nameForId(i)
             let name = nameForId(i)
-            let file = UTMQemuVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
+            let file = ConcreteVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
             if !fileManager.fileExists(atPath: file.path) {
             if !fileManager.fileExists(atPath: file.path) {
                 return name
                 return name
             }
             }
@@ -383,6 +420,13 @@ struct AlertMessage: Identifiable {
     func save(vm: VMData) async throws {
     func save(vm: VMData) async throws {
         do {
         do {
             try await vm.save()
             try await vm.save()
+            #if WITH_SERVER
+            if let qemuConfig = vm.config as? UTMQemuConfiguration {
+                await remoteServer.broadcast { remote in
+                    try await remote.qemuConfigurationHasChanged(id: vm.id, configuration: qemuConfig)
+                }
+            }
+            #endif
         } catch {
         } catch {
             // refresh the VM object as it is now stale
             // refresh the VM object as it is now stale
             let origError = error
             let origError = error
@@ -450,8 +494,8 @@ struct AlertMessage: Identifiable {
     /// - Returns: The new VM
     /// - Returns: The new VM
     @discardableResult func clone(vm: VMData) async throws -> VMData {
     @discardableResult func clone(vm: VMData) async throws -> VMData {
         let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
         let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
-        let newPath = UTMQemuVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
-        
+        let newPath = ConcreteVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
+
         try await copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
         try await copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
         guard let newVM = try? VMData(url: newPath) else {
         guard let newVM = try? VMData(url: newPath) else {
             throw UTMDataError.cloneFailed
             throw UTMDataError.cloneFailed
@@ -532,7 +576,7 @@ struct AlertMessage: Identifiable {
     /// Calculate total size of VM and data
     /// Calculate total size of VM and data
     /// - Parameter vm: VM to calculate size
     /// - Parameter vm: VM to calculate size
     /// - Returns: Size in bytes
     /// - Returns: Size in bytes
-    func computeSize(for vm: VMData) -> Int64 {
+    func computeSize(for vm: VMData) async -> Int64 {
         let path = vm.pathUrl
         let path = vm.pathUrl
         guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
         guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
             logger.error("failed to create enumerator for \(path)")
             logger.error("failed to create enumerator for \(path)")
@@ -616,7 +660,7 @@ struct AlertMessage: Identifiable {
         listSelect(vm: vm)
         listSelect(vm: vm)
     }
     }
 
 
-    func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws {
+    private func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws {
         try await Task.detached(priority: .userInitiated) {
         try await Task.detached(priority: .userInitiated) {
             let status = copyfile(srcURL.path, dstURL.path, nil, copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE | COPYFILE_DATA_SPARSE))
             let status = copyfile(srcURL.path, dstURL.path, nil, copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE | COPYFILE_DATA_SPARSE))
             if status < 0 {
             if status < 0 {
@@ -677,7 +721,10 @@ struct AlertMessage: Identifiable {
         }
         }
     }
     }
     
     
-    func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
+    func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
+        guard let vm = vm as? any UTMSpiceVirtualMachine else {
+            throw UTMDataError.unsupportedBackend
+        }
         let task = UTMDownloadSupportToolsTask(for: vm)
         let task = UTMDownloadSupportToolsTask(for: vm)
         if await task.hasExistingSupportTools {
         if await task.hasExistingSupportTools {
             vm.config.qemu.isGuestToolsInstallRequested = false
             vm.config.qemu.isGuestToolsInstallRequested = false
@@ -756,7 +803,60 @@ struct AlertMessage: Identifiable {
         }
         }
         vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
         vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
     }
     }
-    
+
+    // MARK: - Change listener
+
+    private func beginObservingChanges() {
+        #if WITH_SERVER
+        listChangedListener = $virtualMachines.sink { vms in
+            Task {
+                await self.remoteServer.broadcast { remote in
+                    try await remote.listHasChanged(ids: vms.map({ $0.id }))
+                }
+            }
+        }
+        #endif
+    }
+
+    private func beginObservingChanges(for vm: VMData) {
+        #if WITH_SERVER
+        var observers = Set<AnyCancellable>()
+        let registryEntry = vm.registryEntry
+        observers.insert(vm.objectWillChange.sink { [self] _ in
+            // reset observers when registry changes
+            if vm.registryEntry != registryEntry {
+                endObservingChanges(for: vm)
+                beginObservingChanges(for: vm)
+            }
+        })
+        observers.insert(vm.$state.sink { state in
+            Task {
+                let isTakeoverAllowed = self.vmWindows[vm] is VMRemoteSessionState && (state == .started || state == .paused)
+                await self.remoteServer.broadcast { remote in
+                    try await remote.virtualMachine(id: vm.id, didTransitionToState: state, isTakeoverAllowed: isTakeoverAllowed)
+                }
+            }
+        })
+        if let registryEntry = registryEntry {
+            observers.insert(registryEntry.externalDrivePublisher.sink { drives in
+                let mountedDrives = drives.mapValues({ $0.path })
+                Task {
+                    await self.remoteServer.broadcast { remote in
+                        try await remote.mountedDrivesHasChanged(id: vm.id, mountedDrives: mountedDrives)
+                    }
+                }
+            })
+        }
+        remoteChangeListeners[vm] = observers
+        #endif
+    }
+
+    private func endObservingChanges(for vm: VMData) {
+        #if WITH_SERVER
+        remoteChangeListeners.removeValue(forKey: vm)
+        #endif
+    }
+
     // MARK: - Other utility functions
     // MARK: - Other utility functions
     
     
     /// In some regions, iOS will prompt the user for network access
     /// In some regions, iOS will prompt the user for network access
@@ -790,16 +890,20 @@ struct AlertMessage: Identifiable {
     
     
     /// Execute a task with spinning progress indicator (Swift concurrency version)
     /// Execute a task with spinning progress indicator (Swift concurrency version)
     /// - Parameter work: Function to execute
     /// - Parameter work: Function to execute
-    func busyWorkAsync(_ work: @escaping @Sendable () async throws -> Void) {
+    @discardableResult
+    func busyWorkAsync<T>(_ work: @escaping @Sendable () async throws -> T) -> Task<T, any Error> {
         Task.detached(priority: .userInitiated) {
         Task.detached(priority: .userInitiated) {
             await self.setBusyIndicator(true)
             await self.setBusyIndicator(true)
             do {
             do {
-                try await work()
+                let result = try await work()
+                await self.setBusyIndicator(false)
+                return result
             } catch {
             } catch {
                 logger.error("\(error)")
                 logger.error("\(error)")
                 await self.showErrorAlert(message: error.localizedDescription)
                 await self.showErrorAlert(message: error.localizedDescription)
+                await self.setBusyIndicator(false)
+                throw error
             }
             }
-            await self.setBusyIndicator(false)
         }
         }
     }
     }
     
     
@@ -824,7 +928,7 @@ struct AlertMessage: Identifiable {
     ///   - vm: VM to send mouse/tablet coordinates to
     ///   - vm: VM to send mouse/tablet coordinates to
     ///   - components: Data (see UTM Wiki for details)
     ///   - components: Data (see UTM Wiki for details)
     func automationSendMouse(to vm: VMData, urlComponents components: URLComponents) {
     func automationSendMouse(to vm: VMData, urlComponents components: URLComponents) {
-        guard let qemuVm = vm.wrapped as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
+        guard let qemuVm = vm.wrapped as? any UTMSpiceVirtualMachine else { return } // FIXME: implement for Apple VM
         guard !qemuVm.config.displays.isEmpty else { return }
         guard !qemuVm.config.displays.isEmpty else { return }
         guard let queryItems = components.queryItems else { return }
         guard let queryItems = components.queryItems else { return }
         /// Parse targeted position
         /// Parse targeted position
@@ -868,7 +972,7 @@ struct AlertMessage: Identifiable {
 
 
     // MARK: - AltKit
     // MARK: - AltKit
     
     
-#if canImport(AltKit) && !WITH_QEMU_TCI
+#if canImport(AltKit) && WITH_JIT
     /// Detect if we are installed from AltStore and can use AltJIT
     /// Detect if we are installed from AltStore and can use AltJIT
     var isAltServerCompatible: Bool {
     var isAltServerCompatible: Bool {
         guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
         guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
@@ -968,6 +1072,8 @@ struct AlertMessage: Identifiable {
 // MARK: - Errors
 // MARK: - Errors
 enum UTMDataError: Error {
 enum UTMDataError: Error {
     case virtualMachineAlreadyExists
     case virtualMachineAlreadyExists
+    case virtualMachineUnavailable
+    case unsupportedBackend
     case cloneFailed
     case cloneFailed
     case shortcutCreationFailed
     case shortcutCreationFailed
     case importFailed
     case importFailed
@@ -977,6 +1083,8 @@ enum UTMDataError: Error {
     case jitStreamerDecodeFailed
     case jitStreamerDecodeFailed
     case jitStreamerAttachFailed
     case jitStreamerAttachFailed
     case jitStreamerUrlInvalid(String)
     case jitStreamerUrlInvalid(String)
+    case notImplemented
+    case reconnectFailed
 }
 }
 
 
 extension UTMDataError: LocalizedError {
 extension UTMDataError: LocalizedError {
@@ -984,6 +1092,10 @@ extension UTMDataError: LocalizedError {
         switch self {
         switch self {
         case .virtualMachineAlreadyExists:
         case .virtualMachineAlreadyExists:
             return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
             return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
+        case .virtualMachineUnavailable:
+            return NSLocalizedString("This virtual machine is currently unavailable, make sure it is not open in another session.", comment: "UTMData")
+        case .unsupportedBackend:
+            return NSLocalizedString("Operation not supported by the backend.", comment: "UTMData")
         case .cloneFailed:
         case .cloneFailed:
             return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
             return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
         case .shortcutCreationFailed:
         case .shortcutCreationFailed:
@@ -1002,6 +1114,239 @@ extension UTMDataError: LocalizedError {
             return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
             return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
         case .jitStreamerUrlInvalid(let urlString):
         case .jitStreamerUrlInvalid(let urlString):
             return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
             return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
+        case .notImplemented:
+            return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
+        case .reconnectFailed:
+            return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
         }
         }
     }
     }
 }
 }
+
+// MARK: - Remote Client
+
+/// Declare host capabilities to any remote client
+struct UTMCapabilities: OptionSet, Codable {
+    let rawValue: UInt
+
+    /// If set, no trick is needed to get JIT working as the process is entitled.
+    static let hasJitEntitlements = Self(rawValue: 1 << 0)
+
+    /// If set, virtualization is supported by this host.
+    static let hasHypervisorSupport = Self(rawValue: 1 << 1)
+    
+    /// If set, host is aarch64
+    static let isAarch64 = Self(rawValue: 1 << 2)
+    
+    /// If set, host is x86_64
+    static let isX86_64 = Self(rawValue: 1 << 3)
+
+    static fileprivate(set) var current: Self = {
+        var current = Self()
+        #if WITH_JIT
+        if jb_has_jit_entitlement() {
+            current.insert(.hasJitEntitlements)
+        }
+        if jb_has_hypervisor() {
+            current.insert(.hasHypervisorSupport)
+        }
+        #endif
+        #if arch(arm64)
+        current.insert(.isAarch64)
+        #endif
+        #if arch(x86_64)
+        current.insert(.isX86_64)
+        #endif
+        return current
+    }()
+}
+
+#if WITH_REMOTE
+private let kReconnectTimeoutSeconds: UInt64 = 5
+
+@MainActor
+class UTMRemoteData: UTMData {
+    /// Remote access client
+    private(set) var remoteClient: UTMRemoteClient!
+
+    override init() {
+        super.init()
+        self.remoteClient = UTMRemoteClient(data: self)
+    }
+
+    override func listLoadFromDefaults() {
+        // do nothing since we do not load from VMList
+    }
+
+    override func listRefresh() async {
+        busyWorkAsync {
+            try await self.listRefreshFromRemote()
+        }
+    }
+
+    func reconnect(to server: UTMRemoteClient.State.SavedServer) async throws {
+        var reconnectTask: Task<UTMRemoteClient.Remote, any Error>?
+        let timeoutTask = Task {
+            try await Task.sleep(nanoseconds: kReconnectTimeoutSeconds * NSEC_PER_SEC)
+            reconnectTask?.cancel()
+        }
+        reconnectTask = busyWorkAsync { [self] in
+            do {
+                try await remoteClient.connect(server)
+            } catch is CancellationError {
+                throw UTMDataError.reconnectFailed
+            }
+            timeoutTask.cancel()
+            try await listRefreshFromRemote()
+            return await remoteClient.server
+        }
+        // make all active sessions wait on the reconnect
+        for session in VMSessionState.allActiveSessions.values {
+            let vm = session.vm as! UTMRemoteSpiceVirtualMachine
+            Task {
+                do {
+                    try await vm.reconnectServer {
+                        try await reconnectTask!.value
+                    }
+                } catch {
+                    session.stop()
+                }
+            }
+        }
+        _ = try await reconnectTask!.value
+    }
+
+    private func listRefreshFromRemote() async throws {
+        if let capabilities = await self.remoteClient.server.capabilities {
+            UTMCapabilities.current = capabilities
+        }
+        let ids = try await remoteClient.server.listVirtualMachines()
+        let items = try await remoteClient.server.getVirtualMachineInformation(for: ids)
+        let openSessionVms = VMSessionState.allActiveSessions.values.map({ $0.vm })
+        let vms = items.map { item in
+            let wrapped = openSessionVms.first(where: { $0.id == item.id }) as? UTMRemoteSpiceVirtualMachine
+            return VMRemoteData(fromRemoteItem: item, existingWrapped: wrapped)
+        }
+        await loadVirtualMachines(vms)
+    }
+
+    private func loadVirtualMachines(_ vms: [VMData]) async {
+        listReplace(with: vms)
+        for vm in vms {
+            let remoteVM = vm as! VMRemoteData
+            if remoteVM.isLoaded {
+                continue
+            }
+            do {
+                try await remoteVM.load(withRemoteServer: remoteClient.server)
+            } catch {
+                remoteVM.unavailableReason = error.localizedDescription
+            }
+            await Task.yield()
+        }
+    }
+
+    func remoteListHasChanged(ids: [UUID]) async {
+        var existing = virtualMachines.reduce(into: [:]) { partialResult, vm in
+            partialResult[vm.id] = vm
+        }
+        let new = ids.compactMap { id in
+            if existing[id] == nil {
+                return id
+            } else {
+                return nil
+            }
+        }
+        if !new.isEmpty, let newItems = try? await remoteClient.server.getVirtualMachineInformation(for: new) {
+            newItems.map({ VMRemoteData(fromRemoteItem: $0) }).forEach { vm in
+                existing[vm.id] = vm
+            }
+        }
+        let vms = ids.compactMap({ existing[$0] })
+        await loadVirtualMachines(vms)
+    }
+
+    func remoteQemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async {
+        guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
+            return
+        }
+        await vm.reloadConfiguration(withRemoteServer: remoteClient.server, config: configuration)
+    }
+
+    func remoteMountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async {
+        guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
+            return
+        }
+        vm.updateMountedDrives(mountedDrives)
+    }
+
+    func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async {
+        guard let vm = virtualMachines.first(where: { $0.id == id }) else {
+            return
+        }
+        let remoteVM = vm as! VMRemoteData
+        let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine
+        remoteVM.isTakeoverAllowed = isTakeoverAllowed
+        await wrapped.updateRemoteState(state)
+    }
+
+    func remoteVirtualMachineDidError(id: UUID, message: String) async {
+        if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == id }) {
+            session.nonfatalError = message
+        }
+    }
+
+    override func listMove(fromOffsets: IndexSet, toOffset: Int) {
+        let ids = fromOffsets.map({ virtualMachines[$0].id })
+        Task {
+            try await remoteClient.server.reorderVirtualMachines(fromIds: ids, toOffset: toOffset)
+        }
+        super.listMove(fromOffsets: fromOffsets, toOffset: toOffset)
+    }
+
+    override func save(vm: VMData) async throws {
+        throw UTMDataError.notImplemented
+    }
+
+    override func discardChanges(for vm: VMData) throws {
+        throw UTMDataError.notImplemented
+    }
+
+    override func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
+        throw UTMDataError.notImplemented
+    }
+
+    @discardableResult
+    override func delete(vm: VMData, alsoRegistry: Bool) async throws -> Int? {
+        throw UTMDataError.notImplemented
+    }
+
+    @discardableResult
+    override func clone(vm: VMData) async throws -> VMData {
+        throw UTMDataError.notImplemented
+    }
+
+    override func export(vm: VMData, to url: URL) async throws {
+        throw UTMDataError.notImplemented
+    }
+
+    override func move(vm: VMData, to url: URL) async throws {
+        throw UTMDataError.notImplemented
+    }
+
+    override func template(vm: VMData) async throws {
+        throw UTMDataError.notImplemented
+    }
+
+    override func computeSize(for vm: VMData) async -> Int64 {
+        (try? await remoteClient.server.getPackageSize(for: vm.id)) ?? 0
+    }
+
+    override func importUTM(from url: URL, asShortcut: Bool) async throws {
+        throw UTMDataError.notImplemented
+    }
+
+    override func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
+        try await remoteClient.server.mountGuestToolsOnVirtualMachine(id: vm.id)
+    }
+}
+#endif

+ 3 - 3
Platform/UTMDownloadSupportToolsTask.swift

@@ -18,8 +18,8 @@ import Foundation
 
 
 /// Downloads support tools ISO
 /// Downloads support tools ISO
 class UTMDownloadSupportToolsTask: UTMDownloadTask {
 class UTMDownloadSupportToolsTask: UTMDownloadTask {
-    private let vm: UTMQemuVirtualMachine
-    
+    private let vm: any UTMSpiceVirtualMachine
+
     private static let supportToolsDownloadUrl = URL(string: "https://getutm.app/downloads/utm-guest-tools-latest.iso")!
     private static let supportToolsDownloadUrl = URL(string: "https://getutm.app/downloads/utm-guest-tools-latest.iso")!
     
     
     private var toolsUrl: URL {
     private var toolsUrl: URL {
@@ -42,7 +42,7 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
         }
         }
     }
     }
     
     
-    init(for vm: UTMQemuVirtualMachine) {
+    init(for vm: any UTMSpiceVirtualMachine) {
         self.vm = vm
         self.vm = vm
         let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask")
         let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask")
         super.init(for: Self.supportToolsDownloadUrl, named: name)
         super.init(for: Self.supportToolsDownloadUrl, named: name)

+ 4 - 0
Platform/UTMReleaseHelper.swift

@@ -99,6 +99,10 @@ class UTMReleaseHelper: ObservableObject {
                 if platform == "iOS SE" {
                 if platform == "iOS SE" {
                     currentSection.body.append(description)
                     currentSection.body.append(description)
                 }
                 }
+                #elseif WITH_REMOTE
+                if platform == "iOS Remote" {
+                    currentSection.body.append(description)
+                }
                 #endif
                 #endif
                 #if os(visionOS)
                 #if os(visionOS)
                 if platform.hasPrefix("visionOS") {
                 if platform.hasPrefix("visionOS") {

+ 118 - 17
Platform/VMData.swift

@@ -20,7 +20,7 @@ import SwiftUI
 /// Model wrapping a single UTMVirtualMachine for use in views
 /// Model wrapping a single UTMVirtualMachine for use in views
 @MainActor class VMData: ObservableObject {
 @MainActor class VMData: ObservableObject {
     /// Underlying virtual machine
     /// Underlying virtual machine
-    private(set) var wrapped: (any UTMVirtualMachine)? {
+    fileprivate(set) var wrapped: (any UTMVirtualMachine)? {
         willSet {
         willSet {
             objectWillChange.send()
             objectWillChange.send()
         }
         }
@@ -53,8 +53,8 @@ import SwiftUI
     }
     }
     
     
     /// Registry entry before loading
     /// Registry entry before loading
-    private var registryEntryWrapped: UTMRegistryEntry?
-    
+    fileprivate var registryEntryWrapped: UTMRegistryEntry?
+
     /// Set when we use a temporary UUID because we loaded a legacy entry
     /// Set when we use a temporary UUID because we loaded a legacy entry
     private var uuidUnknown: Bool = false
     private var uuidUnknown: Bool = false
     
     
@@ -67,14 +67,22 @@ import SwiftUI
     @Published var state: UTMVirtualMachineState = .stopped
     @Published var state: UTMVirtualMachineState = .stopped
     
     
     /// Copy from wrapped VM
     /// Copy from wrapped VM
-    @Published var screenshot: PlatformImage?
-    
+    @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] = []
     
     
+    /// True if the .utm is loaded outside of the default storage
+    var isShortcut: Bool {
+        isShortcut(pathUrl)
+    }
+
     /// No default init
     /// No default init
-    private init() {
-        
+    fileprivate init() {
+
     }
     }
     
     
     /// Create a VM from an existing object
     /// Create a VM from an existing object
@@ -129,9 +137,11 @@ import SwiftUI
     /// - Parameter config: Configuration to create new VM
     /// - Parameter config: Configuration to create new VM
     convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
     convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
         self.init()
         self.init()
+        #if !WITH_REMOTE
         if let qemuConfig = config as? UTMQemuConfiguration {
         if let qemuConfig = config as? UTMQemuConfiguration {
             wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
             wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
         }
         }
+        #endif
         #if os(macOS)
         #if os(macOS)
         if let appleConfig = config as? UTMAppleConfiguration {
         if let appleConfig = config as? UTMAppleConfiguration {
             wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
             wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
@@ -160,9 +170,11 @@ import SwiftUI
         }
         }
         var loaded: (any UTMVirtualMachine)?
         var loaded: (any UTMVirtualMachine)?
         let config = try UTMQemuConfiguration.load(from: url)
         let config = try UTMQemuConfiguration.load(from: url)
+        #if !WITH_REMOTE
         if let qemuConfig = config as? UTMQemuConfiguration {
         if let qemuConfig = config as? UTMQemuConfiguration {
             loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut(url))
             loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut(url))
         }
         }
+        #endif
         #if os(macOS)
         #if os(macOS)
         if let appleConfig = config as? UTMAppleConfiguration {
         if let appleConfig = config as? UTMAppleConfiguration {
             loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut(url))
             loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut(url))
@@ -195,7 +207,7 @@ import SwiftUI
     }
     }
     
     
     /// Listen to changes in the underlying object and propogate upwards
     /// Listen to changes in the underlying object and propogate upwards
-    private func subscribeToChildren() {
+    fileprivate func subscribeToChildren() {
         var s: [AnyCancellable] = []
         var s: [AnyCancellable] = []
         if let wrapped = wrapped {
         if let wrapped = wrapped {
             wrapped.onConfigurationChange = { [weak self] in
             wrapped.onConfigurationChange = { [weak self] in
@@ -205,10 +217,12 @@ import SwiftUI
                 }
                 }
             }
             }
             
             
-            wrapped.onStateChange = { [weak self] in
+            wrapped.onStateChange = { [weak self, weak wrapped] in
                 Task { @MainActor in
                 Task { @MainActor in
-                    self?.state = wrapped.state
-                    self?.screenshot = wrapped.screenshot
+                    if let wrapped = wrapped {
+                        self?.state = wrapped.state
+                        self?.screenshot = wrapped.screenshot
+                    }
                 }
                 }
             }
             }
         }
         }
@@ -281,11 +295,6 @@ extension VMData: Hashable {
 
 
 // MARK: - VM State
 // MARK: - VM State
 extension VMData {
 extension VMData {
-    /// True if the .utm is loaded outside of the default storage
-    var isShortcut: Bool {
-        isShortcut(pathUrl)
-    }
-    
     func isShortcut(_ url: URL) -> Bool {
     func isShortcut(_ url: URL) -> Bool {
         let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
         let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
         let parentUrl = url.deletingLastPathComponent().standardizedFileURL
         let parentUrl = url.deletingLastPathComponent().standardizedFileURL
@@ -422,6 +431,98 @@ extension VMData {
     
     
     /// If non-null, is the most recent screenshot image of the running VM
     /// If non-null, is the most recent screenshot image of the running VM
     var screenshotImage: PlatformImage? {
     var screenshotImage: PlatformImage? {
-        wrapped?.screenshot
+        wrapped?.screenshot?.image
+    }
+}
+
+#if WITH_REMOTE
+@MainActor
+class VMRemoteData: VMData {
+    private var backend: UTMBackend
+    private var _isShortcut: Bool
+    override var isShortcut: Bool {
+        _isShortcut
+    }
+    private var initialState: UTMVirtualMachineState
+    private var existingWrapped: UTMRemoteSpiceVirtualMachine?
+
+    /// Set by caller when VM is unavailable and there is a reason for it.
+    @Published var unavailableReason: String?
+
+    init(fromRemoteItem item: UTMRemoteMessageServer.VirtualMachineInformation, existingWrapped: UTMRemoteSpiceVirtualMachine? = nil) {
+        self.backend = item.backend
+        self._isShortcut = item.isShortcut
+        self.initialState = item.state
+        self.existingWrapped = existingWrapped
+        super.init()
+        self.isTakeoverAllowed = item.isTakeoverAllowed
+        self.registryEntryWrapped = UTMRegistry.shared.entry(uuid: item.id, name: item.name, path: item.path)
+        self.registryEntryWrapped!.isSuspended = item.isSuspended
+        self.registryEntryWrapped!.externalDrives = item.mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
+    }
+
+    override func load() throws {
+        throw VMRemoteDataError.notImplemented
+    }
+
+    func load(withRemoteServer server: UTMRemoteClient.Remote) async throws {
+        guard backend == .qemu else {
+            throw VMRemoteDataError.backendNotSupported
+        }
+        let entry = registryEntryWrapped!
+        let config = try await server.getQEMUConfiguration(for: entry.uuid)
+        await loadCustomIcon(withRemoteServer: server, id: entry.uuid, config: config)
+        let vm: UTMRemoteSpiceVirtualMachine
+        if let existingWrapped = existingWrapped {
+            vm = existingWrapped
+            wrapped = vm
+            self.existingWrapped = nil
+            await reloadConfiguration(withRemoteServer: server, config: config)
+            vm.updateRegistry(entry)
+        } else {
+            vm = UTMRemoteSpiceVirtualMachine(forRemoteServer: server, remotePath: entry.package.path, entry: entry, config: config)
+            wrapped = vm
+        }
+        vm.updateConfigFromRegistry()
+        subscribeToChildren()
+        await vm.updateRemoteState(initialState)
+    }
+
+    func reloadConfiguration(withRemoteServer server: UTMRemoteClient.Remote, config: UTMQemuConfiguration) async {
+        let spiceVM = wrapped as! UTMRemoteSpiceVirtualMachine
+        await loadCustomIcon(withRemoteServer: server, id: spiceVM.id, config: config)
+        spiceVM.reload(usingConfiguration: config)
+    }
+
+    private func loadCustomIcon(withRemoteServer server: UTMRemoteClient.Remote, id: UUID, config: UTMQemuConfiguration) async {
+        if config.information.isIconCustom, let iconUrl = config.information.iconURL {
+            if let iconUrl = try? await server.getPackageFile(for: id, relativePathComponents: [UTMQemuConfiguration.dataDirectoryName, iconUrl.lastPathComponent]) {
+                config.information.iconURL = iconUrl
+            }
+        }
+    }
+
+    func updateMountedDrives(_ mountedDrives: [String: String]) {
+        guard let registryEntry = registryEntry else {
+            return
+        }
+        registryEntry.externalDrives = mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
+    }
+}
+
+enum VMRemoteDataError: Error {
+    case notImplemented
+    case backendNotSupported
+}
+
+extension VMRemoteDataError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .notImplemented:
+            return NSLocalizedString("This function is not implemented.", comment: "VMData")
+        case .backendNotSupported:
+            return NSLocalizedString("This VM is configured for a backend that does not support remote clients.", comment: "VMData")
+        }
     }
     }
 }
 }
+#endif

+ 6 - 0
Platform/iOS/Display/VMDisplayMetalViewController+Pointer.m

@@ -129,7 +129,11 @@ NS_AVAILABLE_IOS(13.4)
 - (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region {
 - (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region {
     // Hide cursor while hovering in VM view
     // Hide cursor while hovering in VM view
     if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
     if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
+#if TARGET_OS_VISION
+        return nil; // FIXME: hidden pointer seems to jump around due to following gaze
+#else
         return [UIPointerStyle hiddenPointerStyle];
         return [UIPointerStyle hiddenPointerStyle];
+#endif
     }
     }
     return nil;
     return nil;
 }
 }
@@ -153,11 +157,13 @@ NS_AVAILABLE_IOS(13.4)
 
 
 
 
 - (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion {
 - (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion {
+#if !TARGET_OS_VISION
     if (@available(iOS 14.0, *)) {
     if (@available(iOS 14.0, *)) {
         if (self.prefersPointerLocked) {
         if (self.prefersPointerLocked) {
             return nil;
             return nil;
         }
         }
     }
     }
+#endif
     // Requesting region for the VM display?
     // Requesting region for the VM display?
     if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
     if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
         // Then we need to find out if the pointer is in the actual display area or outside
         // Then we need to find out if the pointer is in the actual display area or outside

+ 5 - 1
Platform/iOS/Display/VMDisplayMetalViewController+Touch.m

@@ -181,11 +181,15 @@ const CGFloat kScrollResistance = 10.0f;
 }
 }
 
 
 - (VMMouseType)indirectMouseType {
 - (VMMouseType)indirectMouseType {
+#if TARGET_OS_VISION
+    return VMMouseTypeAbsolute;
+#else
     if (@available(iOS 14.0, *)) {
     if (@available(iOS 14.0, *)) {
         return VMMouseTypeRelative;
         return VMMouseTypeRelative;
     } else {
     } else {
         return VMMouseTypeAbsolute; // legacy iOS 13.4 mouse handling requires absolute
         return VMMouseTypeAbsolute; // legacy iOS 13.4 mouse handling requires absolute
     }
     }
+#endif
 }
 }
 
 
 #pragma mark - Converting view points to VM display points
 #pragma mark - Converting view points to VM display points
@@ -635,7 +639,7 @@ static CGRect CGRectClipToBounds(CGRect rect1, CGRect rect2) {
             VMMouseType type = [self touchTypeToMouseType:touch.type];
             VMMouseType type = [self touchTypeToMouseType:touch.type];
 #if TARGET_OS_VISION
 #if TARGET_OS_VISION
             if ([self isTouchGazeGesture:touch]) {
             if ([self isTouchGazeGesture:touch]) {
-                type = self.indirectMouseType;
+                type = VMMouseTypeRelative;
             }
             }
 #endif
 #endif
             if ([self switchMouseType:type]) {
             if ([self switchMouseType:type]) {

+ 3 - 1
Platform/iOS/Display/VMDisplayMetalViewController.h

@@ -16,7 +16,7 @@
 
 
 #import <UIKit/UIKit.h>
 #import <UIKit/UIKit.h>
 #import "VMDisplayViewController.h"
 #import "VMDisplayViewController.h"
-#if defined(WITH_QEMU_TCI)
+#if !defined(WITH_USB)
 @import CocoaSpiceNoUsb;
 @import CocoaSpiceNoUsb;
 #else
 #else
 @import CocoaSpice;
 @import CocoaSpice;
@@ -42,6 +42,8 @@ NS_ASSUME_NONNULL_BEGIN
 
 
 @property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
 @property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
 
 
+@property (nonatomic) BOOL isDynamicResolutionSupported;
+
 - (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
 - (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
 - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE;
 - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE;
 - (instancetype)initWithDisplay:(CSDisplay *)display input:(nullable CSInput *)input NS_DESIGNATED_INITIALIZER;
 - (instancetype)initWithDisplay:(CSDisplay *)display input:(nullable CSInput *)input NS_DESIGNATED_INITIALIZER;

+ 92 - 33
Platform/iOS/Display/VMDisplayMetalViewController.m

@@ -29,11 +29,15 @@
 #import "UTM-Swift.h"
 #import "UTM-Swift.h"
 @import CocoaSpiceRenderer;
 @import CocoaSpiceRenderer;
 
 
+static const NSInteger kResizeDebounceSecs = 1;
+static const NSInteger kResizeTimeoutSecs = 5;
+
 @interface VMDisplayMetalViewController ()
 @interface VMDisplayMetalViewController ()
 
 
 @property (nonatomic, nullable) CSMetalRenderer *renderer;
 @property (nonatomic, nullable) CSMetalRenderer *renderer;
-@property (nonatomic) CGFloat windowScaling;
-@property (nonatomic) CGPoint windowOrigin;
+@property (nonatomic, nullable) id debounceResize;
+@property (nonatomic, nullable) id cancelResize;
+@property (nonatomic) BOOL ignoreNextResize;
 
 
 @end
 @end
 
 
@@ -43,9 +47,6 @@
     if (self = [super initWithNibName:nil bundle:nil]) {
     if (self = [super initWithNibName:nil bundle:nil]) {
         self.vmDisplay = display;
         self.vmDisplay = display;
         self.vmInput = input;
         self.vmInput = input;
-        self.windowScaling = 1.0;
-        self.windowOrigin = CGPointZero;
-        [self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:NSKeyValueObservingOptionNew context:nil];
     }
     }
     return self;
     return self;
 }
 }
@@ -120,19 +121,25 @@
 - (void)viewWillAppear:(BOOL)animated {
 - (void)viewWillAppear:(BOOL)animated {
     [super viewWillAppear:animated];
     [super viewWillAppear:animated];
     self.prefersHomeIndicatorAutoHidden = YES;
     self.prefersHomeIndicatorAutoHidden = YES;
+#if !TARGET_OS_VISION
     [self startGCMouse];
     [self startGCMouse];
+#endif
     [self.vmDisplay addRenderer:self.renderer];
     [self.vmDisplay addRenderer:self.renderer];
 }
 }
 
 
 - (void)viewWillDisappear:(BOOL)animated {
 - (void)viewWillDisappear:(BOOL)animated {
     [super viewWillDisappear:animated];
     [super viewWillDisappear:animated];
+#if !TARGET_OS_VISION
     [self stopGCMouse];
     [self stopGCMouse];
+#endif
     [self.vmDisplay removeRenderer:self.renderer];
     [self.vmDisplay removeRenderer:self.renderer];
+    [self removeObserver:self forKeyPath:@"vmDisplay.displaySize"];
 }
 }
 
 
 - (void)viewDidAppear:(BOOL)animated {
 - (void)viewDidAppear:(BOOL)animated {
     [super viewDidAppear:animated];
     [super viewDidAppear:animated];
     self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size];
     self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size];
+    [self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial) context:nil];
 }
 }
 
 
 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
@@ -140,10 +147,12 @@
     [coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
     [coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
         self.delegate.displayViewSize = [self convertSizeToNative:size];
         self.delegate.displayViewSize = [self convertSizeToNative:size];
         [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
         [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
+        if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
+            if (!CGSizeEqualToSize(size, self.vmDisplay.displaySize)) {
+                [self requestResolutionChangeToSize:size];
+            }
+        }
     }];
     }];
-    if (self.delegate.qemuDisplayIsDynamicResolution) {
-        [self displayResize:size];
-    }
 }
 }
 
 
 - (void)enterSuspendedWithIsBusy:(BOOL)busy {
 - (void)enterSuspendedWithIsBusy:(BOOL)busy {
@@ -161,8 +170,8 @@
     [super enterLive];
     [super enterLive];
     self.prefersPointerLocked = YES;
     self.prefersPointerLocked = YES;
     self.view.window.isIndirectPointerTouchIgnored = YES;
     self.view.window.isIndirectPointerTouchIgnored = YES;
-    if (self.delegate.qemuDisplayIsDynamicResolution) {
-        [self displayResize:self.view.bounds.size];
+    if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
+        [self requestResolutionChangeToSize:self.view.bounds.size];
     }
     }
     if (self.delegate.qemuHasClipboardSharing) {
     if (self.delegate.qemuHasClipboardSharing) {
         [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
         [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
@@ -200,11 +209,21 @@
     return size;
     return size;
 }
 }
 
 
-- (void)displayResize:(CGSize)size {
-    UTMLog(@"resizing to (%f, %f)", size.width, size.height);
-    size = [self convertSizeToNative:size];
-    CGRect bounds = CGRectMake(0, 0, size.width, size.height);
-    [self.vmDisplay requestResolution:bounds];
+- (void)requestResolutionChangeToSize:(CGSize)size {
+    self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
+        UTMLog(@"DISPLAY: requesting resolution (%f, %f)", size.width, size.height);
+        CGSize newSize = [self convertSizeToNative:size];
+        CGRect bounds = CGRectMake(0, 0, newSize.width, newSize.height);
+        self.debounceResize = nil;
+#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
+        self.cancelResize = [self debounce:kResizeTimeoutSecs context:self.cancelResize action:^{
+            self.cancelResize = nil;
+            UTMLog(@"DISPLAY: requesting resolution cancelled");
+            [self resizeWindowToDisplaySize];
+        }];
+#endif
+        [self.vmDisplay requestResolution:bounds];
+    }];
 }
 }
 
 
 - (void)setVmDisplay:(CSDisplay *)display {
 - (void)setVmDisplay:(CSDisplay *)display {
@@ -217,8 +236,6 @@
 
 
 - (void)setDisplayScaling:(CGFloat)scaling origin:(CGPoint)origin {
 - (void)setDisplayScaling:(CGFloat)scaling origin:(CGPoint)origin {
     self.vmDisplay.viewportOrigin = origin;
     self.vmDisplay.viewportOrigin = origin;
-    self.windowScaling = scaling;
-    self.windowOrigin = origin;
     if (!self.delegate.qemuDisplayIsNativeResolution) {
     if (!self.delegate.qemuDisplayIsNativeResolution) {
         scaling = CGPointToPixel(scaling);
         scaling = CGPointToPixel(scaling);
     }
     }
@@ -229,25 +246,67 @@
 
 
 - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
 - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
     if ([keyPath isEqualToString:@"vmDisplay.displaySize"]) {
     if ([keyPath isEqualToString:@"vmDisplay.displaySize"]) {
-#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
-        dispatch_async(dispatch_get_main_queue(), ^{
-            CGSize minSize = self.vmDisplay.displaySize;
-            if (self.delegate.qemuDisplayIsNativeResolution) {
-                minSize.width = CGPixelToPoint(minSize.width);
-                minSize.height = CGPixelToPoint(minSize.height);
+        UTMLog(@"DISPLAY: vmDisplay.displaySize changed");
+        if (self.cancelResize) {
+            [self debounce:0 context:self.cancelResize action:^{}];
+            self.cancelResize = nil;
+        }
+        self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
+            [self resizeWindowToDisplaySize];
+        }];
+    }
+}
+
+- (void)setIsDynamicResolutionSupported:(BOOL)isDynamicResolutionSupported {
+    if (_isDynamicResolutionSupported != isDynamicResolutionSupported) {
+        _isDynamicResolutionSupported = isDynamicResolutionSupported;
+        UTMLog(@"DISPLAY: isDynamicResolutionSupported = %d", isDynamicResolutionSupported);
+        if (self.delegate.qemuDisplayIsDynamicResolution) {
+            if (isDynamicResolutionSupported) {
+                [self requestResolutionChangeToSize:self.view.bounds.size];
+            } else {
+                [self resizeWindowToDisplaySize];
             }
             }
-            CGSize displaySize = CGSizeMake(minSize.width * self.windowScaling, minSize.height * self.windowScaling);
-            CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
-            UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:displaySize];
-            geoPref.minimumSize = minSize;
-            geoPref.maximumSize = maxSize;
-            geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
-            [self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
-        });
+        }
+    }
+}
+
+- (void)resizeWindowToDisplaySize {
+    CGSize displaySize = self.vmDisplay.displaySize;
+    UTMLog(@"DISPLAY: request window resize to (%f, %f)", displaySize.width, displaySize.height);
+#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
+    CGSize minSize = displaySize;
+    if (self.delegate.qemuDisplayIsNativeResolution) {
+        minSize.width = CGPixelToPoint(minSize.width);
+        minSize.height = CGPixelToPoint(minSize.height);
+    }
+    CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
+    UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:minSize];
+    if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
+        geoPref.minimumSize = CGSizeMake(800, 600);
+        geoPref.maximumSize = maxSize;
+        geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsFreeform;
+    } else {
+        geoPref.minimumSize = minSize;
+        geoPref.maximumSize = maxSize;
+        geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
+    }
+    dispatch_async(dispatch_get_main_queue(), ^{
+        CGSize currentViewSize = self.view.bounds.size;
+        UTMLog(@"DISPLAY: old view size = (%f, %f)", currentViewSize.width, currentViewSize.height);
+        if (CGSizeEqualToSize(minSize, currentViewSize)) {
+            // since `-viewWillTransitionToSize:withTransitionCoordinator:` is not called
+            self.delegate.displayViewSize = [self convertSizeToNative:currentViewSize];
+            [self.delegate display:self.vmDisplay didResizeTo:displaySize];
+        }
+        [self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
+    });
 #else
 #else
-        [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
-#endif
+    if (CGSizeEqualToSize(displaySize, CGSizeZero)) {
+        return;
     }
     }
+    [self.delegate display:self.vmDisplay didResizeTo:displaySize];
+#endif
 }
 }
 
 
 @end
 @end

+ 12 - 19
Platform/iOS/Display/VMDisplayViewController.swift

@@ -55,7 +55,7 @@ public extension VMDisplayViewController {
             parent.setChildViewControllerForPointerLock(self)
             parent.setChildViewControllerForPointerLock(self)
             UIPress.pressResponderOverride = self
             UIPress.pressResponderOverride = self
         }
         }
-        #if !os(visionOS)
+        #if !os(visionOS) && !WITH_REMOTE
         if runInBackground {
         if runInBackground {
             logger.info("Start location tracking to enable running in background")
             logger.info("Start location tracking to enable running in background")
             UTMLocationManager.sharedInstance().startUpdatingLocation()
             UTMLocationManager.sharedInstance().startUpdatingLocation()
@@ -75,24 +75,6 @@ public extension VMDisplayViewController {
     func enterLive() {
     func enterLive() {
         UIApplication.shared.isIdleTimerDisabled = disableIdleTimer
         UIApplication.shared.isIdleTimerDisabled = disableIdleTimer
     }
     }
-    
-    private func suspend() {
-        // dummy function for selector
-    }
-    
-    func terminateApplication() {
-        DispatchQueue.main.async { [self] in
-            // animate to home screen
-            let app = UIApplication.shared
-            app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
-            
-            // wait 2 seconds while app is going background
-            Thread.sleep(forTimeInterval: 2)
-            
-            // exit app when app is in background
-            exit(0);
-        }
-    }
 }
 }
 
 
 // MARK: Toolbar hiding
 // MARK: Toolbar hiding
@@ -134,4 +116,15 @@ public extension VMDisplayViewController {
     func integerForSetting(_ key: String) -> Int {
     func integerForSetting(_ key: String) -> Int {
         return UserDefaults.standard.integer(forKey: key)
         return UserDefaults.standard.integer(forKey: key)
     }
     }
+
+    @discardableResult
+    func debounce(_ delaySeconds: Int, context: Any? = nil, action: @escaping () -> Void) -> Any {
+        if context != nil {
+            let previous = context as! DispatchWorkItem
+            previous.cancel()
+        }
+        let item = DispatchWorkItem(block: action)
+        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(delaySeconds), execute: item)
+        return item
+    }
 }
 }

+ 7 - 7
Platform/iOS/Display/VMKeyboardView.m

@@ -370,7 +370,7 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
 
 
 - (void)insertUTF8Sequence:(const char *)ctext {
 - (void)insertUTF8Sequence:(const char *)ctext {
     unsigned long ctext_len = strlen(ctext);
     unsigned long ctext_len = strlen(ctext);
-    UTMLog(@"ctext length=%lu\n", ctext_len);
+    //UTMLog(@"ctext length=%lu\n", ctext_len);
     unsigned char tc = ctext[0];
     unsigned char tc = ctext[0];
     
     
     int keycode = 0;
     int keycode = 0;
@@ -393,7 +393,7 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
     
     
     switch (ctext_len) {
     switch (ctext_len) {
         case 1:
         case 1:
-            UTMLog(@"char=%d\n", tc);
+            //UTMLog(@"char=%d\n", tc);
             index = indexForChar(_map, _map_len, tc);
             index = indexForChar(_map, _map_len, tc);
             if (index != -1) {
             if (index != -1) {
                 keycode = _map[index].key;
                 keycode = _map[index].key;
@@ -401,8 +401,8 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
             }
             }
             break;
             break;
         case 2:
         case 2:
-            UTMLog(@"char=%d\n", tc);
-            UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
+            //UTMLog(@"char=%d\n", tc);
+            //UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
             index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], 0);
             index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], 0);
             if (index != -1) {
             if (index != -1) {
                 keycode = _ext_map[index].key;
                 keycode = _ext_map[index].key;
@@ -412,9 +412,9 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
             }
             }
             break;
             break;
         case 3:
         case 3:
-            UTMLog(@"char=%d\n", tc);
-            UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
-            UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
+            //UTMLog(@"char=%d\n", tc);
+            //UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
+            //UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
             index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], ctext[2]);
             index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], ctext[2]);
             if (index != -1) {
             if (index != -1) {
                 keycode = _ext_map[index].key;
                 keycode = _ext_map[index].key;

+ 79 - 0
Platform/iOS/Info-Remote.plist

@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleVersion</key>
+	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>ITSAppUsesNonExemptEncryption</key>
+	<false/>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>NSAppTransportSecurity</key>
+	<dict>
+		<key>NSAllowsArbitraryLoads</key>
+		<true/>
+	</dict>
+	<key>NSBonjourServices</key>
+	<array>
+		<string>_utm_server._tcp</string>
+	</array>
+	<key>NSLocalNetworkUsageDescription</key>
+	<string>UTM uses the local network to find and connect to UTM Remote servers.</string>
+	<key>NSMicrophoneUsageDescription</key>
+	<string>Permission is required for any virtual machine to record from the microphone.</string>
+	<key>UIApplicationSupportsIndirectInputEvents</key>
+	<true/>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UIRequiredDeviceCapabilities</key>
+	<array>
+		<string>arm64</string>
+	</array>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>UISupportedInterfaceOrientations~ipad</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationPortraitUpsideDown</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>UIViewControllerBasedStatusBarAppearance</key>
+	<true/>
+	<key>UIApplicationSceneManifest</key>
+	<dict>
+		<key>UIApplicationSupportsMultipleScenes</key>
+		<true/>
+		<key>UISceneConfigurations</key>
+		<dict>
+			<key>UIWindowSceneSessionRoleExternalDisplay</key>
+			<array>
+				<dict>
+					<key>UISceneDelegateClassName</key>
+					<string>$(PRODUCT_MODULE_NAME).UTMExternalSceneDelegate</string>
+					<key>UISceneConfigurationName</key>
+					<string>External</string>
+				</dict>
+			</array>
+		</dict>
+	</dict>
+</dict>
+</plist>

+ 32 - 0
Platform/iOS/RemoteContentView.swift

@@ -0,0 +1,32 @@
+//
+// 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 SwiftUI
+
+struct RemoteContentView: View {
+    @ObservedObject var remoteClientState: UTMRemoteClient.State
+    @EnvironmentObject private var data: UTMRemoteData
+
+    var body: some View {
+        if remoteClientState.isConnected {
+            ContentView()
+                .environmentObject(data as UTMData)
+        } else {
+            UTMRemoteConnectView(remoteClientState: remoteClientState)
+                .transition(.move(edge: .leading))
+        }
+    }
+}

+ 40 - 0
Platform/iOS/Settings.bundle/Root.plist

@@ -21,6 +21,12 @@
 			<string>RunInBackground</string>
 			<string>RunInBackground</string>
 			<key>DefaultValue</key>
 			<key>DefaultValue</key>
 			<false/>
 			<false/>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+			</array>
+			<key>Platform</key>
+			<string>iOS</string>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -31,6 +37,8 @@
 			<string>AutosaveBackground</string>
 			<string>AutosaveBackground</string>
 			<key>DefaultValue</key>
 			<key>DefaultValue</key>
 			<true/>
 			<true/>
+			<key>Platform</key>
+			<string>iOS</string>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -83,6 +91,11 @@
 			<string>NoUsbPrompt</string>
 			<string>NoUsbPrompt</string>
 			<key>DefaultValue</key>
 			<key>DefaultValue</key>
 			<false/>
 			<false/>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+				<string>iOS-SE</string>
+			</array>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -99,6 +112,10 @@
 			<string>PSGroupSpecifier</string>
 			<string>PSGroupSpecifier</string>
 			<key>Title</key>
 			<key>Title</key>
 			<string>Graphics</string>
 			<string>Graphics</string>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+			</array>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -121,6 +138,10 @@
 				<integer>1</integer>
 				<integer>1</integer>
 				<integer>2</integer>
 				<integer>2</integer>
 			</array>
 			</array>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+			</array>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -155,6 +176,10 @@
 				<integer>105</integer>
 				<integer>105</integer>
 				<integer>120</integer>
 				<integer>120</integer>
 			</array>
 			</array>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+			</array>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -2789,6 +2814,11 @@
 			<string>PSGroupSpecifier</string>
 			<string>PSGroupSpecifier</string>
 			<key>Title</key>
 			<key>Title</key>
 			<string>JitStreamer</string>
 			<string>JitStreamer</string>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+				<string>iOS-SE</string>
+			</array>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -2799,6 +2829,11 @@
 			<string>JitStreamerAttach</string>
 			<string>JitStreamerAttach</string>
 			<key>DefaultValue</key>
 			<key>DefaultValue</key>
 			<false/>
 			<false/>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+				<string>iOS-SE</string>
+			</array>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>
@@ -2809,6 +2844,11 @@
 			<string>JitStreamerAddress</string>
 			<string>JitStreamerAddress</string>
 			<key>DefaultValue</key>
 			<key>DefaultValue</key>
 			<string>69.69.0.1</string>
 			<string>69.69.0.1</string>
+			<key>ExcludeTargets</key>
+			<array>
+				<string>iOS-Remote</string>
+				<string>iOS-SE</string>
+			</array>
 		</dict>
 		</dict>
 		<dict>
 		<dict>
 			<key>Type</key>
 			<key>Type</key>

+ 11 - 2
Platform/iOS/UTMDataExtension.swift

@@ -19,15 +19,23 @@ import SwiftUI
 
 
 extension UTMData {
 extension UTMData {
     func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
     func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
+        #if WITH_SOLO_VM
         guard VMSessionState.allActiveSessions.count == 0 else {
         guard VMSessionState.allActiveSessions.count == 0 else {
             logger.error("Session already started")
             logger.error("Session already started")
             return
             return
         }
         }
+        #endif
         guard let wrapped = vm.wrapped else {
         guard let wrapped = vm.wrapped else {
             return
             return
         }
         }
-        let session = VMSessionState(for: wrapped as! UTMQemuVirtualMachine)
-        session.start()
+        if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) {
+            session.showWindow()
+        } else if vm.isStopped || vm.isTakeoverAllowed {
+            let session = VMSessionState(for: wrapped as! (any UTMSpiceVirtualMachine))
+            session.start(options: options)
+        } 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"))
+        }
     }
     }
     
     
     func stop(vm: VMData) {
     func stop(vm: VMData) {
@@ -37,6 +45,7 @@ extension UTMData {
         if wrapped.registryEntry.isSuspended {
         if wrapped.registryEntry.isSuspended {
             wrapped.requestVmDeleteState()
             wrapped.requestVmDeleteState()
         }
         }
+        wrapped.requestVmStop()
     }
     }
     
     
     func close(vm: VMData) {
     func close(vm: VMData) {

+ 300 - 0
Platform/iOS/UTMRemoteConnectView.swift

@@ -0,0 +1,300 @@
+//
+// Copyright © 2023 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 SwiftUI
+
+private let kTimeoutSeconds: UInt64 = 15
+
+struct UTMRemoteConnectView: View {
+    @ObservedObject var remoteClientState: UTMRemoteClient.State
+    @Environment(\.openURL) private var openURL
+    @EnvironmentObject private var data: UTMRemoteData
+    @State private var selectedServer: UTMRemoteClient.State.SavedServer?
+    @State private var isAutoConnect: Bool = false
+
+    private var remoteClient: UTMRemoteClient {
+        data.remoteClient
+    }
+
+    var body: some View {
+        VStack {
+            HStack {
+                ProgressView().progressViewStyle(.circular)
+                Spacer()
+                Text("Select a UTM Server")
+                    .font(.headline)
+                Spacer()
+                Button {
+                    openURL(URL(string: "https://docs.getutm.app/remote/")!)
+                } label: {
+                    Label("Help", systemImage: "questionmark.circle")
+                        .labelStyle(.iconOnly)
+                        .font(.title2)
+                }
+                Button {
+                    selectedServer = .init()
+                } label: {
+                    Label("New Connection", systemImage: "plus")
+                        .labelStyle(.iconOnly)
+                        .font(.title2)
+                }
+            }.padding()
+            List {
+                if remoteClientState.savedServers.count > 0 {
+                    Section(header: Text("Saved")) {
+                        ForEach(remoteClientState.savedServers) { server in
+                            Button {
+                                isAutoConnect = true
+                                selectedServer = server
+                            } label: {
+                                MacDeviceLabel(server.name.isEmpty ? server.hostname : server.name, device: .init(model: server.model))
+                            }.disabled(!server.isAvailable)
+                            .contextMenu {
+                                Button {
+                                    isAutoConnect = false
+                                    selectedServer = server
+                                } label: {
+                                    Label("Edit…", systemImage: "slider.horizontal.3")
+                                }
+                                DestructiveButton("Delete") {
+                                    remoteClientState.delete(server: server)
+                                    Task {
+                                        await remoteClient.refresh()
+                                    }
+                                }
+                            }
+                        }.onDelete { indexSet in
+                            remoteClientState.savedServers.remove(atOffsets: indexSet)
+                            Task {
+                                await remoteClient.refresh()
+                            }
+                        }
+                    }
+                }
+                Section(header: Text("Discovered"), footer: helpText) {
+                    ForEach(remoteClientState.foundServers) { server in
+                        Button {
+                            isAutoConnect = true
+                            selectedServer = UTMRemoteClient.State.SavedServer(from: server)
+                        } label: {
+                            MacDeviceLabel(server.name, device: .init(model: server.model))
+                        }
+                    }
+                }
+            }.listStyle(.insetGrouped)
+        }.alert(item: $remoteClientState.alertMessage) { item in
+            Alert(title: Text(item.message))
+        }
+        .sheet(item: $selectedServer) { server in
+            ServerConnectView(remoteClientState: remoteClientState, server: server, isAutoConnect: $isAutoConnect)
+        }
+        .onAppear {
+            Task {
+                await remoteClient.startScanning()
+            }
+        }
+        .onDisappear {
+            Task {
+                await remoteClient.stopScanning()
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var helpText: some View {
+        if remoteClientState.foundServers.isEmpty {
+            Text("Make sure the latest version of UTM is running on your Mac and UTM Server is enabled. You can download UTM from the Mac App Store.")
+        }
+    }
+}
+
+private struct ServerConnectView: View {
+    @ObservedObject var remoteClientState: UTMRemoteClient.State
+    @State var server: UTMRemoteClient.State.SavedServer
+    @Binding var isAutoConnect: Bool
+
+    @EnvironmentObject private var data: UTMRemoteData
+    @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
+
+    @State private var connectionTask: Task<Void, Error>?
+    private var isConnecting: Bool {
+        connectionTask != nil
+    }
+    @State private var isPasswordRequired: Bool = false
+    @State private var isTrustButton: Bool = false
+
+    private var remoteClient: UTMRemoteClient {
+        data.remoteClient
+    }
+
+    var body: some View {
+        NavigationView {
+            Form {
+                Section {
+                    if #available(iOS 15, *) {
+                        TextField("", text: $server.name, prompt: Text("Name (optional)"))
+                    } else {
+                        DefaultTextField("", text: $server.name, prompt: "Name (optional)")
+                    }
+                } header: {
+                    Text("Name")
+                }
+                Section {
+                    if server.endpoint != nil {
+                        Text(server.hostname)
+                    } else {
+                        if #available(iOS 15, *) {
+                            TextField("", text: $server.hostname, prompt: Text("Hostname or IP address"))
+                                .keyboardType(.asciiCapable)
+                                .autocorrectionDisabled()
+                                .textInputAutocapitalization(.never)
+                            TextField("", value: $server.port, format: .number.grouping(.never), prompt: Text("Port"))
+                                .keyboardType(.decimalPad)
+                        } else {
+                            DefaultTextField("", text: $server.hostname, prompt: "Hostname or IP address")
+                                .keyboardType(.asciiCapable)
+                                .autocorrectionDisabled()
+                            NumberTextField("", number: $server.port, prompt: "Port")
+                        }
+                    }
+                } header: {
+                    Text("Host")
+                }
+                let fingerprint = (server.fingerprint ^ remoteClient.fingerprint).hexString()
+                if !fingerprint.isEmpty {
+                    Section {
+                        if #available(iOS 16.4, *) {
+                            Text(fingerprint).monospaced()
+                        } else {
+                            Text(fingerprint)
+                        }
+                    } header: {
+                        Text("Fingerprint")
+                    }
+                }
+                if isPasswordRequired {
+                    Section {
+                        if #available(iOS 15, *) {
+                            FocusedPasswordView(password: $server.password.bound)
+                        } else {
+                            SecureField("Password", text: $server.password.bound)
+                        }
+                        Toggle("Save Password", isOn: $server.shouldSavePassword)
+                    } header: {
+                        Text("Password")
+                    }
+                }
+            }.disabled(isConnecting)
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button {
+                        presentationMode.wrappedValue.dismiss()
+                    } label: {
+                        Text("Close")
+                    }.disabled(isConnecting)
+                }
+                ToolbarItem(placement: .topBarTrailing) {
+                    HStack {
+                        if isConnecting {
+                            ProgressView().progressViewStyle(.circular)
+                            Button {
+                                connectionTask?.cancel()
+                            } label: {
+                                Text("Cancel")
+                            }
+                        } else {
+                            Button {
+                                connect()
+                            } label: {
+                                if isTrustButton {
+                                    Text("Trust")
+                                } else {
+                                    Text("Connect")
+                                }
+                            }.disabled(server.hostname.isEmpty || !server.isAvailable)
+                        }
+                    }
+                }
+            }
+        }
+        .onAppear {
+            // if we have an existing password, assume it should be saved
+            if server.password?.isEmpty == false {
+                server.shouldSavePassword = true
+            }
+            if isAutoConnect {
+                connect()
+            }
+        }
+        .alert(item: $remoteClientState.alertMessage) { item in
+            Alert(title: Text(item.message))
+        }
+    }
+
+    private func connect() {
+        guard connectionTask == nil else {
+            return
+        }
+        connectionTask = Task {
+            let timeoutTask = Task {
+                try await Task.sleep(nanoseconds: kTimeoutSeconds * NSEC_PER_SEC)
+                connectionTask?.cancel()
+                remoteClientState.showErrorAlert(NSLocalizedString("Timed out trying to connect.", comment: "UTMRemoteConnectView"))
+            }
+            do {
+                try await remoteClient.connect(server)
+            } catch {
+                if case UTMRemoteClient.ConnectionError.passwordRequired = error {
+                    withAnimation {
+                        isPasswordRequired = true
+                        isTrustButton = false
+                    }
+                } else if case UTMRemoteClient.ConnectionError.fingerprintUntrusted(let fingerprint) = error, server.fingerprint.isEmpty {
+                    withAnimation {
+                        server.fingerprint = fingerprint
+                        isTrustButton = true
+                    }
+                    remoteClientState.showErrorAlert(error.localizedDescription)
+                } else if error is CancellationError {
+                    // ignore it
+                } else {
+                    remoteClientState.showErrorAlert(error.localizedDescription)
+                }
+            }
+            timeoutTask.cancel()
+            connectionTask = nil
+        }
+    }
+}
+
+@available(iOS 15, *)
+private struct FocusedPasswordView: View {
+    @Binding var password: String
+
+    @FocusState private var isFocused: Bool
+
+    var body: some View {
+        SecureField("Password", text: $password)
+            .focused($isFocused)
+            .onAppear {
+                isFocused = true
+            }
+    }
+}
+
+#Preview {
+    UTMRemoteConnectView(remoteClientState: .init())
+}

+ 9 - 1
Platform/iOS/UTMSettingsView.swift

@@ -19,12 +19,20 @@ import SwiftUI
 struct UTMSettingsView: View {
 struct UTMSettingsView: View {
     @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
     @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
     
     
+    private var hasContainer: Bool {
+        #if WITH_JIT
+        jb_has_container()
+        #else
+        true
+        #endif
+    }
+
     var body: some View {
     var body: some View {
         NavigationView {
         NavigationView {
             IASKAppSettings()
             IASKAppSettings()
                 .navigationTitle("Settings")
                 .navigationTitle("Settings")
                 .navigationBarTitleDisplayMode(.inline)
                 .navigationBarTitleDisplayMode(.inline)
-                .appSettingsShowPrivacyLink(jb_has_container())
+                .appSettingsShowPrivacyLink(hasContainer)
                 .toolbar {
                 .toolbar {
                     ToolbarItem(placement: .navigationBarLeading) {
                     ToolbarItem(placement: .navigationBarLeading) {
                         Button("Close") {
                         Button("Close") {

+ 9 - 1
Platform/iOS/UTMSingleWindowView.swift

@@ -19,8 +19,12 @@ import SwiftUI
 @MainActor
 @MainActor
 struct UTMSingleWindowView: View {
 struct UTMSingleWindowView: View {
     let isInteractive: Bool
     let isInteractive: Bool
-    
+
+    #if WITH_REMOTE
+    @State private var data: UTMRemoteData = UTMRemoteData()
+    #else
     @State private var data: UTMData = UTMData()
     @State private var data: UTMData = UTMData()
+    #endif
     @State private var session: VMSessionState?
     @State private var session: VMSessionState?
     @State private var identifier: VMSessionState.WindowID?
     @State private var identifier: VMSessionState.WindowID?
 
 
@@ -36,7 +40,11 @@ struct UTMSingleWindowView: View {
             if let session = session {
             if let session = session {
                 VMWindowView(id: identifier!, isInteractive: isInteractive).environmentObject(session)
                 VMWindowView(id: identifier!, isInteractive: isInteractive).environmentObject(session)
             } else if isInteractive {
             } else if isInteractive {
+                #if WITH_REMOTE
+                RemoteContentView(remoteClientState: data.remoteClient.state).environmentObject(data)
+                #else
                 ContentView().environmentObject(data)
                 ContentView().environmentObject(data)
+                #endif
             } else {
             } else {
                 VStack {
                 VStack {
                     Text("Waiting for VM to connect to display...")
                     Text("Waiting for VM to connect to display...")

+ 15 - 9
Platform/iOS/VMDisplayHostedView.swift

@@ -19,7 +19,7 @@ import SwiftUI
 
 
 struct VMDisplayHostedView: UIViewControllerRepresentable {
 struct VMDisplayHostedView: UIViewControllerRepresentable {
     internal class Coordinator: VMDisplayViewControllerDelegate {
     internal class Coordinator: VMDisplayViewControllerDelegate {
-        let vm: UTMQemuVirtualMachine
+        let vm: any UTMSpiceVirtualMachine
         let device: VMWindowState.Device
         let device: VMWindowState.Device
         @Binding var state: VMWindowState
         @Binding var state: VMWindowState
         var vmStateCancellable: AnyCancellable?
         var vmStateCancellable: AnyCancellable?
@@ -37,19 +37,19 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
         }
         }
         
         
         @MainActor var qemuDisplayUpscaler: MTLSamplerMinMagFilter {
         @MainActor var qemuDisplayUpscaler: MTLSamplerMinMagFilter {
-            vmConfig.displays[state.device!.configIndex].upscalingFilter.metalSamplerMinMagFilter
+            vmConfig.displays[device.configIndex].upscalingFilter.metalSamplerMinMagFilter
         }
         }
         
         
         @MainActor var qemuDisplayDownscaler: MTLSamplerMinMagFilter {
         @MainActor var qemuDisplayDownscaler: MTLSamplerMinMagFilter {
-            vmConfig.displays[state.device!.configIndex].downscalingFilter.metalSamplerMinMagFilter
+            vmConfig.displays[device.configIndex].downscalingFilter.metalSamplerMinMagFilter
         }
         }
         
         
         @MainActor var qemuDisplayIsDynamicResolution: Bool {
         @MainActor var qemuDisplayIsDynamicResolution: Bool {
-            vmConfig.displays[state.device!.configIndex].isDynamicResolution
+            vmConfig.displays[device.configIndex].isDynamicResolution
         }
         }
         
         
         @MainActor var qemuDisplayIsNativeResolution: Bool {
         @MainActor var qemuDisplayIsNativeResolution: Bool {
-            vmConfig.displays[state.device!.configIndex].isNativeResolution
+            vmConfig.displays[device.configIndex].isNativeResolution
         }
         }
         
         
         @MainActor var qemuHasClipboardSharing: Bool {
         @MainActor var qemuHasClipboardSharing: Bool {
@@ -57,7 +57,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
         }
         }
         
         
         @MainActor var qemuConsoleResizeCommand: String? {
         @MainActor var qemuConsoleResizeCommand: String? {
-            vmConfig.serials[state.device!.configIndex].terminal?.resizeCommand
+            vmConfig.serials[device.configIndex].terminal?.resizeCommand
         }
         }
         
         
         var isViewportChanged: Bool {
         var isViewportChanged: Bool {
@@ -100,7 +100,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
             }
             }
         }
         }
         
         
-        init(with vm: UTMQemuVirtualMachine, device: VMWindowState.Device, state: Binding<VMWindowState>) {
+        init(with vm: any UTMSpiceVirtualMachine, device: VMWindowState.Device, state: Binding<VMWindowState>) {
             self.vm = vm
             self.vm = vm
             self.device = device
             self.device = device
             self._state = state
             self._state = state
@@ -131,7 +131,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
         }
         }
     }
     }
     
     
-    let vm: UTMQemuVirtualMachine
+    let vm: any UTMSpiceVirtualMachine
     let device: VMWindowState.Device
     let device: VMWindowState.Device
     
     
     @Binding var state: VMWindowState
     @Binding var state: VMWindowState
@@ -168,7 +168,12 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
         if let vc = uiViewController as? VMDisplayMetalViewController {
         if let vc = uiViewController as? VMDisplayMetalViewController {
             vc.vmInput = session.primaryInput
             vc.vmInput = session.primaryInput
         }
         }
-        if state.isKeyboardShown != state.isKeyboardRequested {
+        #if os(visionOS)
+        let useSystemOsk = !(uiViewController is VMDisplayMetalViewController)
+        #else
+        let useSystemOsk = true
+        #endif
+        if useSystemOsk && state.isKeyboardShown != state.isKeyboardRequested {
             DispatchQueue.main.async {
             DispatchQueue.main.async {
                 if state.isKeyboardRequested {
                 if state.isKeyboardRequested {
                     uiViewController.showKeyboard()
                     uiViewController.showKeyboard()
@@ -190,6 +195,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
                 }
                 }
                 // some obscure SwiftUI error means we cannot refer to Coordinator's state binding
                 // some obscure SwiftUI error means we cannot refer to Coordinator's state binding
                 vc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
                 vc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
+                vc.isDynamicResolutionSupported = state.isDynamicResolutionSupported
             }
             }
         case .serial(let serial, _):
         case .serial(let serial, _):
             if let vc = uiViewController as? VMDisplayTerminalViewController {
             if let vc = uiViewController as? VMDisplayTerminalViewController {

+ 52 - 19
Platform/iOS/VMSessionState.swift

@@ -37,21 +37,21 @@ import SwiftUI
 
 
     let id: ID = ID()
     let id: ID = ID()
 
 
-    let vm: UTMQemuVirtualMachine
-    
+    let vm: any UTMSpiceVirtualMachine
+
     var qemuConfig: UTMQemuConfiguration {
     var qemuConfig: UTMQemuConfiguration {
         vm.config
         vm.config
     }
     }
     
     
     @Published var vmState: UTMVirtualMachineState = .stopped
     @Published var vmState: UTMVirtualMachineState = .stopped
     
     
-    @Published var fatalError: String?
-    
     @Published var nonfatalError: String?
     @Published var nonfatalError: String?
-    
+
+    @Published var fatalError: String?
+
     @Published var primaryInput: CSInput?
     @Published var primaryInput: CSInput?
     
     
-    #if !WITH_QEMU_TCI
+    #if WITH_USB
     private var primaryUsbManager: CSUSBManager?
     private var primaryUsbManager: CSUSBManager?
     
     
     private var usbManagerQueue = DispatchQueue(label: "USB Manager Queue", qos: .utility)
     private var usbManagerQueue = DispatchQueue(label: "USB Manager Queue", qos: .utility)
@@ -78,10 +78,12 @@ import SwiftUI
     @Published var externalWindowBinding: Binding<VMWindowState>?
     @Published var externalWindowBinding: Binding<VMWindowState>?
     
     
     @Published var hasShownMemoryWarning: Bool = false
     @Published var hasShownMemoryWarning: Bool = false
-    
+
+    @Published var isDynamicResolutionSupported: Bool = false
+
     private var hasAutosave: Bool = false
     private var hasAutosave: Bool = false
 
 
-    init(for vm: UTMQemuVirtualMachine) {
+    init(for vm: any UTMSpiceVirtualMachine) {
         self.vm = vm
         self.vm = vm
         super.init()
         super.init()
         vm.delegate = self
         vm.delegate = self
@@ -148,7 +150,7 @@ extension VMSessionState: UTMVirtualMachineDelegate {
         Task { @MainActor in
         Task { @MainActor in
             vmState = state
             vmState = state
             if state == .stopped {
             if state == .stopped {
-                #if !WITH_QEMU_TCI
+                #if WITH_USB
                 clearDevices()
                 clearDevices()
                 #endif
                 #endif
             }
             }
@@ -157,7 +159,7 @@ extension VMSessionState: UTMVirtualMachineDelegate {
     
     
     nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
     nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
         Task { @MainActor in
         Task { @MainActor in
-            fatalError = message
+            nonfatalError = message
         }
         }
     }
     }
     
     
@@ -281,7 +283,7 @@ extension VMSessionState: UTMSpiceIODelegate {
         }
         }
     }
     }
     
     
-    #if !WITH_QEMU_TCI
+    #if WITH_USB
     nonisolated func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) {
     nonisolated func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) {
         Task { @MainActor in
         Task { @MainActor in
             primaryUsbManager?.delegate = nil
             primaryUsbManager?.delegate = nil
@@ -291,9 +293,21 @@ extension VMSessionState: UTMSpiceIODelegate {
         }
         }
     }
     }
     #endif
     #endif
+
+    nonisolated func spiceDynamicResolutionSupportDidChange(_ supported: Bool) {
+        Task { @MainActor in
+            isDynamicResolutionSupported = supported
+        }
+    }
+
+    nonisolated func spiceDidDisconnect() {
+        Task { @MainActor in
+            fatalError = NSLocalizedString("Connection to the server was lost.", comment: "VMSessionState")
+        }
+    }
 }
 }
 
 
-#if !WITH_QEMU_TCI
+#if WITH_USB
 extension VMSessionState: CSUSBManagerDelegate {
 extension VMSessionState: CSUSBManagerDelegate {
     nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceError error: String, for device: CSUSBDevice) {
     nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceError error: String, for device: CSUSBDevice) {
         Task { @MainActor in
         Task { @MainActor in
@@ -419,10 +433,18 @@ extension VMSessionState {
             logger.warning("Error starting audio session: \(error.localizedDescription)")
             logger.warning("Error starting audio session: \(error.localizedDescription)")
         }
         }
         Self.allActiveSessions[id] = self
         Self.allActiveSessions[id] = self
+        showWindow()
+        if vm.state == .paused {
+            vm.requestVmResume()
+        } else {
+            vm.requestVmStart(options: options)
+        }
+    }
+
+    func showWindow() {
         NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
         NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
-        vm.requestVmStart(options: options)
     }
     }
-    
+
     @objc private func suspend() {
     @objc private func suspend() {
         // dummy function for selector
         // dummy function for selector
     }
     }
@@ -436,7 +458,9 @@ extension VMSessionState {
         }
         }
         // tell other screens to shut down
         // tell other screens to shut down
         Self.allActiveSessions.removeValue(forKey: id)
         Self.allActiveSessions.removeValue(forKey: id)
-        NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
+        closeWindows()
+
+        #if WITH_SOLO_VM
         // animate to home screen
         // animate to home screen
         let app = UIApplication.shared
         let app = UIApplication.shared
         app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
         app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
@@ -446,12 +470,17 @@ extension VMSessionState {
         
         
         // exit app when app is in background
         // exit app when app is in background
         exit(0)
         exit(0)
+        #endif
     }
     }
-    
-    func powerDown() {
+
+    func closeWindows() {
+        NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
+    }
+
+    func powerDown(isKill: Bool = false) {
         Task {
         Task {
             try? await vm.deleteSnapshot(name: nil)
             try? await vm.deleteSnapshot(name: nil)
-            try await vm.stop(usingMethod: .force)
+            try await vm.stop(usingMethod: isKill ? .kill : .force)
             self.stop()
             self.stop()
         }
         }
     }
     }
@@ -482,6 +511,7 @@ extension VMSessionState {
     }
     }
     
     
     func didEnterBackground() {
     func didEnterBackground() {
+        #if !os(visionOS)
         logger.info("Entering background")
         logger.info("Entering background")
         let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
         let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
         if shouldAutosaveBackground && vmState == .started {
         if shouldAutosaveBackground && vmState == .started {
@@ -494,7 +524,7 @@ extension VMSessionState {
             }
             }
             Task {
             Task {
                 do {
                 do {
-                    try await vm.saveSnapshot()
+                    try await vm.saveSnapshot(name: nil)
                     self.hasAutosave = true
                     self.hasAutosave = true
                     logger.info("Save snapshot complete")
                     logger.info("Save snapshot complete")
                 } catch {
                 } catch {
@@ -504,14 +534,17 @@ extension VMSessionState {
                 task = .invalid
                 task = .invalid
             }
             }
         }
         }
+        #endif
     }
     }
     
     
     func didEnterForeground() {
     func didEnterForeground() {
+        #if !os(visionOS)
         logger.info("Entering foreground!")
         logger.info("Entering foreground!")
         if (hasAutosave && vmState == .started) {
         if (hasAutosave && vmState == .started) {
             logger.info("Deleting snapshot")
             logger.info("Deleting snapshot")
             vm.requestVmDeleteState()
             vm.requestVmDeleteState()
         }
         }
+        #endif
     }
     }
 }
 }
 
 

+ 7 - 0
Platform/iOS/VMToolbarDriveMenuView.swift

@@ -52,6 +52,7 @@ struct VMToolbarDriveMenuView: View {
             }
             }
             ForEach(config.drives) { drive in
             ForEach(config.drives) { drive in
                 if drive.isExternal {
                 if drive.isExternal {
+                    #if !WITH_REMOTE // FIXME: implement remote feature
                     Menu {
                     Menu {
                         Button {
                         Button {
                             selectedDrive = drive
                             selectedDrive = drive
@@ -68,6 +69,12 @@ struct VMToolbarDriveMenuView: View {
                     } label: {
                     } label: {
                         MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
                         MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
                     }
                     }
+                    #else
+                    Button {
+                    } label: {
+                        MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
+                    }.disabled(true)
+                    #endif
                 } else if drive.imageType == .disk || drive.imageType == .cd {
                 } else if drive.imageType == .disk || drive.imageType == .cd {
                     Button {
                     Button {
                     } label: {
                     } label: {

+ 7 - 3
Platform/iOS/VMToolbarView.swift

@@ -82,13 +82,17 @@ struct VMToolbarView: View {
         GeometryReader { geometry in
         GeometryReader { geometry in
             Group {
             Group {
                 Button {
                 Button {
-                    if session.vm.state == .started {
+                    if state.isRunning {
                         state.alert = .powerDown
                         state.alert = .powerDown
                     } else {
                     } else {
                         state.alert = .terminateApp
                         state.alert = .terminateApp
                     }
                     }
                 } label: {
                 } label: {
-                    Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
+                    if state.isRunning {
+                        Label("Power Off", systemImage: "power")
+                    } else {
+                        Label("Force Kill", systemImage: "xmark")
+                    }
                 }.offset(offset(for: 8))
                 }.offset(offset(for: 8))
                 Button {
                 Button {
                     session.pauseResume()
                     session.pauseResume()
@@ -110,7 +114,7 @@ struct VMToolbarView: View {
                 } label: {
                 } label: {
                     Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
                     Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
                 }.offset(offset(for: 5))
                 }.offset(offset(for: 5))
-                #if !WITH_QEMU_TCI
+                #if WITH_USB
                 if session.vm.hasUsbRedirection {
                 if session.vm.hasUsbRedirection {
                     VMToolbarUSBMenuView()
                     VMToolbarUSBMenuView()
                     .offset(offset(for: 4))
                     .offset(offset(for: 4))

+ 4 - 2
Platform/iOS/VMWindowState.swift

@@ -71,6 +71,8 @@ struct VMWindowState: Identifiable {
     var isRunning: Bool = false
     var isRunning: Bool = false
     
     
     var alert: Alert?
     var alert: Alert?
+
+    var isDynamicResolutionSupported: Bool = false
 }
 }
 
 
 // MARK: - VM action alerts
 // MARK: - VM action alerts
@@ -82,7 +84,7 @@ extension VMWindowState {
             case .powerDown: return 0
             case .powerDown: return 0
             case .terminateApp: return 1
             case .terminateApp: return 1
             case .restart: return 2
             case .restart: return 2
-            #if !WITH_QEMU_TCI
+            #if WITH_USB
             case .deviceConnected(_): return 3
             case .deviceConnected(_): return 3
             #endif
             #endif
             case .nonfatalError(_): return 4
             case .nonfatalError(_): return 4
@@ -94,7 +96,7 @@ extension VMWindowState {
         case powerDown
         case powerDown
         case terminateApp
         case terminateApp
         case restart
         case restart
-        #if !WITH_QEMU_TCI
+        #if WITH_USB
         case deviceConnected(CSUSBDevice)
         case deviceConnected(CSUSBDevice)
         #endif
         #endif
         case nonfatalError(String)
         case nonfatalError(String)

+ 38 - 6
Platform/iOS/VMWindowView.swift

@@ -16,6 +16,9 @@
 
 
 import SwiftUI
 import SwiftUI
 import SwiftUIVisualEffects
 import SwiftUIVisualEffects
+#if os(visionOS)
+import VisionKeyboardKit
+#endif
 
 
 struct VMWindowView: View {
 struct VMWindowView: View {
     let id: VMSessionState.WindowID
     let id: VMSessionState.WindowID
@@ -24,7 +27,10 @@ struct VMWindowView: View {
     @State private var state: VMWindowState
     @State private var state: VMWindowState
     @EnvironmentObject private var session: VMSessionState
     @EnvironmentObject private var session: VMSessionState
     @Environment(\.scenePhase) private var scenePhase
     @Environment(\.scenePhase) private var scenePhase
-    
+    #if os(visionOS)
+    @Environment(\.dismissWindow) private var dismissWindow
+    #endif
+
     private let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
     private let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
     private let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
     private let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
     private let didReceiveMemoryWarningNotification = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
     private let didReceiveMemoryWarningNotification = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
@@ -108,13 +114,13 @@ struct VMWindowView: View {
                 }, secondaryButton: .cancel(Text("No")))
                 }, secondaryButton: .cancel(Text("No")))
             case .terminateApp:
             case .terminateApp:
                 return Alert(title: Text("Are you sure you want to exit UTM?"), primaryButton: .destructive(Text("Yes")) {
                 return Alert(title: Text("Are you sure you want to exit UTM?"), primaryButton: .destructive(Text("Yes")) {
-                    session.stop()
+                    session.powerDown(isKill: true)
                 }, secondaryButton: .cancel(Text("No")))
                 }, secondaryButton: .cancel(Text("No")))
             case .restart:
             case .restart:
                 return Alert(title: Text("Are you sure you want to reset this VM? Any unsaved changes will be lost."), primaryButton: .destructive(Text("Yes")) {
                 return Alert(title: Text("Are you sure you want to reset this VM? Any unsaved changes will be lost."), primaryButton: .destructive(Text("Yes")) {
                     session.reset()
                     session.reset()
                 }, secondaryButton: .cancel(Text("No")))
                 }, secondaryButton: .cancel(Text("No")))
-            #if !WITH_QEMU_TCI
+            #if WITH_USB
             case .deviceConnected(let device):
             case .deviceConnected(let device):
                 return Alert(title: Text("Would you like to connect '\(device.name ?? device.description)' to this virtual machine?"), primaryButton: .default(Text("Yes")) {
                 return Alert(title: Text("Would you like to connect '\(device.name ?? device.description)' to this virtual machine?"), primaryButton: .default(Text("Yes")) {
                     session.mostRecentConnectedDevice = nil
                     session.mostRecentConnectedDevice = nil
@@ -127,6 +133,8 @@ struct VMWindowView: View {
                 return Alert(title: Text(message), dismissButton: .cancel(Text("OK")) {
                 return Alert(title: Text(message), dismissButton: .cancel(Text("OK")) {
                     if case .fatalError(_) = type {
                     if case .fatalError(_) = type {
                         session.stop()
                         session.stop()
+                    } else if session.vmState == .stopped {
+                        session.stop()
                     } else {
                     } else {
                         session.nonfatalError = nil
                         session.nonfatalError = nil
                     }
                     }
@@ -151,7 +159,7 @@ struct VMWindowView: View {
             state.saveWindow(to: session.vm.registryEntry, device: oldDevice)
             state.saveWindow(to: session.vm.registryEntry, device: oldDevice)
             state.restoreWindow(from: session.vm.registryEntry, device: newDevice)
             state.restoreWindow(from: session.vm.registryEntry, device: newDevice)
         }
         }
-        #if !WITH_QEMU_TCI
+        #if WITH_USB
         .onChange(of: session.mostRecentConnectedDevice) { newValue in
         .onChange(of: session.mostRecentConnectedDevice) { newValue in
             if session.activeWindow == state.id, let device = newValue {
             if session.activeWindow == state.id, let device = newValue {
                 state.alert = .deviceConnected(device)
                 state.alert = .deviceConnected(device)
@@ -171,6 +179,9 @@ struct VMWindowView: View {
         .onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
         .onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
             vmStateUpdated(from: oldValue, to: newValue)
             vmStateUpdated(from: oldValue, to: newValue)
         }
         }
+        .onChange(of: session.isDynamicResolutionSupported) { newValue in
+            state.isDynamicResolutionSupported = newValue
+        }
         .onReceive(keyboardDidShowNotification) { _ in
         .onReceive(keyboardDidShowNotification) { _ in
             state.isKeyboardShown = true
             state.isKeyboardShown = true
             state.isKeyboardRequested = true
             state.isKeyboardRequested = true
@@ -202,12 +213,30 @@ struct VMWindowView: View {
             if !isInteractive {
             if !isInteractive {
                 session.externalWindowBinding = $state
                 session.externalWindowBinding = $state
             }
             }
+            state.isDynamicResolutionSupported = session.isDynamicResolutionSupported
+            // in case an alert appeared before we created the view
+            if session.activeWindow == state.id {
+                #if WITH_USB
+                if let device = session.mostRecentConnectedDevice {
+                    state.alert = .deviceConnected(device)
+                }
+                #endif
+                if let nonfatalError = session.nonfatalError {
+                    state.alert = .nonfatalError(nonfatalError)
+                }
+                if let fatalError = session.fatalError {
+                    state.alert = .fatalError(fatalError)
+                }
+            }
         }
         }
         .onDisappear {
         .onDisappear {
             session.removeWindow(state.id)
             session.removeWindow(state.id)
             if !isInteractive {
             if !isInteractive {
                 session.externalWindowBinding = nil
                 session.externalWindowBinding = nil
             }
             }
+            #if os(visionOS)
+            dismissWindow(keyboardFor: state.id)
+            #endif
         }
         }
     }
     }
     
     
@@ -221,9 +250,12 @@ struct VMWindowView: View {
                 state.isBusy = false
                 state.isBusy = false
                 state.isRunning = false
                 state.isRunning = false
             }
             }
+            // do not close if we have a popup open
             DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
             DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
-                if session.vmState == .stopped && session.fatalError == nil {
-                    session.stop()
+                if session.nonfatalError == nil && session.fatalError == nil {
+                    if session.vmState == .stopped {
+                        session.stop()
+                    }
                 }
                 }
             }
             }
         case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
         case .pausing, .stopping, .starting, .resuming, .saving, .restoring:

+ 1 - 1
Platform/macOS/Display/VMDisplayAppleTerminalWindowController.swift

@@ -44,7 +44,7 @@ class VMDisplayAppleTerminalWindowController: VMDisplayAppleWindowController, VM
     private var isSizeChangeIgnored: Bool = true
     private var isSizeChangeIgnored: Bool = true
     @Setting("OptionAsMetaKey") var isOptionAsMetaKey: Bool = false
     @Setting("OptionAsMetaKey") var isOptionAsMetaKey: Bool = false
     
     
-    convenience init(primaryForIndex index: Int, vm: UTMAppleVirtualMachine, onClose: ((Notification) -> Void)?) {
+    convenience init(primaryForIndex index: Int, vm: UTMAppleVirtualMachine, onClose: (() -> Void)?) {
         self.init(vm: vm, onClose: onClose)
         self.init(vm: vm, onClose: onClose)
         self.index = index
         self.index = index
     }
     }

+ 2 - 2
Platform/macOS/Display/VMDisplayAppleWindowController.swift

@@ -257,9 +257,9 @@ extension VMDisplayAppleWindowController {
 }
 }
 
 
 extension VMDisplayAppleWindowController: UTMScreenshotProvider {
 extension VMDisplayAppleWindowController: UTMScreenshotProvider {
-    var screenshot: PlatformImage? {
+    var screenshot: UTMVirtualMachineScreenshot? {
         if let image = mainView?.image() {
         if let image = mainView?.image() {
-            return image
+            return UTMVirtualMachineScreenshot(wrapping: image)
         } else {
         } else {
             return nil
             return nil
         }
         }

+ 1 - 1
Platform/macOS/Display/VMDisplayQemuMetalWindowController.swift

@@ -149,7 +149,7 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController {
     override func enterSuspended(isBusy busy: Bool) {
     override func enterSuspended(isBusy busy: Bool) {
         if !busy {
         if !busy {
             metalView.isHidden = true
             metalView.isHidden = true
-            screenshotView.image = vm.screenshot
+            screenshotView.image = vm.screenshot?.image
             screenshotView.isHidden = false
             screenshotView.isHidden = false
         }
         }
         if vm.state == .stopped {
         if vm.state == .stopped {

+ 4 - 4
Platform/macOS/Display/VMDisplayWindowController.swift

@@ -38,7 +38,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
     
     
     var shouldAutoStartVM: Bool = true
     var shouldAutoStartVM: Bool = true
     var vm: (any UTMVirtualMachine)!
     var vm: (any UTMVirtualMachine)!
-    var onClose: ((Notification) -> Void)?
+    var onClose: (() -> Void)?
     private(set) var secondaryWindows: [VMDisplayWindowController] = []
     private(set) var secondaryWindows: [VMDisplayWindowController] = []
     private(set) weak var primaryWindow: VMDisplayWindowController?
     private(set) weak var primaryWindow: VMDisplayWindowController?
     private var preventIdleSleepAssertion: IOPMAssertionID?
     private var preventIdleSleepAssertion: IOPMAssertionID?
@@ -60,7 +60,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
         self
         self
     }
     }
     
     
-    convenience init(vm: any UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
+    convenience init(vm: any UTMVirtualMachine, onClose: (() -> Void)?) {
         self.init(window: nil)
         self.init(window: nil)
         self.vm = vm
         self.vm = vm
         self.onClose = onClose
         self.onClose = onClose
@@ -236,7 +236,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
     
     
     func registerSecondaryWindow(_ secondaryWindow: VMDisplayWindowController, at index: Int? = nil) {
     func registerSecondaryWindow(_ secondaryWindow: VMDisplayWindowController, at index: Int? = nil) {
         secondaryWindows.insert(secondaryWindow, at: index ?? secondaryWindows.endIndex)
         secondaryWindows.insert(secondaryWindow, at: index ?? secondaryWindows.endIndex)
-        secondaryWindow.onClose = { [weak self] _ in
+        secondaryWindow.onClose = { [weak self] in
             self?.secondaryWindows.removeAll(where: { $0 == secondaryWindow })
             self?.secondaryWindows.removeAll(where: { $0 == secondaryWindow })
         }
         }
         secondaryWindow.primaryWindow = self
         secondaryWindow.primaryWindow = self
@@ -367,7 +367,7 @@ extension VMDisplayWindowController: NSWindowDelegate {
             IOPMAssertionRelease(preventIdleSleepAssertion)
             IOPMAssertionRelease(preventIdleSleepAssertion)
         }
         }
         isFinalizing = true
         isFinalizing = true
-        onClose?(notification)
+        onClose?()
     }
     }
     
     
     func windowDidBecomeKey(_ notification: Notification) {
     func windowDidBecomeKey(_ notification: Notification) {

+ 63 - 0
Platform/macOS/SettingsView.swift

@@ -37,6 +37,10 @@ struct SettingsView: View {
                 .tabItem {
                 .tabItem {
                     Label("Input", systemImage: "keyboard")
                     Label("Input", systemImage: "keyboard")
                 }
                 }
+            ServerSettingsView().padding()
+                .tabItem {
+                    Label("Server", systemImage: "server.rack")
+                }
         }.frame(minWidth: 600, minHeight: 350, alignment: .topLeading)
         }.frame(minWidth: 600, minHeight: 350, alignment: .topLeading)
     }
     }
 }
 }
@@ -181,6 +185,65 @@ struct InputSettingsView: View {
     }
     }
 }
 }
 
 
+struct ServerSettingsView: View {
+    private let defaultPort = 21589
+
+    @AppStorage("ServerAutostart") var isServerAutostart: Bool = false
+    @AppStorage("ServerExternal") var isServerExternal: Bool = false
+    @AppStorage("ServerAutoblock") var isServerAutoblock: Bool = false
+    @AppStorage("ServerPort") var serverPort: Int = 0
+    @AppStorage("ServerPasswordRequired") var isServerPasswordRequired: Bool = false
+    @AppStorage("ServerPassword") var serverPassword: String = ""
+
+    // note it is okay to store the server password in plaintext in the settings plist because if the attacker is able to see the password,
+    // they can gain execution in UTM application context... which is the context needed to read the password.
+
+    var body: some View {
+        Form {
+            Section(header: Text("Startup")) {
+                Toggle("Automatically start UTM server", isOn: $isServerAutostart)
+            }
+            Section(header: Text("Network")) {
+                Toggle("Reject unknown connections by default", isOn: $isServerAutoblock)
+                    .help("If checked, you will not be prompted about any unknown connection and they will be rejected.")
+                Toggle("Allow access from external clients", isOn: $isServerExternal)
+                    .help("By default, the server is only available on LAN but setting this will use UPnP/NAT-PMP to port forward to WAN.")
+                    .onChange(of: isServerExternal) { newValue in
+                        if newValue {
+                            if serverPort == 0 {
+                                serverPort = defaultPort
+                            }
+                            if !isServerPasswordRequired {
+                                isServerPasswordRequired = true
+                            }
+                        }
+                    }
+                NumberTextField("", number: $serverPort, prompt: "Any")
+                    .frame(width: 80)
+                    .multilineTextAlignment(.trailing)
+                    .help("Specify a port number to listen on. This is required if external clients are permitted.")
+                    .onChange(of: serverPort) { newValue in
+                        if serverPort == 0 {
+                            isServerExternal = false
+                        }
+                    }
+            }
+            Section(header: Text("Authentication")) {
+                Toggle("Require Password", isOn: $isServerPasswordRequired)
+                    .disabled(isServerExternal)
+                    .help("If enabled, clients must enter a password. This is required if you want to access the server externally.")
+                    .onChange(of: isServerPasswordRequired) { newValue in
+                        if newValue && serverPassword.count == 0 {
+                            serverPassword = .random(length: 32)
+                        }
+                    }
+                TextField("Password", text: $serverPassword)
+                    .disabled(!isServerPasswordRequired)
+            }
+        }
+    }
+}
+
 extension UserDefaults {
 extension UserDefaults {
     @objc dynamic var KeepRunningAfterLastWindowClosed: Bool { false }
     @objc dynamic var KeepRunningAfterLastWindowClosed: Bool { false }
     @objc dynamic var ShowMenuIcon: Bool { false }
     @objc dynamic var ShowMenuIcon: Bool { false }

+ 3 - 0
Platform/macOS/UTMApp.swift

@@ -58,6 +58,9 @@ struct UTMApp: App {
             SettingsView()
             SettingsView()
         }
         }
         UTMMenuBarExtraScene(data: data)
         UTMMenuBarExtraScene(data: data)
+        Window("UTM Server", id: "server") {
+            UTMServerView().environmentObject(data.remoteServer.state)
+        }
     }
     }
     
     
     // HACK: SwiftUI doesn't provide if-statement support in SceneBuilder
     // HACK: SwiftUI doesn't provide if-statement support in SceneBuilder

+ 32 - 1
Platform/macOS/UTMDataExtension.swift

@@ -22,7 +22,7 @@ extension UTMData {
     func run(vm: VMData, options: UTMVirtualMachineStartOptions = [], startImmediately: Bool = true) {
     func run(vm: VMData, options: UTMVirtualMachineStartOptions = [], startImmediately: Bool = true) {
         var window: Any? = vmWindows[vm]
         var window: Any? = vmWindows[vm]
         if window == nil {
         if window == nil {
-            let close = { (notification: Notification) -> Void in
+            let close = {
                 self.vmWindows.removeValue(forKey: vm)
                 self.vmWindows.removeValue(forKey: vm)
                 window = nil
                 window = nil
             }
             }
@@ -76,6 +76,37 @@ extension UTMData {
         }
         }
     }
     }
     
     
+    /// Start a remote session and return SPICE server port.
+    /// - Parameters:
+    ///   - vm: VM to start
+    ///   - options: Start options
+    ///   - server: Remote server
+    /// - Returns: Port number to SPICE server
+    func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> UTMRemoteMessageServer.StartVirtualMachine.ServerInformation {
+        guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else {
+            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 {
+            throw UTMDataError.virtualMachineUnavailable
+        }
+        let session = VMRemoteSessionState(for: wrapped, client: client) {
+            self.vmWindows.removeValue(forKey: vm)
+        }
+        try await wrapped.start(options: options.union(.remoteSession))
+        vmWindows[vm] = session
+        guard let spiceServerInfo = wrapped.spiceServerInfo else {
+            throw UTMDataError.unsupportedBackend
+        }
+        return spiceServerInfo
+    }
+
     func stop(vm: VMData) {
     func stop(vm: VMData) {
         guard let wrapped = vm.wrapped else {
         guard let wrapped = vm.wrapped else {
             return
             return

+ 173 - 0
Platform/macOS/UTMServerView.swift

@@ -0,0 +1,173 @@
+//
+// Copyright © 2023 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 SwiftUI
+
+@available(macOS 13, *)
+struct UTMServerView: View {
+    @EnvironmentObject private var remoteServer: UTMRemoteServer.State
+    @State private var isDeletingAll: Bool = false
+
+    var body: some View {
+        VStack(alignment: .leading) {
+            HStack {
+                Toggle("Enable UTM Server", isOn: Binding<Bool>(get: {
+                    remoteServer.isServerActive
+                }, set: { value in
+                    if value {
+                        remoteServer.requestServerAction(.start)
+                    } else {
+                        remoteServer.requestServerAction(.stop)
+                    }
+                }))
+                Spacer()
+                Button {
+                    isDeletingAll = true
+                } label: {
+                    Text("Reset Identity")
+                }
+                .alert("Confirmation", isPresented: $isDeletingAll) {
+                    Button(role: .destructive) {
+                        remoteServer.allClients.removeAll()
+                        remoteServer.requestServerAction(.reset)
+                    } label: {
+                        Text("Reset Identity")
+                    }.keyboardShortcut(.defaultAction)
+                } message: {
+                    Text("Do you want to forget all clients and generate a new server identity? Any clients that previously paired with this server will be instructed to manually unpair with this server before they can connect again.")
+                }
+            }.padding([.top, .leading, .trailing])
+            ServerOverview()
+            Divider()
+            HStack {
+                if let address = remoteServer.externalIPAddress, let port = remoteServer.externalPort {
+                    Text("Server IP: \(address), Port: \(String(port))")
+                        .textSelection(.enabled)
+                }
+                Spacer()
+                if remoteServer.isServerActive {
+                    Image(systemName: "circle.fill")
+                        .foregroundStyle(.green)
+                    Text("Running")
+                } else {
+                    Image(systemName: "circle.fill")
+                        .foregroundStyle(.red)
+                    Text("Stopped")
+                }
+            }.padding([.bottom, .leading, .trailing])
+        }.disabled(remoteServer.isBusy)
+    }
+}
+
+@available(macOS 13, *)
+fileprivate struct ServerOverview: View {
+    @EnvironmentObject private var remoteServer: UTMRemoteServer.State
+    @State private var sortOrder = [KeyPathComparator(\UTMRemoteServer.State.Client.name)]
+    @State private var selectedFingerprints = Set<UTMRemoteServer.State.ClientFingerprint>()
+    @State private var isDeleting: Bool = false
+
+    var body: some View {
+        Table(remoteServer.allClients, selection: $selectedFingerprints, sortOrder: $sortOrder) {
+            TableColumn("") { client in
+                if remoteServer.isConnected(client.fingerprint) {
+                    Image(systemName: "circle.fill")
+                        .foregroundStyle(.green)
+                }
+            }.width(16)
+            TableColumn("Name", value: \.name)
+                .width(ideal: 200)
+            TableColumn("Fingerprint") { client in
+                Text((client.fingerprint ^ remoteServer.serverFingerprint).hexString())
+            }.width(ideal: 300)
+            TableColumn("Last Seen", value: \.lastSeen) { client in
+                Text(DateFormatter.localizedString(from: client.lastSeen, dateStyle: .short, timeStyle: .short))
+            }.width(ideal: 150)
+            TableColumn("Status") { client in
+                if remoteServer.isConnected(client.fingerprint) {
+                    Text("Connected")
+                } else if remoteServer.isBlocked(client.fingerprint) {
+                    Text("Blocked")
+                } else if !remoteServer.isApproved(client.fingerprint) {
+                    HStack {
+                        Button {
+                            remoteServer.approve(client.fingerprint)
+                        } label: {
+                            Text("Approve")
+                        }.buttonStyle(.bordered)
+                        Button {
+                            remoteServer.block(client.fingerprint)
+                        } label: {
+                            Text("Block")
+                        }.buttonStyle(.bordered)
+                    }
+                }
+            }.width(ideal: 140)
+        }
+        .contextMenu(forSelectionType: UTMRemoteServer.State.ClientFingerprint.self) { items in
+            if items.count == 1 {
+                if remoteServer.isConnected(items.first!) {
+                    Button {
+                        remoteServer.disconnect(items.first!)
+                    } label: {
+                        Text("Disconnect")
+                    }
+                }
+                if !remoteServer.isApproved(items.first!) {
+                    Button {
+                        remoteServer.approve(items.first!)
+                    } label: {
+                        Text("Approve")
+                    }
+                }
+                if !remoteServer.isBlocked(items.first!) {
+                    Button {
+                        remoteServer.block(items.first!)
+                    } label: {
+                        Text("Block")
+                    }
+                }
+            }
+            if items.count > 0 {
+                Button {
+                    isDeleting = true
+                    selectedFingerprints = items
+                } label: {
+                    Text("Delete")
+                }
+            }
+        }
+        .onChange(of: sortOrder) {
+            remoteServer.allClients.sort(using: $0)
+        }
+        .onDeleteCommand {
+            isDeleting = true
+        }
+        .alert("Confirmation", isPresented: $isDeleting) {
+            Button(role: .destructive) {
+                remoteServer.allClients.removeAll(where: { selectedFingerprints.contains($0.fingerprint) })
+            } label: {
+                Text("Delete")
+            }.keyboardShortcut(.defaultAction)
+        } message: {
+            Text("Do you want to forget the selected client(s)?")
+        }
+    }
+}
+
+@available(macOS 13, *)
+#Preview {
+    UTMServerView()
+}

+ 4 - 8
Platform/macOS/VMHeadlessSessionState.swift

@@ -18,20 +18,18 @@ import Foundation
 import IOKit.pwr_mgt
 import IOKit.pwr_mgt
 
 
 /// Represents the UI state for a single headless VM session.
 /// Represents the UI state for a single headless VM session.
-@MainActor class VMHeadlessSessionState: NSObject, ObservableObject {
+@MainActor class VMHeadlessSessionState: NSObject, ObservableObject, UTMVirtualMachineDelegate {
     let vm: any UTMVirtualMachine
     let vm: any UTMVirtualMachine
-    var onStop: ((Notification) -> Void)?
+    var onStop: (() -> Void)?
     
     
     @Published var vmState: UTMVirtualMachineState = .stopped
     @Published var vmState: UTMVirtualMachineState = .stopped
     
     
-    @Published var fatalError: String?
-    
     private var hasStarted: Bool = false
     private var hasStarted: Bool = false
     private var preventIdleSleepAssertion: IOPMAssertionID?
     private var preventIdleSleepAssertion: IOPMAssertionID?
     
     
     @Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
     @Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
     
     
-    init(for vm: any UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
+    init(for vm: any UTMVirtualMachine, onStop: (() -> Void)?) {
         self.vm = vm
         self.vm = vm
         self.onStop = onStop
         self.onStop = onStop
         super.init()
         super.init()
@@ -42,9 +40,7 @@ import IOKit.pwr_mgt
     deinit {
     deinit {
         NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
         NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
     }
     }
-}
 
 
-extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
     nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
     nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
         Task { @MainActor in
         Task { @MainActor in
             vmState = state
             vmState = state
@@ -63,7 +59,6 @@ extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
     
     
     nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
     nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
         Task { @MainActor in
         Task { @MainActor in
-            fatalError = message
             NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
             NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
             if !hasStarted {
             if !hasStarted {
                 // if we got an error and haven't started, then cleanup
                 // if we got an error and haven't started, then cleanup
@@ -101,6 +96,7 @@ extension VMHeadlessSessionState {
         if let preventIdleSleepAssertion = preventIdleSleepAssertion {
         if let preventIdleSleepAssertion = preventIdleSleepAssertion {
             IOPMAssertionRelease(preventIdleSleepAssertion)
             IOPMAssertionRelease(preventIdleSleepAssertion)
         }
         }
+        onStop?()
     }
     }
 }
 }
 
 

+ 35 - 0
Platform/macOS/VMRemoteSessionState.swift

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

+ 6 - 0
Platform/macOS/macOS-unsigned.entitlements

@@ -4,6 +4,10 @@
 <dict>
 <dict>
 	<key>com.apple.security.app-sandbox</key>
 	<key>com.apple.security.app-sandbox</key>
 	<true/>
 	<true/>
+	<key>com.apple.security.application-groups</key>
+	<array>
+		<string>$(TeamIdentifierPrefix)$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM</string>
+	</array>
 	<key>com.apple.security.cs.disable-library-validation</key>
 	<key>com.apple.security.cs.disable-library-validation</key>
 	<true/>
 	<true/>
 	<key>com.apple.security.device.audio-input</key>
 	<key>com.apple.security.device.audio-input</key>
@@ -14,6 +18,8 @@
 	<true/>
 	<true/>
 	<key>com.apple.security.network.client</key>
 	<key>com.apple.security.network.client</key>
 	<true/>
 	<true/>
+	<key>com.apple.security.network.server</key>
+	<true/>
 	<key>com.apple.security.temporary-exception.sbpl</key>
 	<key>com.apple.security.temporary-exception.sbpl</key>
 	<array>
 	<array>
 		<string>(allow network-outbound)</string>
 		<string>(allow network-outbound)</string>

+ 2 - 0
Platform/macOS/macOS.entitlements

@@ -16,6 +16,8 @@
 	<true/>
 	<true/>
 	<key>com.apple.security.network.client</key>
 	<key>com.apple.security.network.client</key>
 	<true/>
 	<true/>
+	<key>com.apple.security.network.server</key>
+	<true/>
 	<key>com.apple.security.virtualization</key>
 	<key>com.apple.security.virtualization</key>
 	<true/>
 	<true/>
 	<key>com.apple.vm.device-access</key>
 	<key>com.apple.vm.device-access</key>

+ 24 - 2
Platform/visionOS/UTMApp.swift

@@ -15,23 +15,40 @@
 //
 //
 
 
 import SwiftUI
 import SwiftUI
+import VisionKeyboardKit
 
 
 @MainActor
 @MainActor
 struct UTMApp: App {
 struct UTMApp: App {
+    #if WITH_REMOTE
+    @State private var data: UTMRemoteData = UTMRemoteData()
+    #else
     @State private var data: UTMData = UTMData()
     @State private var data: UTMData = UTMData()
+    #endif
     @Environment(\.openWindow) private var openWindow
     @Environment(\.openWindow) private var openWindow
     @Environment(\.dismissWindow) private var dismissWindow
     @Environment(\.dismissWindow) private var dismissWindow
 
 
     private let vmSessionCreatedNotification = NotificationCenter.default.publisher(for: .vmSessionCreated)
     private let vmSessionCreatedNotification = NotificationCenter.default.publisher(for: .vmSessionCreated)
     private let vmSessionEndedNotification = NotificationCenter.default.publisher(for: .vmSessionEnded)
     private let vmSessionEndedNotification = NotificationCenter.default.publisher(for: .vmSessionEnded)
 
 
+    private var contentView: some View {
+        #if WITH_REMOTE
+        RemoteContentView(remoteClientState: data.remoteClient.state)
+        #else
+        ContentView()
+        #endif
+    }
+
     var body: some Scene {
     var body: some Scene {
         WindowGroup(id: "home") {
         WindowGroup(id: "home") {
-            ContentView()
+            contentView
             .environmentObject(data)
             .environmentObject(data)
             .onReceive(vmSessionCreatedNotification) { output in
             .onReceive(vmSessionCreatedNotification) { output in
                 let newSession = output.userInfo!["Session"] as! VMSessionState
                 let newSession = output.userInfo!["Session"] as! VMSessionState
-                openWindow(value: newSession.newWindow())
+                if let window = newSession.windows.first {
+                    openWindow(value: window)
+                } else {
+                    openWindow(value: newSession.newWindow())
+                }
             }
             }
             .onReceive(vmSessionEndedNotification) { output in
             .onReceive(vmSessionEndedNotification) { output in
                 let endedSession = output.userInfo!["Session"] as! VMSessionState
                 let endedSession = output.userInfo!["Session"] as! VMSessionState
@@ -46,12 +63,17 @@ struct UTMApp: App {
         WindowGroup(for: VMSessionState.GlobalWindowID.self) { $globalID in
         WindowGroup(for: VMSessionState.GlobalWindowID.self) { $globalID in
             if let globalID = globalID, let session = VMSessionState.allActiveSessions[globalID.sessionID] {
             if let globalID = globalID, let session = VMSessionState.allActiveSessions[globalID.sessionID] {
                 VMWindowView(id: globalID.windowID).environmentObject(session)
                 VMWindowView(id: globalID.windowID).environmentObject(session)
+                    .glassBackgroundEffect(in: .rect(cornerRadius: 15))
+                    #if WITH_SOLO_VM
                     .onAppear {
                     .onAppear {
                         // currently we only support one session, so close the home window
                         // currently we only support one session, so close the home window
                         dismissWindow(id: "home")
                         dismissWindow(id: "home")
                     }
                     }
+                    #endif
             }
             }
         }
         }
+        .windowStyle(.plain)
         .windowResizability(.contentMinSize)
         .windowResizability(.contentMinSize)
+        KeyboardWindowGroup()
     }
     }
 }
 }

+ 56 - 4
Platform/visionOS/VMToolbarOrnamentModifier.swift

@@ -15,23 +15,35 @@
 //
 //
 
 
 import SwiftUI
 import SwiftUI
+import VisionKeyboardKit
+#if !WITH_USB
+import CocoaSpiceNoUsb
+#else
+import CocoaSpice
+#endif
 
 
 struct VMToolbarOrnamentModifier: ViewModifier {
 struct VMToolbarOrnamentModifier: ViewModifier {
     @Binding var state: VMWindowState
     @Binding var state: VMWindowState
     @EnvironmentObject private var session: VMSessionState
     @EnvironmentObject private var session: VMSessionState
     @AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
     @AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
+    @Environment(\.openWindow) private var openWindow
+    @Environment(\.dismissWindow) private var dismissWindow
 
 
     func body(content: Content) -> some View {
     func body(content: Content) -> some View {
         content.ornament(visibility: isCollapsed ? .hidden : .visible, attachmentAnchor: .scene(.top)) {
         content.ornament(visibility: isCollapsed ? .hidden : .visible, attachmentAnchor: .scene(.top)) {
             HStack {
             HStack {
                 Button {
                 Button {
-                    if session.vm.state == .started {
+                    if state.isRunning {
                         state.alert = .powerDown
                         state.alert = .powerDown
                     } else {
                     } else {
                         state.alert = .terminateApp
                         state.alert = .terminateApp
                     }
                     }
                 } label: {
                 } label: {
-                    Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
+                    if state.isRunning {
+                        Label("Power Off", systemImage: "power")
+                    } else {
+                        Label("Force Kill", systemImage: "xmark")
+                    }
                 }
                 }
                 .disabled(state.isBusy)
                 .disabled(state.isBusy)
                 Button {
                 Button {
@@ -56,7 +68,7 @@ struct VMToolbarOrnamentModifier: ViewModifier {
                     }
                     }
                     .disabled(state.isBusy)
                     .disabled(state.isBusy)
                 }
                 }
-                #if !WITH_QEMU_TCI
+                #if WITH_USB
                 if session.vm.hasUsbRedirection {
                 if session.vm.hasUsbRedirection {
                     VMToolbarUSBMenuView()
                     VMToolbarUSBMenuView()
                         .disabled(state.isBusy)
                         .disabled(state.isBusy)
@@ -67,11 +79,39 @@ struct VMToolbarOrnamentModifier: ViewModifier {
                 VMToolbarDisplayMenuView(state: $state)
                 VMToolbarDisplayMenuView(state: $state)
                     .disabled(state.isBusy)
                     .disabled(state.isBusy)
                 Button {
                 Button {
-                    state.isKeyboardRequested = true
+                    if case .display(_, _) = state.device {
+                        state.isKeyboardRequested = !state.isKeyboardShown
+                    } else {
+                        state.isKeyboardRequested = true
+                    }
                 } label: {
                 } label: {
                     Label("Keyboard", systemImage: "keyboard")
                     Label("Keyboard", systemImage: "keyboard")
                 }
                 }
                 .disabled(state.isBusy)
                 .disabled(state.isBusy)
+                .onChange(of: state.isKeyboardRequested) { _, newValue in
+                    guard case .display(_, _) = state.device else {
+                        return
+                    }
+                    if newValue {
+                        openWindow(keyboardFor: state.id)
+                    } else {
+                        dismissWindow(keyboardFor: state.id)
+                    }
+                }
+                .onReceive(KeyboardEvent.publisher(for: state.id)) { event in
+                    switch event {
+                    case .keyboardDidAppear:
+                        state.isKeyboardShown = true
+                        state.isKeyboardRequested = true
+                    case .keyboardDidDisappear:
+                        state.isKeyboardShown = false
+                        state.isKeyboardRequested = false
+                    case .keyUp(let keyCode, let modifier):
+                        handleKeyEvent(keyCode, modifier: modifier, isKeyDown: false)
+                    case .keyDown(let keyCode, let modifier):
+                        handleKeyEvent(keyCode, modifier: modifier, isKeyDown: true)
+                    }
+                }
                 Divider()
                 Divider()
                 Button {
                 Button {
                     isCollapsed = true
                     isCollapsed = true
@@ -90,6 +130,18 @@ struct VMToolbarOrnamentModifier: ViewModifier {
                 .modifier(ToolbarOrnamentViewModifier())
                 .modifier(ToolbarOrnamentViewModifier())
         }
         }
     }
     }
+
+    private func handleKeyEvent(_ keyCode: KeyboardKeyCode, modifier: KeyboardModifier, isKeyDown: Bool) {
+        guard let primaryInput = session.primaryInput else {
+            logger.debug("ignoring key event because input channel is not ready")
+            return
+        }
+        var scanCode = keyCode.ps2Set1ScanMake(modifier).reduce(Int32(0), { ($0 << 8) | Int32($1) })
+        if ((scanCode & 0xFF00) == 0xE000) {
+            scanCode = 0x100 | (scanCode & 0xFF);
+        }
+        primaryInput.send(isKeyDown ? .press : .release, code: scanCode)
+    }
 }
 }
 
 
 // the following was suggested by Apple via Feedback to look close to .toolbar() with .bottomOrnament
 // the following was suggested by Apple via Feedback to look close to .toolbar() with .bottomOrnament

+ 276 - 0
Remote/GenerateKey.c

@@ -0,0 +1,276 @@
+//
+// Copyright © 2023 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.
+//
+
+#include "GenerateKey.h"
+#include <stdio.h>
+#include <openssl/bio.h>
+#include <openssl/conf.h>
+#include <openssl/err.h>
+#include <openssl/objects.h>
+#include <openssl/pem.h>
+#include <openssl/pkcs12.h>
+#include <openssl/x509v3.h>
+
+#define X509_ENTRY_MAX_LENGTH (1024)
+
+/* Add extension using V3 code: we can set the config file as NULL
+ * because we wont reference any other sections.
+ */
+static int add_ext(X509 *cert, int nid, char *value) {
+    X509_EXTENSION *ex;
+    X509V3_CTX ctx;
+    /* This sets the 'context' of the extensions. */
+    /* No configuration database */
+    X509V3_set_ctx_nodb(&ctx);
+    /* Issuer and subject certs: both the target since it is self signed,
+     * no request and no CRL
+     */
+    X509V3_set_ctx(&ctx, cert, cert, NULL, NULL, 0);
+    ex = X509V3_EXT_conf_nid(NULL, &ctx, nid, value);
+    if (!ex) {
+        return 0;
+    }
+
+    X509_add_ext(cert, ex, -1);
+    X509_EXTENSION_free(ex);
+    return 1;
+}
+
+static int mkrsacert(X509 **x509p, EVP_PKEY **pkeyp, const char *commonName, const char *organizationName, long serial, int days, int isClient) {
+    X509 *x = NULL;
+    EVP_PKEY *pk = NULL;
+    BIGNUM *bne = NULL;
+    RSA *rsa = NULL;
+    X509_NAME *name = NULL;
+
+    if ((pk = EVP_PKEY_new()) == NULL) {
+        goto err;
+    }
+
+    if ((x = X509_new()) == NULL) {
+        goto err;
+    }
+
+    bne = BN_new();
+    if (!bne || !BN_set_word(bne, RSA_F4)){
+        goto err;
+    }
+
+    rsa = RSA_new();
+    if (!rsa || !RSA_generate_key_ex(rsa, 4096, bne, NULL)) {
+        goto err;
+    }
+    BN_free(bne);
+    bne = NULL;
+    if (!EVP_PKEY_assign_RSA(pk, rsa)) {
+        goto err;
+    }
+    rsa = NULL; // EVP_PKEY_assign_RSA takes ownership
+
+    X509_set_version(x, 2);
+    ASN1_INTEGER_set(X509_get_serialNumber(x), serial);
+    X509_gmtime_adj(X509_get_notBefore(x), 0);
+    X509_gmtime_adj(X509_get_notAfter(x), (long)60*60*24*days);
+    X509_set_pubkey(x, pk);
+
+    name = X509_get_subject_name(x);
+
+    /* This function creates and adds the entry, working out the
+     * correct string type and performing checks on its length.
+     * Normally we'd check the return value for errors...
+     */
+    X509_NAME_add_entry_by_txt(name, SN_commonName,
+                MBSTRING_UTF8, (const unsigned char *)commonName, -1, -1, 0);
+    X509_NAME_add_entry_by_txt(name, SN_organizationName,
+                MBSTRING_UTF8, (const unsigned char *)organizationName, -1, -1, 0);
+
+    /* Its self signed so set the issuer name to be the same as the
+      * subject.
+     */
+    X509_set_issuer_name(x, name);
+
+    /* Add various extensions: standard extensions */
+    add_ext(x, NID_basic_constraints, "critical,CA:TRUE");
+    add_ext(x, NID_key_usage, "critical,keyCertSign,cRLSign,keyEncipherment,digitalSignature");
+    if (isClient) {
+        add_ext(x, NID_ext_key_usage, "clientAuth");
+    } else {
+        add_ext(x, NID_ext_key_usage, "serverAuth");
+    }
+    add_ext(x, NID_subject_key_identifier, "hash");
+
+    if (!X509_sign(x, pk, EVP_sha256())) {
+        goto err;
+    }
+
+    *x509p = x;
+    *pkeyp = pk;
+    return 1;
+err:
+    if (pk) {
+        EVP_PKEY_free(pk);
+    }
+    if (x) {
+        X509_free(x);
+    }
+    if (bne) {
+        BN_free(bne);
+    }
+    return 0;
+}
+
+static _Nullable CFDataRef CreateP12FromKey(EVP_PKEY *pkey, X509 *cert) {
+    PKCS12 *p12;
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    p12 = PKCS12_create("password", NULL, pkey, cert, NULL, NID_pbe_WithSHA1And3_Key_TripleDES_CBC, NID_pbe_WithSHA1And40BitRC2_CBC, PKCS12_DEFAULT_ITER, 1, 0);
+    if (!p12) {
+        ERR_print_errors_fp(stderr);
+        return NULL;
+    }
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !i2d_PKCS12_bio(mem, p12)) {
+        ERR_print_errors_fp(stderr);
+        PKCS12_free(p12);
+        BIO_free(mem);
+        return NULL;
+    }
+    PKCS12_free(p12);
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    return data;
+}
+
+static _Nullable CFDataRef CreatePrivatePEMFromKey(EVP_PKEY *pkey) {
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !PEM_write_bio_PrivateKey(mem, pkey, NULL, NULL, 0, NULL, NULL)) {
+        ERR_print_errors_fp(stderr);
+        BIO_free(mem);
+        return NULL;
+    }
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    return data;
+}
+
+static _Nullable CFDataRef CreatePublicPEMFromCert(X509 *cert) {
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !PEM_write_bio_X509(mem, cert)) {
+        ERR_print_errors_fp(stderr);
+        BIO_free(mem);
+        return NULL;
+    }
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    return data;
+}
+
+static _Nullable CFDataRef CreatePublicKeyFromCert(X509 *cert) {
+    EVP_PKEY* pubkey;
+    BIO *mem;
+    char *ptr;
+    long length;
+    CFDataRef data;
+
+    pubkey = X509_get_pubkey(cert);
+    if (!pubkey) {
+        ERR_print_errors_fp(stderr);
+        return NULL;
+    }
+    mem = BIO_new(BIO_s_mem());
+    if (!mem || !i2d_PUBKEY_bio(mem, pubkey)) {
+        ERR_print_errors_fp(stderr);
+        EVP_PKEY_free(pubkey);
+        BIO_free(mem);
+        return NULL;
+    }
+    length = BIO_get_mem_data(mem, &ptr);
+    data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
+    BIO_free(mem);
+    EVP_PKEY_free(pubkey);
+    return data;
+}
+
+_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient) {
+    char _commonName[X509_ENTRY_MAX_LENGTH];
+    char _organizationName[X509_ENTRY_MAX_LENGTH];
+    long _serial = 0;
+    int _days = 365;
+    int _isClient = 0;
+    X509 *cert;
+    EVP_PKEY *pkey;
+    CFDataRef arr[4] = {NULL};
+    CFArrayRef cfarr = NULL;
+
+    if (!CFStringGetCString(commonName, _commonName, X509_ENTRY_MAX_LENGTH, kCFStringEncodingUTF8)) {
+        return NULL;
+    }
+    if (!CFStringGetCString(organizationName, _organizationName, X509_ENTRY_MAX_LENGTH, kCFStringEncodingUTF8)) {
+        return NULL;
+    }
+    if (serial) {
+        CFNumberGetValue(serial, kCFNumberLongType, &_serial);
+    }
+    if (days) {
+        CFNumberGetValue(days, kCFNumberIntType, &_days);
+    }
+    _isClient = CFBooleanGetValue(isClient);
+
+    OpenSSL_add_all_algorithms();
+    ERR_load_crypto_strings();
+    if (!mkrsacert(&cert, &pkey, _commonName, _organizationName, _serial, _days, _isClient)) {
+        ERR_print_errors_fp(stderr);
+        return NULL;
+    }
+    arr[0] = CreateP12FromKey(pkey, cert);
+    arr[1] = CreatePrivatePEMFromKey(pkey);
+    arr[2] = CreatePublicPEMFromCert(cert);
+    arr[3] = CreatePublicKeyFromCert(cert);
+    if (arr[0] && arr[1] && arr[2] && arr[3]) {
+        cfarr = CFArrayCreate(kCFAllocatorDefault, (const void **)arr, 4, &kCFTypeArrayCallBacks);
+    }
+    if (arr[0]) {
+        CFRelease(arr[0]);
+    }
+    if (arr[1]) {
+        CFRelease(arr[1]);
+    }
+    if (arr[2]) {
+        CFRelease(arr[2]);
+    }
+    if (arr[3]) {
+        CFRelease(arr[3]);
+    }
+    EVP_PKEY_free(pkey);
+    X509_free(cert);
+    return cfarr;
+}

+ 33 - 0
Remote/GenerateKey.h

@@ -0,0 +1,33 @@
+//
+// Copyright © 2023 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.
+//
+
+#ifndef GenerateKey_h
+#define GenerateKey_h
+
+#include <CoreFoundation/CoreFoundation.h>
+
+/// Generate a RSA-4096 key and return a PKCS#12 encoded data
+///
+/// The password of the blob is `password`. Returns NULL on error.
+/// - Parameters:
+///   - commonName: CN field of the certificate, max length is 1024 bytes
+///   - organizationName: O field of the certificate, max length is 1024 bytes
+///   - serial: Serial number of the certificate
+///   - days: Validity in days from today
+///   - isClient: If 0 then a TLS Server certificate is generated, otherwise a TLS Client certificate is generated
+_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
+
+#endif /* GenerateKey_h */

+ 588 - 0
Remote/UTMRemoteClient.swift

@@ -0,0 +1,588 @@
+//
+// 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 let connectionQueue = DispatchQueue(label: "UTM Remote Client Connection")
+    private var local: Local
+
+    private var scanTask: Task<Void, Error>?
+
+    private(set) var server: Remote!
+
+    nonisolated var fingerprint: [UInt8] {
+        keyManager.fingerprint ?? []
+    }
+
+    @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 results in Connection.browse(forServiceType: service) {
+                    await self.didFindResults(results)
+                }
+            }
+        }
+    }
+
+    func stopScanning() {
+        scanTask?.cancel()
+        scanTask = nil
+    }
+
+    func refresh() {
+        stopScanning()
+        startScanning()
+    }
+
+    func didFindResults(_ results: Set<NWBrowser.Result>) async {
+        let servers = results.compactMap { result in
+            let model: String?
+            if case .bonjour(let txtRecord) = result.metadata,
+                case .string(let value) = txtRecord.getEntry(for: "Model") {
+                model = value
+            } else {
+                model = nil
+            }
+            switch result.endpoint {
+            case .service(let name, _, _, _):
+                return State.DiscoveredServer(hostname: result.endpoint.debugDescription, model: model, name: name, endpoint: result.endpoint)
+            default:
+                return nil
+            }
+        }
+        await state.updateFoundServers(servers)
+    }
+
+    func connect(_ server: State.SavedServer) async throws {
+        var isSuccessful = false
+        let endpoint = server.endpoint ?? NWEndpoint.hostPort(host: .init(server.hostname), port: .init(integerLiteral: UInt16(server.port ?? 0)))
+        try await keyManager.load()
+        let connection = try await Connection(endpoint: endpoint, connectionQueue: connectionQueue, identity: keyManager.identity) { connection, error in
+            Task {
+                do {
+                    try await self.local.data.reconnect(to: server)
+                } catch {
+                    // reconnect failed
+                    await self.state.setConnected(false)
+                    await self.state.showErrorAlert(error.localizedDescription)
+                }
+            }
+        }
+        defer {
+            if !isSuccessful {
+                connection.close()
+            }
+        }
+        guard let host = connection.connection.currentPath?.remoteEndpoint?.hostname else {
+            throw ConnectionError.cannotDetermineHost
+        }
+        guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else {
+            throw ConnectionError.cannotFindFingerprint
+        }
+        if server.fingerprint.isEmpty {
+            throw ConnectionError.fingerprintUntrusted(fingerprint)
+        } else if server.fingerprint != fingerprint {
+            throw ConnectionError.fingerprintMismatch(fingerprint)
+        }
+        try Task.checkCancellation()
+        let peer = Peer(connection: connection, localInterface: local)
+        let remote = Remote(peer: peer, host: host)
+        let (isAuthenticated, device) = try await remote.handshake(password: server.password)
+        if !isAuthenticated {
+            if server.password == nil {
+                throw ConnectionError.passwordRequired
+            } else {
+                throw ConnectionError.passwordInvalid
+            }
+        }
+        self.server = remote
+        var server = server
+        await state.setConnected(true)
+        if !server.shouldSavePassword {
+            server.password = nil
+        }
+        if server.name.isEmpty {
+            server.name = server.hostname
+        }
+        server.lastSeen = Date()
+        server.model = device.model
+        await state.save(server: server)
+        isSuccessful = true
+    }
+}
+
+extension UTMRemoteClient {
+    @MainActor
+    class State: ObservableObject {
+        typealias ServerFingerprint = [UInt8]
+
+        struct DiscoveredServer: Identifiable {
+            let hostname: String
+            var model: String?
+            var name: String
+            var endpoint: NWEndpoint
+
+            var id: String {
+                hostname
+            }
+        }
+
+        struct SavedServer: Codable, Identifiable {
+            var fingerprint: ServerFingerprint
+            var hostname: String
+            var port: Int?
+            var model: String?
+            var name: String
+            var lastSeen: Date
+            var password: String?
+            var endpoint: NWEndpoint?
+            var shouldSavePassword: Bool = false
+
+            private enum CodingKeys: String, CodingKey {
+                case fingerprint, hostname, port, model, name, lastSeen, password
+            }
+
+            var id: ServerFingerprint {
+                fingerprint
+            }
+
+            var isAvailable: Bool {
+                endpoint != nil || (port != nil && port != 0)
+            }
+
+            init() {
+                self.hostname = ""
+                self.name = ""
+                self.lastSeen = Date()
+                self.fingerprint = []
+            }
+
+            init(from discovered: DiscoveredServer) {
+                self.hostname = discovered.hostname
+                self.model = discovered.model
+                self.name = discovered.name
+                self.lastSeen = Date()
+                self.endpoint = discovered.endpoint
+                self.fingerprint = []
+            }
+        }
+
+        struct AlertMessage: Identifiable {
+            let id = UUID()
+            let message: String
+        }
+
+        @Published var savedServers: [SavedServer] {
+            didSet {
+                UserDefaults.standard.setValue(try! savedServers.propertyList(), forKey: "TrustedServers")
+            }
+        }
+
+        @Published var foundServers: [DiscoveredServer] = []
+
+        @Published var isScanning: Bool = false
+
+        @Published private(set) var isConnected: Bool = false
+
+        @Published var alertMessage: AlertMessage?
+
+        init() {
+            var _savedServers = Array<SavedServer>()
+            if let array = UserDefaults.standard.array(forKey: "TrustedServers") {
+                if let servers = try? Array<SavedServer>(fromPropertyList: array) {
+                    _savedServers = servers
+                }
+            }
+            self.savedServers = _savedServers
+        }
+
+        func showErrorAlert(_ message: String) {
+            alertMessage = AlertMessage(message: message)
+        }
+
+        func updateFoundServers(_ servers: [DiscoveredServer]) {
+            for idx in savedServers.indices {
+                savedServers[idx].endpoint = nil
+            }
+            foundServers = servers.filter { server in
+                if let idx = savedServers.firstIndex(where: { $0.port == nil && $0.hostname == server.hostname }) {
+                    savedServers[idx].endpoint = server.endpoint
+                    return false
+                } else {
+                    return true
+                }
+            }
+        }
+
+        func save(server: SavedServer) {
+            if let idx = savedServers.firstIndex(where: { $0.fingerprint == server.fingerprint }) {
+                savedServers[idx] = server
+            } else {
+                savedServers.append(server)
+            }
+        }
+
+        func delete(server: SavedServer) {
+            savedServers.removeAll(where: { $0.fingerprint == server.fingerprint })
+        }
+
+        fileprivate func setConnected(_ connected: Bool) {
+            isConnected = connected
+        }
+    }
+}
+
+extension UTMRemoteClient {
+    class Local: LocalInterface {
+        typealias M = UTMRemoteMessageClient
+
+        fileprivate 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 .listHasChanged:
+                return try await _listHasChanged(parameters: .decode(data)).encode()
+            case .qemuConfigurationHasChanged:
+                return try await _qemuConfigurationHasChanged(parameters: .decode(data)).encode()
+            case .mountedDrivesHasChanged:
+                return try await _mountedDrivesHasChanged(parameters: .decode(data)).encode()
+            case .virtualMachineDidTransition:
+                return try await _virtualMachineDidTransition(parameters: .decode(data)).encode()
+            case .virtualMachineDidError:
+                return try await _virtualMachineDidError(parameters: .decode(data)).encode()
+            }
+        }
+
+        private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
+            return .init(version: UTMRemoteMessageClient.version, capabilities: .current)
+        }
+
+        private func _listHasChanged(parameters: M.ListHasChanged.Request) async throws -> M.ListHasChanged.Reply {
+            await data.remoteListHasChanged(ids: parameters.ids)
+            return .init()
+        }
+
+        private func _qemuConfigurationHasChanged(parameters: M.QEMUConfigurationHasChanged.Request) async throws -> M.QEMUConfigurationHasChanged.Reply {
+            await data.remoteQemuConfigurationHasChanged(id: parameters.id, configuration: parameters.configuration)
+            return .init()
+        }
+
+        private func _mountedDrivesHasChanged(parameters: M.MountedDrivesHasChanged.Request) async throws -> M.MountedDrivesHasChanged.Reply {
+            await data.remoteMountedDrivesHasChanged(id: parameters.id, mountedDrives: parameters.mountedDrives)
+            return .init()
+        }
+
+        private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
+            await data.remoteVirtualMachineDidTransition(id: parameters.id, state: parameters.state, isTakeoverAllowed: parameters.isTakeoverAllowed)
+            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(password: String?) async throws -> (isAuthenticated: Bool, device: MacDevice) {
+            let reply = try await _handshake(parameters: .init(version: UTMRemoteMessageServer.version, password: password))
+            guard reply.version == UTMRemoteMessageServer.version else {
+                throw ClientError.versionMismatch
+            }
+            capabilities = reply.capabilities
+            return (isAuthenticated: reply.isAuthenticated, device: MacDevice(model: reply.model))
+        }
+
+        func listVirtualMachines() async throws -> [UUID] {
+            try await _listVirtualMachines(parameters: .init()).ids
+        }
+
+        func reorderVirtualMachines(fromIds ids: [UUID], toOffset offset: Int) async throws {
+            try await _reorderVirtualMachines(parameters: .init(ids: ids, offset: offset))
+        }
+
+        func getVirtualMachineInformation(for ids: [UUID]) async throws -> [M.VirtualMachineInformation] {
+            try await _getVirtualMachineInformation(parameters: .init(ids: ids)).informations
+        }
+
+        func getQEMUConfiguration(for id: UUID) async throws -> UTMQemuConfiguration {
+            try await _getQEMUConfiguration(parameters: .init(id: id)).configuration
+        }
+
+        func getPackageSize(for id: UUID) async throws -> Int64 {
+            try await _getPackageSize(parameters: .init(id: id)).size
+        }
+
+        func getPackageFile(for id: UUID, relativePathComponents: [String]) async throws -> URL {
+            let fm = FileManager.default
+            let packageUrl = try packageUrl(for: id)
+            let fileUrl = packageUrl.appendingPathComponent(relativePathComponents.joined(separator: "_"))
+            var lastModified: Date?
+            if fm.fileExists(atPath: fileUrl.path) {
+                lastModified = try? fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date
+            }
+            let reply = try await _getPackageFile(parameters: .init(id: id, relativePathComponents: relativePathComponents, lastModified: lastModified))
+            if let data = reply.data {
+                fm.createFile(atPath: fileUrl.path, contents: data, attributes: [.modificationDate: reply.lastModified])
+            }
+            return fileUrl
+        }
+
+        func sendPackageFile(for id: UUID, relativePathComponents: [String], data: Data) async throws {
+            let fm = FileManager.default
+            let packageUrl = try packageUrl(for: id)
+            let fileUrl = packageUrl.appendingPathComponent(relativePathComponents.joined(separator: "_"))
+            guard fm.createFile(atPath: fileUrl.path, contents: data) else {
+                throw ConnectionError.failedToAccessFile
+            }
+            guard let lastModified = try fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date else {
+                throw ConnectionError.failedToAccessFile
+            }
+            try await _sendPackageFile(parameters: .init(id: id, relativePathComponents: relativePathComponents, lastModified: lastModified, data: data))
+        }
+
+        func deletePackageFile(for id: UUID, relativePathComponents: [String]) async throws {
+            let fm = FileManager.default
+            let packageUrl = try packageUrl(for: id)
+            let fileUrl = packageUrl.appendingPathComponent(relativePathComponents.joined(separator: "_"))
+            try fm.removeItem(at: fileUrl)
+            try await _deletePackageFile(parameters: .init(id: id, relativePathComponents: relativePathComponents))
+        }
+
+        func mountGuestToolsOnVirtualMachine(id: UUID) async throws {
+            try await _mountGuestToolsOnVirtualMachine(parameters: .init(id: id))
+        }
+
+        func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> UTMRemoteMessageServer.StartVirtualMachine.ServerInformation {
+            return try await _startVirtualMachine(parameters: .init(id: id, options: options)).serverInfo
+        }
+
+        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 packageUrl(for id: UUID) 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)
+            }
+            return packageUrl
+        }
+
+        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)
+        }
+
+        @discardableResult
+        private func _reorderVirtualMachines(parameters: M.ReorderVirtualMachines.Request) async throws -> M.ReorderVirtualMachines.Reply {
+            try await M.ReorderVirtualMachines.send(parameters, to: peer)
+        }
+
+        private func _getVirtualMachineInformation(parameters: M.GetVirtualMachineInformation.Request) async throws -> M.GetVirtualMachineInformation.Reply {
+            try await M.GetVirtualMachineInformation.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 _getPackageSize(parameters: M.GetPackageSize.Request) async throws -> M.GetPackageSize.Reply {
+            try await M.GetPackageSize.send(parameters, to: peer)
+        }
+
+        private func _getPackageFile(parameters: M.GetPackageFile.Request) async throws -> M.GetPackageFile.Reply {
+            try await M.GetPackageFile.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _sendPackageFile(parameters: M.SendPackageFile.Request) async throws -> M.SendPackageFile.Reply {
+            try await M.SendPackageFile.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _deletePackageFile(parameters: M.DeletePackageFile.Request) async throws -> M.DeletePackageFile.Reply {
+            try await M.DeletePackageFile.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _mountGuestToolsOnVirtualMachine(parameters: M.MountGuestToolsOnVirtualMachine.Request) async throws -> M.MountGuestToolsOnVirtualMachine.Reply {
+            try await M.MountGuestToolsOnVirtualMachine.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 cannotDetermineHost
+        case cannotFindFingerprint
+        case passwordRequired
+        case passwordInvalid
+        case fingerprintUntrusted(State.ServerFingerprint)
+        case fingerprintMismatch(State.ServerFingerprint)
+        case failedToAccessFile
+
+        var errorDescription: String? {
+            switch self {
+            case .cannotDetermineHost:
+                return NSLocalizedString("Failed to determine host name.", comment: "UTMRemoteClient")
+            case .cannotFindFingerprint:
+                return NSLocalizedString("Failed to get host fingerprint.", comment: "UTMRemoteClient")
+            case .passwordRequired:
+                return NSLocalizedString("Password is required.", comment: "UTMRemoteClient")
+            case .passwordInvalid:
+                return NSLocalizedString("Password is incorrect.", comment: "UTMRemoteClient")
+            case .fingerprintUntrusted(_):
+                return NSLocalizedString("This host is not yet trusted. You should verify that the fingerprints match what is displayed on the host and then select Trust to continue.", comment: "UTMRemoteClient")
+            case .fingerprintMismatch(_):
+                return String.localizedStringWithFormat(NSLocalizedString("The host fingerprint does not match the saved value. This means that UTM Server was reset, a different host is using the same name, or an attacker is pretending to be the host. For your protection, you need to delete this saved host to continue.", comment: "UTMRemoteClient"))
+            case .failedToAccessFile:
+                return NSLocalizedString("Failed to access file.", 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")
+            }
+        }
+    }
+}

+ 39 - 0
Remote/UTMRemoteConnectInterface.h

@@ -0,0 +1,39 @@
+//
+// 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/Foundation.h>
+
+@protocol UTMRemoteConnectDelegate;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol UTMRemoteConnectInterface <NSObject>
+
+@property (nonatomic, weak) id<UTMRemoteConnectDelegate> connectDelegate;
+
+- (BOOL)connectWithError:(NSError * _Nullable *)error;
+- (void)disconnect;
+
+@end
+
+@protocol UTMRemoteConnectDelegate <NSObject>
+
+- (void)remoteInterface:(id<UTMRemoteConnectInterface>)remoteInterface didErrorWithMessage:(NSString *)message;
+- (void)remoteInterfaceDidConnect:(id<UTMRemoteConnectInterface>)remoteInterface;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 196 - 0
Remote/UTMRemoteKeyManager.swift

@@ -0,0 +1,196 @@
+//
+// Copyright © 2023 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 Security
+import CryptoKit
+#if os(macOS)
+import SystemConfiguration
+#endif
+
+class UTMRemoteKeyManager {
+    let isClient: Bool
+    private(set) var isLoaded: Bool = false
+    private(set) var identity: SecIdentity!
+    private(set) var fingerprint: [UInt8]?
+
+    init(forClient client: Bool) {
+        self.isClient = client
+    }
+
+    private var certificateCommonNamePrefix: String {
+        "UTM Remote \(isClient ? "Client" : "Server")"
+    }
+
+    private lazy var certificateCommonName: String = {
+        #if os(macOS)
+        let deviceName = SCDynamicStoreCopyComputerName(nil, nil) as? String ?? "macOS"
+        #else
+        let deviceName = UIDevice.current.name
+        #endif
+        return "\(certificateCommonNamePrefix) (\(deviceName))"
+    }()
+
+    private func generateKey() throws -> SecIdentity {
+        let commonName = certificateCommonName as CFString
+        let organizationName = "UTM" as CFString
+        let serialNumber = Int.random(in: 1..<CLong.max) as CFNumber
+        let days = 3650 as CFNumber
+        guard let data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() as? [CFData] else {
+            throw UTMRemoteKeyManagerError.generateKeyFailure
+        }
+        let importOptions = [ kSecImportExportPassphrase as String: "password" ] as CFDictionary
+        var rawItems: CFArray?
+        try withSecurityThrow(SecPKCS12Import(data[0], importOptions, &rawItems))
+        guard let items = (rawItems! as! [[String: Any]]).first else {
+            throw UTMRemoteKeyManagerError.parseKeyFailure
+        }
+        return items[kSecImportItemIdentity as String] as! SecIdentity
+    }
+
+    private func importIdentity(_ identity: SecIdentity) throws {
+        let attributes = [
+            kSecValueRef as String: identity,
+        ] as CFDictionary
+        try withSecurityThrow(SecItemAdd(attributes, nil))
+    }
+
+    private func loadIdentity() throws -> SecIdentity? {
+        var query = [
+            kSecClass as String: kSecClassIdentity,
+            kSecReturnRef as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne,
+            kSecMatchPolicy as String: SecPolicyCreateSSL(!isClient, nil),
+        ] as [String : Any]
+        #if os(macOS)
+        query[kSecMatchSubjectStartsWith as String] = certificateCommonNamePrefix
+        #endif
+        var copyResult: AnyObject? = nil
+        let result = SecItemCopyMatching(query as CFDictionary, &copyResult)
+        if result == errSecItemNotFound {
+            return nil
+        }
+        try withSecurityThrow(result)
+        return (copyResult as! SecIdentity)
+    }
+
+    private func deleteIdentity(_ identity: SecIdentity) throws {
+        let query = [
+            kSecClass as String: kSecClassIdentity,
+            kSecMatchItemList as String: [identity],
+        ] as CFDictionary
+        try withSecurityThrow(SecItemDelete(query))
+    }
+
+    private func withSecurityThrow(_ block: @autoclosure () -> OSStatus) throws {
+        let err = block()
+        if err != errSecSuccess && err != errSecDuplicateItem {
+            throw NSError(domain: NSOSStatusErrorDomain, code: Int(err), userInfo: nil)
+        }
+    }
+}
+
+extension UTMRemoteKeyManager {
+    func load() async throws {
+        guard !isLoaded else {
+            return
+        }
+        let identity = try await Task.detached { [self] in
+            if let identity = try loadIdentity() {
+                return identity
+            } else {
+                let identity = try generateKey()
+                try importIdentity(identity)
+                return identity
+            }
+        }.value
+        var certificate: SecCertificate?
+        try withSecurityThrow(SecIdentityCopyCertificate(identity, &certificate))
+        self.identity = identity
+        self.fingerprint = certificate!.fingerprint()
+        self.isLoaded = true
+    }
+
+    func reset() async throws {
+        try await Task.detached { [self] in
+            if let identity = try loadIdentity() {
+                try deleteIdentity(identity)
+            }
+        }.value
+        if isLoaded {
+            isLoaded = false
+            try await load()
+        }
+    }
+}
+
+extension SecCertificate {
+    func fingerprint() -> [UInt8] {
+        let data = SecCertificateCopyData(self)
+        return SHA256.hash(data: data as Data).map({ $0 })
+    }
+}
+
+extension Array where Element == UInt8 {
+    func hexString() -> String {
+        self.map({ String(format: "%02X", $0) }).joined(separator: ":")
+    }
+
+    init?(hexString: String) {
+        let cleanString = hexString.replacingOccurrences(of: ":", with: "")
+        guard cleanString.count % 2 == 0 else {
+            return nil
+        }
+
+        var byteArray = [UInt8]()
+        var index = cleanString.startIndex
+
+        while index < cleanString.endIndex {
+            let nextIndex = cleanString.index(index, offsetBy: 2)
+            if let byte = UInt8(cleanString[index..<nextIndex], radix: 16) {
+                byteArray.append(byte)
+            } else {
+                return nil // Invalid hex character
+            }
+            index = nextIndex
+        }
+        self = byteArray
+    }
+
+    static func ^(lhs: Self, rhs: Self) -> Self {
+        let length = Swift.min(lhs.count, rhs.count)
+        return (0..<length).map({ lhs[$0] ^ rhs[$0] })
+    }
+}
+
+enum UTMRemoteKeyManagerError: Error {
+    case generateKeyFailure
+    case parseKeyFailure
+    case importKeyFailure
+}
+
+extension UTMRemoteKeyManagerError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .generateKeyFailure:
+            return NSLocalizedString("Failed to generate a key pair.", comment: "UTMRemoteKeyManager")
+        case .parseKeyFailure:
+            return NSLocalizedString("Failed to parse generated key pair.", comment: "UTMRemoteKeyManager")
+        case .importKeyFailure:
+            return NSLocalizedString("Failed to import generated key.", comment: "UTMRemoteKeyManager")
+        }
+    }
+}

+ 380 - 0
Remote/UTMRemoteMessage.swift

@@ -0,0 +1,380 @@
+//
+// 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 SwiftConnect
+
+enum UTMRemoteMessageServer: UInt8, MessageID {
+    static let version = 1
+    case serverHandshake
+    case listVirtualMachines
+    case reorderVirtualMachines
+    case getVirtualMachineInformation
+    case getQEMUConfiguration
+    case getPackageSize
+    case getPackageFile
+    case sendPackageFile
+    case deletePackageFile
+    case mountGuestToolsOnVirtualMachine
+    case startVirtualMachine
+    case stopVirtualMachine
+    case restartVirtualMachine
+    case pauseVirtualMachine
+    case resumeVirtualMachine
+    case saveSnapshotVirtualMachine
+    case deleteSnapshotVirtualMachine
+    case restoreSnapshotVirtualMachine
+    case changePointerTypeVirtualMachine
+}
+
+
+enum UTMRemoteMessageClient: UInt8, MessageID {
+    static let version = 1
+    case clientHandshake
+    case listHasChanged
+    case qemuConfigurationHasChanged
+    case mountedDrivesHasChanged
+    case virtualMachineDidTransition
+    case virtualMachineDidError
+}
+
+extension UTMRemoteMessageServer {
+    struct ServerHandshake: Message {
+        static let id = UTMRemoteMessageServer.serverHandshake
+
+        struct Request: Serializable, Codable {
+            let version: Int
+            let password: String?
+        }
+
+        struct Reply: Serializable, Codable {
+            let version: Int
+            let isAuthenticated: Bool
+            let capabilities: UTMCapabilities
+            let model: String
+        }
+    }
+
+    struct VirtualMachineInformation: Serializable, Codable {
+        let id: UUID
+        let name: String
+        let path: String
+        let isShortcut: Bool
+        let isSuspended: Bool
+        let isTakeoverAllowed: Bool
+        let backend: UTMBackend
+        let state: UTMVirtualMachineState
+        let mountedDrives: [String: String]
+    }
+
+    struct ListVirtualMachines: Message {
+        static let id = UTMRemoteMessageServer.listVirtualMachines
+
+        struct Request: Serializable, Codable {}
+
+        struct Reply: Serializable, Codable {
+            let ids: [UUID]
+        }
+    }
+
+    struct ReorderVirtualMachines: Message {
+        static let id = UTMRemoteMessageServer.reorderVirtualMachines
+
+        struct Request: Serializable, Codable {
+            let ids: [UUID]
+            let offset: Int
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct GetVirtualMachineInformation: Message {
+        static let id = UTMRemoteMessageServer.getVirtualMachineInformation
+
+        struct Request: Serializable, Codable {
+            let ids: [UUID]
+        }
+
+        struct Reply: Serializable, Codable {
+            let informations: [VirtualMachineInformation]
+        }
+    }
+
+    struct GetQEMUConfiguration: Message {
+        static let id = UTMRemoteMessageServer.getQEMUConfiguration
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+        }
+
+        struct Reply: Serializable, Codable {
+            let configuration: UTMQemuConfiguration
+        }
+    }
+
+    struct GetPackageSize: Message {
+        static let id = UTMRemoteMessageServer.getPackageSize
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+        }
+
+        struct Reply: Serializable, Codable {
+            let size: Int64
+        }
+    }
+
+    struct GetPackageFile: Message {
+        static let id = UTMRemoteMessageServer.getPackageFile
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let relativePathComponents: [String]
+            let lastModified: Date?
+        }
+
+        struct Reply: Serializable, Codable {
+            let data: Data?
+            let lastModified: Date
+        }
+    }
+
+    struct SendPackageFile: Message {
+        static let id = UTMRemoteMessageServer.sendPackageFile
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let relativePathComponents: [String]
+            let lastModified: Date
+            let data: Data
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct DeletePackageFile: Message {
+        static let id = UTMRemoteMessageServer.deletePackageFile
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let relativePathComponents: [String]
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct MountGuestToolsOnVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.mountGuestToolsOnVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct StartVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.startVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let options: UTMVirtualMachineStartOptions
+        }
+
+        struct ServerInformation: Serializable, Codable {
+            let spicePortInternal: UInt16
+            let spicePortExternal: UInt16?
+            let spiceHostExternal: String?
+            let spicePublicKey: Data
+            let spicePassword: String
+        }
+
+        struct Reply: Serializable, Codable {
+            let serverInfo: ServerInformation
+        }
+    }
+
+    struct StopVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.stopVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let method: UTMVirtualMachineStopMethod
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct RestartVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.restartVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct PauseVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.pauseVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct ResumeVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.resumeVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct SaveSnapshotVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.saveSnapshotVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let name: String?
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct DeleteSnapshotVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.deleteSnapshotVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let name: String?
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct RestoreSnapshotVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.restoreSnapshotVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let name: String?
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct ChangePointerTypeVirtualMachine: Message {
+        static let id = UTMRemoteMessageServer.changePointerTypeVirtualMachine
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let isTabletMode: Bool
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+}
+
+extension Serializable where Self == UTMRemoteMessageServer.GetQEMUConfiguration.Reply {
+    static func decode(_ data: Data) throws -> Self {
+        let decoder = Decoder()
+        decoder.userInfo[.dataURL] = URL(fileURLWithPath: "/")
+        return try decoder.decode(Self.self, from: data)
+    }
+}
+
+extension Serializable where Self == UTMRemoteMessageClient.QEMUConfigurationHasChanged.Request {
+    static func decode(_ data: Data) throws -> Self {
+        let decoder = Decoder()
+        decoder.userInfo[.dataURL] = URL(fileURLWithPath: "/")
+        return try decoder.decode(Self.self, from: data)
+    }
+}
+
+extension UTMRemoteMessageClient {
+    struct ClientHandshake: Message {
+        static let id = UTMRemoteMessageClient.clientHandshake
+
+        struct Request: Serializable, Codable {
+            let version: Int
+        }
+
+        struct Reply: Serializable, Codable {
+            let version: Int
+            let capabilities: UTMCapabilities
+        }
+    }
+
+    struct ListHasChanged: Message {
+        static let id = UTMRemoteMessageClient.listHasChanged
+
+        struct Request: Serializable, Codable {
+            let ids: [UUID]
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct QEMUConfigurationHasChanged: Message {
+        static let id = UTMRemoteMessageClient.qemuConfigurationHasChanged
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let configuration: UTMQemuConfiguration
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct MountedDrivesHasChanged: Message {
+        static let id = UTMRemoteMessageClient.mountedDrivesHasChanged
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let mountedDrives: [String: String]
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct VirtualMachineDidTransition: Message {
+        static let id = UTMRemoteMessageClient.virtualMachineDidTransition
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let state: UTMVirtualMachineState
+            let isTakeoverAllowed: Bool
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+
+    struct VirtualMachineDidError: Message {
+        static let id = UTMRemoteMessageClient.virtualMachineDidError
+
+        struct Request: Serializable, Codable {
+            let id: UUID
+            let errorMessage: String
+        }
+
+        struct Reply: Serializable, Codable {}
+    }
+}

+ 981 - 0
Remote/UTMRemoteServer.swift

@@ -0,0 +1,981 @@
+//
+// Copyright © 2023 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 Combine
+import Network
+import SwiftConnect
+import SwiftPortmap
+import UserNotifications
+
+let service = "_utm_server._tcp"
+
+actor UTMRemoteServer {
+    fileprivate let data: UTMData
+    private let keyManager = UTMRemoteKeyManager(forClient: false)
+    private let center = UNUserNotificationCenter.current()
+    private let connectionQueue = DispatchQueue(label: "UTM Remote Server Connection")
+    let state: State
+
+    private var cancellables = Set<AnyCancellable>()
+    private var notificationDelegate: NotificationDelegate?
+    private var listener: Task<Void, Error>?
+    private var pendingConnections: [State.ClientFingerprint: Connection] = [:]
+    private var establishedConnections: [State.ClientFingerprint: Remote] = [:]
+    private var natPort: SwiftPortmap.Port?
+
+    private func _replaceCancellables(with set: Set<AnyCancellable>) {
+        cancellables = set
+    }
+
+    @Setting("ServerAutostart") private var isServerAutostart: Bool = false
+    @Setting("ServerExternal") private var isServerExternal: Bool = false
+    @Setting("ServerAutoblock") private var isServerAutoblock: Bool = false
+    @Setting("ServerPort") private var serverPort: Int = 0
+    @Setting("ServerPasswordRequired") private var isServerPasswordRequired: Bool = false
+    @Setting("ServerPassword") private var serverPassword: String = ""
+
+    @MainActor
+    init(data: UTMData) {
+        let _state = State()
+        var _cancellables = Set<AnyCancellable>()
+        self.data = data
+        self.state = _state
+
+        _cancellables.insert(_state.$approvedClients.sink { approved in
+            Task {
+                await self.approvedClientsHasChanged(approved)
+            }
+        })
+        _cancellables.insert(_state.$blockedClients.sink { blocked in
+            Task {
+                await self.blockedClientsHasChanged(blocked)
+            }
+        })
+        _cancellables.insert(_state.$connectedClients.sink { connected in
+            Task {
+                await self.connectedClientsHasChanged(connected)
+            }
+        })
+        _cancellables.insert(_state.$serverAction.sink { action in
+            guard action != .none else {
+                return
+            }
+            Task {
+                switch action {
+                case .stop:
+                    await self.stop()
+                    break
+                case .start:
+                    await self.start()
+                    break
+                case .reset:
+                    await self.resetServer()
+                    break
+                default:
+                    break
+                }
+                self.state.requestServerAction(.none)
+            }
+        })
+        // this is a really ugly way to make sure that we keep a reference to the AnyCancellables even though
+        // we cannot access self._cancellables from init() due to it being associated with @MainActor.
+        // it should be fine because we only need to make sure the references are not dropped, we will never
+        // actually read from _cancellables
+        Task {
+            await self._replaceCancellables(with: _cancellables)
+        }
+    }
+
+    private func withErrorNotification(_ body: () async throws -> Void) async {
+        do {
+            try await body()
+        } catch {
+            if case .silentError(let error) = error as? ServerError {
+                logger.error("Error message inhibited: \(error)")
+            } else {
+                await notifyError(error)
+            }
+        }
+    }
+
+    private var metadata: NWTXTRecord {
+        NWTXTRecord(["Model": MacDevice.current.model])
+    }
+
+    func start() async {
+        do {
+            try await center.requestAuthorization(options: .alert)
+        } catch {
+            logger.error("Failed to authorize notifications.")
+        }
+        await withErrorNotification {
+            guard await !state.isServerActive else {
+                return
+            }
+            try await keyManager.load()
+            await state.setServerFingerprint(keyManager.fingerprint!)
+            registerNotifications()
+            listener = Task {
+                await withErrorNotification {
+                    if isServerExternal && serverPort > 0 {
+                        natPort = Port.TCP(internalPort: UInt16(serverPort))
+                        natPort!.mappingChangedHandler = { port in
+                            Task {
+                                let address = try? await port.externalIpv4Address
+                                let port = try? await port.externalPort
+                                await self.state.setExternalAddress(address, port: port)
+                            }
+                        }
+                        await withErrorNotification {
+                            guard try await natPort!.externalPort == serverPort else {
+                                throw ServerError.natReservationMismatch(serverPort)
+                            }
+                        }
+                    }
+                    let port = serverPort > 0 ? NWEndpoint.Port(integerLiteral: UInt16(serverPort)) : .any
+                    for try await connection in Connection.advertise(on: port, forServiceType: service, txtRecord: metadata, connectionQueue: connectionQueue, identity: keyManager.identity) {
+                        let connection = try? await Connection(connection: connection, connectionQueue: connectionQueue) { connection, error in
+                            Task {
+                                guard let fingerprint = connection.fingerprint else {
+                                    return
+                                }
+                                if !(error is NWError) {
+                                    // connection errors are too noisy
+                                    await self.notifyError(error)
+                                }
+                                await self.state.disconnect(fingerprint)
+                            }
+                        }
+                        if let connection = connection {
+                            await newRemoteConnection(connection)
+                        }
+                    }
+                }
+                natPort = nil
+                await stop()
+            }
+            await state.setServerActive(true)
+        }
+    }
+
+    func stop() async {
+        await state.disconnectAll()
+        unregisterNotifications()
+        if let listener = listener {
+            self.listener = nil
+            listener.cancel()
+            _ = await listener.result
+        }
+        await state.setExternalAddress()
+        await state.setServerActive(false)
+    }
+
+    private func newRemoteConnection(_ connection: Connection) async {
+        let remoteAddress = connection.connection.endpoint.hostname ?? "\(connection.connection.endpoint)"
+        guard let fingerprint = connection.fingerprint else {
+            connection.close()
+            return
+        }
+        guard await !state.isBlocked(fingerprint) else {
+            connection.close()
+            return
+        }
+        await state.seen(fingerprint, name: remoteAddress)
+        if await state.isApproved(fingerprint) {
+            await notifyNewConnection(remoteAddress: remoteAddress, fingerprint: fingerprint)
+            await establishConnection(connection)
+        } else if isServerAutoblock {
+            await state.block(fingerprint)
+            connection.close()
+        } else {
+            pendingConnections[fingerprint] = connection
+            await notifyNewConnection(remoteAddress: remoteAddress, fingerprint: fingerprint, isUnknown: true)
+        }
+    }
+
+    private func approvedClientsHasChanged(_ approvedClients: Set<State.Client>) async {
+        for approvedClient in approvedClients {
+            if let connection = pendingConnections.removeValue(forKey: approvedClient.fingerprint) {
+                await establishConnection(connection)
+            }
+        }
+    }
+
+    private func blockedClientsHasChanged(_ blockedClients: Set<State.Client>) {
+        for blockedClient in blockedClients {
+            if let connection = pendingConnections.removeValue(forKey: blockedClient.fingerprint) {
+                connection.close()
+            }
+        }
+    }
+
+    private func connectedClientsHasChanged(_ connectedClients: Set<State.ClientFingerprint>) {
+        for client in establishedConnections.keys {
+            if !connectedClients.contains(client) {
+                if let remote = establishedConnections.removeValue(forKey: client) {
+                    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 {
+        guard let fingerprint = connection.fingerprint else {
+            connection.close()
+            return
+        }
+        await withErrorNotification {
+            let remote = Remote()
+            let local = Local(server: self, client: remote)
+            let peer = Peer(connection: connection, localInterface: local)
+            remote.peer = peer
+            do {
+                try await remote.handshake()
+            } catch {
+                if let error = error as? NWError, case .posix(let code) = error, code == .ECONNRESET {
+                    // if the user canceled the connection, we don't do anything
+                    throw ServerError.silentError(error)
+                }
+                peer.close()
+                throw error
+            }
+            establishedConnections.updateValue(remote, forKey: fingerprint)
+            await state.connect(fingerprint)
+        }
+    }
+
+    private func resetServer() async {
+        await withErrorNotification {
+            try await keyManager.reset()
+            await state.setServerFingerprint(keyManager.fingerprint!)
+        }
+    }
+    
+    /// Send message to every connected remote client.
+    ///
+    /// If any are disconnected, we will gracefully handle the disconnect.
+    /// If `body` throws an error for any remote client (excluding NWError), then we ignore it.
+    /// - Parameter body: What to broadcast
+    func broadcast(_ body: @escaping (Remote) async throws -> Void) async {
+        enum BroadcastError: Error {
+            case connectionError(NWError, State.ClientFingerprint)
+        }
+        await withThrowingTaskGroup(of: Void.self) { group in
+            for (fingerprint, remote) in establishedConnections {
+                if Task.isCancelled {
+                    break
+                }
+                group.addTask {
+                    do {
+                        try await body(remote)
+                    } catch {
+                        if let error = error as? NWError {
+                            throw BroadcastError.connectionError(error, fingerprint)
+                        } else {
+                            throw error
+                        }
+                    }
+                }
+            }
+            while !group.isEmpty {
+                switch await group.nextResult() {
+                case .failure(let error):
+                    if case BroadcastError.connectionError(_, let fingerprint) = error {
+                        // disconnect any clients who failed to respond
+                        await state.disconnect(fingerprint)
+                    } else {
+                        logger.error("client returned error on broadcast: \(error)")
+                    }
+                default:
+                    break
+                }
+            }
+        }
+    }
+}
+
+extension UTMRemoteServer {
+    private class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
+        private let state: UTMRemoteServer.State
+
+        init(state: UTMRemoteServer.State) {
+            self.state = state
+        }
+
+        func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
+            .banner
+        }
+
+        func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
+            Task {
+                let userInfo = response.notification.request.content.userInfo
+                guard let hexString = userInfo["FINGERPRINT"] as? String, let fingerprint = State.ClientFingerprint(hexString: hexString) else {
+                    return
+                }
+                switch response.actionIdentifier {
+                case "ALLOW_ACTION":
+                    await state.approve(fingerprint)
+                case "DENY_ACTION":
+                    await state.block(fingerprint)
+                case "DISCONNECT_ACTION":
+                    await state.disconnect(fingerprint)
+                default:
+                    break
+                }
+                completionHandler()
+            }
+        }
+    }
+
+    private func registerNotifications() {
+        let allowAction = UNNotificationAction(identifier: "ALLOW_ACTION",
+                                               title: NSString.localizedUserNotificationString(forKey: "Allow", arguments: nil),
+                                               options: [])
+        let denyAction = UNNotificationAction(identifier: "DENY_ACTION",
+                                              title: NSString.localizedUserNotificationString(forKey: "Deny", arguments: nil),
+                                              options: [])
+        let disconnectAction = UNNotificationAction(identifier: "DISCONNECT_ACTION",
+                                                    title: NSString.localizedUserNotificationString(forKey: "Disconnect", arguments: nil),
+                                                    options: [])
+        let unknownRemoteCategory = UNNotificationCategory(identifier: "UNKNOWN_REMOTE_CLIENT",
+                                                           actions: [denyAction, allowAction],
+                                                           intentIdentifiers: [],
+                                                           hiddenPreviewsBodyPlaceholder: NSString.localizedUserNotificationString(forKey: "New unknown remote client connection.", arguments: nil),
+                                                           options: .customDismissAction)
+        let trustedRemoteCategory = UNNotificationCategory(identifier: "TRUSTED_REMOTE_CLIENT",
+                                                           actions: [disconnectAction],
+                                                           intentIdentifiers: [],
+                                                           hiddenPreviewsBodyPlaceholder: NSString.localizedUserNotificationString(forKey: "New trusted remote client connection.", arguments: nil),
+                                                           options: [])
+        center.setNotificationCategories([unknownRemoteCategory, trustedRemoteCategory])
+        notificationDelegate = NotificationDelegate(state: state)
+        center.delegate = notificationDelegate
+    }
+
+    private func unregisterNotifications() {
+        center.setNotificationCategories([])
+        notificationDelegate = nil
+        center.delegate = nil
+    }
+
+    private func notifyNewConnection(remoteAddress: String, fingerprint: State.ClientFingerprint, isUnknown: Bool = false) async {
+        let settings = await center.notificationSettings()
+        let combinedFingerprint = (fingerprint ^ keyManager.fingerprint!).hexString()
+        guard settings.authorizationStatus == .authorized else {
+            logger.info("Notifications disabled, ignoring connection request from '\(remoteAddress)' with fingerprint '\(combinedFingerprint)'")
+            return
+        }
+        let content = UNMutableNotificationContent()
+        if isUnknown {
+            content.title = NSString.localizedUserNotificationString(forKey: "Unknown Remote Client", arguments: nil)
+            content.body = NSString.localizedUserNotificationString(forKey: "A client with fingerprint '%@' is attempting to connect.", arguments: [combinedFingerprint])
+            content.categoryIdentifier = "UNKNOWN_REMOTE_CLIENT"
+        } else {
+            content.title = NSString.localizedUserNotificationString(forKey: "Remote Client Connected", arguments: nil)
+            content.body = NSString.localizedUserNotificationString(forKey: "Established connection from %@.", arguments: [remoteAddress])
+            content.categoryIdentifier = "TRUSTED_REMOTE_CLIENT"
+        }
+        let clientFingerprint = fingerprint.hexString()
+        content.userInfo = ["FINGERPRINT": clientFingerprint]
+        let request = UNNotificationRequest(identifier: clientFingerprint,
+                                            content: content,
+                                            trigger: nil)
+        do {
+            try await center.add(request)
+            if !isUnknown {
+                DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(15)) {
+                    self.center.removeDeliveredNotifications(withIdentifiers: [clientFingerprint])
+                }
+            }
+        } catch {
+            logger.error("Error sending remote connection request: \(error.localizedDescription)")
+        }
+    }
+
+    fileprivate func notifyError(_ error: Error) async {
+        logger.error("UTM Remote Server error: '\(error)'")
+        let settings = await center.notificationSettings()
+        guard settings.authorizationStatus == .authorized else {
+            return
+        }
+        let content = UNMutableNotificationContent()
+        content.title = NSString.localizedUserNotificationString(forKey: "UTM Remote Server Error", arguments: nil)
+        content.body = error.localizedDescription
+        let request = UNNotificationRequest(identifier: UUID().uuidString,
+                                            content: content,
+                                            trigger: nil)
+        do {
+            try await center.add(request)
+        } catch {
+            logger.error("Error sending error notification: \(error.localizedDescription)")
+        }
+    }
+}
+
+extension UTMRemoteServer {
+    @MainActor
+    class State: ObservableObject {
+        typealias ClientFingerprint = [UInt8]
+        typealias ServerFingerprint = [UInt8]
+        struct Client: Codable, Identifiable, Hashable {
+            let fingerprint: ClientFingerprint
+            var name: String
+            var lastSeen: Date
+
+            var id: ClientFingerprint {
+                fingerprint
+            }
+
+            func hash(into hasher: inout Hasher) {
+                hasher.combine(fingerprint)
+            }
+
+            static func == (lhs: Client, rhs: Client) -> Bool {
+                lhs.hashValue == rhs.hashValue
+            }
+        }
+
+        enum ServerAction {
+            case none
+            case stop
+            case start
+            case reset
+        }
+
+        @Published var allClients: [Client] {
+            didSet {
+                let all = Set(allClients)
+                approvedClients.subtract(approvedClients.subtracting(all))
+                blockedClients.subtract(blockedClients.subtracting(all))
+                connectedClients.subtract(connectedClients.subtracting(all.map({ $0.fingerprint })))
+            }
+        }
+
+        @Published var approvedClients: Set<Client> {
+            didSet {
+                UserDefaults.standard.setValue(try! approvedClients.propertyList(), forKey: "TrustedClients")
+            }
+        }
+
+        @Published var blockedClients: Set<Client> {
+            didSet {
+                UserDefaults.standard.setValue(try! blockedClients.propertyList(), forKey: "BlockedClients")
+            }
+        }
+
+        @Published var connectedClients = Set<ClientFingerprint>()
+
+        @Published var serverAction: ServerAction = .none
+
+        var isBusy: Bool {
+            serverAction != .none
+        }
+
+        @Published private(set) var isServerActive = false
+
+        @Published private(set) var serverFingerprint: ServerFingerprint = [] {
+            didSet {
+                UserDefaults.standard.setValue(serverFingerprint.hexString(), forKey: "ServerFingerprint")
+            }
+        }
+
+        @Published private(set) var externalIPAddress: String?
+
+        @Published private(set) var externalPort: UInt16?
+
+        init() {
+            var _approvedClients = Set<Client>()
+            if let array = UserDefaults.standard.array(forKey: "TrustedClients") {
+                if let clients = try? Set<Client>(fromPropertyList: array) {
+                    _approvedClients = clients
+                }
+            }
+            self.approvedClients = _approvedClients
+            var _blockedClients = Set<Client>()
+            if let array = UserDefaults.standard.array(forKey: "BlockedClients") {
+                if let clients = try? Set<Client>(fromPropertyList: array) {
+                    _blockedClients = clients
+                }
+            }
+            self.blockedClients = _blockedClients
+            self.allClients = Array(_approvedClients) + Array(_blockedClients)
+            if let value = UserDefaults.standard.string(forKey: "ServerFingerprint"), let serverFingerprint = ServerFingerprint(hexString: value) {
+                self.serverFingerprint = serverFingerprint
+            }
+        }
+
+        func isConnected(_ fingerprint: ClientFingerprint) -> Bool {
+            connectedClients.contains(fingerprint)
+        }
+
+        func isApproved(_ fingerprint: ClientFingerprint) -> Bool {
+            approvedClients.contains(where: { $0.fingerprint == fingerprint }) && !isBlocked(fingerprint)
+        }
+
+        func isBlocked(_ fingerprint: ClientFingerprint) -> Bool {
+            blockedClients.contains(where: { $0.fingerprint == fingerprint })
+        }
+
+        fileprivate func setServerActive(_ isActive: Bool) {
+            isServerActive = isActive
+        }
+
+        func requestServerAction(_ action: ServerAction) {
+            serverAction = action
+        }
+
+        private func client(forFingerprint fingerprint: ClientFingerprint, name: String? = nil) -> (Int?, Client) {
+            if let idx = allClients.firstIndex(where: { $0.fingerprint == fingerprint }) {
+                if let name = name {
+                    allClients[idx].name = name
+                }
+                return (idx, allClients[idx])
+            } else {
+                return (nil, Client(fingerprint: fingerprint, name: name ?? "", lastSeen: Date()))
+            }
+        }
+
+        func seen(_ fingerprint: ClientFingerprint, name: String? = nil) {
+            var (idx, client) = client(forFingerprint: fingerprint, name: name)
+            client.lastSeen = Date()
+            if let idx = idx {
+                allClients[idx] = client
+            } else {
+                allClients.append(client)
+            }
+        }
+
+        fileprivate func connect(_ fingerprint: ClientFingerprint, name: String? = nil) {
+            connectedClients.insert(fingerprint)
+        }
+
+        func disconnect(_ fingerprint: ClientFingerprint) {
+            connectedClients.remove(fingerprint)
+        }
+
+        func disconnectAll() {
+            connectedClients.removeAll()
+        }
+
+        func approve(_ fingerprint: ClientFingerprint) {
+            let (_, client) = client(forFingerprint: fingerprint)
+            approvedClients.insert(client)
+            blockedClients.remove(client)
+        }
+
+        func block(_ fingerprint: ClientFingerprint) {
+            let (_, client) = client(forFingerprint: fingerprint)
+            approvedClients.remove(client)
+            blockedClients.insert(client)
+        }
+
+        fileprivate func setServerFingerprint(_ fingerprint: ServerFingerprint) {
+            serverFingerprint = fingerprint
+        }
+
+        fileprivate func setExternalAddress(_ address: String? = nil, port: UInt16? = nil) {
+            externalIPAddress = address
+            externalPort = port
+        }
+    }
+}
+
+extension UTMRemoteServer {
+    class Local: LocalInterface {
+        typealias M = UTMRemoteMessageServer
+
+        private let server: UTMRemoteServer
+        private let client: UTMRemoteServer.Remote
+        private var isAuthenticated: Bool = false
+
+        private var data: UTMData {
+            server.data
+        }
+
+        init(server: UTMRemoteServer, client: UTMRemoteServer.Remote) {
+            self.server = server
+            self.client = client
+        }
+
+        func handle(message: M, data: Data) async throws -> Data {
+            guard isAuthenticated || message == .serverHandshake else {
+                throw ServerError.notAuthenticated
+            }
+            switch message {
+            case .serverHandshake:
+                return try await _handshake(parameters: .decode(data)).encode()
+            case .listVirtualMachines:
+                return try await _listVirtualMachines(parameters: .decode(data)).encode()
+            case .reorderVirtualMachines:
+                return try await _reorderVirtualMachines(parameters: .decode(data)).encode()
+            case .getVirtualMachineInformation:
+                return try await _getVirtualMachineInformation(parameters: .decode(data)).encode()
+            case .getQEMUConfiguration:
+                return try await _getQEMUConfiguration(parameters: .decode(data)).encode()
+            case .getPackageSize:
+                return try await _getPackageSize(parameters: .decode(data)).encode()
+            case .getPackageFile:
+                return try await _getPackageFile(parameters: .decode(data)).encode()
+            case .sendPackageFile:
+                return try await _sendPackageFile(parameters: .decode(data)).encode()
+            case .deletePackageFile:
+                return try await _deletePackageFile(parameters: .decode(data)).encode()
+            case .mountGuestToolsOnVirtualMachine:
+                return try await _mountGuestToolsOnVirtualMachine(parameters: .decode(data)).encode()
+            case .startVirtualMachine:
+                return try await _startVirtualMachine(parameters: .decode(data)).encode()
+            case .stopVirtualMachine:
+                return try await _stopVirtualMachine(parameters: .decode(data)).encode()
+            case .restartVirtualMachine:
+                return try await _restartVirtualMachine(parameters: .decode(data)).encode()
+            case .pauseVirtualMachine:
+                return try await _pauseVirtualMachine(parameters: .decode(data)).encode()
+            case .resumeVirtualMachine:
+                return try await _resumeVirtualMachine(parameters: .decode(data)).encode()
+            case .saveSnapshotVirtualMachine:
+                return try await _saveSnapshotVirtualMachine(parameters: .decode(data)).encode()
+            case .deleteSnapshotVirtualMachine:
+                return try await _deleteSnapshotVirtualMachine(parameters: .decode(data)).encode()
+            case .restoreSnapshotVirtualMachine:
+                return try await _restoreSnapshotVirtualMachine(parameters: .decode(data)).encode()
+            case .changePointerTypeVirtualMachine:
+                return try await _changePointerTypeVirtualMachine(parameters: .decode(data)).encode()
+            }
+        }
+
+        @MainActor
+        private func findVM(withId id: UUID) throws -> VMData {
+            let vm = data.virtualMachines.first(where: { $0.id == id })
+            if let vm = vm, let _ = vm.wrapped {
+                return vm
+            } else {
+                throw UTMRemoteServer.ServerError.notFound(id)
+            }
+        }
+
+        @MainActor
+        private func packageFileHasChanged(for vm: VMData, relativePathComponents: [String]) throws {
+            if relativePathComponents.count == 1 && relativePathComponents[0] == kUTMBundleScreenshotFilename {
+                try vm.wrapped?.reloadScreenshotFromFile()
+            }
+        }
+
+        private func _handshake(parameters: M.ServerHandshake.Request) async throws -> M.ServerHandshake.Reply {
+            let serverPassword = await server.serverPassword
+            if await server.isServerPasswordRequired && !serverPassword.isEmpty {
+                if serverPassword == parameters.password {
+                    isAuthenticated = true
+                }
+            } else {
+                isAuthenticated = true
+            }
+            return .init(version: UTMRemoteMessageServer.version, isAuthenticated: isAuthenticated, capabilities: .current, model: MacDevice.current.model)
+        }
+
+        private func _listVirtualMachines(parameters: M.ListVirtualMachines.Request) async throws -> M.ListVirtualMachines.Reply {
+            let ids = await Task { @MainActor in
+                data.virtualMachines.map({ $0.id })
+            }.value
+            return .init(ids: ids)
+        }
+
+        private func _reorderVirtualMachines(parameters: M.ReorderVirtualMachines.Request) async throws -> M.ReorderVirtualMachines.Reply {
+            await Task { @MainActor in
+                let vms = data.virtualMachines
+                let source = parameters.ids.reduce(into: IndexSet(), { indexSet, id in
+                    if let index = vms.firstIndex(where: { $0.id == id }) {
+                        indexSet.insert(index)
+                    }
+                })
+                let destination = min(max(0, parameters.offset), vms.count)
+                data.listMove(fromOffsets: source, toOffset: destination)
+                return .init()
+            }.value
+        }
+
+        private func _getVirtualMachineInformation(parameters: M.GetVirtualMachineInformation.Request) async throws -> M.GetVirtualMachineInformation.Reply {
+            let informations = try await Task { @MainActor in
+                try parameters.ids.map { id in
+                    let vm = try findVM(withId: id)
+                    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,
+                                                       name: vm.detailsTitleLabel,
+                                                       path: vm.pathUrl.path,
+                                                       isShortcut: vm.isShortcut,
+                                                       isSuspended: vm.registryEntry?.isSuspended ?? false,
+                                                       isTakeoverAllowed: isTakeoverAllowed,
+                                                       backend: vm.wrapped is UTMQemuVirtualMachine ? .qemu : .unknown,
+                                                       state: vm.wrapped?.state ?? .stopped,
+                                                       mountedDrives: mountedDrives)
+                }
+            }.value
+            return .init(informations: informations)
+        }
+
+        private func _getQEMUConfiguration(parameters: M.GetQEMUConfiguration.Request) async throws -> M.GetQEMUConfiguration.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            if let config = await vm.config as? UTMQemuConfiguration {
+                return .init(configuration: config)
+            } else {
+                throw ServerError.invalidBackend
+            }
+        }
+
+        private func _getPackageSize(parameters: M.GetPackageSize.Request) async throws -> M.GetPackageSize.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            let size = await data.computeSize(for: vm)
+            return .init(size: size)
+        }
+
+        private func _getPackageFile(parameters: M.GetPackageFile.Request) async throws -> M.GetPackageFile.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            let fm = FileManager.default
+            let pathUrl = await vm.pathUrl
+            let fileUrl = parameters.relativePathComponents.reduce(pathUrl, { $0.appendingPathComponent($1) })
+            guard let lastModified = try fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date else {
+                throw ServerError.failedToAccessFile
+            }
+            if let requestLastModified = parameters.lastModified {
+                if lastModified.distance(to: requestLastModified).rounded(.towardZero) == 0 {
+                    return .init(data: nil, lastModified: lastModified)
+                }
+            }
+            guard let data = fm.contents(atPath: fileUrl.path) else {
+                throw ServerError.failedToAccessFile
+            }
+            return .init(data: data, lastModified: lastModified)
+        }
+
+        private func _sendPackageFile(parameters: M.SendPackageFile.Request) async throws -> M.SendPackageFile.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            let fm = FileManager.default
+            let pathUrl = await vm.pathUrl
+            let fileUrl = parameters.relativePathComponents.reduce(pathUrl, { $0.appendingPathComponent($1) })
+            try? fm.removeItem(at: fileUrl)
+            guard fm.createFile(atPath: fileUrl.path, contents: parameters.data, attributes: [.modificationDate: parameters.lastModified]) else {
+                throw ServerError.failedToAccessFile
+            }
+            try await packageFileHasChanged(for: vm, relativePathComponents: parameters.relativePathComponents)
+            return .init()
+        }
+
+        private func _deletePackageFile(parameters: M.DeletePackageFile.Request) async throws -> M.DeletePackageFile.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            let fm = FileManager.default
+            let pathUrl = await vm.pathUrl
+            let fileUrl = parameters.relativePathComponents.reduce(pathUrl, { $0.appendingPathComponent($1) })
+            try fm.removeItem(at: fileUrl)
+            try await packageFileHasChanged(for: vm, relativePathComponents: parameters.relativePathComponents)
+            return .init()
+        }
+
+        private func _mountGuestToolsOnVirtualMachine(parameters: M.MountGuestToolsOnVirtualMachine.Request) async throws -> M.MountGuestToolsOnVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            if let wrapped = await vm.wrapped {
+                try await data.mountSupportTools(for: wrapped)
+            }
+            return .init()
+        }
+
+        private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            let serverInfo = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
+            return .init(serverInfo: serverInfo)
+        }
+
+        private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            try await vm.wrapped!.stop(usingMethod: parameters.method)
+            return .init()
+        }
+
+        private func _restartVirtualMachine(parameters: M.RestartVirtualMachine.Request) async throws -> M.RestartVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            try await vm.wrapped!.restart()
+            return .init()
+        }
+
+        private func _pauseVirtualMachine(parameters: M.PauseVirtualMachine.Request) async throws -> M.PauseVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            try await vm.wrapped!.pause()
+            return .init()
+        }
+
+        private func _resumeVirtualMachine(parameters: M.ResumeVirtualMachine.Request) async throws -> M.ResumeVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            try await vm.wrapped!.resume()
+            return .init()
+        }
+
+        private func _saveSnapshotVirtualMachine(parameters: M.SaveSnapshotVirtualMachine.Request) async throws -> M.SaveSnapshotVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            try await vm.wrapped!.saveSnapshot(name: parameters.name)
+            return .init()
+        }
+
+        private func _deleteSnapshotVirtualMachine(parameters: M.DeleteSnapshotVirtualMachine.Request) async throws -> M.DeleteSnapshotVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            try await vm.wrapped!.deleteSnapshot(name: parameters.name)
+            return .init()
+        }
+
+        private func _restoreSnapshotVirtualMachine(parameters: M.RestoreSnapshotVirtualMachine.Request) async throws -> M.RestoreSnapshotVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            try await vm.wrapped!.restoreSnapshot(name: parameters.name)
+            return .init()
+        }
+
+        private func _changePointerTypeVirtualMachine(parameters: M.ChangePointerTypeVirtualMachine.Request) async throws -> M.ChangePointerTypeVirtualMachine.Reply {
+            let vm = try await findVM(withId: parameters.id)
+            guard let wrapped = await vm.wrapped as? UTMQemuVirtualMachine else {
+                throw ServerError.invalidBackend
+            }
+            try await wrapped.changeInputTablet(parameters.isTabletMode)
+            return .init()
+        }
+    }
+}
+
+extension UTMRemoteServer {
+    class Remote: Identifiable {
+        typealias M = UTMRemoteMessageClient
+        fileprivate(set) var peer: Peer<UTMRemoteMessageServer>!
+        let id = UUID()
+
+        func close() {
+            peer.close()
+        }
+
+        func handshake() async throws {
+            guard try await _handshake(parameters: .init(version: UTMRemoteMessageClient.version)).version == UTMRemoteMessageClient.version else {
+                throw ServerError.versionMismatch
+            }
+        }
+
+        func listHasChanged(ids: [UUID]) async throws {
+            try await _listHasChanged(parameters: .init(ids: ids))
+        }
+
+        func qemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async throws {
+            try await _qemuConfigurationHasChanged(parameters: .init(id: id, configuration: configuration))
+        }
+
+        func mountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async throws {
+            try await _mountedDrivesHasChanged(parameters: .init(id: id, mountedDrives: mountedDrives))
+        }
+
+        func virtualMachine(id: UUID, didTransitionToState state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async throws {
+            try await _virtualMachineDidTransition(parameters: .init(id: id, state: state, isTakeoverAllowed: isTakeoverAllowed))
+        }
+
+        func virtualMachine(id: UUID, didErrorWithMessage message: String) async throws {
+            try await _virtualMachineDidError(parameters: .init(id: id, errorMessage: message))
+        }
+
+        private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
+            try await M.ClientHandshake.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _listHasChanged(parameters: M.ListHasChanged.Request) async throws -> M.ListHasChanged.Reply {
+            try await M.ListHasChanged.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _qemuConfigurationHasChanged(parameters: M.QEMUConfigurationHasChanged.Request) async throws -> M.QEMUConfigurationHasChanged.Reply {
+            try await M.QEMUConfigurationHasChanged.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _mountedDrivesHasChanged(parameters: M.MountedDrivesHasChanged.Request) async throws -> M.MountedDrivesHasChanged.Reply {
+            try await M.MountedDrivesHasChanged.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
+            try await M.VirtualMachineDidTransition.send(parameters, to: peer)
+        }
+
+        @discardableResult
+        private func _virtualMachineDidError(parameters: M.VirtualMachineDidError.Request) async throws -> M.VirtualMachineDidError.Reply {
+            try await M.VirtualMachineDidError.send(parameters, to: peer)
+        }
+    }
+}
+
+extension UTMRemoteServer {
+    enum ServerError: LocalizedError {
+        case silentError(Error)
+        case natReservationMismatch(Int)
+        case notAuthenticated
+        case versionMismatch
+        case notFound(UUID)
+        case invalidBackend
+        case failedToAccessFile
+
+        var errorDescription: String? {
+            switch self {
+            case .silentError(let error):
+                return error.localizedDescription
+            case .natReservationMismatch(let port):
+                return String.localizedStringWithFormat(NSLocalizedString("Cannot reserve port '%@' for external access from NAT. Make sure no other device on the network has reserved it.", comment: "UTMRemoteServer"), port)
+            case .notAuthenticated:
+                return NSLocalizedString("Not authenticated.", comment: "UTMRemoteServer")
+            case .versionMismatch:
+                return NSLocalizedString("The client interface version does not match the server.", comment: "UTMRemoteServer")
+            case .notFound(let id):
+                return String.localizedStringWithFormat(NSLocalizedString("Cannot find VM with ID: %@", comment: "UTMRemoteServer"), id.uuidString)
+            case .invalidBackend:
+                return NSLocalizedString("Invalid backend.", comment: "UTMRemoteServer")
+            case .failedToAccessFile:
+                return NSLocalizedString("Failed to access file.", comment: "UTMRemoteServer")
+            }
+        }
+    }
+}
+
+extension Connection {
+    var fingerprint: [UInt8]? {
+        return peerCertificateChain.first?.fingerprint()
+    }
+}

+ 424 - 0
Remote/UTMRemoteSpiceVirtualMachine.swift

@@ -0,0 +1,424 @@
+//
+// 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()
+    }
+
+    @MainActor
+    func updateRegistry(_ entry: UTMRegistryEntry) {
+        self.registryEntry = entry
+    }
+
+    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")
+            }
+        }
+    }
+}

+ 8 - 1
Services/Swift-Bridging-Header.h

@@ -25,14 +25,21 @@
 #include "UTMLegacyQemuConfiguration+Sharing.h"
 #include "UTMLegacyQemuConfiguration+Sharing.h"
 #include "UTMLegacyQemuConfiguration+System.h"
 #include "UTMLegacyQemuConfiguration+System.h"
 #include "UTMLegacyQemuConfigurationPortForward.h"
 #include "UTMLegacyQemuConfigurationPortForward.h"
+#include "UTMLogging.h"
+#if !defined(WITH_REMOTE)
 #include "UTMProcess.h"
 #include "UTMProcess.h"
 #include "UTMQemuSystem.h"
 #include "UTMQemuSystem.h"
 #include "UTMJailbreak.h"
 #include "UTMJailbreak.h"
-#include "UTMLogging.h"
+#else
+#include "UTMQemuSystemBackends.h"
+#endif
 #include "UTMLegacyViewState.h"
 #include "UTMLegacyViewState.h"
 #include "UTMSpiceIO.h"
 #include "UTMSpiceIO.h"
+#include "GenerateKey.h"
 #if TARGET_OS_IPHONE
 #if TARGET_OS_IPHONE
+#if !defined(WITH_REMOTE)
 #include "UTMLocationManager.h"
 #include "UTMLocationManager.h"
+#endif
 #include "VMDisplayViewController.h"
 #include "VMDisplayViewController.h"
 //#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION
 //#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION
 #include "VMDisplayMetalViewController.h"
 #include "VMDisplayMetalViewController.h"

+ 11 - 3
Services/UTMAppleVirtualMachine.swift

@@ -40,6 +40,10 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
         var supportsRecoveryMode: Bool {
         var supportsRecoveryMode: Bool {
             true
             true
         }
         }
+
+        var supportsRemoteSession: Bool {
+            false
+        }
     }
     }
     
     
     static let capabilities = Capabilities()
     static let capabilities = Capabilities()
@@ -85,7 +89,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
         }
         }
     }
     }
     
     
-    private(set) var screenshot: PlatformImage? {
+    private(set) var screenshot: UTMVirtualMachineScreenshot? {
         willSet {
         willSet {
             onStateChange?()
             onStateChange?()
         }
         }
@@ -474,7 +478,11 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
         screenshot = screenshotDelegate?.screenshot
         screenshot = screenshotDelegate?.screenshot
         return true
         return true
     }
     }
-    
+
+    func reloadScreenshotFromFile() {
+        screenshot = loadScreenshot()
+    }
+
     @MainActor private func createAppleVM() throws {
     @MainActor private func createAppleVM() throws {
         for i in config.serials.indices {
         for i in config.serials.indices {
             let (fd, sfd, name) = try createPty()
             let (fd, sfd, name) = try createPty()
@@ -721,7 +729,7 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
 }
 }
 
 
 protocol UTMScreenshotProvider: AnyObject {
 protocol UTMScreenshotProvider: AnyObject {
-    var screenshot: PlatformImage? { get }
+    var screenshot: UTMVirtualMachineScreenshot? { get }
 }
 }
 
 
 enum UTMAppleVirtualMachineError: Error {
 enum UTMAppleVirtualMachineError: Error {

+ 41 - 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? {
@@ -383,4 +384,44 @@ extension String {
         }
         }
         return Int(numeric)
         return Int(numeric)
     }
     }
+
+    static func random(length: Int) -> String {
+        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+        return String((0..<length).map{ _ in letters.randomElement()! })
+    }
+}
+
+extension Encodable {
+    func propertyList() throws -> Any {
+        let encoder = PropertyListEncoder()
+        encoder.outputFormat = .xml
+        let xml = try encoder.encode(self)
+        return try PropertyListSerialization.propertyList(from: xml, format: nil)
+    }
+}
+
+extension Decodable {
+    init(fromPropertyList propertyList: Any) throws {
+        let data = try PropertyListSerialization.data(fromPropertyList: propertyList, format: .xml, options: 0)
+        let decoder = PropertyListDecoder()
+        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
+    }
 }
 }

+ 5 - 5
Services/UTMJailbreak.m

@@ -65,7 +65,7 @@ typedef struct memorystatus_memlimit_properties {
 
 
 int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, user_addr_t buffer, size_t buffersize);
 int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, user_addr_t buffer, size_t buffersize);
 
 
-#if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
+#if !TARGET_OS_OSX && defined(WITH_JIT)
 extern int csops(pid_t pid, unsigned int ops, void * useraddr, size_t usersize);
 extern int csops(pid_t pid, unsigned int ops, void * useraddr, size_t usersize);
 extern boolean_t exc_server(mach_msg_header_t *, mach_msg_header_t *);
 extern boolean_t exc_server(mach_msg_header_t *, mach_msg_header_t *);
 extern int ptrace(int request, pid_t pid, caddr_t addr, int data);
 extern int ptrace(int request, pid_t pid, caddr_t addr, int data);
@@ -100,7 +100,7 @@ static bool jb_has_debugger_attached(void) {
 #endif
 #endif
 
 
 bool jb_has_cs_disabled(void) {
 bool jb_has_cs_disabled(void) {
-#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
+#if TARGET_OS_OSX || !defined(WITH_JIT)
     return false;
     return false;
 #else
 #else
     int flags;
     int flags;
@@ -236,7 +236,7 @@ static bool is_device_A12_or_newer(void) {
 bool jb_has_jit_entitlement(void) {
 bool jb_has_jit_entitlement(void) {
 #if TARGET_OS_OSX
 #if TARGET_OS_OSX
     return true;
     return true;
-#elif defined(WITH_QEMU_TCI)
+#elif !defined(WITH_JIT)
     return false;
     return false;
 #else
 #else
     NSDictionary *entitlements = cached_app_entitlements();
     NSDictionary *entitlements = cached_app_entitlements();
@@ -330,7 +330,7 @@ bool jb_has_cs_execseg_allow_unsigned(void) {
 }
 }
 
 
 bool jb_enable_ptrace_hack(void) {
 bool jb_enable_ptrace_hack(void) {
-#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
+#if TARGET_OS_OSX || !defined(WITH_JIT)
     return false;
     return false;
 #else
 #else
     bool debugged = jb_has_debugger_attached();
     bool debugged = jb_has_debugger_attached();
@@ -380,7 +380,7 @@ bool jb_increase_memlimit(void) {
     return ret1 == 0 && ret2 == 0;
     return ret1 == 0 && ret2 == 0;
 }
 }
 
 
-#if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
+#if !TARGET_OS_OSX && defined(WITH_JIT)
 extern const char *environ[];
 extern const char *environ[];
 
 
 static char *childArgv[] = {NULL, "debugme", NULL};
 static char *childArgv[] = {NULL, "debugme", NULL};

+ 6 - 0
Services/UTMLogging.m

@@ -15,7 +15,9 @@
 //
 //
 
 
 #import "UTMLogging.h"
 #import "UTMLogging.h"
+#if !defined(WITH_REMOTE)
 @import QEMUKitInternal;
 @import QEMUKitInternal;
+#endif
 
 
 static UTMLogging *gLoggingInstance;
 static UTMLogging *gLoggingInstance;
 
 
@@ -42,7 +44,11 @@ void UTMLog(NSString *format, ...) {
 }
 }
 
 
 - (void)writeLine:(NSString *)line {
 - (void)writeLine:(NSString *)line {
+#if defined(WITH_REMOTE)
+    NSLog(@"%@", line);
+#else
     [QEMULogging.sharedInstance writeLine:line];
     [QEMULogging.sharedInstance writeLine:line];
+#endif
 }
 }
 
 
 @end
 @end

+ 1 - 1
Services/UTMPasteboard.swift

@@ -26,7 +26,7 @@ typealias SystemPasteboardType = NSPasteboard.PasteboardType
 #else
 #else
 #error("Neither UIKit nor AppKit found!")
 #error("Neither UIKit nor AppKit found!")
 #endif
 #endif
-#if WITH_QEMU_TCI
+#if !WITH_USB
 import CocoaSpiceNoUsb
 import CocoaSpiceNoUsb
 #else
 #else
 import CocoaSpice
 import CocoaSpice

+ 152 - 0
Services/UTMPipeInterface.swift

@@ -0,0 +1,152 @@
+//
+// 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 QEMUKit
+
+class UTMPipeInterface: NSObject, QEMUInterface {
+    weak var connectDelegate: QEMUInterfaceConnectDelegate?
+
+    var monitorOutPipeURL: URL!
+    var monitorInPipeURL: URL!
+    var guestAgentOutPipeURL: URL!
+    var guestAgentInPipeURL: URL!
+
+    private var pipeIOQueue = DispatchQueue(label: "UTMPipeInterface")
+    private var qemuMonitorPort: Port!
+    private var qemuGuestAgentPort: Port!
+
+    func start() throws {
+        try initializePipe(at: monitorOutPipeURL)
+        try initializePipe(at: monitorInPipeURL)
+        try initializePipe(at: guestAgentOutPipeURL)
+        try initializePipe(at: guestAgentInPipeURL)
+    }
+
+    func connect() throws {
+        pipeIOQueue.async { [self] in
+            do {
+                try openQemuPipes()
+                connectDelegate?.qemuInterface(self, didCreateMonitorPort: qemuMonitorPort)
+                connectDelegate?.qemuInterface(self, didCreateGuestAgentPort: qemuGuestAgentPort)
+            } catch {
+                connectDelegate?.qemuInterface(self, didErrorWithMessage: error.localizedDescription)
+            }
+        }
+    }
+
+    func disconnect() {
+        cleanupPipes()
+    }
+}
+
+extension UTMPipeInterface {
+    class Port: NSObject, QEMUPort {
+        let readPipe: FileHandle
+
+        let writePipe: FileHandle
+
+        var readDataHandler: readDataHandler_t?
+
+        var errorHandler: errorHandler_t?
+
+        var disconnectHandler: disconnectHandler_t?
+
+        let isOpen: Bool = true
+
+        init(readPipe: FileHandle, writePipe: FileHandle) {
+            self.readPipe = readPipe
+            self.writePipe = writePipe
+            super.init()
+            readPipe.readabilityHandler = { fileHandle in
+                self.readDataHandler?(fileHandle.availableData)
+            }
+        }
+
+        func write(_ data: Data) {
+            writePipe.write(data)
+        }
+    }
+
+    private var fileManager: FileManager {
+        FileManager.default
+    }
+
+    private func initializePipe(at url: URL) throws {
+        if fileManager.fileExists(atPath: url.path) {
+            try fileManager.removeItem(at: url)
+        }
+        guard mkfifo(url.path, S_IRUSR | S_IWUSR) == 0 else {
+            throw ServerError.failedToCreatePipe(errno)
+        }
+    }
+
+    private func openPipe(at url: URL, forReading isRead: Bool) throws -> FileHandle {
+        let fileHandle: FileHandle
+        if isRead {
+            fileHandle = try FileHandle(forReadingFrom: url)
+        } else {
+            fileHandle = try FileHandle(forWritingTo: url)
+        }
+        return fileHandle
+    }
+
+    private func cleanupPipes() {
+        // unblock any un-opened pipes
+        _ = try? FileHandle(forUpdating: monitorOutPipeURL)
+        _ = try? FileHandle(forUpdating: monitorInPipeURL)
+        _ = try? FileHandle(forUpdating: guestAgentOutPipeURL)
+        _ = try? FileHandle(forUpdating: guestAgentInPipeURL)
+        pipeIOQueue.sync {
+            if let monitorOutPipeURL = monitorOutPipeURL {
+                try? fileManager.removeItem(at: monitorOutPipeURL)
+            }
+            if let monitorInPipeURL = monitorInPipeURL {
+                try? fileManager.removeItem(at: monitorInPipeURL)
+            }
+            if let guestAgentOutPipeURL = guestAgentOutPipeURL {
+                try? fileManager.removeItem(at: guestAgentOutPipeURL)
+            }
+            if let guestAgentInPipeURL = guestAgentInPipeURL {
+                try? fileManager.removeItem(at: guestAgentInPipeURL)
+            }
+            qemuMonitorPort = nil
+            qemuGuestAgentPort = nil
+        }
+    }
+
+    private func openQemuPipes() throws {
+        let qmpReadPipe = try openPipe(at: monitorOutPipeURL, forReading: true)
+        let qmpWritePipe = try openPipe(at: monitorInPipeURL, forReading: false)
+        qemuMonitorPort = Port(readPipe: qmpReadPipe, writePipe: qmpWritePipe)
+        let qgaReadPipe = try openPipe(at: guestAgentOutPipeURL, forReading: true)
+        let qgaWritePipe = try openPipe(at: guestAgentInPipeURL, forReading: false)
+        qemuGuestAgentPort = Port(readPipe: qgaReadPipe, writePipe: qgaWritePipe)
+    }
+}
+
+extension UTMPipeInterface {
+    enum ServerError: LocalizedError {
+        case failedToCreatePipe(Int32)
+
+        var errorDescription: String? {
+            switch self {
+            case .failedToCreatePipe(_):
+                return NSLocalizedString("Failed to create pipe for communications.", comment: "UTMPipeInterface")
+            }
+        }
+    }
+}

+ 1 - 1
Services/UTMQemuPort.swift

@@ -15,7 +15,7 @@
 //
 //
 
 
 import QEMUKitInternal
 import QEMUKitInternal
-#if WITH_QEMU_TCI
+#if !WITH_USB
 import CocoaSpiceNoUsb
 import CocoaSpiceNoUsb
 #else
 #else
 import CocoaSpice
 import CocoaSpice

+ 1 - 16
Services/UTMQemuSystem.h

@@ -15,24 +15,9 @@
 //
 //
 
 
 #import "UTMProcess.h"
 #import "UTMProcess.h"
+#import "UTMQemuSystemBackends.h"
 @import QEMUKitInternal;
 @import QEMUKitInternal;
 
 
-/// Specify the backend renderer for this VM
-typedef NS_ENUM(NSInteger, UTMQEMURendererBackend) {
-    kQEMURendererBackendDefault = 0,
-    kQEMURendererBackendAngleGL = 1,
-    kQEMURendererBackendAngleMetal = 2,
-    kQEMURendererBackendMax = 3,
-};
-
-/// Specify the sound backend for this VM
-typedef NS_ENUM(NSInteger, UTMQEMUSoundBackend) {
-    kQEMUSoundBackendDefault = 0,
-    kQEMUSoundBackendSPICE = 1,
-    kQEMUSoundBackendCoreAudio = 2,
-    kQEMUSoundBackendMax = 3,
-};
-
 NS_ASSUME_NONNULL_BEGIN
 NS_ASSUME_NONNULL_BEGIN
 
 
 @interface UTMQemuSystem : UTMProcess <QEMULauncher>
 @interface UTMQemuSystem : UTMProcess <QEMULauncher>

+ 36 - 0
Services/UTMQemuSystemBackends.h

@@ -0,0 +1,36 @@
+//
+// 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.
+//
+
+#ifndef UTMQemuSystemBackends_h
+#define UTMQemuSystemBackends_h
+
+/// Specify the backend renderer for this VM
+typedef NS_ENUM(NSInteger, UTMQEMURendererBackend) {
+    kQEMURendererBackendDefault = 0,
+    kQEMURendererBackendAngleGL = 1,
+    kQEMURendererBackendAngleMetal = 2,
+    kQEMURendererBackendMax = 3,
+};
+
+/// Specify the sound backend for this VM
+typedef NS_ENUM(NSInteger, UTMQEMUSoundBackend) {
+    kQEMUSoundBackendDefault = 0,
+    kQEMUSoundBackendSPICE = 1,
+    kQEMUSoundBackendCoreAudio = 2,
+    kQEMUSoundBackendMax = 3,
+};
+
+#endif /* UTMQemuSystemBackends_h */

+ 139 - 141
Services/UTMQemuVirtualMachine.swift

@@ -16,13 +16,16 @@
 
 
 import Foundation
 import Foundation
 import QEMUKit
 import QEMUKit
+#if os(macOS)
+import SwiftPortmap
+#endif
 
 
 private var SpiceIoServiceGuestAgentContext = 0
 private var SpiceIoServiceGuestAgentContext = 0
 private let kSuspendSnapshotName = "suspend"
 private let kSuspendSnapshotName = "suspend"
 private let kProbeSuspendDelay = 1*NSEC_PER_SEC
 private let kProbeSuspendDelay = 1*NSEC_PER_SEC
 
 
 /// QEMU backend virtual machine
 /// QEMU backend virtual machine
-final class UTMQemuVirtualMachine: UTMVirtualMachine {
+final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine {
     struct Capabilities: UTMVirtualMachineCapabilities {
     struct Capabilities: UTMVirtualMachineCapabilities {
         var supportsProcessKill: Bool {
         var supportsProcessKill: Bool {
             true
             true
@@ -43,6 +46,10 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
         var supportsRecoveryMode: Bool {
         var supportsRecoveryMode: Bool {
             false
             false
         }
         }
+
+        var supportsRemoteSession: Bool {
+            true
+        }
     }
     }
     
     
     static let capabilities = Capabilities()
     static let capabilities = Capabilities()
@@ -88,7 +95,7 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
         }
         }
     }
     }
     
     
-    private(set) var screenshot: PlatformImage? {
+    var screenshot: UTMVirtualMachineScreenshot? {
         willSet {
         willSet {
             onStateChange?()
             onStateChange?()
         }
         }
@@ -117,6 +124,9 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
         }
         }
     }
     }
     
     
+    /// Pipe interface (alternative to UTMSpiceIO)
+    private var pipeInterface: UTMPipeInterface?
+
     private let qemuVM = QEMUVirtualMachine()
     private let qemuVM = QEMUVirtualMachine()
     
     
     private var system: UTMQemuSystem? {
     private var system: UTMQemuSystem? {
@@ -144,7 +154,13 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
     private var swtpm: UTMSWTPM?
     private var swtpm: UTMSWTPM?
     
     
     private var changeCursorRequestInProgress: Bool = false
     private var changeCursorRequestInProgress: Bool = false
-    
+
+    #if WITH_SERVER
+    @Setting("ServerPort") private var serverPort: Int = 0
+    private var spicePort: SwiftPortmap.Port?
+    private(set) var spiceServerInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation?
+    #endif
+
     @MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool = false) throws {
     @MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool = false) throws {
         self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
         self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
         // load configuration
         // load configuration
@@ -267,10 +283,24 @@ extension UTMQemuVirtualMachine {
             await qemuVM.setRedirectLog(url: nil)
             await qemuVM.setRedirectLog(url: nil)
         }
         }
         let isRunningAsDisposible = options.contains(.bootDisposibleMode)
         let isRunningAsDisposible = options.contains(.bootDisposibleMode)
+        let isRemoteSession = options.contains(.remoteSession)
+        #if WITH_SERVER
+        let spicePassword = isRemoteSession ? String.random(length: 32) : nil
+        let spicePort = isRemoteSession ? try SwiftPortmap.Port.TCP(unusedPortStartingAt: UInt16(serverPort)) : nil
+        #else
+        if isRemoteSession {
+            throw UTMVirtualMachineError.notImplemented
+        }
+        #endif
         await MainActor.run {
         await MainActor.run {
             config.qemu.isDisposable = isRunningAsDisposible
             config.qemu.isDisposable = isRunningAsDisposible
+            #if WITH_SERVER
+            config.qemu.spiceServerPort = spicePort?.internalPort
+            config.qemu.spiceServerPassword = spicePassword
+            config.qemu.isSpiceServerTlsEnabled = true
+            #endif
         }
         }
-        
+
         // start TPM
         // start TPM
         if await config.qemu.hasTPMDevice {
         if await config.qemu.hasTPMDevice {
             let swtpm = UTMSWTPM()
             let swtpm = UTMSWTPM()
@@ -280,12 +310,12 @@ extension UTMQemuVirtualMachine {
             try await swtpm.start()
             try await swtpm.start()
             self.swtpm = swtpm
             self.swtpm = swtpm
         }
         }
-        
+
         let allArguments = await config.allArguments
         let allArguments = await config.allArguments
         let arguments = allArguments.map({ $0.string })
         let arguments = allArguments.map({ $0.string })
         let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
         let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
         let remoteBookmarks = await remoteBookmarks
         let remoteBookmarks = await remoteBookmarks
-        
+
         let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
         let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
         system.resources = resources
         system.resources = resources
         system.currentDirectoryUrl = await config.socketURL
         system.currentDirectoryUrl = await config.socketURL
@@ -295,12 +325,12 @@ extension UTMQemuVirtualMachine {
         system.hasDebugLog = hasDebugLog
         system.hasDebugLog = hasDebugLog
         #endif
         #endif
         try Task.checkCancellation()
         try Task.checkCancellation()
-        
+
         if isShortcut {
         if isShortcut {
             try await accessShortcut()
             try await accessShortcut()
             try Task.checkCancellation()
             try Task.checkCancellation()
         }
         }
-        
+
         var options = UTMSpiceIOOptions()
         var options = UTMSpiceIOOptions()
         if await !config.sound.isEmpty {
         if await !config.sound.isEmpty {
             options.insert(.hasAudio)
             options.insert(.hasAudio)
@@ -317,14 +347,41 @@ extension UTMQemuVirtualMachine {
         }
         }
         #endif
         #endif
         let spiceSocketUrl = await config.spiceSocketURL
         let spiceSocketUrl = await config.spiceSocketURL
-        let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
-        ioService.logHandler = { [weak system] (line: String) -> Void in
-            guard !line.contains("spice_make_scancode") else {
-                return // do not log key presses for privacy reasons
+        let interface: any QEMUInterface
+        let spicePublicKey: Data?
+        if isRemoteSession {
+            let pipeInterface = UTMPipeInterface()
+            await MainActor.run {
+                pipeInterface.monitorInPipeURL = config.monitorPipeURL.appendingPathExtension("in")
+                pipeInterface.monitorOutPipeURL = config.monitorPipeURL.appendingPathExtension("out")
+                pipeInterface.guestAgentInPipeURL = config.guestAgentPipeURL.appendingPathExtension("in")
+                pipeInterface.guestAgentOutPipeURL = config.guestAgentPipeURL.appendingPathExtension("out")
             }
             }
-            system?.logging?.writeLine(line)
+            try pipeInterface.start()
+            interface = pipeInterface
+            // generate a TLS key for this session
+            guard let key = GenerateRSACertificate("UTM Remote SPICE Server" as CFString,
+                                                   "UTM" as CFString,
+                                                   Int.random(in: 1..<CLong.max) as CFNumber,
+                                                   1 as CFNumber,
+                                                   false as CFBoolean)?.takeUnretainedValue() as? [Data] else {
+                throw UTMQemuVirtualMachineError.keyGenerationFailed
+            }
+            try await key[1].write(to: config.spiceTlsKeyUrl)
+            try await key[2].write(to: config.spiceTlsCertUrl)
+            spicePublicKey = key[3]
+        } else {
+            let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
+            ioService.logHandler = { [weak system] (line: String) -> Void in
+                guard !line.contains("spice_make_scancode") else {
+                    return // do not log key presses for privacy reasons
+                }
+                system?.logging?.writeLine(line)
+            }
+            try ioService.start()
+            interface = ioService
+            spicePublicKey = nil
         }
         }
-        try ioService.start()
         try Task.checkCancellation()
         try Task.checkCancellation()
         
         
         // create EFI variables for legacy config as well as handle UEFI resets
         // create EFI variables for legacy config as well as handle UEFI resets
@@ -333,7 +390,7 @@ extension UTMQemuVirtualMachine {
         
         
         // start QEMU
         // start QEMU
         await qemuVM.setDelegate(self)
         await qemuVM.setDelegate(self)
-        try await qemuVM.start(launcher: system, interface: ioService)
+        try await qemuVM.start(launcher: system, interface: interface)
         let monitor = await monitor!
         let monitor = await monitor!
         try Task.checkCancellation()
         try Task.checkCancellation()
         
         
@@ -346,7 +403,11 @@ extension UTMQemuVirtualMachine {
         
         
         // set up SPICE sharing and removable drives
         // set up SPICE sharing and removable drives
         try await self.restoreExternalDrives(withMounting: !isSuspended)
         try await self.restoreExternalDrives(withMounting: !isSuspended)
-        try await self.restoreSharedDirectory(for: ioService)
+        if let ioService = interface as? UTMSpiceIO {
+            try await self.restoreSharedDirectory(for: ioService)
+        } else {
+            // TODO: implement shared directory in remote interface
+        }
         try Task.checkCancellation()
         try Task.checkCancellation()
         
         
         // continue VM boot
         // continue VM boot
@@ -358,11 +419,24 @@ extension UTMQemuVirtualMachine {
         }
         }
         
         
         // save ioService and let it set the delegate
         // save ioService and let it set the delegate
-        self.ioService = ioService
+        self.ioService = interface as? UTMSpiceIO
+        self.pipeInterface = interface as? UTMPipeInterface
         self.isRunningAsDisposible = isRunningAsDisposible
         self.isRunningAsDisposible = isRunningAsDisposible
         
         
         // test out snapshots
         // test out snapshots
         self.snapshotUnsupportedError = await determineSnapshotSupport()
         self.snapshotUnsupportedError = await determineSnapshotSupport()
+
+        #if WITH_SERVER
+        // save server details
+        if let spicePort = spicePort, let spicePublicKey = spicePublicKey, let spicePassword = spicePassword {
+            self.spiceServerInfo = .init(spicePortInternal: spicePort.internalPort,
+                                         spicePortExternal: try? await spicePort.externalPort,
+                                         spiceHostExternal: try? await spicePort.externalIpv4Address,
+                                         spicePublicKey: spicePublicKey,
+                                         spicePassword: spicePassword)
+            self.spicePort = spicePort
+        }
+        #endif
     }
     }
     
     
     func start(options: UTMVirtualMachineStartOptions = []) async throws {
     func start(options: UTMVirtualMachineStartOptions = []) async throws {
@@ -379,7 +453,7 @@ extension UTMQemuVirtualMachine {
             }
             }
             try await startTask!.value
             try await startTask!.value
             state = .started
             state = .started
-            if screenshotTimer == nil {
+            if screenshotTimer == nil && !options.contains(.remoteSession) {
                 screenshotTimer = startScreenshotTimer()
                 screenshotTimer = startScreenshotTimer()
             }
             }
         } catch {
         } catch {
@@ -584,10 +658,16 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
     }
     }
     
     
     func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
     func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
+        #if WITH_SERVER
+        spicePort = nil
+        spiceServerInfo = nil
+        #endif
         swtpm?.stop()
         swtpm?.stop()
         swtpm = nil
         swtpm = nil
         ioService = nil
         ioService = nil
         ioServiceDelegate = nil
         ioServiceDelegate = nil
+        pipeInterface?.disconnect()
+        pipeInterface = nil
         snapshotUnsupportedError = nil
         snapshotUnsupportedError = nil
         try? saveScreenshot()
         try? saveScreenshot()
         state = .stopped
         state = .stopped
@@ -621,11 +701,27 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
 
 
 // MARK: - Input device switching
 // MARK: - Input device switching
 extension UTMQemuVirtualMachine {
 extension UTMQemuVirtualMachine {
-    func requestInputTablet(_ tablet: Bool) {
-        guard !changeCursorRequestInProgress else {
+    func changeInputTablet(_ tablet: Bool) async throws {
+        defer {
+            changeCursorRequestInProgress = false
+        }
+        guard state == .started else {
+            return
+        }
+        guard let monitor = await monitor else {
             return
             return
         }
         }
-        guard let spiceIO = ioService else {
+        do {
+            let index = try await monitor.mouseIndex(forAbsolute: tablet)
+            try await monitor.mouseSelect(index)
+            ioService?.primaryInput?.requestMouseMode(!tablet)
+        } catch {
+            logger.error("Error changing mouse mode: \(error)")
+        }
+    }
+
+    func requestInputTablet(_ tablet: Bool) {
+        guard !changeCursorRequestInProgress else {
             return
             return
         }
         }
         changeCursorRequestInProgress = true
         changeCursorRequestInProgress = true
@@ -633,40 +729,11 @@ extension UTMQemuVirtualMachine {
             defer {
             defer {
                 changeCursorRequestInProgress = false
                 changeCursorRequestInProgress = false
             }
             }
-            guard state == .started else {
-                return
-            }
-            guard let monitor = await monitor else {
-                return
-            }
-            do {
-                let index = try await monitor.mouseIndex(forAbsolute: tablet)
-                try await monitor.mouseSelect(index)
-                spiceIO.primaryInput?.requestMouseMode(!tablet)
-            } catch {
-                logger.error("Error changing mouse mode: \(error)")
-            }
+            try await changeInputTablet(tablet)
         }
         }
     }
     }
 }
 }
 
 
-// MARK: - USB redirection
-extension UTMQemuVirtualMachine {
-    var hasUsbRedirection: Bool {
-        return jb_has_usb_entitlement()
-    }
-}
-
-// MARK: - Screenshot
-extension UTMQemuVirtualMachine {
-    @MainActor @discardableResult
-    func takeScreenshot() async -> Bool {
-        let screenshot = await ioService?.screenshot()
-        self.screenshot = screenshot?.image
-        return true
-    }
-}
-
 // MARK: - Architecture supported
 // MARK: - Architecture supported
 extension UTMQemuVirtualMachine {
 extension UTMQemuVirtualMachine {
     /// Check if a QEMU target is supported
     /// Check if a QEMU target is supported
@@ -695,7 +762,11 @@ extension UTMQemuVirtualMachine {
 
 
 // MARK: - External drives
 // MARK: - External drives
 extension UTMQemuVirtualMachine {
 extension UTMQemuVirtualMachine {
-    func eject(_ drive: UTMQemuConfigurationDrive, isForced: Bool = false) async throws {
+    func eject(_ drive: UTMQemuConfigurationDrive) async throws {
+        try await eject(drive, isForced: false)
+    }
+
+    private func eject(_ drive: UTMQemuConfigurationDrive, isForced: Bool) async throws {
         guard drive.isExternal else {
         guard drive.isExternal else {
             return
             return
         }
         }
@@ -707,8 +778,12 @@ extension UTMQemuVirtualMachine {
         }
         }
         await registryEntry.removeExternalDrive(forId: drive.id)
         await registryEntry.removeExternalDrive(forId: drive.id)
     }
     }
-    
-    func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL, isAccessOnly: Bool = false) async throws {
+
+    func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL) async throws {
+        try await changeMedium(drive, to: url, isAccessOnly: false)
+    }
+
+    private func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL, isAccessOnly: Bool) async throws {
         _ = url.startAccessingSecurityScopedResource()
         _ = url.startAccessingSecurityScopedResource()
         defer {
         defer {
             url.stopAccessingSecurityScopedResource()
             url.stopAccessingSecurityScopedResource()
@@ -719,7 +794,7 @@ extension UTMQemuVirtualMachine {
         await registryEntry.setExternalDrive(file, forId: drive.id)
         await registryEntry.setExternalDrive(file, forId: drive.id)
         try await changeMedium(drive, with: tempBookmark, url: url, isSecurityScoped: false, isAccessOnly: isAccessOnly)
         try await changeMedium(drive, with: tempBookmark, url: url, isSecurityScoped: false, isAccessOnly: isAccessOnly)
     }
     }
-    
+
     private func changeMedium(_ drive: UTMQemuConfigurationDrive, with bookmark: Data, url: URL?, isSecurityScoped: Bool, isAccessOnly: Bool) async throws {
     private func changeMedium(_ drive: UTMQemuConfigurationDrive, with bookmark: Data, url: URL?, isSecurityScoped: Bool, isAccessOnly: Bool) async throws {
         let system = await system ?? UTMProcess()
         let system = await system ?? UTMProcess()
         let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
         let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
@@ -731,8 +806,8 @@ extension UTMQemuVirtualMachine {
             try qemu.changeMedium(forDrive: "drive\(drive.id)", path: path)
             try qemu.changeMedium(forDrive: "drive\(drive.id)", path: path)
         }
         }
     }
     }
-    
-    func restoreExternalDrives(withMounting isMounting: Bool) async throws {
+
+    private func restoreExternalDrives(withMounting isMounting: Bool) async throws {
         guard await system != nil else {
         guard await system != nil else {
             throw UTMQemuVirtualMachineError.invalidVmState
             throw UTMQemuVirtualMachineError.invalidVmState
         }
         }
@@ -754,43 +829,14 @@ extension UTMQemuVirtualMachine {
             }
             }
         }
         }
     }
     }
-    
-    @MainActor func externalImageURL(for drive: UTMQemuConfigurationDrive) -> URL? {
-        registryEntry.externalDrives[drive.id]?.url
-    }
 }
 }
 
 
 // MARK: - Shared directory
 // MARK: - Shared directory
 extension UTMQemuVirtualMachine {
 extension UTMQemuVirtualMachine {
-    @MainActor var sharedDirectoryURL: URL? {
-        registryEntry.sharedDirectories.first?.url
-    }
-    
-    func clearSharedDirectory() async {
-        if let oldPath = await registryEntry.sharedDirectories.first?.path {
-            await system?.stopAccessingPath(oldPath)
-        }
-        await registryEntry.removeAllSharedDirectories()
-    }
-    
-    func changeSharedDirectory(to url: URL) async throws {
-        await clearSharedDirectory()
-        _ = url.startAccessingSecurityScopedResource()
-        defer {
-            url.stopAccessingSecurityScopedResource()
-        }
-        let file = try await UTMRegistryEntry.File(url: url, isReadOnly: config.sharing.isDirectoryShareReadOnly)
-        await registryEntry.setSingleSharedDirectory(file)
-        if await config.sharing.directoryShareMode == .webdav {
-            if let ioService = ioService {
-                ioService.changeSharedDirectory(url)
-            }
-        } else if await config.sharing.directoryShareMode == .virtfs {
-            let tempBookmark = try url.bookmarkData()
-            try await changeVirtfsSharedDirectory(with: tempBookmark, isSecurityScoped: false)
-        }
+    func stopAccessingPath(_ path: String) async {
+        await system?.stopAccessingPath(path)
     }
     }
-    
+
     func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
     func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
         let system = await system ?? UTMProcess()
         let system = await system ?? UTMProcess()
         let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
         let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
@@ -799,61 +845,10 @@ extension UTMQemuVirtualMachine {
         }
         }
         await registryEntry.updateSingleSharedDirectoryRemoteBookmark(bookmark)
         await registryEntry.updateSingleSharedDirectoryRemoteBookmark(bookmark)
     }
     }
-    
-    func restoreSharedDirectory(for ioService: UTMSpiceIO) async throws {
-        guard let share = await registryEntry.sharedDirectories.first else {
-            return
-        }
-        if await config.sharing.directoryShareMode == .virtfs {
-            if let bookmark = share.remoteBookmark {
-                // a share bookmark was saved while QEMU was running
-                try await changeVirtfsSharedDirectory(with: bookmark, isSecurityScoped: true)
-            } else {
-                // a share bookmark was saved while QEMU was NOT running
-                let url = try URL(resolvingPersistentBookmarkData: share.bookmark)
-                try await changeSharedDirectory(to: url)
-            }
-        } else if await config.sharing.directoryShareMode == .webdav {
-            ioService.changeSharedDirectory(share.url)
-        }
-    }
 }
 }
 
 
 // MARK: - Registry syncing
 // MARK: - Registry syncing
 extension UTMQemuVirtualMachine {
 extension UTMQemuVirtualMachine {
-    @MainActor func updateRegistryFromConfig() async throws {
-        // save a copy to not collide with updateConfigFromRegistry()
-        let configShare = config.sharing.directoryShareUrl
-        let configDrives = config.drives
-        try await updateRegistryBasics()
-        for drive in configDrives {
-            if drive.isExternal, let url = drive.imageURL {
-                try await changeMedium(drive, to: url)
-            } else if drive.isExternal {
-                try await eject(drive)
-            }
-        }
-        if let url = configShare {
-            try await changeSharedDirectory(to: url)
-        } else {
-            await clearSharedDirectory()
-        }
-        // remove any unreferenced drives
-        registryEntry.externalDrives = registryEntry.externalDrives.filter({ element in
-            configDrives.contains(where: { $0.id == element.key && $0.isExternal })
-        })
-    }
-    
-    @MainActor func updateConfigFromRegistry() {
-        config.sharing.directoryShareUrl = sharedDirectoryURL
-        for i in config.drives.indices {
-            let id = config.drives[i].id
-            if config.drives[i].isExternal {
-                config.drives[i].imageURL = registryEntry.externalDrives[id]?.url
-            }
-        }
-    }
-    
     @MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
     @MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
         config.information.uuid = uuid
         config.information.uuid = uuid
         if let name = name {
         if let name = name {
@@ -864,7 +859,7 @@ extension UTMQemuVirtualMachine {
             registryEntry.update(copying: entry)
             registryEntry.update(copying: entry)
         }
         }
     }
     }
-    
+
     @MainActor var remoteBookmarks: [URL: Data] {
     @MainActor var remoteBookmarks: [URL: Data] {
         var dict = [URL: Data]()
         var dict = [URL: Data]()
         for file in registryEntry.externalDrives.values {
         for file in registryEntry.externalDrives.values {
@@ -889,6 +884,7 @@ enum UTMQemuVirtualMachineError: Error {
     case accessShareFailed
     case accessShareFailed
     case invalidVmState
     case invalidVmState
     case saveSnapshotFailed(Error)
     case saveSnapshotFailed(Error)
+    case keyGenerationFailed
 }
 }
 
 
 extension UTMQemuVirtualMachineError: LocalizedError {
 extension UTMQemuVirtualMachineError: LocalizedError {
@@ -905,6 +901,8 @@ extension UTMQemuVirtualMachineError: LocalizedError {
         case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
         case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
         case .saveSnapshotFailed(let error):
         case .saveSnapshotFailed(let error):
             return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
             return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
+        case .keyGenerationFailed:
+            return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
         }
         }
     }
     }
 }
 }

+ 1 - 1
Services/UTMRegistry.swift

@@ -59,7 +59,7 @@ class UTMRegistry: NSObject {
         super.init()
         super.init()
         if let newEntries = try? serializedEntries.mapValues({ value in
         if let newEntries = try? serializedEntries.mapValues({ value in
             let dict = value as! [String: Any]
             let dict = value as! [String: Any]
-            return try UTMRegistryEntry(from: dict)
+            return try UTMRegistryEntry(fromPropertyList: dict)
         }) {
         }) {
             entries = newEntries
             entries = newEntries
         }
         }

+ 10 - 16
Services/UTMRegistryEntry.swift

@@ -15,6 +15,7 @@
 //
 //
 
 
 import Foundation
 import Foundation
+import Combine
 
 
 @objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
 @objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
     /// Empty registry entry used only as a workaround for object initialization
     /// Empty registry entry used only as a workaround for object initialization
@@ -61,7 +62,7 @@ import Foundation
         } else {
         } else {
             package = nil
             package = nil
         }
         }
-        _package = package ?? File(path: path)
+        _package = package ?? File(dummyFromPath: path)
         self.uuid = uuid
         self.uuid = uuid
         _isSuspended = false
         _isSuspended = false
         _externalDrives = [:]
         _externalDrives = [:]
@@ -109,11 +110,7 @@ import Foundation
     }
     }
     
     
     func asDictionary() throws -> [String: Any] {
     func asDictionary() throws -> [String: Any] {
-        let encoder = PropertyListEncoder()
-        encoder.outputFormat = .xml
-        let xml = try encoder.encode(self)
-        let dict = try PropertyListSerialization.propertyList(from: xml, format: nil)
-        return dict as! [String: Any]
+        return try propertyList() as! [String: Any]
     }
     }
     
     
     /// Update the UUID
     /// Update the UUID
@@ -128,13 +125,6 @@ import Foundation
 
 
 protocol UTMRegistryEntryDecodable: Decodable {}
 protocol UTMRegistryEntryDecodable: Decodable {}
 extension UTMRegistryEntry: UTMRegistryEntryDecodable {}
 extension UTMRegistryEntry: UTMRegistryEntryDecodable {}
-extension UTMRegistryEntryDecodable {
-    init(from dictionary: [String: Any]) throws {
-        let data = try PropertyListSerialization.data(fromPropertyList: dictionary, format: .xml, options: 0)
-        let decoder = PropertyListDecoder()
-        self = try decoder.decode(Self.self, from: data)
-    }
-}
 
 
 // MARK: - Accessors
 // MARK: - Accessors
 @MainActor extension UTMRegistryEntry {
 @MainActor extension UTMRegistryEntry {
@@ -177,7 +167,11 @@ extension UTMRegistryEntryDecodable {
             _externalDrives = newValue
             _externalDrives = newValue
         }
         }
     }
     }
-    
+
+    var externalDrivePublisher: Published<[String: File]>.Publisher {
+        $_externalDrives
+    }
+
     var sharedDirectories: [File] {
     var sharedDirectories: [File] {
         get {
         get {
             _sharedDirectories
             _sharedDirectories
@@ -308,7 +302,7 @@ extension UTMRegistryEntry {
         }
         }
         for drive in viewState.allDrives() {
         for drive in viewState.allDrives() {
             if let bookmark = viewState.bookmark(forRemovableDrive: drive), let path = viewState.path(forRemovableDrive: drive) {
             if let bookmark = viewState.bookmark(forRemovableDrive: drive), let path = viewState.path(forRemovableDrive: drive) {
-                let file = File(path: path, remoteBookmark: bookmark)
+                let file = File(dummyFromPath: path, remoteBookmark: bookmark)
                 _externalDrives[drive] = file
                 _externalDrives[drive] = file
             }
             }
         }
         }
@@ -393,7 +387,7 @@ extension UTMRegistryEntry {
             self.isValid = true
             self.isValid = true
         }
         }
         
         
-        fileprivate init(path: String, remoteBookmark: Data = Data()) {
+        init(dummyFromPath path: String, remoteBookmark: Data = Data()) {
             self.path = path
             self.path = path
             self.bookmark = Data()
             self.bookmark = Data()
             self.isReadOnly = false
             self.isReadOnly = false

+ 11 - 2
Services/UTMSpiceIO.h

@@ -16,8 +16,12 @@
 
 
 #import <Foundation/Foundation.h>
 #import <Foundation/Foundation.h>
 #import "UTMSpiceIODelegate.h"
 #import "UTMSpiceIODelegate.h"
+#if defined(WITH_REMOTE)
+#import "UTMRemoteConnectInterface.h"
+#else
 @import QEMUKitInternal;
 @import QEMUKitInternal;
-#if defined(WITH_QEMU_TCI)
+#endif
+#if !defined(WITH_USB)
 @import CocoaSpiceNoUsb;
 @import CocoaSpiceNoUsb;
 #else
 #else
 @import CocoaSpice;
 @import CocoaSpice;
@@ -34,14 +38,18 @@ typedef NS_OPTIONS(NSUInteger, UTMSpiceIOOptions) {
 
 
 NS_ASSUME_NONNULL_BEGIN
 NS_ASSUME_NONNULL_BEGIN
 
 
+#if defined(WITH_REMOTE)
+@interface UTMSpiceIO : NSObject<CSConnectionDelegate, UTMRemoteConnectInterface>
+#else
 @interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
 @interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
+#endif
 
 
 @property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
 @property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
 @property (nonatomic, readonly, nullable) CSInput *primaryInput;
 @property (nonatomic, readonly, nullable) CSInput *primaryInput;
 @property (nonatomic, readonly, nullable) CSPort *primarySerial;
 @property (nonatomic, readonly, nullable) CSPort *primarySerial;
 @property (nonatomic, readonly) NSArray<CSDisplay *> *displays;
 @property (nonatomic, readonly) NSArray<CSDisplay *> *displays;
 @property (nonatomic, readonly) NSArray<CSPort *> *serials;
 @property (nonatomic, readonly) NSArray<CSPort *> *serials;
-#if !defined(WITH_QEMU_TCI)
+#if defined(WITH_USB)
 @property (nonatomic, readonly, nullable) CSUSBManager *primaryUsbManager;
 @property (nonatomic, readonly, nullable) CSUSBManager *primaryUsbManager;
 #endif
 #endif
 @property (nonatomic, weak, nullable) id<UTMSpiceIODelegate> delegate;
 @property (nonatomic, weak, nullable) id<UTMSpiceIODelegate> delegate;
@@ -50,6 +58,7 @@ NS_ASSUME_NONNULL_BEGIN
 
 
 - (instancetype)init NS_UNAVAILABLE;
 - (instancetype)init NS_UNAVAILABLE;
 - (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
 - (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
+- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey password:(NSString *)password options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
 - (void)changeSharedDirectory:(NSURL *)url;
 - (void)changeSharedDirectory:(NSURL *)url;
 
 
 - (BOOL)startWithError:(NSError * _Nullable *)error;
 - (BOOL)startWithError:(NSError * _Nullable *)error;

+ 56 - 17
Services/UTMSpiceIO.m

@@ -22,20 +22,23 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
 
 
 @interface UTMSpiceIO ()
 @interface UTMSpiceIO ()
 
 
-@property (nonatomic) NSURL *socketUrl;
+@property (nonatomic, nullable) NSURL *socketUrl;
+@property (nonatomic, nullable) NSString *host;
+@property (nonatomic) NSInteger tlsPort;
+@property (nonatomic, nullable) NSData *serverPublicKey;
+@property (nonatomic, nullable) NSString *password;
 @property (nonatomic) UTMSpiceIOOptions options;
 @property (nonatomic) UTMSpiceIOOptions options;
 @property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
 @property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
 @property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
 @property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
 @property (nonatomic, readwrite, nullable) CSInput *primaryInput;
 @property (nonatomic, readwrite, nullable) CSInput *primaryInput;
 @property (nonatomic, readwrite, nullable) CSPort *primarySerial;
 @property (nonatomic, readwrite, nullable) CSPort *primarySerial;
 @property (nonatomic) NSMutableArray<CSPort *> *mutableSerials;
 @property (nonatomic) NSMutableArray<CSPort *> *mutableSerials;
-#if !defined(WITH_QEMU_TCI)
+#if defined(WITH_USB)
 @property (nonatomic, readwrite, nullable) CSUSBManager *primaryUsbManager;
 @property (nonatomic, readwrite, nullable) CSUSBManager *primaryUsbManager;
 #endif
 #endif
 @property (nonatomic, nullable) CSConnection *spiceConnection;
 @property (nonatomic, nullable) CSConnection *spiceConnection;
 @property (nonatomic, nullable) CSMain *spice;
 @property (nonatomic, nullable) CSMain *spice;
 @property (nonatomic, nullable, copy) NSURL *sharedDirectory;
 @property (nonatomic, nullable, copy) NSURL *sharedDirectory;
-@property (nonatomic) NSInteger port;
 @property (nonatomic) BOOL dynamicResolutionSupported;
 @property (nonatomic) BOOL dynamicResolutionSupported;
 @property (nonatomic, readwrite) BOOL isConnected;
 @property (nonatomic, readwrite) BOOL isConnected;
 
 
@@ -72,10 +75,29 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
     return self;
     return self;
 }
 }
 
 
+- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey password:(NSString *)password options:(UTMSpiceIOOptions)options {
+    if (self = [super init]) {
+        self.host = host;
+        self.tlsPort = tlsPort;
+        self.serverPublicKey = serverPublicKey;
+        self.password = password;
+        self.options = options;
+        self.mutableDisplays = [NSMutableArray array];
+        self.mutableSerials = [NSMutableArray array];
+    }
+
+    return self;
+}
+
 - (void)initializeSpiceIfNeeded {
 - (void)initializeSpiceIfNeeded {
     if (!self.spiceConnection) {
     if (!self.spiceConnection) {
-        NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
-        self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
+        if (self.socketUrl) {
+            NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
+            self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
+        } else {
+            self.spiceConnection = [[CSConnection alloc] initWithHost:self.host tlsPort:[@(self.tlsPort) stringValue] serverPublicKey:self.serverPublicKey];
+            self.spiceConnection.password = self.password;
+        }
         self.spiceConnection.delegate = self;
         self.spiceConnection.delegate = self;
         self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
         self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
         self.spiceConnection.session.shareClipboard = (self.options & UTMSpiceIOOptionsHasClipboardSharing) == UTMSpiceIOOptionsHasClipboardSharing;
         self.spiceConnection.session.shareClipboard = (self.options & UTMSpiceIOOptionsHasClipboardSharing) == UTMSpiceIOOptionsHasClipboardSharing;
@@ -94,13 +116,15 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
     }
     }
     // do not need to encode/decode audio locally
     // do not need to encode/decode audio locally
     g_setenv("SPICE_DISABLE_OPUS", "1", YES);
     g_setenv("SPICE_DISABLE_OPUS", "1", YES);
-    // need to chdir to workaround AF_UNIX sun_len limitations
-    NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path;
-    if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) {
-        if (error) {
-            *error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to change current directory.", "UTMSpiceIO")}];
+    if (self.socketUrl) {
+        // need to chdir to workaround AF_UNIX sun_len limitations
+        NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path;
+        if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) {
+            if (error) {
+                *error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to change current directory.", "UTMSpiceIO")}];
+            }
+            return NO;
         }
         }
-        return NO;
     }
     }
     if (![self.spice spiceStart]) {
     if (![self.spice spiceStart]) {
         if (error) {
         if (error) {
@@ -135,7 +159,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
     self.primaryInput = nil;
     self.primaryInput = nil;
     self.primarySerial = nil;
     self.primarySerial = nil;
     [self.mutableSerials removeAllObjects];
     [self.mutableSerials removeAllObjects];
-#if !defined(WITH_QEMU_TCI)
+#if defined(WITH_USB)
     self.primaryUsbManager = nil;
     self.primaryUsbManager = nil;
 #endif
 #endif
 }
 }
@@ -154,10 +178,13 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
 - (void)spiceConnected:(CSConnection *)connection {
 - (void)spiceConnected:(CSConnection *)connection {
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     self.isConnected = YES;
     self.isConnected = YES;
-#if !defined(WITH_QEMU_TCI)
+#if defined(WITH_USB)
     self.primaryUsbManager = connection.usbManager;
     self.primaryUsbManager = connection.usbManager;
     [self.delegate spiceDidChangeUsbManager:connection.usbManager];
     [self.delegate spiceDidChangeUsbManager:connection.usbManager];
 #endif
 #endif
+#if defined(WITH_REMOTE)
+    [self.connectDelegate remoteInterfaceDidConnect:self];
+#endif
 }
 }
 
 
 - (void)spiceInputAvailable:(CSConnection *)connection input:(CSInput *)input {
 - (void)spiceInputAvailable:(CSConnection *)connection input:(CSInput *)input {
@@ -177,12 +204,17 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
 - (void)spiceDisconnected:(CSConnection *)connection {
 - (void)spiceDisconnected:(CSConnection *)connection {
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     self.isConnected = NO;
     self.isConnected = NO;
+    [self.delegate spiceDidDisconnect];
 }
 }
 
 
 - (void)spiceError:(CSConnection *)connection code:(CSConnectionError)code message:(nullable NSString *)message {
 - (void)spiceError:(CSConnection *)connection code:(CSConnectionError)code message:(nullable NSString *)message {
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     self.isConnected = NO;
     self.isConnected = NO;
+#if defined(WITH_REMOTE)
+    [self.connectDelegate remoteInterface:self didErrorWithMessage:message];
+#else
     [self.connectDelegate qemuInterface:self didErrorWithMessage:message];
     [self.connectDelegate qemuInterface:self didErrorWithMessage:message];
+#endif
 }
 }
 
 
 - (void)spiceDisplayCreated:(CSConnection *)connection display:(CSDisplay *)display {
 - (void)spiceDisplayCreated:(CSConnection *)connection display:(CSDisplay *)display {
@@ -202,6 +234,9 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
 - (void)spiceDisplayDestroyed:(CSConnection *)connection display:(CSDisplay *)display {
 - (void)spiceDisplayDestroyed:(CSConnection *)connection display:(CSDisplay *)display {
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     NSAssert(connection == self.spiceConnection, @"Unknown connection");
     [self.mutableDisplays removeObject:display];
     [self.mutableDisplays removeObject:display];
+    if (self.primaryDisplay == display) {
+        self.primaryDisplay = nil;
+    }
     [self.delegate spiceDidDestroyDisplay:display];
     [self.delegate spiceDidDestroyDisplay:display];
 }
 }
 
 
@@ -215,12 +250,16 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
 
 
 - (void)spiceForwardedPortOpened:(CSConnection *)connection port:(CSPort *)port {
 - (void)spiceForwardedPortOpened:(CSConnection *)connection port:(CSPort *)port {
     if ([port.name isEqualToString:@"org.qemu.monitor.qmp.0"]) {
     if ([port.name isEqualToString:@"org.qemu.monitor.qmp.0"]) {
+#if !defined(WITH_REMOTE)
         UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
         UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
         [self.connectDelegate qemuInterface:self didCreateMonitorPort:qemuPort];
         [self.connectDelegate qemuInterface:self didCreateMonitorPort:qemuPort];
+#endif
     }
     }
     if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
     if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
+#if !defined(WITH_REMOTE)
         UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
         UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
         [self.connectDelegate qemuInterface:self didCreateGuestAgentPort:qemuPort];
         [self.connectDelegate qemuInterface:self didCreateGuestAgentPort:qemuPort];
+#endif
     }
     }
     if ([port.name isEqualToString:@"com.utmapp.terminal.0"]) {
     if ([port.name isEqualToString:@"com.utmapp.terminal.0"]) {
         self.primarySerial = port;
         self.primarySerial = port;
@@ -236,11 +275,11 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
     }
     }
     if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
     if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
     }
     }
-    if ([port.name isEqualToString:@"com.utmapp.terminal.0"]) {
-        self.primarySerial = port;
-    }
     if ([port.name hasPrefix:@"com.utmapp.terminal."]) {
     if ([port.name hasPrefix:@"com.utmapp.terminal."]) {
         [self.mutableSerials removeObject:port];
         [self.mutableSerials removeObject:port];
+        if (self.primarySerial == port) {
+            self.primarySerial = nil;
+        }
         [self.delegate spiceDidDestroySerial:port];
         [self.delegate spiceDidDestroySerial:port];
     }
     }
 }
 }
@@ -285,7 +324,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
     if (self.primarySerial) {
     if (self.primarySerial) {
         [self.delegate spiceDidCreateSerial:self.primarySerial];
         [self.delegate spiceDidCreateSerial:self.primarySerial];
     }
     }
-#if !defined(WITH_QEMU_TCI)
+#if defined(WITH_USB)
     if (self.primaryUsbManager) {
     if (self.primaryUsbManager) {
         [self.delegate spiceDidChangeUsbManager:self.primaryUsbManager];
         [self.delegate spiceDidChangeUsbManager:self.primaryUsbManager];
     }
     }

+ 2 - 1
Services/UTMSpiceIODelegate.h

@@ -32,12 +32,13 @@ NS_ASSUME_NONNULL_BEGIN
 - (void)spiceDidUpdateDisplay:(CSDisplay *)display NS_SWIFT_NAME(spiceDidUpdateDisplay(_:));
 - (void)spiceDidUpdateDisplay:(CSDisplay *)display NS_SWIFT_NAME(spiceDidUpdateDisplay(_:));
 - (void)spiceDidCreateSerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidCreateSerial(_:));
 - (void)spiceDidCreateSerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidCreateSerial(_:));
 - (void)spiceDidDestroySerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidDestroySerial(_:));
 - (void)spiceDidDestroySerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidDestroySerial(_:));
-#if !defined(WITH_QEMU_TCI)
+#if defined(WITH_USB)
 - (void)spiceDidChangeUsbManager:(nullable CSUSBManager *)usbManager NS_SWIFT_NAME(spiceDidChangeUsbManager(_:));
 - (void)spiceDidChangeUsbManager:(nullable CSUSBManager *)usbManager NS_SWIFT_NAME(spiceDidChangeUsbManager(_:));
 #endif
 #endif
 
 
 @optional
 @optional
 - (void)spiceDynamicResolutionSupportDidChange:(BOOL)supported;
 - (void)spiceDynamicResolutionSupportDidChange:(BOOL)supported;
+- (void)spiceDidDisconnect;
 
 
 @end
 @end
 
 

+ 177 - 0
Services/UTMSpiceVirtualMachine.swift

@@ -0,0 +1,177 @@
+//
+// 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
+
+/// Common methods for all SPICE virtual machines
+protocol UTMSpiceVirtualMachine: UTMVirtualMachine where Configuration == UTMQemuConfiguration {
+    /// Set when VM is running with saving changes
+    var isRunningAsDisposible: Bool { get }
+    
+    /// Get and set screenshot
+    var screenshot: UTMVirtualMachineScreenshot? { get set }
+
+    /// Handles IO
+    var ioServiceDelegate: UTMSpiceIODelegate? { get set }
+    
+    /// SPICE interface
+    var ioService: UTMSpiceIO? { get }
+    
+    /// Change input mode
+    /// - Parameter tablet: If true, mouse events will be absolute
+    func requestInputTablet(_ tablet: Bool)
+
+    /// Eject a removable drive
+    /// - Parameter drive: Removable drive
+    func eject(_ drive: UTMQemuConfigurationDrive) async throws
+    
+    /// Change mount image of a removable drive
+    /// - Parameters:
+    ///   - drive: Removable drive
+    ///   - url: New mount image
+    func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL) async throws
+    
+    /// Release resources for accessing a path
+    /// - Parameter path: Path to stop accessing
+    func stopAccessingPath(_ path: String) async
+
+    /// Setup access to a VirtFS shared directory
+    ///
+    /// Throw an exception if this is not supported.
+    /// - Parameters:
+    ///   - bookmark: Bookmark to access
+    ///   - isSecurityScoped: Is the bookmark security scoped?
+    func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws
+}
+
+// MARK: - USB redirection
+extension UTMSpiceVirtualMachine {
+    var hasUsbRedirection: Bool {
+        #if HAS_USB
+        return jb_has_usb_entitlement()
+        #else
+        return false
+        #endif
+    }
+}
+
+// MARK: - Screenshot
+extension UTMSpiceVirtualMachine {
+    @MainActor @discardableResult
+    func takeScreenshot() async -> Bool {
+        if let screenshot = await ioService?.screenshot() {
+            self.screenshot = UTMVirtualMachineScreenshot(wrapping: screenshot.image)
+        }
+        return true
+    }
+
+    func reloadScreenshotFromFile() {
+        screenshot = loadScreenshot()
+    }
+}
+
+// MARK: - External drives
+extension UTMSpiceVirtualMachine {
+    @MainActor func externalImageURL(for drive: UTMQemuConfigurationDrive) -> URL? {
+        registryEntry.externalDrives[drive.id]?.url
+    }
+}
+
+// MARK: - Shared directory
+extension UTMSpiceVirtualMachine {
+    @MainActor var sharedDirectoryURL: URL? {
+        registryEntry.sharedDirectories.first?.url
+    }
+
+    func clearSharedDirectory() async {
+        if let oldPath = await registryEntry.sharedDirectories.first?.path {
+            await stopAccessingPath(oldPath)
+        }
+        await registryEntry.removeAllSharedDirectories()
+    }
+
+    func changeSharedDirectory(to url: URL) async throws {
+        await clearSharedDirectory()
+        _ = url.startAccessingSecurityScopedResource()
+        defer {
+            url.stopAccessingSecurityScopedResource()
+        }
+        let file = try await UTMRegistryEntry.File(url: url, isReadOnly: config.sharing.isDirectoryShareReadOnly)
+        await registryEntry.setSingleSharedDirectory(file)
+        if await config.sharing.directoryShareMode == .webdav {
+            if let ioService = ioService {
+                ioService.changeSharedDirectory(url)
+            }
+        } else if await config.sharing.directoryShareMode == .virtfs {
+            let tempBookmark = try url.bookmarkData()
+            try await changeVirtfsSharedDirectory(with: tempBookmark, isSecurityScoped: false)
+        }
+    }
+
+    func restoreSharedDirectory(for ioService: UTMSpiceIO) async throws {
+        guard let share = await registryEntry.sharedDirectories.first else {
+            return
+        }
+        if await config.sharing.directoryShareMode == .virtfs {
+            if let bookmark = share.remoteBookmark {
+                // a share bookmark was saved while QEMU was running
+                try await changeVirtfsSharedDirectory(with: bookmark, isSecurityScoped: true)
+            } else {
+                // a share bookmark was saved while QEMU was NOT running
+                let url = try URL(resolvingPersistentBookmarkData: share.bookmark)
+                try await changeSharedDirectory(to: url)
+            }
+        } else if await config.sharing.directoryShareMode == .webdav {
+            ioService.changeSharedDirectory(share.url)
+        }
+    }
+}
+
+// MARK: - Registry syncing
+extension UTMSpiceVirtualMachine {
+    @MainActor func updateRegistryFromConfig() async throws {
+        // save a copy to not collide with updateConfigFromRegistry()
+        let configShare = config.sharing.directoryShareUrl
+        let configDrives = config.drives
+        try await updateRegistryBasics()
+        for drive in configDrives {
+            if drive.isExternal, let url = drive.imageURL {
+                try await changeMedium(drive, to: url)
+            } else if drive.isExternal {
+                try await eject(drive)
+            }
+        }
+        if let url = configShare {
+            try await changeSharedDirectory(to: url)
+        } else {
+            await clearSharedDirectory()
+        }
+        // remove any unreferenced drives
+        registryEntry.externalDrives = registryEntry.externalDrives.filter({ element in
+            configDrives.contains(where: { $0.id == element.key && $0.isExternal })
+        })
+    }
+
+    @MainActor func updateConfigFromRegistry() {
+        config.sharing.directoryShareUrl = sharedDirectoryURL
+        for i in config.drives.indices {
+            let id = config.drives[i].id
+            if config.drives[i].isExternal {
+                config.drives[i].imageURL = registryEntry.externalDrives[id]?.url
+            }
+        }
+    }
+}

+ 54 - 23
Services/UTMVirtualMachine.swift

@@ -24,7 +24,7 @@ import UIKit
 
 
 private let kUTMBundleExtension = "utm"
 private let kUTMBundleExtension = "utm"
 private let kScreenshotPeriodSeconds = 60.0
 private let kScreenshotPeriodSeconds = 60.0
-private let kUTMBundleScreenshotFilename = "screenshot.png"
+let kUTMBundleScreenshotFilename = "screenshot.png"
 private let kUTMBundleViewFilename = "view.plist"
 private let kUTMBundleViewFilename = "view.plist"
 
 
 /// UTM virtual machine backend
 /// UTM virtual machine backend
@@ -66,8 +66,8 @@ protocol UTMVirtualMachine: AnyObject, Identifiable {
     var state: UTMVirtualMachineState { get }
     var state: UTMVirtualMachineState { get }
     
     
     /// If non-null, is the most recent screenshot of the running VM
     /// If non-null, is the most recent screenshot of the running VM
-    var screenshot: PlatformImage? { get }
-    
+    var screenshot: UTMVirtualMachineScreenshot? { get }
+
     /// If non-null, `saveSnapshot` and `restoreSnapshot` will not work due to the reason specified
     /// If non-null, `saveSnapshot` and `restoreSnapshot` will not work due to the reason specified
     var snapshotUnsupportedError: Error? { get }
     var snapshotUnsupportedError: Error? { get }
     
     
@@ -149,6 +149,9 @@ protocol UTMVirtualMachine: AnyObject, Identifiable {
     /// Request a screenshot of the primary graphics device
     /// Request a screenshot of the primary graphics device
     /// - Returns: true if successful and the screenshot will be in `screenshot`
     /// - Returns: true if successful and the screenshot will be in `screenshot`
     @discardableResult func takeScreenshot() async -> Bool
     @discardableResult func takeScreenshot() async -> Bool
+    
+    /// If screenshot is modified externally, this must be called
+    func reloadScreenshotFromFile() throws
 }
 }
 
 
 /// Supported capabilities for a UTM backend
 /// Supported capabilities for a UTM backend
@@ -167,6 +170,9 @@ protocol UTMVirtualMachineCapabilities {
     
     
     /// The backend supports booting into recoveryOS.
     /// The backend supports booting into recoveryOS.
     var supportsRecoveryMode: Bool { get }
     var supportsRecoveryMode: Bool { get }
+    
+    /// The backend supports remote sessions.
+    var supportsRemoteSession: Bool { get }
 }
 }
 
 
 /// Delegate for UTMVirtualMachine events
 /// Delegate for UTMVirtualMachine events
@@ -201,7 +207,7 @@ protocol UTMVirtualMachineDelegate: AnyObject {
 }
 }
 
 
 /// Virtual machine state
 /// Virtual machine state
-enum UTMVirtualMachineState {
+enum UTMVirtualMachineState: Codable {
     case stopped
     case stopped
     case starting
     case starting
     case started
     case started
@@ -214,17 +220,19 @@ enum UTMVirtualMachineState {
 }
 }
 
 
 /// Additional options for VM start
 /// Additional options for VM start
-struct UTMVirtualMachineStartOptions: OptionSet {
+struct UTMVirtualMachineStartOptions: OptionSet, Codable {
     let rawValue: UInt
     let rawValue: UInt
     
     
     /// Boot without persisting any changes.
     /// Boot without persisting any changes.
     static let bootDisposibleMode = Self(rawValue: 1 << 0)
     static let bootDisposibleMode = Self(rawValue: 1 << 0)
     /// Boot into recoveryOS (when supported).
     /// Boot into recoveryOS (when supported).
     static let bootRecovery = Self(rawValue: 1 << 1)
     static let bootRecovery = Self(rawValue: 1 << 1)
+    /// Start VDI session where a remote client will connect to.
+    static let remoteSession = Self(rawValue: 1 << 2)
 }
 }
 
 
 /// Method to stop the VM
 /// Method to stop the VM
-enum UTMVirtualMachineStopMethod {
+enum UTMVirtualMachineStopMethod: Codable {
     /// Sends a request to the guest to shut down gracefully.
     /// Sends a request to the guest to shut down gracefully.
     case request
     case request
     /// Sends a hardware power down signal.
     /// Sends a hardware power down signal.
@@ -282,6 +290,43 @@ extension UTMVirtualMachine {
 
 
 // MARK: - Screenshot
 // MARK: - Screenshot
 
 
+struct UTMVirtualMachineScreenshot {
+    let image: PlatformImage
+    let pngData: Data?
+
+    init?(contentsOfURL url: URL) {
+        #if canImport(AppKit)
+        guard let image = NSImage(contentsOf: url) else {
+            return nil
+        }
+        #elseif canImport(UIKit)
+        guard let image = UIImage(contentsOfURL: url) else {
+            return nil
+        }
+        #endif
+        self.image = image
+        self.pngData = Self.createData(from: image)
+    }
+
+    init(wrapping image: PlatformImage) {
+        self.image = image
+        self.pngData = Self.createData(from: image)
+    }
+
+    private static func createData(from image: PlatformImage) -> Data? {
+        #if canImport(AppKit)
+        guard let cgref = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+            return nil
+        }
+        let newrep = NSBitmapImageRep(cgImage: cgref)
+        newrep.size = image.size
+        return newrep.representation(using: .png, properties: [:])
+        #elseif canImport(UIKit)
+        return image.pngData()
+        #endif
+    }
+}
+
 extension UTMVirtualMachine {
 extension UTMVirtualMachine {
     private var isScreenshotSaveEnabled: Bool {
     private var isScreenshotSaveEnabled: Bool {
         !UserDefaults.standard.bool(forKey: "NoSaveScreenshot")
         !UserDefaults.standard.bool(forKey: "NoSaveScreenshot")
@@ -311,12 +356,8 @@ extension UTMVirtualMachine {
         return timer
         return timer
     }
     }
     
     
-    func loadScreenshot() -> PlatformImage? {
-        #if canImport(AppKit)
-        return NSImage(contentsOf: screenshotUrl)
-        #elseif canImport(UIKit)
-        return UIImage(contentsOfURL: screenshotUrl)
-        #endif
+    func loadScreenshot() -> UTMVirtualMachineScreenshot? {
+        UTMVirtualMachineScreenshot(contentsOfURL: screenshotUrl)
     }
     }
     
     
     func saveScreenshot() throws {
     func saveScreenshot() throws {
@@ -326,17 +367,7 @@ extension UTMVirtualMachine {
         guard let screenshot = screenshot else {
         guard let screenshot = screenshot else {
             return
             return
         }
         }
-        #if canImport(AppKit)
-        guard let cgref = screenshot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
-            return
-        }
-        let newrep = NSBitmapImageRep(cgImage: cgref)
-        newrep.size = screenshot.size
-        let pngdata = newrep.representation(using: .png, properties: [:])
-        try pngdata?.write(to: screenshotUrl)
-        #elseif canImport(UIKit)
-        try screenshot.pngData()?.write(to: screenshotUrl)
-        #endif
+        try screenshot.pngData?.write(to: screenshotUrl)
     }
     }
     
     
     func deleteScreenshot() throws {
     func deleteScreenshot() throws {

File diff suppressed because it is too large
+ 545 - 7
UTM.xcodeproj/project.pbxproj


+ 40 - 4
UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -15,7 +15,16 @@
       "location" : "https://github.com/utmapp/CocoaSpice.git",
       "location" : "https://github.com/utmapp/CocoaSpice.git",
       "state" : {
       "state" : {
         "branch" : "visionos",
         "branch" : "visionos",
-        "revision" : "4529c9686259e8d1e94d6253ad2e3a563fd1498d"
+        "revision" : "9fd682e0f78c884036609d4a19db2cfb3ed50c33"
+      }
+    },
+    {
+      "identity" : "cod",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/saagarjha/Cod.git",
+      "state" : {
+        "branch" : "main",
+        "revision" : "c359a08accfb49662a17cdfc5e333c7b4e5c2c56"
       }
       }
     },
     },
     {
     {
@@ -63,13 +72,31 @@
         "version" : "1.5.3"
         "version" : "1.5.3"
       }
       }
     },
     },
+    {
+      "identity" : "swiftconnect",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/utmapp/SwiftConnect",
+      "state" : {
+        "branch" : "main",
+        "revision" : "af855e47ca222da163cc7f4f185230f36ba8694a"
+      }
+    },
+    {
+      "identity" : "swiftportmap",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/osy/SwiftPortmap.git",
+      "state" : {
+        "branch" : "main",
+        "revision" : "72782141ab6f6f6db58bd16bac96d4e7ce901e9a"
+      }
+    },
     {
     {
       "identity" : "swiftterm",
       "identity" : "swiftterm",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
-      "location" : "https://github.com/osy/SwiftTerm.git",
+      "location" : "https://github.com/migueldeicaza/SwiftTerm.git",
       "state" : {
       "state" : {
-        "branch" : "visionos",
-        "revision" : "8b0900a4c516eb8c87813f11e797f349e7fca014"
+        "branch" : "main",
+        "revision" : "ea0f681b25c8385b4a5a48d435e61d11392216e0"
       }
       }
     },
     },
     {
     {
@@ -81,6 +108,15 @@
         "version" : "1.0.3"
         "version" : "1.0.3"
       }
       }
     },
     },
+    {
+      "identity" : "visionkeyboardkit",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/utmapp/VisionKeyboardKit.git",
+      "state" : {
+        "branch" : "main",
+        "revision" : "0804e4d64267acc8d08fb23160f5b6ac6134414f"
+      }
+    },
     {
     {
       "identity" : "zipfoundation",
       "identity" : "zipfoundation",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",

+ 2 - 0
scripts/build_dependencies.sh

@@ -409,6 +409,8 @@ build_angle () {
     pwd="$(pwd)"
     pwd="$(pwd)"
     cd "$BUILD_DIR/WebKit.git/Source/ThirdParty/ANGLE"
     cd "$BUILD_DIR/WebKit.git/Source/ThirdParty/ANGLE"
     xcodebuild archive -archivePath "ANGLE" -scheme "ANGLE" -sdk $SDK -arch $ARCH -configuration Release WEBCORE_LIBRARY_DIR="/usr/local/lib" IPHONEOS_DEPLOYMENT_TARGET="14.0" MACOSX_DEPLOYMENT_TARGET="11.0" XROS_DEPLOYMENT_TARGET="1.0"
     xcodebuild archive -archivePath "ANGLE" -scheme "ANGLE" -sdk $SDK -arch $ARCH -configuration Release WEBCORE_LIBRARY_DIR="/usr/local/lib" IPHONEOS_DEPLOYMENT_TARGET="14.0" MACOSX_DEPLOYMENT_TARGET="11.0" XROS_DEPLOYMENT_TARGET="1.0"
+    # strip broken entitlements from signature
+    find "ANGLE.xcarchive/Products/usr/local/lib/" -name '*.dylib' -exec codesign -fs - \{\} \;
     rsync -a "ANGLE.xcarchive/Products/usr/local/lib/" "$PREFIX/lib"
     rsync -a "ANGLE.xcarchive/Products/usr/local/lib/" "$PREFIX/lib"
     rsync -a "include/" "$PREFIX/include"
     rsync -a "include/" "$PREFIX/include"
     cd "$pwd"
     cd "$pwd"

+ 18 - 41
scripts/build_utm.sh

@@ -7,11 +7,12 @@ command -v realpath >/dev/null 2>&1 || realpath() {
 BASEDIR="$(dirname "$(realpath $0)")"
 BASEDIR="$(dirname "$(realpath $0)")"
 
 
 usage () {
 usage () {
-    echo "Usage: $(basename $0)  [-t teamid] [-p platform] [-a architecture] [-t targetversion] [-o output]"
+    echo "Usage: $(basename $0)  [-t teamid] [-p platform] [-s scheme] [-a architecture] [-t targetversion] [-o output]"
     echo ""
     echo ""
     echo "  -t teamid        Team Identifier for app groups. Optional for iOS. Required for macOS."
     echo "  -t teamid        Team Identifier for app groups. Optional for iOS. Required for macOS."
-    echo "  -p platform      Target platform. Default ios. [ios|ios_simulator|ios-tci|ios_simulator-tci|macos|visionos|visionos_simulator]"
-    echo "  -a architecture  Target architecture. Default arm64. [armv7|armv7s|arm64|i386|x86_64]"
+    echo "  -k sdk           Target SDK. Default iphoneos. [iphoneos|iphonesimulator|xros|xrsimulator|macosx]"
+    echo "  -s scheme        Target scheme. Default iOS/macOS depending on platform. [iOS|iOS-TCI|iOS-Remote|macOS]"
+    echo "  -a architecture  Target architecture. Default arm64. [arm64|x86_64]"
     echo "  -o output        Output archive path. Default is current directory."
     echo "  -o output        Output archive path. Default is current directory."
     echo ""
     echo ""
     exit 1
     exit 1
@@ -20,9 +21,8 @@ usage () {
 PRODUCT_BUNDLE_PREFIX="com.utmapp"
 PRODUCT_BUNDLE_PREFIX="com.utmapp"
 TEAM_IDENTIFIER=
 TEAM_IDENTIFIER=
 ARCH=arm64
 ARCH=arm64
-PLATFORM=ios
 OUTPUT=$PWD
 OUTPUT=$PWD
-SDK=
+SDK=iphoneos
 SCHEME=
 SCHEME=
 
 
 while [ "x$1" != "x" ]; do
 while [ "x$1" != "x" ]; do
@@ -35,8 +35,12 @@ while [ "x$1" != "x" ]; do
         ARCH=$2
         ARCH=$2
         shift
         shift
         ;;
         ;;
-    -p )
-        PLATFORM=$2
+    -k )
+        SDK=$2
+        shift
+        ;;
+    -s )
+        SCHEME=$2
         shift
         shift
         ;;
         ;;
     -o )
     -o )
@@ -50,39 +54,14 @@ while [ "x$1" != "x" ]; do
     shift
     shift
 done
 done
 
 
-case $PLATFORM in
-*-tci )
-    SCHEME="iOS-TCI"
-    ;;
-ios* | visionos* )
-    SCHEME="iOS"
-    ;;
+case $SDK in
 macos )
 macos )
     SCHEME="macOS"
     SCHEME="macOS"
     ;;
     ;;
 * )
 * )
-    usage
-    ;;
-esac
-
-case $PLATFORM in
-visionos_simulator* )
-    SDK=xrsimulator
-    ;;
-visionos* )
-    SDK=xros
-    ;;
-ios_simulator* )
-    SDK=iphonesimulator
-    ;;
-ios* )
-    SDK=iphoneos
-    ;;
-macos )
-    SDK=macosx
-    ;;
-* )
-    usage
+    if [ -z "$SCHEME" ]; then
+        SCHEME="iOS"
+    fi
     ;;
     ;;
 esac
 esac
 
 
@@ -94,8 +73,7 @@ fi
 xcodebuild archive -archivePath "$OUTPUT" -scheme "$SCHEME" -sdk "$SDK" $ARCH_ARGS -configuration Release CODE_SIGNING_ALLOWED=NO $TEAM_IDENTIFIER_PREFIX
 xcodebuild archive -archivePath "$OUTPUT" -scheme "$SCHEME" -sdk "$SDK" $ARCH_ARGS -configuration Release CODE_SIGNING_ALLOWED=NO $TEAM_IDENTIFIER_PREFIX
 BUILT_PATH=$(find $OUTPUT.xcarchive -name '*.app' -type d | head -1)
 BUILT_PATH=$(find $OUTPUT.xcarchive -name '*.app' -type d | head -1)
 # Only retain the target architecture to address < iOS 15 crash & save disk space
 # Only retain the target architecture to address < iOS 15 crash & save disk space
-case $PLATFORM in
-ios | ios-tci )
+if [ "$SDK" == "iphoneos" ]; then
     find "$BUILT_PATH" -type f -path '*/Frameworks/*.dylib' | while read FILE; do
     find "$BUILT_PATH" -type f -path '*/Frameworks/*.dylib' | while read FILE; do
         if [[ $(lipo -info "$FILE") =~ "Architectures in the fat file" ]]; then
         if [[ $(lipo -info "$FILE") =~ "Architectures in the fat file" ]]; then
             lipo -thin $ARCH "$FILE" -output "$FILE"
             lipo -thin $ARCH "$FILE" -output "$FILE"
@@ -107,10 +85,9 @@ ios | ios-tci )
             lipo -thin $ARCH "$FILE" -output "$FILE"
             lipo -thin $ARCH "$FILE" -output "$FILE"
         fi
         fi
     done
     done
-    ;;
-esac
+fi
 find "$BUILT_PATH" -type d -path '*/Frameworks/*.framework' -exec codesign --force --sign - --timestamp=none \{\} \;
 find "$BUILT_PATH" -type d -path '*/Frameworks/*.framework' -exec codesign --force --sign - --timestamp=none \{\} \;
-if [ "$PLATFORM" == "macos" ]; then
+if [ "$SDK" == "macosx" ]; then
     # always build with vm entitlements, package_mac.sh can strip it later
     # always build with vm entitlements, package_mac.sh can strip it later
     # this way we can import into Xcode and re-sign from there
     # this way we can import into Xcode and re-sign from there
     UTM_ENTITLEMENTS="/tmp/utm.$$.entitlements"
     UTM_ENTITLEMENTS="/tmp/utm.$$.entitlements"

+ 8 - 2
scripts/package.sh

@@ -12,7 +12,8 @@ usage() {
 	echo "  MODE is one of:"
 	echo "  MODE is one of:"
 	echo "          deb (Cydia DEB)"
 	echo "          deb (Cydia DEB)"
 	echo "          ipa (unsigned IPA of full build with all entitlements)"
 	echo "          ipa (unsigned IPA of full build with all entitlements)"
-	echo "          ipa-se (unsigned IPA of TCI build)"
+	echo "          ipa-se (unsigned IPA of SE build)"
+	echo "          ipa-remote (unsigned IPA of Remote build)"
 	echo "          ipa-hv (unsigned IPA of full build without JIT entitlement)"
 	echo "          ipa-hv (unsigned IPA of full build without JIT entitlement)"
 	echo "          ipa-signed (developer signed IPA with valid PROFILE_NAME and TEAM_ID)"
 	echo "          ipa-signed (developer signed IPA with valid PROFILE_NAME and TEAM_ID)"
 	echo "  inputXcarchive is path to UTM.xcarchive"
 	echo "  inputXcarchive is path to UTM.xcarchive"
@@ -42,6 +43,11 @@ ipa-se )
 	BUNDLE_ID="com.utmapp.UTM-SE"
 	BUNDLE_ID="com.utmapp.UTM-SE"
 	INPUT_APP="$INPUT/Products/Applications/UTM SE.app"
 	INPUT_APP="$INPUT/Products/Applications/UTM SE.app"
 	;;
 	;;
+ipa-remote )
+	NAME="UTM Remote"
+	BUNDLE_ID="com.utmapp.UTM-Remote"
+	INPUT_APP="$INPUT/Products/Applications/UTM Remote.app"
+	;;
 * )
 * )
 	usage
 	usage
 	;;
 	;;
@@ -298,7 +304,7 @@ EOL
 	create_fake_ipa "$NAME" "$BUNDLE_ID" "$INPUT" "$OUTPUT" "$FAKEENT"
 	create_fake_ipa "$NAME" "$BUNDLE_ID" "$INPUT" "$OUTPUT" "$FAKEENT"
 	rm "$FAKEENT"
 	rm "$FAKEENT"
 	;;
 	;;
-ipa-se )
+ipa-se | ipa-remote )
 	FAKEENT="/tmp/fakeent.$$.plist"
 	FAKEENT="/tmp/fakeent.$$.plist"
 	cat >"$FAKEENT" <<EOL
 	cat >"$FAKEENT" <<EOL
 <?xml version="1.0" encoding="UTF-8"?>
 <?xml version="1.0" encoding="UTF-8"?>

Some files were not shown because too many files changed in this diff