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'
env:
BUILD_XCODE_PATH: /Applications/Xcode_15.1.app
BUILD_XCODE_PATH: /Applications/Xcode_15.2.app
RUNNER_IMAGE: macos-13
jobs:
@ -53,7 +53,7 @@ jobs:
strategy:
matrix:
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:
# x86_64 supported only for macOS and simulators
- arch: x86_64
@ -91,7 +91,7 @@ jobs:
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 }}
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
if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event_name == 'release' || github.event.inputs.test_release == 'true'
run: tar -acf sysroot.tgz sysroot*
@ -152,14 +152,16 @@ jobs:
needs: [configuration, build-sysroot]
strategy:
matrix:
arch: [arm64]
platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci]
include:
# x86_64 supported only for macOS and simulators
- arch: x86_64
platform: macos
- arch: x86_64
platform: ios_simulator
configuration: [
{arch: "arm64", sdk: "iphoneos", platform: "ios", scheme: "iOS"},
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-SE"},
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-Remote"},
{arch: "arm64", sdk: "xros", platform: "visionos", scheme: "iOS"},
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-SE"},
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-Remote"},
{arch: "arm64", sdk: "macosx", platform: "macos", scheme: "macOS"},
{arch: "x86_64", sdk: "macosx", platform: "macos", scheme: "macOS"},
]
steps:
- name: Checkout
uses: actions/checkout@v3
@ -169,8 +171,8 @@ jobs:
id: cache-sysroot
uses: osy/actions-cache@v3
with:
path: sysroot-${{ matrix.platform }}-${{ matrix.arch }}
key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
path: sysroot-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
key: ${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
- name: Check Cache
if: steps.cache-sysroot.outputs.cache-hit != 'true'
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 }}"
- name: Build UTM
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
- name: Upload UTM
uses: actions/upload-artifact@v3
with:
name: UTM-${{ matrix.platform }}-${{ matrix.arch }}
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
path: UTM.xcarchive.tgz
build-universal:
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 }}"
- name: Build UTM
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
env:
SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
@ -231,12 +233,14 @@ jobs:
strategy:
matrix:
configuration: [
{platform: "ios", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
{platform: "ios-tci", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
{platform: "ios", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
{platform: "ios", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
{platform: "visionos", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
{platform: "visionos-tci", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"}
{platform: "ios", scheme: "iOS", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
{platform: "ios-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
{platform: "ios", scheme: "iOS", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
{platform: "ios", scheme: "iOS", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
{platform: "visionos", scheme: "iOS", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
{platform: "visionos-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"},
{platform: "ios-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote.ipa", path: "UTM Remote.ipa"},
{platform: "visionos-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote-visionOS.ipa", path: "UTM Remote.ipa"},
]
if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
steps:
@ -245,7 +249,7 @@ jobs:
- name: Download Artifact
uses: actions/download-artifact@v3
with:
name: UTM-${{ matrix.configuration.platform }}-arm64
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-arm64
- name: Install ldid + dpkg
run: brew install ldid dpkg
- name: Fakesign IPA

View File

@ -424,20 +424,20 @@ extension QEMUArchitecture {
default: return true
}
}
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
}
#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
var hasTSOSupport: Bool {
#if os(iOS) || os(visionOS)

View File

@ -120,7 +120,7 @@ extension UTMConfiguration {
#endif
// is it a legacy QEMU config?
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)
return UTMQemuConfiguration(migrating: legacy)
} else if stub.backend == .qemu {

View File

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

View File

@ -61,6 +61,26 @@ import Virtualization // for getting network interfaces
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.
@QEMUArgumentBuilder var allArguments: [QEMUArgument] {
generatedArguments
@ -109,16 +129,48 @@ import Virtualization // for getting network interfaces
@QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
f("-spice")
"unix=on"
"addr=\(spiceSocketURL.lastPathComponent)"
"disable-ticketing=on"
"image-compression=off"
"playback-compression=off"
"streaming-video=off"
"gl=\(isGLOn ? "on" : "off")"
if let port = qemu.spiceServerPort {
if qemu.isSpiceServerTlsEnabled {
"tls-port=\(port)"
"tls-channel=default"
"x509-key-file="
spiceTlsKeyUrl
"x509-cert-file="
spiceTlsCertUrl
"x509-cacert-file="
spiceTlsCertUrl
} else {
"port=\(port)"
}
} else {
"unix=on"
"addr=\(spiceSocketURL.lastPathComponent)"
}
if let _ = qemu.spiceServerPassword {
"password-secret=secspice0"
} else {
"disable-ticketing=on"
}
if !isRemoteSpice {
"image-compression=off"
"playback-compression=off"
"streaming-video=off"
} else {
"streaming-video=filter"
}
"gl=\(isGLSupported && !isRemoteSpice ? "on" : "off")"
f()
f("-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("chardev=org.qemu.monitor.qmp,mode=control")
if !isSparc { // disable -vga and other default devices
@ -128,8 +180,28 @@ import Virtualization // for getting network interfaces
f("-vga")
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] {
if displays.isEmpty {
f("-nographic")
@ -143,7 +215,7 @@ import Virtualization // for getting network interfaces
} else {
for display in displays {
f("-device")
display.hardware
filterDisplayIfRemote(display.hardware)
if let vgaRamSize = displays[0].vgaRamMib {
"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
display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl")
}
@ -161,7 +233,11 @@ import Virtualization // for getting network interfaces
private var isSparc: Bool {
system.architecture == .sparc || system.architecture == .sparc64
}
private var isRemoteSpice: Bool {
qemu.spiceServerPort != nil
}
@QEMUArgumentBuilder private var serialArguments: [QEMUArgument] {
for i in serials.indices {
f("-chardev")
@ -318,9 +394,9 @@ import Virtualization // for getting network interfaces
}
let tbSize = system.jitCacheSize > 0 ? system.jitCacheSize : system.memorySize / 4
"tb-size=\(tbSize)"
#if !WITH_QEMU_TCI
#if WITH_JIT
// use mirror mapping when we don't have JIT entitlements
if !jb_has_jit_entitlement() {
if !UTMCapabilities.current.contains(.hasJitEntitlements) {
"split-wx=on"
}
#endif
@ -433,6 +509,10 @@ import Virtualization // for getting network interfaces
#if os(iOS) || os(visionOS)
return false
#else
// only support SPICE audio if we are running remotely
if isRemoteSpice {
return false
}
// force CoreAudio backend for mac99 which only supports 44100 Hz
// pcspk doesn't work with SPICE audio
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("-device")
f("usb-kbd,bus=usb-bus.0")
#if !WITH_QEMU_TCI
#if WITH_USB
let maxDevices = input.maximumUsbShare
let buses = (maxDevices + 2) / 3
if input.usbBusSupport == .usb3_0 {
@ -859,7 +939,16 @@ import Virtualization // for getting network interfaces
f("-device")
f("virtserialport,chardev=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
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 {
f("-device")

View File

@ -69,6 +69,15 @@ struct UTMQemuConfigurationQEMU: Codable {
/// Set to true to request UEFI variable reset. Not saved.
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 {
case hasDebugLog = "DebugLog"
case hasUefiBoot = "UEFIBoot"

View File

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

View File

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

View File

@ -20,8 +20,11 @@ import UniformTypeIdentifiers
import IQKeyboardManagerSwift
#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"
#elseif WITH_REMOTE && !os(visionOS)
let productName = "UTM Remote"
#else
let productName = "UTM"
#endif
@ -33,7 +36,8 @@ struct ContentView: View {
@State private var newPopupPresented = false
@State private var openSheetPresented = false
@Environment(\.openURL) var openURL
@AppStorage("ServerAutostart") private var isServerAutostart: Bool = false
var body: some View {
VMNavigationListView()
.overlay(data.showSettingsModal ? AnyView(EmptyView()) : AnyView(BusyOverlay()))
@ -67,6 +71,11 @@ struct ContentView: View {
.onAppear {
Task {
await data.listRefresh()
#if os(macOS)
if isServerAutostart {
await data.remoteServer.start()
}
#endif
}
Task {
await releaseHelper.fetchReleaseNotes()
@ -78,7 +87,7 @@ struct ContentView: View {
#if !os(visionOS)
IQKeyboardManager.shared.enable = true
#endif
#if !WITH_QEMU_TCI
#if WITH_JIT
if !Main.jitAvailable {
data.busyWorkAsync {
let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach")
@ -95,7 +104,7 @@ struct ContentView: View {
#endif
// 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")
}
}
@ -163,7 +172,7 @@ struct ContentView: View {
case "pause":
if let vm = findVM(), vm.state == .started {
let shouldSaveOnPause: Bool
if let vm = vm.wrapped as? UTMQemuVirtualMachine {
if let vm = vm.wrapped as? (any UTMSpiceVirtualMachine) {
shouldSaveOnPause = !vm.isRunningAsDisposible
} else {
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.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 }) {
let nsnumber = Binding<NSNumber?> {
return number.wrappedValue as NSNumber

View File

@ -25,7 +25,13 @@ struct UTMUnavailableVMView: View {
subtitle: vm.detailsSubtitleLabel,
progress: nil,
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)
}
@ -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 {
static var previews: some View {
UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))

View File

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

View File

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

View File

@ -101,7 +101,7 @@ struct VMConfigSystemView: View {
}
#endif
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
if Double(estMemoryUsage) > Double(totalDeviceMemory) * warningThreshold {
warningMessage = WarningMessage.overallocatedRam(totalMib: totalDeviceMemory / bytesInMib, estimatedMib: estMemoryUsage / bytesInMib)
@ -177,7 +177,7 @@ private struct HardwareOptions: View {
}
}
.onChange(of: config.architecture) { newValue in
isArchitectureSupported = UTMQemuVirtualMachine.isSupported(systemArchitecture: newValue)
isArchitectureSupported = ConcreteVirtualMachine.isSupported(systemArchitecture: newValue)
if newValue != architecture {
architecture = newValue
}

View File

@ -61,6 +61,7 @@ struct VMContextMenuModifier: ViewModifier {
}.help("Reveal where the VM is stored.")
Divider()
#endif
#if !WITH_REMOTE // FIXME: implement remote feature
Button {
data.close(vm: vm) // close window
data.edit(vm: vm)
@ -68,6 +69,7 @@ struct VMContextMenuModifier: ViewModifier {
Label("Edit", systemImage: "slider.horizontal.3")
}.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
.help("Modify settings for this VM.")
#endif
if vm.hasSuspendState || !vm.isStopped {
Button {
confirmAction = .confirmStopVM
@ -99,7 +101,7 @@ struct VMContextMenuModifier: ViewModifier {
}
#endif
if let _ = vm.wrapped as? UTMQemuVirtualMachine {
if let _ = vm.config as? UTMQemuConfiguration {
Button {
data.run(vm: vm, options: .bootDisposibleMode)
} label: {
@ -120,6 +122,7 @@ struct VMContextMenuModifier: ViewModifier {
Divider()
}
#if !WITH_REMOTE // FIXME: implement remote feature
Button {
shareItem = .utmCopy(vm)
showSharePopup.toggle()
@ -164,6 +167,7 @@ struct VMContextMenuModifier: ViewModifier {
}.disabled(!vm.isModifyAllowed)
.help("Delete this VM and all its data.")
}
#endif
}
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
.modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
@ -175,7 +179,7 @@ struct VMContextMenuModifier: ViewModifier {
.onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
if newValue == true {
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
private let regularScreenSizeClass: Bool = true
#endif
@State private var size: Int64 = 0
private var sizeLabel: String {
let size = data.computeSize(for: vm)
return ByteCountFormatter.string(fromByteCount: size, countStyle: .binary)
}
@ -70,8 +71,8 @@ struct VMDetailsView: View {
.padding([.leading, .trailing, .bottom])
}
#else
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
.padding([.leading, .trailing, .bottom])
#endif
} else {
@ -89,8 +90,8 @@ struct VMDetailsView: View {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
}
#else
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
#endif
}.padding([.leading, .trailing, .bottom])
}
@ -109,6 +110,16 @@ struct VMDetailsView: View {
}
#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)
#if os(visionOS)
.overlay {
if vm.isStopped {
if vm.isStopped || vm.isTakeoverAllowed {
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 100, height: 100)
@ -164,7 +175,7 @@ struct Screenshot: View {
#endif
if vm.isBusy {
Spinner(size: .large)
} else if vm.isStopped {
} else if vm.isStopped || vm.isTakeoverAllowed {
#if !os(visionOS)
Button(action: { data.run(vm: vm) }, label: {
Label("Run", systemImage: "play.circle.fill")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,9 +21,19 @@ import AppKit
import UIKit
import SwiftUI
#endif
#if canImport(AltKit) && !WITH_QEMU_TCI
#if canImport(AltKit) && WITH_JIT
import AltKit
#endif
#if WITH_SERVER
import Combine
#endif
#if WITH_REMOTE
import CocoaSpiceNoUsb
typealias ConcreteVirtualMachine = UTMRemoteSpiceVirtualMachine
#else
typealias ConcreteVirtualMachine = UTMQemuVirtualMachine
#endif
struct AlertMessage: Identifiable {
var message: String
@ -88,7 +98,18 @@ struct AlertMessage: Identifiable {
nonisolated private var documentsURL: URL {
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
private var busyQueue: DispatchQueue
@ -100,6 +121,10 @@ struct AlertMessage: Identifiable {
self.virtualMachines = []
self.pendingVMs = []
self.selectedVM = nil
#if WITH_SERVER
self.remoteServer = UTMRemoteServer(data: self)
beginObservingChanges()
#endif
listLoadFromDefaults()
}
@ -133,7 +158,7 @@ struct AlertMessage: Identifiable {
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
continue
}
guard UTMQemuVirtualMachine.isVirtualMachine(url: file) else {
guard ConcreteVirtualMachine.isVirtualMachine(url: file) else {
continue
}
await Task.yield()
@ -168,7 +193,7 @@ struct AlertMessage: Identifiable {
}
/// Load VM list (and order) from persistent storage
private func listLoadFromDefaults() {
fileprivate func listLoadFromDefaults() {
let defaults = UserDefaults.standard
guard defaults.object(forKey: "VMList") == nil else {
listLegacyLoadFromDefaults()
@ -186,7 +211,7 @@ struct AlertMessage: Identifiable {
guard let list = defaults.stringArray(forKey: "VMEntryList") else {
return
}
virtualMachines = list.uniqued().compactMap { uuidString in
let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in
guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
return nil
}
@ -198,6 +223,7 @@ struct AlertMessage: Identifiable {
}
return vm
}
listReplace(with: virtualMachines)
}
/// Load VM list (and order) from persistent storage (legacy)
@ -205,7 +231,7 @@ struct AlertMessage: Identifiable {
let defaults = UserDefaults.standard
// legacy path list
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)
if let vm = try? VMData(url: url) {
return vm
@ -213,10 +239,11 @@ struct AlertMessage: Identifiable {
return nil
}
})
listReplace(with: virtualMachines)
}
// bookmark list
if let list = defaults.array(forKey: "VMList") {
virtualMachines = list.compactMap { item in
let virtualMachines = list.compactMap { item in
let vm: VMData?
if let bookmark = item as? Data {
vm = VMData(bookmark: bookmark)
@ -228,6 +255,7 @@ struct AlertMessage: Identifiable {
try? vm?.load()
return vm
}
listReplace(with: virtualMachines)
}
}
@ -238,8 +266,15 @@ struct AlertMessage: Identifiable {
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
vms.forEach({ beginObservingChanges(for: $0) })
if let vm = selectedVM, !vms.contains(where: { $0 == vm }) {
selectedVM = nil
}
}
/// Add VM to list
@ -254,6 +289,7 @@ struct AlertMessage: Identifiable {
} else {
virtualMachines.append(vm)
}
beginObservingChanges(for: vm)
}
/// Select VM in list
@ -267,6 +303,7 @@ struct AlertMessage: Identifiable {
/// - Returns: Index of item removed or nil if already removed
@discardableResult public func listRemove(vm: VMData) -> Int? {
let index = virtualMachines.firstIndex(of: vm)
endObservingChanges(for: vm)
if let index = index {
virtualMachines.remove(at: index)
}
@ -316,7 +353,7 @@ struct AlertMessage: Identifiable {
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
for i in 1..<1000 {
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) {
return name
}
@ -383,6 +420,13 @@ struct AlertMessage: Identifiable {
func save(vm: VMData) async throws {
do {
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 {
// refresh the VM object as it is now stale
let origError = error
@ -450,8 +494,8 @@ struct AlertMessage: Identifiable {
/// - Returns: The new VM
@discardableResult func clone(vm: VMData) async throws -> VMData {
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)
guard let newVM = try? VMData(url: newPath) else {
throw UTMDataError.cloneFailed
@ -532,7 +576,7 @@ struct AlertMessage: Identifiable {
/// Calculate total size of VM and data
/// - Parameter vm: VM to calculate size
/// - Returns: Size in bytes
func computeSize(for vm: VMData) -> Int64 {
func computeSize(for vm: VMData) async -> Int64 {
let path = vm.pathUrl
guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
logger.error("failed to create enumerator for \(path)")
@ -616,7 +660,7 @@ struct AlertMessage: Identifiable {
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) {
let status = copyfile(srcURL.path, dstURL.path, nil, copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE | COPYFILE_DATA_SPARSE))
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)
if await task.hasExistingSupportTools {
vm.config.qemu.isGuestToolsInstallRequested = false
@ -756,7 +803,60 @@ struct AlertMessage: Identifiable {
}
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
/// 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)
/// - 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) {
await self.setBusyIndicator(true)
do {
try await work()
let result = try await work()
await self.setBusyIndicator(false)
return result
} catch {
logger.error("\(error)")
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
/// - components: Data (see UTM Wiki for details)
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 let queryItems = components.queryItems else { return }
/// Parse targeted position
@ -868,7 +972,7 @@ struct AlertMessage: Identifiable {
// MARK: - AltKit
#if canImport(AltKit) && !WITH_QEMU_TCI
#if canImport(AltKit) && WITH_JIT
/// Detect if we are installed from AltStore and can use AltJIT
var isAltServerCompatible: Bool {
guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
@ -968,6 +1072,8 @@ struct AlertMessage: Identifiable {
// MARK: - Errors
enum UTMDataError: Error {
case virtualMachineAlreadyExists
case virtualMachineUnavailable
case unsupportedBackend
case cloneFailed
case shortcutCreationFailed
case importFailed
@ -977,6 +1083,8 @@ enum UTMDataError: Error {
case jitStreamerDecodeFailed
case jitStreamerAttachFailed
case jitStreamerUrlInvalid(String)
case notImplemented
case reconnectFailed
}
extension UTMDataError: LocalizedError {
@ -984,6 +1092,10 @@ extension UTMDataError: LocalizedError {
switch self {
case .virtualMachineAlreadyExists:
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:
return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
case .shortcutCreationFailed:
@ -1002,6 +1114,239 @@ extension UTMDataError: LocalizedError {
return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
case .jitStreamerUrlInvalid(let 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
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 var toolsUrl: URL {
@ -42,7 +42,7 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
}
}
init(for vm: UTMQemuVirtualMachine) {
init(for vm: any UTMSpiceVirtualMachine) {
self.vm = vm
let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask")
super.init(for: Self.supportToolsDownloadUrl, named: name)

View File

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

View File

@ -20,7 +20,7 @@ import SwiftUI
/// Model wrapping a single UTMVirtualMachine for use in views
@MainActor class VMData: ObservableObject {
/// Underlying virtual machine
private(set) var wrapped: (any UTMVirtualMachine)? {
fileprivate(set) var wrapped: (any UTMVirtualMachine)? {
willSet {
objectWillChange.send()
}
@ -53,8 +53,8 @@ import SwiftUI
}
/// 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
private var uuidUnknown: Bool = false
@ -67,14 +67,22 @@ import SwiftUI
@Published var state: UTMVirtualMachineState = .stopped
/// 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
private var observers: [AnyCancellable] = []
/// True if the .utm is loaded outside of the default storage
var isShortcut: Bool {
isShortcut(pathUrl)
}
/// No default init
private init() {
fileprivate init() {
}
/// Create a VM from an existing object
@ -129,9 +137,11 @@ import SwiftUI
/// - Parameter config: Configuration to create new VM
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
self.init()
#if !WITH_REMOTE
if let qemuConfig = config as? UTMQemuConfiguration {
wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
}
#endif
#if os(macOS)
if let appleConfig = config as? UTMAppleConfiguration {
wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
@ -160,9 +170,11 @@ import SwiftUI
}
var loaded: (any UTMVirtualMachine)?
let config = try UTMQemuConfiguration.load(from: url)
#if !WITH_REMOTE
if let qemuConfig = config as? UTMQemuConfiguration {
loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut(url))
}
#endif
#if os(macOS)
if let appleConfig = config as? UTMAppleConfiguration {
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
private func subscribeToChildren() {
fileprivate func subscribeToChildren() {
var s: [AnyCancellable] = []
if let wrapped = wrapped {
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
self?.state = wrapped.state
self?.screenshot = wrapped.screenshot
if let wrapped = wrapped {
self?.state = wrapped.state
self?.screenshot = wrapped.screenshot
}
}
}
}
@ -281,11 +295,6 @@ extension VMData: Hashable {
// MARK: - VM State
extension VMData {
/// True if the .utm is loaded outside of the default storage
var isShortcut: Bool {
isShortcut(pathUrl)
}
func isShortcut(_ url: URL) -> Bool {
let defaultStorageUrl = UTMData.defaultStorageUrl.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
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 {
// Hide cursor while hovering in VM view
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];
#endif
}
return nil;
}
@ -153,11 +157,13 @@ NS_AVAILABLE_IOS(13.4)
- (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion {
#if !TARGET_OS_VISION
if (@available(iOS 14.0, *)) {
if (self.prefersPointerLocked) {
return nil;
}
}
#endif
// Requesting region for the VM display?
if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
// 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 {
#if TARGET_OS_VISION
return VMMouseTypeAbsolute;
#else
if (@available(iOS 14.0, *)) {
return VMMouseTypeRelative;
} else {
return VMMouseTypeAbsolute; // legacy iOS 13.4 mouse handling requires absolute
}
#endif
}
#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];
#if TARGET_OS_VISION
if ([self isTouchGazeGesture:touch]) {
type = self.indirectMouseType;
type = VMMouseTypeRelative;
}
#endif
if ([self switchMouseType:type]) {

View File

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

View File

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

View File

@ -55,7 +55,7 @@ public extension VMDisplayViewController {
parent.setChildViewControllerForPointerLock(self)
UIPress.pressResponderOverride = self
}
#if !os(visionOS)
#if !os(visionOS) && !WITH_REMOTE
if runInBackground {
logger.info("Start location tracking to enable running in background")
UTMLocationManager.sharedInstance().startUpdatingLocation()
@ -75,24 +75,6 @@ public extension VMDisplayViewController {
func enterLive() {
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
@ -134,4 +116,15 @@ public extension VMDisplayViewController {
func integerForSetting(_ key: String) -> Int {
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 {
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];
int keycode = 0;
@ -393,7 +393,7 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
switch (ctext_len) {
case 1:
UTMLog(@"char=%d\n", tc);
//UTMLog(@"char=%d\n", tc);
index = indexForChar(_map, _map_len, tc);
if (index != -1) {
keycode = _map[index].key;
@ -401,8 +401,8 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
}
break;
case 2:
UTMLog(@"char=%d\n", tc);
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
//UTMLog(@"char=%d\n", tc);
//UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], 0);
if (index != -1) {
keycode = _ext_map[index].key;
@ -412,9 +412,9 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
}
break;
case 3:
UTMLog(@"char=%d\n", tc);
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
//UTMLog(@"char=%d\n", tc);
//UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
//UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], ctext[2]);
if (index != -1) {
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>
<key>DefaultValue</key>
<false/>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
</array>
<key>Platform</key>
<string>iOS</string>
</dict>
<dict>
<key>Type</key>
@ -31,6 +37,8 @@
<string>AutosaveBackground</string>
<key>DefaultValue</key>
<true/>
<key>Platform</key>
<string>iOS</string>
</dict>
<dict>
<key>Type</key>
@ -83,6 +91,11 @@
<string>NoUsbPrompt</string>
<key>DefaultValue</key>
<false/>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
<string>iOS-SE</string>
</array>
</dict>
<dict>
<key>Type</key>
@ -99,6 +112,10 @@
<string>PSGroupSpecifier</string>
<key>Title</key>
<string>Graphics</string>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
</array>
</dict>
<dict>
<key>Type</key>
@ -121,6 +138,10 @@
<integer>1</integer>
<integer>2</integer>
</array>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
</array>
</dict>
<dict>
<key>Type</key>
@ -155,6 +176,10 @@
<integer>105</integer>
<integer>120</integer>
</array>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
</array>
</dict>
<dict>
<key>Type</key>
@ -2789,6 +2814,11 @@
<string>PSGroupSpecifier</string>
<key>Title</key>
<string>JitStreamer</string>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
<string>iOS-SE</string>
</array>
</dict>
<dict>
<key>Type</key>
@ -2799,6 +2829,11 @@
<string>JitStreamerAttach</string>
<key>DefaultValue</key>
<false/>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
<string>iOS-SE</string>
</array>
</dict>
<dict>
<key>Type</key>
@ -2809,6 +2844,11 @@
<string>JitStreamerAddress</string>
<key>DefaultValue</key>
<string>69.69.0.1</string>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
<string>iOS-SE</string>
</array>
</dict>
<dict>
<key>Type</key>

View File

@ -19,15 +19,23 @@ import SwiftUI
extension UTMData {
func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
#if WITH_SOLO_VM
guard VMSessionState.allActiveSessions.count == 0 else {
logger.error("Session already started")
return
}
#endif
guard let wrapped = vm.wrapped else {
return
}
let session = VMSessionState(for: wrapped as! UTMQemuVirtualMachine)
session.start()
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) {
session.showWindow()
} else if vm.isStopped || vm.isTakeoverAllowed {
let session = VMSessionState(for: wrapped as! (any UTMSpiceVirtualMachine))
session.start(options: options)
} else {
showErrorAlert(message: NSLocalizedString("This virtual machine is already running. In order to run it from this device, you must stop it first.", comment: "UTMDataExtension"))
}
}
func stop(vm: VMData) {
@ -37,6 +45,7 @@ extension UTMData {
if wrapped.registryEntry.isSuspended {
wrapped.requestVmDeleteState()
}
wrapped.requestVmStop()
}
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 {
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
private var hasContainer: Bool {
#if WITH_JIT
jb_has_container()
#else
true
#endif
}
var body: some View {
NavigationView {
IASKAppSettings()
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.appSettingsShowPrivacyLink(jb_has_container())
.appSettingsShowPrivacyLink(hasContainer)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {

View File

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

View File

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

View File

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

View File

@ -52,6 +52,7 @@ struct VMToolbarDriveMenuView: View {
}
ForEach(config.drives) { drive in
if drive.isExternal {
#if !WITH_REMOTE // FIXME: implement remote feature
Menu {
Button {
selectedDrive = drive
@ -68,6 +69,12 @@ struct VMToolbarDriveMenuView: View {
} label: {
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 {
Button {
} label: {

View File

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

View File

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

View File

@ -16,6 +16,9 @@
import SwiftUI
import SwiftUIVisualEffects
#if os(visionOS)
import VisionKeyboardKit
#endif
struct VMWindowView: View {
let id: VMSessionState.WindowID
@ -24,7 +27,10 @@ struct VMWindowView: View {
@State private var state: VMWindowState
@EnvironmentObject private var session: VMSessionState
@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 keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
private let didReceiveMemoryWarningNotification = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
@ -108,13 +114,13 @@ struct VMWindowView: View {
}, secondaryButton: .cancel(Text("No")))
case .terminateApp:
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")))
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")) {
session.reset()
}, secondaryButton: .cancel(Text("No")))
#if !WITH_QEMU_TCI
#if WITH_USB
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")) {
session.mostRecentConnectedDevice = nil
@ -127,6 +133,8 @@ struct VMWindowView: View {
return Alert(title: Text(message), dismissButton: .cancel(Text("OK")) {
if case .fatalError(_) = type {
session.stop()
} else if session.vmState == .stopped {
session.stop()
} else {
session.nonfatalError = nil
}
@ -151,7 +159,7 @@ struct VMWindowView: View {
state.saveWindow(to: session.vm.registryEntry, device: oldDevice)
state.restoreWindow(from: session.vm.registryEntry, device: newDevice)
}
#if !WITH_QEMU_TCI
#if WITH_USB
.onChange(of: session.mostRecentConnectedDevice) { newValue in
if session.activeWindow == state.id, let device = newValue {
state.alert = .deviceConnected(device)
@ -171,6 +179,9 @@ struct VMWindowView: View {
.onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
vmStateUpdated(from: oldValue, to: newValue)
}
.onChange(of: session.isDynamicResolutionSupported) { newValue in
state.isDynamicResolutionSupported = newValue
}
.onReceive(keyboardDidShowNotification) { _ in
state.isKeyboardShown = true
state.isKeyboardRequested = true
@ -202,12 +213,30 @@ struct VMWindowView: View {
if !isInteractive {
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 {
session.removeWindow(state.id)
if !isInteractive {
session.externalWindowBinding = nil
}
#if os(visionOS)
dismissWindow(keyboardFor: state.id)
#endif
}
}
@ -221,9 +250,12 @@ struct VMWindowView: View {
state.isBusy = false
state.isRunning = false
}
// do not close if we have a popup open
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
if session.vmState == .stopped && session.fatalError == nil {
session.stop()
if session.nonfatalError == nil && session.fatalError == nil {
if session.vmState == .stopped {
session.stop()
}
}
}
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:

View File

@ -44,7 +44,7 @@ class VMDisplayAppleTerminalWindowController: VMDisplayAppleWindowController, VM
private var isSizeChangeIgnored: Bool = true
@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.index = index
}

View File

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

View File

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

View File

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

View File

@ -37,6 +37,10 @@ struct SettingsView: View {
.tabItem {
Label("Input", systemImage: "keyboard")
}
ServerSettingsView().padding()
.tabItem {
Label("Server", systemImage: "server.rack")
}
}.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 {
@objc dynamic var KeepRunningAfterLastWindowClosed: Bool { false }
@objc dynamic var ShowMenuIcon: Bool { false }

View File

@ -58,6 +58,9 @@ struct UTMApp: App {
SettingsView()
}
UTMMenuBarExtraScene(data: data)
Window("UTM Server", id: "server") {
UTMServerView().environmentObject(data.remoteServer.state)
}
}
// 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) {
var window: Any? = vmWindows[vm]
if window == nil {
let close = { (notification: Notification) -> Void in
let close = {
self.vmWindows.removeValue(forKey: vm)
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) {
guard let wrapped = vm.wrapped else {
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
/// 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
var onStop: ((Notification) -> Void)?
var onStop: (() -> Void)?
@Published var vmState: UTMVirtualMachineState = .stopped
@Published var fatalError: String?
private var hasStarted: Bool = false
private var preventIdleSleepAssertion: IOPMAssertionID?
@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.onStop = onStop
super.init()
@ -42,9 +40,7 @@ import IOKit.pwr_mgt
deinit {
NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
}
}
extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
Task { @MainActor in
vmState = state
@ -63,7 +59,6 @@ extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
Task { @MainActor in
fatalError = message
NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
if !hasStarted {
// if we got an error and haven't started, then cleanup
@ -101,6 +96,7 @@ extension VMHeadlessSessionState {
if let preventIdleSleepAssertion = 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>
<key>com.apple.security.app-sandbox</key>
<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>
<true/>
<key>com.apple.security.device.audio-input</key>
@ -14,6 +18,8 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.temporary-exception.sbpl</key>
<array>
<string>(allow network-outbound)</string>

View File

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

View File

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

View File

@ -15,23 +15,35 @@
//
import SwiftUI
import VisionKeyboardKit
#if !WITH_USB
import CocoaSpiceNoUsb
#else
import CocoaSpice
#endif
struct VMToolbarOrnamentModifier: ViewModifier {
@Binding var state: VMWindowState
@EnvironmentObject private var session: VMSessionState
@AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
func body(content: Content) -> some View {
content.ornament(visibility: isCollapsed ? .hidden : .visible, attachmentAnchor: .scene(.top)) {
HStack {
Button {
if session.vm.state == .started {
if state.isRunning {
state.alert = .powerDown
} else {
state.alert = .terminateApp
}
} 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)
Button {
@ -56,7 +68,7 @@ struct VMToolbarOrnamentModifier: ViewModifier {
}
.disabled(state.isBusy)
}
#if !WITH_QEMU_TCI
#if WITH_USB
if session.vm.hasUsbRedirection {
VMToolbarUSBMenuView()
.disabled(state.isBusy)
@ -67,11 +79,39 @@ struct VMToolbarOrnamentModifier: ViewModifier {
VMToolbarDisplayMenuView(state: $state)
.disabled(state.isBusy)
Button {
state.isKeyboardRequested = true
if case .display(_, _) = state.device {
state.isKeyboardRequested = !state.isKeyboardShown
} else {
state.isKeyboardRequested = true
}
} label: {
Label("Keyboard", systemImage: "keyboard")
}
.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()
Button {
isCollapsed = true
@ -90,6 +130,18 @@ struct VMToolbarOrnamentModifier: ViewModifier {
.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

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+System.h"
#include "UTMLegacyQemuConfigurationPortForward.h"
#include "UTMLogging.h"
#if !defined(WITH_REMOTE)
#include "UTMProcess.h"
#include "UTMQemuSystem.h"
#include "UTMJailbreak.h"
#include "UTMLogging.h"
#else
#include "UTMQemuSystemBackends.h"
#endif
#include "UTMLegacyViewState.h"
#include "UTMSpiceIO.h"
#include "GenerateKey.h"
#if TARGET_OS_IPHONE
#if !defined(WITH_REMOTE)
#include "UTMLocationManager.h"
#endif
#include "VMDisplayViewController.h"
//#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION
#include "VMDisplayMetalViewController.h"

View File

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

View File

@ -16,6 +16,7 @@
import SwiftUI
import UniformTypeIdentifiers
import Network
extension Optional where Wrapped == String {
var _bound: String? {
@ -383,4 +384,44 @@ extension String {
}
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);
#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 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);
@ -100,7 +100,7 @@ static bool jb_has_debugger_attached(void) {
#endif
bool jb_has_cs_disabled(void) {
#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
#if TARGET_OS_OSX || !defined(WITH_JIT)
return false;
#else
int flags;
@ -236,7 +236,7 @@ static bool is_device_A12_or_newer(void) {
bool jb_has_jit_entitlement(void) {
#if TARGET_OS_OSX
return true;
#elif defined(WITH_QEMU_TCI)
#elif !defined(WITH_JIT)
return false;
#else
NSDictionary *entitlements = cached_app_entitlements();
@ -330,7 +330,7 @@ bool jb_has_cs_execseg_allow_unsigned(void) {
}
bool jb_enable_ptrace_hack(void) {
#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
#if TARGET_OS_OSX || !defined(WITH_JIT)
return false;
#else
bool debugged = jb_has_debugger_attached();
@ -380,7 +380,7 @@ bool jb_increase_memlimit(void) {
return ret1 == 0 && ret2 == 0;
}
#if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
#if !TARGET_OS_OSX && defined(WITH_JIT)
extern const char *environ[];
static char *childArgv[] = {NULL, "debugme", NULL};

View File

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

View File

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

View File

@ -15,24 +15,9 @@
//
#import "UTMProcess.h"
#import "UTMQemuSystemBackends.h"
@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
@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 QEMUKit
#if os(macOS)
import SwiftPortmap
#endif
private var SpiceIoServiceGuestAgentContext = 0
private let kSuspendSnapshotName = "suspend"
private let kProbeSuspendDelay = 1*NSEC_PER_SEC
/// QEMU backend virtual machine
final class UTMQemuVirtualMachine: UTMVirtualMachine {
final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine {
struct Capabilities: UTMVirtualMachineCapabilities {
var supportsProcessKill: Bool {
true
@ -43,6 +46,10 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
var supportsRecoveryMode: Bool {
false
}
var supportsRemoteSession: Bool {
true
}
}
static let capabilities = Capabilities()
@ -88,7 +95,7 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
}
}
private(set) var screenshot: PlatformImage? {
var screenshot: UTMVirtualMachineScreenshot? {
willSet {
onStateChange?()
}
@ -117,6 +124,9 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
}
}
/// Pipe interface (alternative to UTMSpiceIO)
private var pipeInterface: UTMPipeInterface?
private let qemuVM = QEMUVirtualMachine()
private var system: UTMQemuSystem? {
@ -144,7 +154,13 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
private var swtpm: UTMSWTPM?
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 {
self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
// load configuration
@ -267,10 +283,24 @@ extension UTMQemuVirtualMachine {
await qemuVM.setRedirectLog(url: nil)
}
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 {
config.qemu.isDisposable = isRunningAsDisposible
#if WITH_SERVER
config.qemu.spiceServerPort = spicePort?.internalPort
config.qemu.spiceServerPassword = spicePassword
config.qemu.isSpiceServerTlsEnabled = true
#endif
}
// start TPM
if await config.qemu.hasTPMDevice {
let swtpm = UTMSWTPM()
@ -280,12 +310,12 @@ extension UTMQemuVirtualMachine {
try await swtpm.start()
self.swtpm = swtpm
}
let allArguments = await config.allArguments
let arguments = allArguments.map({ $0.string })
let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
let remoteBookmarks = await remoteBookmarks
let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
system.resources = resources
system.currentDirectoryUrl = await config.socketURL
@ -295,12 +325,12 @@ extension UTMQemuVirtualMachine {
system.hasDebugLog = hasDebugLog
#endif
try Task.checkCancellation()
if isShortcut {
try await accessShortcut()
try Task.checkCancellation()
}
var options = UTMSpiceIOOptions()
if await !config.sound.isEmpty {
options.insert(.hasAudio)
@ -317,14 +347,41 @@ extension UTMQemuVirtualMachine {
}
#endif
let spiceSocketUrl = await config.spiceSocketURL
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
ioService.logHandler = { [weak system] (line: String) -> Void in
guard !line.contains("spice_make_scancode") else {
return // do not log key presses for privacy reasons
let interface: any QEMUInterface
let spicePublicKey: Data?
if isRemoteSession {
let pipeInterface = UTMPipeInterface()
await MainActor.run {
pipeInterface.monitorInPipeURL = config.monitorPipeURL.appendingPathExtension("in")
pipeInterface.monitorOutPipeURL = config.monitorPipeURL.appendingPathExtension("out")
pipeInterface.guestAgentInPipeURL = config.guestAgentPipeURL.appendingPathExtension("in")
pipeInterface.guestAgentOutPipeURL = config.guestAgentPipeURL.appendingPathExtension("out")
}
system?.logging?.writeLine(line)
try pipeInterface.start()
interface = pipeInterface
// generate a TLS key for this session
guard let key = GenerateRSACertificate("UTM Remote SPICE Server" as CFString,
"UTM" as CFString,
Int.random(in: 1..<CLong.max) as CFNumber,
1 as CFNumber,
false as CFBoolean)?.takeUnretainedValue() as? [Data] else {
throw UTMQemuVirtualMachineError.keyGenerationFailed
}
try await key[1].write(to: config.spiceTlsKeyUrl)
try await key[2].write(to: config.spiceTlsCertUrl)
spicePublicKey = key[3]
} else {
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
ioService.logHandler = { [weak system] (line: String) -> Void in
guard !line.contains("spice_make_scancode") else {
return // do not log key presses for privacy reasons
}
system?.logging?.writeLine(line)
}
try ioService.start()
interface = ioService
spicePublicKey = nil
}
try ioService.start()
try Task.checkCancellation()
// create EFI variables for legacy config as well as handle UEFI resets
@ -333,7 +390,7 @@ extension UTMQemuVirtualMachine {
// start QEMU
await qemuVM.setDelegate(self)
try await qemuVM.start(launcher: system, interface: ioService)
try await qemuVM.start(launcher: system, interface: interface)
let monitor = await monitor!
try Task.checkCancellation()
@ -346,7 +403,11 @@ extension UTMQemuVirtualMachine {
// set up SPICE sharing and removable drives
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()
// continue VM boot
@ -358,11 +419,24 @@ extension UTMQemuVirtualMachine {
}
// save ioService and let it set the delegate
self.ioService = ioService
self.ioService = interface as? UTMSpiceIO
self.pipeInterface = interface as? UTMPipeInterface
self.isRunningAsDisposible = isRunningAsDisposible
// test out snapshots
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 {
@ -379,7 +453,7 @@ extension UTMQemuVirtualMachine {
}
try await startTask!.value
state = .started
if screenshotTimer == nil {
if screenshotTimer == nil && !options.contains(.remoteSession) {
screenshotTimer = startScreenshotTimer()
}
} catch {
@ -584,10 +658,16 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
}
func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
#if WITH_SERVER
spicePort = nil
spiceServerInfo = nil
#endif
swtpm?.stop()
swtpm = nil
ioService = nil
ioServiceDelegate = nil
pipeInterface?.disconnect()
pipeInterface = nil
snapshotUnsupportedError = nil
try? saveScreenshot()
state = .stopped
@ -621,11 +701,27 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
// MARK: - Input device switching
extension UTMQemuVirtualMachine {
func requestInputTablet(_ tablet: Bool) {
guard !changeCursorRequestInProgress else {
func changeInputTablet(_ tablet: Bool) async throws {
defer {
changeCursorRequestInProgress = false
}
guard state == .started else {
return
}
guard let 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
}
changeCursorRequestInProgress = true
@ -633,40 +729,11 @@ extension UTMQemuVirtualMachine {
defer {
changeCursorRequestInProgress = false
}
guard state == .started else {
return
}
guard let monitor = await monitor else {
return
}
do {
let index = try await monitor.mouseIndex(forAbsolute: tablet)
try await monitor.mouseSelect(index)
spiceIO.primaryInput?.requestMouseMode(!tablet)
} catch {
logger.error("Error changing mouse mode: \(error)")
}
try await changeInputTablet(tablet)
}
}
}
// MARK: - USB redirection
extension UTMQemuVirtualMachine {
var hasUsbRedirection: Bool {
return jb_has_usb_entitlement()
}
}
// MARK: - Screenshot
extension UTMQemuVirtualMachine {
@MainActor @discardableResult
func takeScreenshot() async -> Bool {
let screenshot = await ioService?.screenshot()
self.screenshot = screenshot?.image
return true
}
}
// MARK: - Architecture supported
extension UTMQemuVirtualMachine {
/// Check if a QEMU target is supported
@ -695,7 +762,11 @@ extension UTMQemuVirtualMachine {
// MARK: - External drives
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 {
return
}
@ -707,8 +778,12 @@ extension UTMQemuVirtualMachine {
}
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()
defer {
url.stopAccessingSecurityScopedResource()
@ -719,7 +794,7 @@ extension UTMQemuVirtualMachine {
await registryEntry.setExternalDrive(file, forId: drive.id)
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 {
let system = await system ?? UTMProcess()
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)
}
}
func restoreExternalDrives(withMounting isMounting: Bool) async throws {
private func restoreExternalDrives(withMounting isMounting: Bool) async throws {
guard await system != nil else {
throw UTMQemuVirtualMachineError.invalidVmState
}
@ -754,43 +829,14 @@ extension UTMQemuVirtualMachine {
}
}
}
@MainActor func externalImageURL(for drive: UTMQemuConfigurationDrive) -> URL? {
registryEntry.externalDrives[drive.id]?.url
}
}
// MARK: - Shared directory
extension UTMQemuVirtualMachine {
@MainActor var sharedDirectoryURL: URL? {
registryEntry.sharedDirectories.first?.url
func stopAccessingPath(_ path: String) async {
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 {
let system = await system ?? UTMProcess()
let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
@ -799,61 +845,10 @@ extension UTMQemuVirtualMachine {
}
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
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) {
config.information.uuid = uuid
if let name = name {
@ -864,7 +859,7 @@ extension UTMQemuVirtualMachine {
registryEntry.update(copying: entry)
}
}
@MainActor var remoteBookmarks: [URL: Data] {
var dict = [URL: Data]()
for file in registryEntry.externalDrives.values {
@ -889,6 +884,7 @@ enum UTMQemuVirtualMachineError: Error {
case accessShareFailed
case invalidVmState
case saveSnapshotFailed(Error)
case keyGenerationFailed
}
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 .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)
case .keyGenerationFailed:
return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
}
}
}

View File

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

View File

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

View File

@ -16,8 +16,12 @@
#import <Foundation/Foundation.h>
#import "UTMSpiceIODelegate.h"
#if defined(WITH_REMOTE)
#import "UTMRemoteConnectInterface.h"
#else
@import QEMUKitInternal;
#if defined(WITH_QEMU_TCI)
#endif
#if !defined(WITH_USB)
@import CocoaSpiceNoUsb;
#else
@import CocoaSpice;
@ -34,14 +38,18 @@ typedef NS_OPTIONS(NSUInteger, UTMSpiceIOOptions) {
NS_ASSUME_NONNULL_BEGIN
#if defined(WITH_REMOTE)
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, UTMRemoteConnectInterface>
#else
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
#endif
@property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
@property (nonatomic, readonly, nullable) CSInput *primaryInput;
@property (nonatomic, readonly, nullable) CSPort *primarySerial;
@property (nonatomic, readonly) NSArray<CSDisplay *> *displays;
@property (nonatomic, readonly) NSArray<CSPort *> *serials;
#if !defined(WITH_QEMU_TCI)
#if defined(WITH_USB)
@property (nonatomic, readonly, nullable) CSUSBManager *primaryUsbManager;
#endif
@property (nonatomic, weak, nullable) id<UTMSpiceIODelegate> delegate;
@ -50,6 +58,7 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)init NS_UNAVAILABLE;
- (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;
- (BOOL)startWithError:(NSError * _Nullable *)error;

View File

@ -22,20 +22,23 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
@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, readwrite, nullable) CSDisplay *primaryDisplay;
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
@property (nonatomic, readwrite, nullable) CSInput *primaryInput;
@property (nonatomic, readwrite, nullable) CSPort *primarySerial;
@property (nonatomic) NSMutableArray<CSPort *> *mutableSerials;
#if !defined(WITH_QEMU_TCI)
#if defined(WITH_USB)
@property (nonatomic, readwrite, nullable) CSUSBManager *primaryUsbManager;
#endif
@property (nonatomic, nullable) CSConnection *spiceConnection;
@property (nonatomic, nullable) CSMain *spice;
@property (nonatomic, nullable, copy) NSURL *sharedDirectory;
@property (nonatomic) NSInteger port;
@property (nonatomic) BOOL dynamicResolutionSupported;
@property (nonatomic, readwrite) BOOL isConnected;
@ -72,10 +75,29 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
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 {
if (!self.spiceConnection) {
NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
if (self.socketUrl) {
NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
} else {
self.spiceConnection = [[CSConnection alloc] initWithHost:self.host tlsPort:[@(self.tlsPort) stringValue] serverPublicKey:self.serverPublicKey];
self.spiceConnection.password = self.password;
}
self.spiceConnection.delegate = self;
self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
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
g_setenv("SPICE_DISABLE_OPUS", "1", YES);
// need to chdir to workaround AF_UNIX sun_len limitations
NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path;
if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) {
if (error) {
*error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to change current directory.", "UTMSpiceIO")}];
if (self.socketUrl) {
// need to chdir to workaround AF_UNIX sun_len limitations
NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path;
if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) {
if (error) {
*error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to change current directory.", "UTMSpiceIO")}];
}
return NO;
}
return NO;
}
if (![self.spice spiceStart]) {
if (error) {
@ -135,7 +159,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
self.primaryInput = nil;
self.primarySerial = nil;
[self.mutableSerials removeAllObjects];
#if !defined(WITH_QEMU_TCI)
#if defined(WITH_USB)
self.primaryUsbManager = nil;
#endif
}
@ -154,10 +178,13 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
- (void)spiceConnected:(CSConnection *)connection {
NSAssert(connection == self.spiceConnection, @"Unknown connection");
self.isConnected = YES;
#if !defined(WITH_QEMU_TCI)
#if defined(WITH_USB)
self.primaryUsbManager = connection.usbManager;
[self.delegate spiceDidChangeUsbManager:connection.usbManager];
#endif
#if defined(WITH_REMOTE)
[self.connectDelegate remoteInterfaceDidConnect:self];
#endif
}
- (void)spiceInputAvailable:(CSConnection *)connection input:(CSInput *)input {
@ -177,12 +204,17 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
- (void)spiceDisconnected:(CSConnection *)connection {
NSAssert(connection == self.spiceConnection, @"Unknown connection");
self.isConnected = NO;
[self.delegate spiceDidDisconnect];
}
- (void)spiceError:(CSConnection *)connection code:(CSConnectionError)code message:(nullable NSString *)message {
NSAssert(connection == self.spiceConnection, @"Unknown connection");
self.isConnected = NO;
#if defined(WITH_REMOTE)
[self.connectDelegate remoteInterface:self didErrorWithMessage:message];
#else
[self.connectDelegate qemuInterface:self didErrorWithMessage:message];
#endif
}
- (void)spiceDisplayCreated:(CSConnection *)connection display:(CSDisplay *)display {
@ -202,6 +234,9 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
- (void)spiceDisplayDestroyed:(CSConnection *)connection display:(CSDisplay *)display {
NSAssert(connection == self.spiceConnection, @"Unknown connection");
[self.mutableDisplays removeObject:display];
if (self.primaryDisplay == display) {
self.primaryDisplay = nil;
}
[self.delegate spiceDidDestroyDisplay:display];
}
@ -215,12 +250,16 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
- (void)spiceForwardedPortOpened:(CSConnection *)connection port:(CSPort *)port {
if ([port.name isEqualToString:@"org.qemu.monitor.qmp.0"]) {
#if !defined(WITH_REMOTE)
UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
[self.connectDelegate qemuInterface:self didCreateMonitorPort:qemuPort];
#endif
}
if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
#if !defined(WITH_REMOTE)
UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
[self.connectDelegate qemuInterface:self didCreateGuestAgentPort:qemuPort];
#endif
}
if ([port.name isEqualToString:@"com.utmapp.terminal.0"]) {
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:@"com.utmapp.terminal.0"]) {
self.primarySerial = port;
}
if ([port.name hasPrefix:@"com.utmapp.terminal."]) {
[self.mutableSerials removeObject:port];
if (self.primarySerial == port) {
self.primarySerial = nil;
}
[self.delegate spiceDidDestroySerial:port];
}
}
@ -285,7 +324,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
if (self.primarySerial) {
[self.delegate spiceDidCreateSerial:self.primarySerial];
}
#if !defined(WITH_QEMU_TCI)
#if defined(WITH_USB)
if (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)spiceDidCreateSerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidCreateSerial(_:));
- (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(_:));
#endif
@optional
- (void)spiceDynamicResolutionSupportDidChange:(BOOL)supported;
- (void)spiceDidDisconnect;
@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 kScreenshotPeriodSeconds = 60.0
private let kUTMBundleScreenshotFilename = "screenshot.png"
let kUTMBundleScreenshotFilename = "screenshot.png"
private let kUTMBundleViewFilename = "view.plist"
/// UTM virtual machine backend
@ -66,8 +66,8 @@ protocol UTMVirtualMachine: AnyObject, Identifiable {
var state: UTMVirtualMachineState { get }
/// 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
var snapshotUnsupportedError: Error? { get }
@ -149,6 +149,9 @@ protocol UTMVirtualMachine: AnyObject, Identifiable {
/// Request a screenshot of the primary graphics device
/// - Returns: true if successful and the screenshot will be in `screenshot`
@discardableResult func takeScreenshot() async -> Bool
/// If screenshot is modified externally, this must be called
func reloadScreenshotFromFile() throws
}
/// Supported capabilities for a UTM backend
@ -167,6 +170,9 @@ protocol UTMVirtualMachineCapabilities {
/// The backend supports booting into recoveryOS.
var supportsRecoveryMode: Bool { get }
/// The backend supports remote sessions.
var supportsRemoteSession: Bool { get }
}
/// Delegate for UTMVirtualMachine events
@ -201,7 +207,7 @@ protocol UTMVirtualMachineDelegate: AnyObject {
}
/// Virtual machine state
enum UTMVirtualMachineState {
enum UTMVirtualMachineState: Codable {
case stopped
case starting
case started
@ -214,17 +220,19 @@ enum UTMVirtualMachineState {
}
/// Additional options for VM start
struct UTMVirtualMachineStartOptions: OptionSet {
struct UTMVirtualMachineStartOptions: OptionSet, Codable {
let rawValue: UInt
/// Boot without persisting any changes.
static let bootDisposibleMode = Self(rawValue: 1 << 0)
/// Boot into recoveryOS (when supported).
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
enum UTMVirtualMachineStopMethod {
enum UTMVirtualMachineStopMethod: Codable {
/// Sends a request to the guest to shut down gracefully.
case request
/// Sends a hardware power down signal.
@ -282,6 +290,43 @@ extension UTMVirtualMachine {
// 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 {
private var isScreenshotSaveEnabled: Bool {
!UserDefaults.standard.bool(forKey: "NoSaveScreenshot")
@ -311,12 +356,8 @@ extension UTMVirtualMachine {
return timer
}
func loadScreenshot() -> PlatformImage? {
#if canImport(AppKit)
return NSImage(contentsOf: screenshotUrl)
#elseif canImport(UIKit)
return UIImage(contentsOfURL: screenshotUrl)
#endif
func loadScreenshot() -> UTMVirtualMachineScreenshot? {
UTMVirtualMachineScreenshot(contentsOfURL: screenshotUrl)
}
func saveScreenshot() throws {
@ -326,17 +367,7 @@ extension UTMVirtualMachine {
guard let screenshot = screenshot else {
return
}
#if canImport(AppKit)
guard let cgref = screenshot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return
}
let newrep = NSBitmapImageRep(cgImage: cgref)
newrep.size = screenshot.size
let pngdata = newrep.representation(using: .png, properties: [:])
try pngdata?.write(to: screenshotUrl)
#elseif canImport(UIKit)
try screenshot.pngData()?.write(to: screenshotUrl)
#endif
try screenshot.pngData?.write(to: screenshotUrl)
}
func deleteScreenshot() throws {

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,16 @@
"location" : "https://github.com/utmapp/CocoaSpice.git",
"state" : {
"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"
}
},
{
"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",
"kind" : "remoteSourceControl",
"location" : "https://github.com/osy/SwiftTerm.git",
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
"state" : {
"branch" : "visionos",
"revision" : "8b0900a4c516eb8c87813f11e797f349e7fca014"
"branch" : "main",
"revision" : "ea0f681b25c8385b4a5a48d435e61d11392216e0"
}
},
{
@ -81,6 +108,15 @@
"version" : "1.0.3"
}
},
{
"identity" : "visionkeyboardkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/utmapp/VisionKeyboardKit.git",
"state" : {
"branch" : "main",
"revision" : "0804e4d64267acc8d08fb23160f5b6ac6134414f"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",

View File

@ -409,6 +409,8 @@ build_angle () {
pwd="$(pwd)"
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"
# 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 "include/" "$PREFIX/include"
cd "$pwd"

View File

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

View File

@ -12,7 +12,8 @@ usage() {
echo " MODE is one of:"
echo " deb (Cydia DEB)"
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-signed (developer signed IPA with valid PROFILE_NAME and TEAM_ID)"
echo " inputXcarchive is path to UTM.xcarchive"
@ -42,6 +43,11 @@ ipa-se )
BUNDLE_ID="com.utmapp.UTM-SE"
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
;;
@ -298,7 +304,7 @@ EOL
create_fake_ipa "$NAME" "$BUNDLE_ID" "$INPUT" "$OUTPUT" "$FAKEENT"
rm "$FAKEENT"
;;
ipa-se )
ipa-se | ipa-remote )
FAKEENT="/tmp/fakeent.$$.plist"
cat >"$FAKEENT" <<EOL
<?xml version="1.0" encoding="UTF-8"?>