Merge branch 'utmapp:main' into patch-3

This commit is contained in:
MMP0 2024-02-26 09:54:23 +09:00 committed by GitHub
commit 0c21e39b8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 6737 additions and 558 deletions

View File

@ -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] configuration: [
platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci] {arch: "arm64", sdk: "iphoneos", platform: "ios", scheme: "iOS"},
include: {arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-SE"},
# x86_64 supported only for macOS and simulators {arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-Remote"},
- arch: x86_64 {arch: "arm64", sdk: "xros", platform: "visionos", scheme: "iOS"},
platform: macos {arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-SE"},
- arch: x86_64 {arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-Remote"},
platform: ios_simulator {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 }} path: sysroot-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }} 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", scheme: "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-tci", scheme: "iOS-SE", 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", scheme: "iOS", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
{platform: "ios", mode: "deb", name: "UTM.deb", path: "UTM.deb"}, {platform: "ios", scheme: "iOS", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
{platform: "visionos", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"}, {platform: "visionos", scheme: "iOS", 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: "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

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -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" if let port = qemu.spiceServerPort {
"addr=\(spiceSocketURL.lastPathComponent)" if qemu.isSpiceServerTlsEnabled {
"disable-ticketing=on" "tls-port=\(port)"
"image-compression=off" "tls-channel=default"
"playback-compression=off" "x509-key-file="
"streaming-video=off" spiceTlsKeyUrl
"gl=\(isGLOn ? "on" : "off")" "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")

View File

@ -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"

View File

@ -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")

View File

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

View File

@ -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

View File

@ -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"))
}

View File

@ -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

View File

@ -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))

View File

@ -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) {

View File

@ -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() {

View File

@ -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
} }

View File

@ -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!)
} }
} }
} }

View File

@ -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 let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuVM.config) 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 let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuVM.config) 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")

View File

@ -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

View File

@ -17,39 +17,100 @@
import SwiftUI import SwiftUI
struct VMPlaceholderView: View { struct VMPlaceholderView: View {
@EnvironmentObject private var data: UTMData var body: some View {
@Environment(\.openURL) private var openURL 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")) { SecondRow()
data.newVM()
}
TileButton(Label(String.browse, systemImage: "arrow.down.circle")) {
openURL(URL(string: "https://mac.getutm.app/gallery/")!)
}
} }
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()
} }
TileButton(Label(String.support, systemImage: "questionmark.circle")) { GridRow {
openURL(URL(string: "https://docs.getutm.app/")!) SecondRow()
} }
#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 {

View File

@ -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! { private var qemuVM: (any UTMSpiceVirtualMachine)! {
vm.wrapped as? UTMQemuVirtualMachine 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))

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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") {

View File

@ -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 if let wrapped = wrapped {
self?.screenshot = wrapped.screenshot 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

View File

@ -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

View File

@ -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]) {

View File

@ -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;

View File

@ -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, nullable) id debounceResize;
@property (nonatomic) CGPoint windowOrigin; @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) { if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
[self displayResize:self.view.bounds.size]; [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 { - (void)requestResolutionChangeToSize:(CGSize)size {
UTMLog(@"resizing to (%f, %f)", size.width, size.height); self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
size = [self convertSizeToNative:size]; UTMLog(@"DISPLAY: requesting resolution (%f, %f)", size.width, size.height);
CGRect bounds = CGRectMake(0, 0, size.width, size.height); CGSize newSize = [self convertSizeToNative:size];
[self.vmDisplay requestResolution:bounds]; 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 UTMLog(@"DISPLAY: vmDisplay.displaySize changed");
dispatch_async(dispatch_get_main_queue(), ^{ if (self.cancelResize) {
CGSize minSize = self.vmDisplay.displaySize; [self debounce:0 context:self.cancelResize action:^{}];
if (self.delegate.qemuDisplayIsNativeResolution) { self.cancelResize = nil;
minSize.width = CGPixelToPoint(minSize.width); }
minSize.height = CGPixelToPoint(minSize.height); self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
} [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];
});
#else
[self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
#endif
} }
} }
- (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];
}
}
}
}
- (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
if (CGSizeEqualToSize(displaySize, CGSizeZero)) {
return;
}
[self.delegate display:self.vmDisplay didResizeTo:displaySize];
#endif
}
@end @end

View File

@ -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
}
} }

View File

@ -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(@"char=%d\n", tc);
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]); //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(@"char=%d\n", tc);
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]); //UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]); //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;

View File

@ -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>

View File

@ -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))
}
}
}

View File

@ -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>

View File

@ -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) if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) {
session.start() 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) {

View File

@ -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())
}

View File

@ -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") {

View File

@ -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...")

