Merge branch 'utmapp:main' into patch-3
This commit is contained in:
commit
0c21e39b8f
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"))
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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())
|
||||
}
|
|
@ -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") {
|
||||
|
|
|
@ -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...")
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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?()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 */
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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, ©Result)
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
//
|
||||
|
||||
import QEMUKitInternal
|
||||
#if WITH_QEMU_TCI
|
||||
#if !WITH_USB
|
||||
import CocoaSpiceNoUsb
|
||||
#else
|
||||
import CocoaSpice
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 */
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"?>
|
||||
|
|
Loading…
Reference in New Issue