View File

@ -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 {

View File

@ -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
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self]) showWindow()
vm.requestVmStart(options: options) if vm.state == .paused {
vm.requestVmResume()
} else {
vm.requestVmStart(options: options)
}
} }
func showWindow() {
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
}
@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
} }
} }

View File

@ -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: {

View File

@ -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))

View File

@ -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)

View File

@ -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 { if session.nonfatalError == nil && session.fatalError == nil {
session.stop() if session.vmState == .stopped {
session.stop()
}
} }
} }
case .pausing, .stopping, .starting, .resuming, .saving, .restoring: case .pausing, .stopping, .starting, .resuming, .saving, .restoring:

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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 {

View File

@ -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) {

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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()
}

View File

@ -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?()
} }
} }

View File

@ -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)
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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()
} }
} }

View File

@ -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
Remote/GenerateKey.c Normal file
View File

@ -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
Remote/GenerateKey.h Normal file
View File

@ -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 */

View File

@ -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")
}
}
}
}

View File

@ -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

View File

@ -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")
}
}
}

View File

@ -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 {}
}
}

View File

@ -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()
}
}

View File

@ -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")
}
}
}
}

View File

@ -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"

View File

@ -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 {

View File

@ -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
}
} }

View File

@ -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};

View File

@ -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

View File

@ -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

View File

@ -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")
}
}
}
}

View File

@ -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

View File

@ -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>

View File

@ -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 */

View File

@ -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) let interface: any QEMUInterface
ioService.logHandler = { [weak system] (line: String) -> Void in let spicePublicKey: Data?
guard !line.contains("spice_make_scancode") else { if isRemoteSession {
return // do not log key presses for privacy reasons 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) { func changeInputTablet(_ tablet: Bool) async throws {
guard !changeCursorRequestInProgress else { defer {
changeCursorRequestInProgress = false
}
guard state == .started else {
return return
} }
guard let spiceIO = ioService else { guard let monitor = await monitor else {
return
}
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 { try await changeInputTablet(tablet)
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)")
}
} }
} }
} }
// 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? { func stopAccessingPath(_ path: String) async {
registryEntry.sharedDirectories.first?.url await system?.stopAccessingPath(path)
} }
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 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")
} }
} }
} }

View File

@ -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
} }

View File

@ -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() return try propertyList() as! [String: Any]
encoder.outputFormat = .xml
let xml = try encoder.encode(self)
let dict = try PropertyListSerialization.propertyList(from: xml, format: nil)
return dict 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

View File

@ -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;

View File

@ -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]; if (self.socketUrl) {
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile]; 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 if (self.socketUrl) {
NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path; // need to chdir to workaround AF_UNIX sun_len limitations
if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) { NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path;
if (error) { if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) {
*error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to change current directory.", "UTMSpiceIO")}]; 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];
} }

View File

@ -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

View File

@ -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
}
}
}
}

View File

@ -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? { func loadScreenshot() -> UTMVirtualMachineScreenshot? {
#if canImport(AppKit) UTMVirtualMachineScreenshot(contentsOfURL: screenshotUrl)
return NSImage(contentsOf: screenshotUrl)
#elseif canImport(UIKit)
return UIImage(contentsOfURL: screenshotUrl)
#endif
} }
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) try screenshot.pngData?.write(to: screenshotUrl)
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
} }
func deleteScreenshot() throws { func deleteScreenshot() throws {

File diff suppressed because it is too large Load Diff

View File

@ -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", "branch" : "main",
"revision" : "8b0900a4c516eb8c87813f11e797f349e7fca014" "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",

View File

@ -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"

View File

@ -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 " -k sdk Target SDK. Default iphoneos. [iphoneos|iphonesimulator|xros|xrsimulator|macosx]"
echo " -a architecture Target architecture. Default arm64. [armv7|armv7s|arm64|i386|x86_64]" 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 ) -k )
PLATFORM=$2 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 case $SDK in
*-tci )
SCHEME="iOS-TCI"
;;
ios* | visionos* )
SCHEME="iOS"
;;
macos ) macos )
SCHEME="macOS" SCHEME="macOS"
;; ;;
* ) * )
usage if [ -z "$SCHEME" ]; then
;; SCHEME="iOS"
esac fi
case $PLATFORM in
visionos_simulator* )
SDK=xrsimulator
;;
visionos* )
SDK=xros
;;
ios_simulator* )
SDK=iphonesimulator
;;
ios* )
SDK=iphoneos
;;
macos )
SDK=macosx
;;
* )
usage
;; ;;
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 if [ "$SDK" == "iphoneos" ]; then
ios | ios-tci )
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
;; fi
esac
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"

View File

@ -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"?>