Merge branch 'utmapp:main' into patch-3
This commit is contained in:
commit
0c21e39b8f
|
@ -23,7 +23,7 @@ on:
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BUILD_XCODE_PATH: /Applications/Xcode_15.1.app
|
BUILD_XCODE_PATH: /Applications/Xcode_15.2.app
|
||||||
RUNNER_IMAGE: macos-13
|
RUNNER_IMAGE: macos-13
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -53,7 +53,7 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
arch: [arm64]
|
arch: [arm64]
|
||||||
platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci]
|
platform: [ios, ios_simulator, ios-tci, ios_simulator-tci, macos, visionos, visionos_simulator, visionos-tci, visionos_simulator-tci]
|
||||||
include:
|
include:
|
||||||
# x86_64 supported only for macOS and simulators
|
# x86_64 supported only for macOS and simulators
|
||||||
- arch: x86_64
|
- arch: x86_64
|
||||||
|
@ -91,7 +91,7 @@ jobs:
|
||||||
if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event.inputs.rebuild_sysroot == 'true'
|
if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event.inputs.rebuild_sysroot == 'true'
|
||||||
run: ./scripts/build_dependencies.sh -p ${{ matrix.platform }} -a ${{ matrix.arch }}
|
run: ./scripts/build_dependencies.sh -p ${{ matrix.platform }} -a ${{ matrix.arch }}
|
||||||
env:
|
env:
|
||||||
NCPU: ${{ matrix.platform == 'ios-tci' && '2' || '0' }} # limit 2 CPU for TCI build due to memory issues, 0 = unlimited for other builds
|
NCPU: ${{ endsWith(matrix.platform, '-tci') && '4' || '0' }} # limit 4 CPU for TCI build due to memory issues, 0 = unlimited for other builds
|
||||||
- name: Compress Sysroot
|
- name: Compress Sysroot
|
||||||
if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event_name == 'release' || github.event.inputs.test_release == 'true'
|
if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event_name == 'release' || github.event.inputs.test_release == 'true'
|
||||||
run: tar -acf sysroot.tgz sysroot*
|
run: tar -acf sysroot.tgz sysroot*
|
||||||
|
@ -152,14 +152,16 @@ jobs:
|
||||||
needs: [configuration, build-sysroot]
|
needs: [configuration, build-sysroot]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
arch: [arm64]
|
configuration: [
|
||||||
platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci]
|
{arch: "arm64", sdk: "iphoneos", platform: "ios", scheme: "iOS"},
|
||||||
include:
|
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-SE"},
|
||||||
# x86_64 supported only for macOS and simulators
|
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-Remote"},
|
||||||
- arch: x86_64
|
{arch: "arm64", sdk: "xros", platform: "visionos", scheme: "iOS"},
|
||||||
platform: macos
|
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-SE"},
|
||||||
- arch: x86_64
|
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-Remote"},
|
||||||
platform: ios_simulator
|
{arch: "arm64", sdk: "macosx", platform: "macos", scheme: "macOS"},
|
||||||
|
{arch: "x86_64", sdk: "macosx", platform: "macos", scheme: "macOS"},
|
||||||
|
]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
@ -169,8 +171,8 @@ jobs:
|
||||||
id: cache-sysroot
|
id: cache-sysroot
|
||||||
uses: osy/actions-cache@v3
|
uses: osy/actions-cache@v3
|
||||||
with:
|
with:
|
||||||
path: sysroot-${{ matrix.platform }}-${{ matrix.arch }}
|
path: sysroot-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
|
||||||
key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
|
key: ${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
|
||||||
- name: Check Cache
|
- name: Check Cache
|
||||||
if: steps.cache-sysroot.outputs.cache-hit != 'true'
|
if: steps.cache-sysroot.outputs.cache-hit != 'true'
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
|
@ -182,12 +184,12 @@ jobs:
|
||||||
[[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
|
[[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
|
||||||
- name: Build UTM
|
- name: Build UTM
|
||||||
run: |
|
run: |
|
||||||
./scripts/build_utm.sh -p ${{ matrix.platform }} -a ${{ matrix.arch }} -o UTM
|
./scripts/build_utm.sh -k ${{ matrix.configuration.sdk }} -s ${{ matrix.configuration.scheme }} -a ${{ matrix.configuration.arch }} -o UTM
|
||||||
tar -acf UTM.xcarchive.tgz UTM.xcarchive
|
tar -acf UTM.xcarchive.tgz UTM.xcarchive
|
||||||
- name: Upload UTM
|
- name: Upload UTM
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: UTM-${{ matrix.platform }}-${{ matrix.arch }}
|
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
|
||||||
path: UTM.xcarchive.tgz
|
path: UTM.xcarchive.tgz
|
||||||
build-universal:
|
build-universal:
|
||||||
name: Build UTM (Universal Mac)
|
name: Build UTM (Universal Mac)
|
||||||
|
@ -215,7 +217,7 @@ jobs:
|
||||||
[[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
|
[[ "$(xcode-select -p)" == "${{ env.BUILD_XCODE_PATH }}"* ]] || sudo xcode-select -s "${{ env.BUILD_XCODE_PATH }}"
|
||||||
- name: Build UTM
|
- name: Build UTM
|
||||||
run: |
|
run: |
|
||||||
./scripts/build_utm.sh -t "$SIGNING_TEAM_ID" -p macos -a "arm64 x86_64" -o UTM
|
./scripts/build_utm.sh -t "$SIGNING_TEAM_ID" -k macosx -s macOS -a "arm64 x86_64" -o UTM
|
||||||
tar -acf UTM.xcarchive.tgz UTM.xcarchive
|
tar -acf UTM.xcarchive.tgz UTM.xcarchive
|
||||||
env:
|
env:
|
||||||
SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
|
SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
|
||||||
|
@ -231,12 +233,14 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
configuration: [
|
configuration: [
|
||||||
{platform: "ios", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
|
{platform: "ios", scheme: "iOS", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
|
||||||
{platform: "ios-tci", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
|
{platform: "ios-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
|
||||||
{platform: "ios", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
|
{platform: "ios", scheme: "iOS", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
|
||||||
{platform: "ios", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
|
{platform: "ios", scheme: "iOS", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
|
||||||
{platform: "visionos", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
|
{platform: "visionos", scheme: "iOS", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
|
||||||
{platform: "visionos-tci", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"}
|
{platform: "visionos-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"},
|
||||||
|
{platform: "ios-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote.ipa", path: "UTM Remote.ipa"},
|
||||||
|
{platform: "visionos-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote-visionOS.ipa", path: "UTM Remote.ipa"},
|
||||||
]
|
]
|
||||||
if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
|
if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
|
||||||
steps:
|
steps:
|
||||||
|
@ -245,7 +249,7 @@ jobs:
|
||||||
- name: Download Artifact
|
- name: Download Artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: UTM-${{ matrix.configuration.platform }}-arm64
|
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-arm64
|
||||||
- name: Install ldid + dpkg
|
- name: Install ldid + dpkg
|
||||||
run: brew install ldid dpkg
|
run: brew install ldid dpkg
|
||||||
- name: Fakesign IPA
|
- name: Fakesign IPA
|
||||||
|
|
|
@ -424,20 +424,20 @@ extension QEMUArchitecture {
|
||||||
default: return true
|
default: return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasHypervisorSupport: Bool {
|
var hasHypervisorSupport: Bool {
|
||||||
guard jb_has_hypervisor() else {
|
guard UTMCapabilities.current.contains(.hasHypervisorSupport) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if UTMCapabilities.current.contains(.isAarch64) {
|
||||||
|
return self == .aarch64
|
||||||
|
} else if UTMCapabilities.current.contains(.isX86_64) {
|
||||||
|
return self == .x86_64
|
||||||
|
} else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
#if arch(arm64)
|
|
||||||
return self == .aarch64
|
|
||||||
#elseif arch(x86_64)
|
|
||||||
return self == .x86_64
|
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TSO is supported on jailbroken iOS devices with Hypervisor support
|
/// TSO is supported on jailbroken iOS devices with Hypervisor support
|
||||||
var hasTSOSupport: Bool {
|
var hasTSOSupport: Bool {
|
||||||
#if os(iOS) || os(visionOS)
|
#if os(iOS) || os(visionOS)
|
||||||
|
|
|
@ -120,7 +120,7 @@ extension UTMConfiguration {
|
||||||
#endif
|
#endif
|
||||||
// is it a legacy QEMU config?
|
// is it a legacy QEMU config?
|
||||||
let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
|
let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
|
||||||
let name = UTMQemuVirtualMachine.virtualMachineName(for: packageURL)
|
let name = ConcreteVirtualMachine.virtualMachineName(for: packageURL)
|
||||||
let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
|
let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
|
||||||
return UTMQemuConfiguration(migrating: legacy)
|
return UTMQemuConfiguration(migrating: legacy)
|
||||||
} else if stub.backend == .qemu {
|
} else if stub.backend == .qemu {
|
||||||
|
|
|
@ -15,7 +15,6 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import QEMUKitInternal
|
|
||||||
|
|
||||||
/// Settings for single disk device
|
/// Settings for single disk device
|
||||||
protocol UTMConfigurationDrive: Codable, Hashable, Identifiable {
|
protocol UTMConfigurationDrive: Codable, Hashable, Identifiable {
|
||||||
|
@ -101,13 +100,17 @@ extension UTMConfigurationDrive {
|
||||||
try handle.close()
|
try handle.close()
|
||||||
}.value
|
}.value
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createQcow2Image(at newURL: URL, size sizeMib: Int) async throws {
|
private func createQcow2Image(at newURL: URL, size sizeMib: Int) async throws {
|
||||||
|
#if WITH_REMOTE
|
||||||
|
fatalError("Not implemented")
|
||||||
|
#else
|
||||||
try await Task.detached {
|
try await Task.detached {
|
||||||
if !QEMUGenerateDefaultQcow2File(newURL as CFURL, sizeMib) {
|
if !QEMUGenerateDefaultQcow2File(newURL as CFURL, sizeMib) {
|
||||||
throw UTMConfigurationError.cannotCreateDiskImage
|
throw UTMConfigurationError.cannotCreateDiskImage
|
||||||
}
|
}
|
||||||
}.value
|
}.value
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|
|
@ -61,6 +61,26 @@ import Virtualization // for getting network interfaces
|
||||||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("swtpm")
|
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("swtpm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Used only if in remote sever mode.
|
||||||
|
var monitorPipeURL: URL {
|
||||||
|
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used only if in remote sever mode.
|
||||||
|
var guestAgentPipeURL: URL {
|
||||||
|
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qga")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used only if in remote sever mode.
|
||||||
|
var spiceTlsKeyUrl: URL {
|
||||||
|
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("pem")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used only if in remote sever mode.
|
||||||
|
var spiceTlsCertUrl: URL {
|
||||||
|
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("crt")
|
||||||
|
}
|
||||||
|
|
||||||
/// Combined generated and user specified arguments.
|
/// Combined generated and user specified arguments.
|
||||||
@QEMUArgumentBuilder var allArguments: [QEMUArgument] {
|
@QEMUArgumentBuilder var allArguments: [QEMUArgument] {
|
||||||
generatedArguments
|
generatedArguments
|
||||||
|
@ -109,16 +129,48 @@ import Virtualization // for getting network interfaces
|
||||||
|
|
||||||
@QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
|
@QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
|
||||||
f("-spice")
|
f("-spice")
|
||||||
"unix=on"
|
if let port = qemu.spiceServerPort {
|
||||||
"addr=\(spiceSocketURL.lastPathComponent)"
|
if qemu.isSpiceServerTlsEnabled {
|
||||||
"disable-ticketing=on"
|
"tls-port=\(port)"
|
||||||
"image-compression=off"
|
"tls-channel=default"
|
||||||
"playback-compression=off"
|
"x509-key-file="
|
||||||
"streaming-video=off"
|
spiceTlsKeyUrl
|
||||||
"gl=\(isGLOn ? "on" : "off")"
|
"x509-cert-file="
|
||||||
|
spiceTlsCertUrl
|
||||||
|
"x509-cacert-file="
|
||||||
|
spiceTlsCertUrl
|
||||||
|
} else {
|
||||||
|
"port=\(port)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"unix=on"
|
||||||
|
"addr=\(spiceSocketURL.lastPathComponent)"
|
||||||
|
}
|
||||||
|
if let _ = qemu.spiceServerPassword {
|
||||||
|
"password-secret=secspice0"
|
||||||
|
} else {
|
||||||
|
"disable-ticketing=on"
|
||||||
|
}
|
||||||
|
if !isRemoteSpice {
|
||||||
|
"image-compression=off"
|
||||||
|
"playback-compression=off"
|
||||||
|
"streaming-video=off"
|
||||||
|
} else {
|
||||||
|
"streaming-video=filter"
|
||||||
|
}
|
||||||
|
"gl=\(isGLSupported && !isRemoteSpice ? "on" : "off")"
|
||||||
f()
|
f()
|
||||||
f("-chardev")
|
f("-chardev")
|
||||||
f("spiceport,id=org.qemu.monitor.qmp,name=org.qemu.monitor.qmp.0")
|
if isRemoteSpice {
|
||||||
|
"pipe"
|
||||||
|
"path="
|
||||||
|
monitorPipeURL
|
||||||
|
} else {
|
||||||
|
"spiceport"
|
||||||
|
"name=org.qemu.monitor.qmp.0"
|
||||||
|
}
|
||||||
|
"id=org.qemu.monitor.qmp"
|
||||||
|
f()
|
||||||
f("-mon")
|
f("-mon")
|
||||||
f("chardev=org.qemu.monitor.qmp,mode=control")
|
f("chardev=org.qemu.monitor.qmp,mode=control")
|
||||||
if !isSparc { // disable -vga and other default devices
|
if !isSparc { // disable -vga and other default devices
|
||||||
|
@ -128,8 +180,28 @@ import Virtualization // for getting network interfaces
|
||||||
f("-vga")
|
f("-vga")
|
||||||
f("none")
|
f("none")
|
||||||
}
|
}
|
||||||
|
if let password = qemu.spiceServerPassword {
|
||||||
|
// assume anyone who can read this is in our trust domain
|
||||||
|
f("-object")
|
||||||
|
f("secret,id=secspice0,data=\(password)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func filterDisplayIfRemote(_ display: any QEMUDisplayDevice) -> any QEMUDisplayDevice {
|
||||||
|
if isRemoteSpice {
|
||||||
|
let rawValue = display.rawValue
|
||||||
|
if rawValue.hasSuffix("-gl") {
|
||||||
|
return AnyQEMUConstant(rawValue: String(rawValue.dropLast(3)))!
|
||||||
|
} else if rawValue.contains("-gl-") {
|
||||||
|
return AnyQEMUConstant(rawValue: String(rawValue.replacingOccurrences(of: "-gl-", with: "-")))!
|
||||||
|
} else {
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@QEMUArgumentBuilder private var displayArguments: [QEMUArgument] {
|
@QEMUArgumentBuilder private var displayArguments: [QEMUArgument] {
|
||||||
if displays.isEmpty {
|
if displays.isEmpty {
|
||||||
f("-nographic")
|
f("-nographic")
|
||||||
|
@ -143,7 +215,7 @@ import Virtualization // for getting network interfaces
|
||||||
} else {
|
} else {
|
||||||
for display in displays {
|
for display in displays {
|
||||||
f("-device")
|
f("-device")
|
||||||
display.hardware
|
filterDisplayIfRemote(display.hardware)
|
||||||
if let vgaRamSize = displays[0].vgaRamMib {
|
if let vgaRamSize = displays[0].vgaRamMib {
|
||||||
"vgamem_mb=\(vgaRamSize)"
|
"vgamem_mb=\(vgaRamSize)"
|
||||||
}
|
}
|
||||||
|
@ -152,7 +224,7 @@ import Virtualization // for getting network interfaces
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isGLOn: Bool {
|
private var isGLSupported: Bool {
|
||||||
displays.contains { display in
|
displays.contains { display in
|
||||||
display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl")
|
display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl")
|
||||||
}
|
}
|
||||||
|
@ -161,7 +233,11 @@ import Virtualization // for getting network interfaces
|
||||||
private var isSparc: Bool {
|
private var isSparc: Bool {
|
||||||
system.architecture == .sparc || system.architecture == .sparc64
|
system.architecture == .sparc || system.architecture == .sparc64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isRemoteSpice: Bool {
|
||||||
|
qemu.spiceServerPort != nil
|
||||||
|
}
|
||||||
|
|
||||||
@QEMUArgumentBuilder private var serialArguments: [QEMUArgument] {
|
@QEMUArgumentBuilder private var serialArguments: [QEMUArgument] {
|
||||||
for i in serials.indices {
|
for i in serials.indices {
|
||||||
f("-chardev")
|
f("-chardev")
|
||||||
|
@ -318,9 +394,9 @@ import Virtualization // for getting network interfaces
|
||||||
}
|
}
|
||||||
let tbSize = system.jitCacheSize > 0 ? system.jitCacheSize : system.memorySize / 4
|
let tbSize = system.jitCacheSize > 0 ? system.jitCacheSize : system.memorySize / 4
|
||||||
"tb-size=\(tbSize)"
|
"tb-size=\(tbSize)"
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_JIT
|
||||||
// use mirror mapping when we don't have JIT entitlements
|
// use mirror mapping when we don't have JIT entitlements
|
||||||
if !jb_has_jit_entitlement() {
|
if !UTMCapabilities.current.contains(.hasJitEntitlements) {
|
||||||
"split-wx=on"
|
"split-wx=on"
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
@ -433,6 +509,10 @@ import Virtualization // for getting network interfaces
|
||||||
#if os(iOS) || os(visionOS)
|
#if os(iOS) || os(visionOS)
|
||||||
return false
|
return false
|
||||||
#else
|
#else
|
||||||
|
// only support SPICE audio if we are running remotely
|
||||||
|
if isRemoteSpice {
|
||||||
|
return false
|
||||||
|
}
|
||||||
// force CoreAudio backend for mac99 which only supports 44100 Hz
|
// force CoreAudio backend for mac99 which only supports 44100 Hz
|
||||||
// pcspk doesn't work with SPICE audio
|
// pcspk doesn't work with SPICE audio
|
||||||
if sound.contains(where: { $0.hardware.rawValue == "screamer" || $0.hardware.rawValue == "pcspk" }) {
|
if sound.contains(where: { $0.hardware.rawValue == "screamer" || $0.hardware.rawValue == "pcspk" }) {
|
||||||
|
@ -671,7 +751,7 @@ import Virtualization // for getting network interfaces
|
||||||
f("usb-mouse,bus=usb-bus.0")
|
f("usb-mouse,bus=usb-bus.0")
|
||||||
f("-device")
|
f("-device")
|
||||||
f("usb-kbd,bus=usb-bus.0")
|
f("usb-kbd,bus=usb-bus.0")
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
let maxDevices = input.maximumUsbShare
|
let maxDevices = input.maximumUsbShare
|
||||||
let buses = (maxDevices + 2) / 3
|
let buses = (maxDevices + 2) / 3
|
||||||
if input.usbBusSupport == .usb3_0 {
|
if input.usbBusSupport == .usb3_0 {
|
||||||
|
@ -859,7 +939,16 @@ import Virtualization // for getting network interfaces
|
||||||
f("-device")
|
f("-device")
|
||||||
f("virtserialport,chardev=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
|
f("virtserialport,chardev=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
|
||||||
f("-chardev")
|
f("-chardev")
|
||||||
f("spiceport,id=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
|
if isRemoteSpice {
|
||||||
|
"pipe"
|
||||||
|
"path="
|
||||||
|
guestAgentPipeURL
|
||||||
|
} else {
|
||||||
|
"spiceport"
|
||||||
|
"name=org.qemu.guest_agent.0"
|
||||||
|
}
|
||||||
|
"id=org.qemu.guest_agent"
|
||||||
|
f()
|
||||||
}
|
}
|
||||||
if isSpiceAgentUsed {
|
if isSpiceAgentUsed {
|
||||||
f("-device")
|
f("-device")
|
||||||
|
|
|
@ -69,6 +69,15 @@ struct UTMQemuConfigurationQEMU: Codable {
|
||||||
/// Set to true to request UEFI variable reset. Not saved.
|
/// Set to true to request UEFI variable reset. Not saved.
|
||||||
var isUefiVariableResetRequested: Bool = false
|
var isUefiVariableResetRequested: Bool = false
|
||||||
|
|
||||||
|
/// Set to open a port for remote SPICE session. Not saved.
|
||||||
|
var spiceServerPort: UInt16?
|
||||||
|
|
||||||
|
/// If true, all SPICE channels will be over TLS. Not saved.
|
||||||
|
var isSpiceServerTlsEnabled: Bool = false
|
||||||
|
|
||||||
|
/// Set to a password shared with the client. Not saved.
|
||||||
|
var spiceServerPassword: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case hasDebugLog = "DebugLog"
|
case hasDebugLog = "DebugLog"
|
||||||
case hasUefiBoot = "UEFIBoot"
|
case hasUefiBoot = "UEFIBoot"
|
||||||
|
|
|
@ -34,7 +34,7 @@ class Main {
|
||||||
static var jitAvailable = true
|
static var jitAvailable = true
|
||||||
|
|
||||||
static func main() {
|
static func main() {
|
||||||
#if (os(iOS) || os(visionOS)) && !WITH_QEMU_TCI
|
#if (os(iOS) || os(visionOS)) && WITH_JIT
|
||||||
// check if we have jailbreak
|
// check if we have jailbreak
|
||||||
if jb_spawn_ptrace_child(CommandLine.argc, CommandLine.unsafeArgv) {
|
if jb_spawn_ptrace_child(CommandLine.argc, CommandLine.unsafeArgv) {
|
||||||
logger.info("JIT: ptrace() child spawn trick")
|
logger.info("JIT: ptrace() child spawn trick")
|
||||||
|
|
|
@ -17,12 +17,12 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct BigButtonStyle: ButtonStyle {
|
struct BigButtonStyle: ButtonStyle {
|
||||||
let width: CGFloat
|
let width: CGFloat?
|
||||||
let height: CGFloat
|
let height: CGFloat?
|
||||||
|
|
||||||
fileprivate struct BigButtonView: View {
|
fileprivate struct BigButtonView: View {
|
||||||
let width: CGFloat
|
let width: CGFloat?
|
||||||
let height: CGFloat
|
let height: CGFloat?
|
||||||
let configuration: BigButtonStyle.Configuration
|
let configuration: BigButtonStyle.Configuration
|
||||||
@Environment(\.isEnabled) private var isEnabled: Bool
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
||||||
|
|
||||||
|
|
|
@ -20,8 +20,11 @@ import UniformTypeIdentifiers
|
||||||
import IQKeyboardManagerSwift
|
import IQKeyboardManagerSwift
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if WITH_QEMU_TCI
|
// on visionOS, there is no text to show more than UTM
|
||||||
|
#if WITH_QEMU_TCI && !os(visionOS)
|
||||||
let productName = "UTM SE"
|
let productName = "UTM SE"
|
||||||
|
#elseif WITH_REMOTE && !os(visionOS)
|
||||||
|
let productName = "UTM Remote"
|
||||||
#else
|
#else
|
||||||
let productName = "UTM"
|
let productName = "UTM"
|
||||||
#endif
|
#endif
|
||||||
|
@ -33,7 +36,8 @@ struct ContentView: View {
|
||||||
@State private var newPopupPresented = false
|
@State private var newPopupPresented = false
|
||||||
@State private var openSheetPresented = false
|
@State private var openSheetPresented = false
|
||||||
@Environment(\.openURL) var openURL
|
@Environment(\.openURL) var openURL
|
||||||
|
@AppStorage("ServerAutostart") private var isServerAutostart: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VMNavigationListView()
|
VMNavigationListView()
|
||||||
.overlay(data.showSettingsModal ? AnyView(EmptyView()) : AnyView(BusyOverlay()))
|
.overlay(data.showSettingsModal ? AnyView(EmptyView()) : AnyView(BusyOverlay()))
|
||||||
|
@ -67,6 +71,11 @@ struct ContentView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
await data.listRefresh()
|
await data.listRefresh()
|
||||||
|
#if os(macOS)
|
||||||
|
if isServerAutostart {
|
||||||
|
await data.remoteServer.start()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
await releaseHelper.fetchReleaseNotes()
|
await releaseHelper.fetchReleaseNotes()
|
||||||
|
@ -78,7 +87,7 @@ struct ContentView: View {
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
IQKeyboardManager.shared.enable = true
|
IQKeyboardManager.shared.enable = true
|
||||||
#endif
|
#endif
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_JIT
|
||||||
if !Main.jitAvailable {
|
if !Main.jitAvailable {
|
||||||
data.busyWorkAsync {
|
data.busyWorkAsync {
|
||||||
let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach")
|
let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach")
|
||||||
|
@ -95,7 +104,7 @@ struct ContentView: View {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// ignore error when we are running on a HV only build
|
// ignore error when we are running on a HV only build
|
||||||
if !jb_has_hypervisor() {
|
if !UTMCapabilities.current.contains(.hasHypervisorSupport) {
|
||||||
throw NSLocalizedString("Your version of iOS does not support running VMs while unmodified. You must either run UTM while jailbroken or with a remote debugger attached. See https://getutm.app/install/ for more details.", comment: "ContentView")
|
throw NSLocalizedString("Your version of iOS does not support running VMs while unmodified. You must either run UTM while jailbroken or with a remote debugger attached. See https://getutm.app/install/ for more details.", comment: "ContentView")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -163,7 +172,7 @@ struct ContentView: View {
|
||||||
case "pause":
|
case "pause":
|
||||||
if let vm = findVM(), vm.state == .started {
|
if let vm = findVM(), vm.state == .started {
|
||||||
let shouldSaveOnPause: Bool
|
let shouldSaveOnPause: Bool
|
||||||
if let vm = vm.wrapped as? UTMQemuVirtualMachine {
|
if let vm = vm.wrapped as? (any UTMSpiceVirtualMachine) {
|
||||||
shouldSaveOnPause = !vm.isRunningAsDisposible
|
shouldSaveOnPause = !vm.isRunningAsDisposible
|
||||||
} else {
|
} else {
|
||||||
shouldSaveOnPause = true
|
shouldSaveOnPause = true
|
||||||
|
|
|
@ -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.onEditingChanged = onEditingChanged
|
||||||
self.promptKey = prompt
|
self.promptKey = prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(_ titleKey: LocalizedStringKey, number: Binding<Int?>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
|
||||||
|
let nsnumber = Binding<NSNumber?> {
|
||||||
|
return number.wrappedValue as NSNumber?
|
||||||
|
} set: { newValue in
|
||||||
|
number.wrappedValue = newValue?.intValue
|
||||||
|
}
|
||||||
|
self.init(titleKey, number: nsnumber, prompt: prompt, onEditingChanged: onEditingChanged)
|
||||||
|
}
|
||||||
|
|
||||||
init(_ titleKey: LocalizedStringKey, number: Binding<Int>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
|
init(_ titleKey: LocalizedStringKey, number: Binding<Int>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
|
||||||
let nsnumber = Binding<NSNumber?> {
|
let nsnumber = Binding<NSNumber?> {
|
||||||
return number.wrappedValue as NSNumber
|
return number.wrappedValue as NSNumber
|
||||||
|
|
|
@ -25,7 +25,13 @@ struct UTMUnavailableVMView: View {
|
||||||
subtitle: vm.detailsSubtitleLabel,
|
subtitle: vm.detailsSubtitleLabel,
|
||||||
progress: nil,
|
progress: nil,
|
||||||
imageOverlaySystemName: "questionmark.circle.fill",
|
imageOverlaySystemName: "questionmark.circle.fill",
|
||||||
popover: { WrappedVMDetailsView(path: vm.pathUrl.path, onRemove: remove) },
|
popover: {
|
||||||
|
#if WITH_REMOTE
|
||||||
|
UnsupportedVMDetailsView(vm: vm)
|
||||||
|
#else
|
||||||
|
WrappedVMDetailsView(path: vm.pathUrl.path, onRemove: remove)
|
||||||
|
#endif
|
||||||
|
},
|
||||||
onRemove: remove)
|
onRemove: remove)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,6 +77,26 @@ fileprivate struct WrappedVMDetailsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if WITH_REMOTE
|
||||||
|
fileprivate struct UnsupportedVMDetailsView: View {
|
||||||
|
@ObservedObject var vm: VMData
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .center) {
|
||||||
|
if let remotevm = vm as? VMRemoteData, let reason = remotevm.unavailableReason {
|
||||||
|
Text(reason)
|
||||||
|
.lineLimit(nil)
|
||||||
|
} else {
|
||||||
|
Text("This VM is unavailable.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(width: 230)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
struct UTMUnavailableVMView_Previews: PreviewProvider {
|
struct UTMUnavailableVMView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))
|
UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))
|
||||||
|
|
|
@ -21,6 +21,7 @@ struct VMCommands: Commands {
|
||||||
|
|
||||||
@CommandsBuilder
|
@CommandsBuilder
|
||||||
var body: some Commands {
|
var body: some Commands {
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
CommandGroup(replacing: .newItem) {
|
CommandGroup(replacing: .newItem) {
|
||||||
Button(action: { NotificationCenter.default.post(name: NSNotification.NewVirtualMachine, object: nil) }, label: {
|
Button(action: { NotificationCenter.default.post(name: NSNotification.NewVirtualMachine, object: nil) }, label: {
|
||||||
Text("New…")
|
Text("New…")
|
||||||
|
@ -29,6 +30,7 @@ struct VMCommands: Commands {
|
||||||
Text("Open…")
|
Text("Open…")
|
||||||
}).keyboardShortcut(KeyEquivalent("o"))
|
}).keyboardShortcut(KeyEquivalent("o"))
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
SidebarCommands()
|
SidebarCommands()
|
||||||
ToolbarCommands()
|
ToolbarCommands()
|
||||||
CommandGroup(replacing: .help) {
|
CommandGroup(replacing: .help) {
|
||||||
|
|
|
@ -26,7 +26,7 @@ struct VMConfigInputView: View {
|
||||||
VMConfigConstantPicker("USB Support", selection: $config.usbBusSupport)
|
VMConfigConstantPicker("USB Support", selection: $config.usbBusSupport)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
if config.usbBusSupport != .disabled {
|
if config.usbBusSupport != .disabled {
|
||||||
Section(header: Text("USB Sharing")) {
|
Section(header: Text("USB Sharing")) {
|
||||||
if !jb_has_usb_entitlement() {
|
if !jb_has_usb_entitlement() {
|
||||||
|
|
|
@ -101,7 +101,7 @@ struct VMConfigSystemView: View {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
let actualJitSizeMib = jitSizeMib == 0 ? memorySizeMib / 4 : jitSizeMib
|
let actualJitSizeMib = jitSizeMib == 0 ? memorySizeMib / 4 : jitSizeMib
|
||||||
let jitMirrorMultiplier = jb_has_jit_entitlement() ? 1 : 2;
|
let jitMirrorMultiplier = UTMCapabilities.current.contains(.hasJitEntitlements) ? 1 : 2;
|
||||||
let estMemoryUsage = UInt64(memorySizeMib + jitMirrorMultiplier*actualJitSizeMib + baseUsageMib) * bytesInMib
|
let estMemoryUsage = UInt64(memorySizeMib + jitMirrorMultiplier*actualJitSizeMib + baseUsageMib) * bytesInMib
|
||||||
if Double(estMemoryUsage) > Double(totalDeviceMemory) * warningThreshold {
|
if Double(estMemoryUsage) > Double(totalDeviceMemory) * warningThreshold {
|
||||||
warningMessage = WarningMessage.overallocatedRam(totalMib: totalDeviceMemory / bytesInMib, estimatedMib: estMemoryUsage / bytesInMib)
|
warningMessage = WarningMessage.overallocatedRam(totalMib: totalDeviceMemory / bytesInMib, estimatedMib: estMemoryUsage / bytesInMib)
|
||||||
|
@ -177,7 +177,7 @@ private struct HardwareOptions: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: config.architecture) { newValue in
|
.onChange(of: config.architecture) { newValue in
|
||||||
isArchitectureSupported = UTMQemuVirtualMachine.isSupported(systemArchitecture: newValue)
|
isArchitectureSupported = ConcreteVirtualMachine.isSupported(systemArchitecture: newValue)
|
||||||
if newValue != architecture {
|
if newValue != architecture {
|
||||||
architecture = newValue
|
architecture = newValue
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
}.help("Reveal where the VM is stored.")
|
}.help("Reveal where the VM is stored.")
|
||||||
Divider()
|
Divider()
|
||||||
#endif
|
#endif
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
Button {
|
Button {
|
||||||
data.close(vm: vm) // close window
|
data.close(vm: vm) // close window
|
||||||
data.edit(vm: vm)
|
data.edit(vm: vm)
|
||||||
|
@ -68,6 +69,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
Label("Edit", systemImage: "slider.horizontal.3")
|
Label("Edit", systemImage: "slider.horizontal.3")
|
||||||
}.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
|
}.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
|
||||||
.help("Modify settings for this VM.")
|
.help("Modify settings for this VM.")
|
||||||
|
#endif
|
||||||
if vm.hasSuspendState || !vm.isStopped {
|
if vm.hasSuspendState || !vm.isStopped {
|
||||||
Button {
|
Button {
|
||||||
confirmAction = .confirmStopVM
|
confirmAction = .confirmStopVM
|
||||||
|
@ -99,7 +101,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if let _ = vm.wrapped as? UTMQemuVirtualMachine {
|
if let _ = vm.config as? UTMQemuConfiguration {
|
||||||
Button {
|
Button {
|
||||||
data.run(vm: vm, options: .bootDisposibleMode)
|
data.run(vm: vm, options: .bootDisposibleMode)
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -120,6 +122,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
Button {
|
Button {
|
||||||
shareItem = .utmCopy(vm)
|
shareItem = .utmCopy(vm)
|
||||||
showSharePopup.toggle()
|
showSharePopup.toggle()
|
||||||
|
@ -164,6 +167,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
}.disabled(!vm.isModifyAllowed)
|
}.disabled(!vm.isModifyAllowed)
|
||||||
.help("Delete this VM and all its data.")
|
.help("Delete this VM and all its data.")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
|
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
|
||||||
.modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
|
.modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
|
||||||
|
@ -175,7 +179,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
.onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
|
.onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
|
||||||
if newValue == true {
|
if newValue == true {
|
||||||
data.busyWorkAsync {
|
data.busyWorkAsync {
|
||||||
try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)
|
try await data.mountSupportTools(for: vm.wrapped!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,9 +29,10 @@ struct VMDetailsView: View {
|
||||||
#else
|
#else
|
||||||
private let regularScreenSizeClass: Bool = true
|
private let regularScreenSizeClass: Bool = true
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@State private var size: Int64 = 0
|
||||||
|
|
||||||
private var sizeLabel: String {
|
private var sizeLabel: String {
|
||||||
let size = data.computeSize(for: vm)
|
|
||||||
return ByteCountFormatter.string(fromByteCount: size, countStyle: .binary)
|
return ByteCountFormatter.string(fromByteCount: size, countStyle: .binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,8 +71,8 @@ struct VMDetailsView: View {
|
||||||
.padding([.leading, .trailing, .bottom])
|
.padding([.leading, .trailing, .bottom])
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
|
let qemuConfig = vm.config as! UTMQemuConfiguration
|
||||||
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
VMRemovableDrivesView(vm: vm, config: qemuConfig)
|
||||||
.padding([.leading, .trailing, .bottom])
|
.padding([.leading, .trailing, .bottom])
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
|
@ -89,8 +90,8 @@ struct VMDetailsView: View {
|
||||||
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
|
let qemuConfig = vm.config as! UTMQemuConfiguration
|
||||||
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
VMRemovableDrivesView(vm: vm, config: qemuConfig)
|
||||||
#endif
|
#endif
|
||||||
}.padding([.leading, .trailing, .bottom])
|
}.padding([.leading, .trailing, .bottom])
|
||||||
}
|
}
|
||||||
|
@ -109,6 +110,16 @@ struct VMDetailsView: View {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
size = await data.computeSize(for: vm)
|
||||||
|
#if WITH_REMOTE
|
||||||
|
if let vm = vm.wrapped as? UTMRemoteSpiceVirtualMachine {
|
||||||
|
await vm.loadScreenshotFromServer()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -151,7 +162,7 @@ struct Screenshot: View {
|
||||||
.blendMode(.hardLight)
|
.blendMode(.hardLight)
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
.overlay {
|
.overlay {
|
||||||
if vm.isStopped {
|
if vm.isStopped || vm.isTakeoverAllowed {
|
||||||
Image(systemName: "play.circle.fill")
|
Image(systemName: "play.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 100, height: 100)
|
.frame(width: 100, height: 100)
|
||||||
|
@ -164,7 +175,7 @@ struct Screenshot: View {
|
||||||
#endif
|
#endif
|
||||||
if vm.isBusy {
|
if vm.isBusy {
|
||||||
Spinner(size: .large)
|
Spinner(size: .large)
|
||||||
} else if vm.isStopped {
|
} else if vm.isStopped || vm.isTakeoverAllowed {
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
Button(action: { data.run(vm: vm) }, label: {
|
Button(action: { data.run(vm: vm) }, label: {
|
||||||
Label("Run", systemImage: "play.circle.fill")
|
Label("Run", systemImage: "play.circle.fill")
|
||||||
|
|
|
@ -66,8 +66,10 @@ struct VMNavigationListView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onMove(perform: move)
|
}.onMove(perform: move)
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
.onDelete(perform: delete)
|
.onDelete(perform: delete)
|
||||||
|
#endif
|
||||||
|
|
||||||
if data.pendingVMs.count > 0 {
|
if data.pendingVMs.count > 0 {
|
||||||
Section(header: Text("Pending")) {
|
Section(header: Text("Pending")) {
|
||||||
ForEach(data.pendingVMs, id: \.name) { vm in
|
ForEach(data.pendingVMs, id: \.name) { vm in
|
||||||
|
@ -119,10 +121,12 @@ private struct VMListModifier: ViewModifier {
|
||||||
newButton
|
newButton
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
newButton
|
newButton
|
||||||
}
|
}
|
||||||
#if !os(visionOS)
|
#endif
|
||||||
|
#if !os(visionOS) && !WITH_REMOTE
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Settings") {
|
Button("Settings") {
|
||||||
settingsPresented.toggle()
|
settingsPresented.toggle()
|
||||||
|
@ -140,7 +144,9 @@ private struct VMListModifier: ViewModifier {
|
||||||
if data.showNewVMSheet {
|
if data.showNewVMSheet {
|
||||||
VMWizardView()
|
VMWizardView()
|
||||||
} else if settingsPresented {
|
} else if settingsPresented {
|
||||||
|
#if !WITH_REMOTE
|
||||||
UTMSettingsView()
|
UTMSettingsView()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: data.showNewVMSheet) { newValue in
|
.onChange(of: data.showNewVMSheet) { newValue in
|
||||||
|
|
|
@ -17,39 +17,100 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VMPlaceholderView: View {
|
struct VMPlaceholderView: View {
|
||||||
@EnvironmentObject private var data: UTMData
|
var body: some View {
|
||||||
@Environment(\.openURL) private var openURL
|
if #available(iOS 16, macOS 13, *) {
|
||||||
|
VMPlaceholderViewNew()
|
||||||
|
} else {
|
||||||
|
VMPlaceholderViewOld()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct VMPlaceholderViewOld: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
Title()
|
||||||
HStack {
|
HStack {
|
||||||
Text("Welcome to UTM").font(.title)
|
FirstRow()
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
TileButton(Label(String.create, systemImage: "plus.circle")) {
|
SecondRow()
|
||||||
data.newVM()
|
|
||||||
}
|
|
||||||
TileButton(Label(String.browse, systemImage: "arrow.down.circle")) {
|
|
||||||
openURL(URL(string: "https://mac.getutm.app/gallery/")!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
HStack {
|
}
|
||||||
TileButton(Label(String.guide, systemImage: "book.circle")) {
|
}
|
||||||
openURL(URL(string: "https://docs.getutm.app/basics/basics/")!)
|
}
|
||||||
|
|
||||||
|
@available(iOS 16, macOS 13, *)
|
||||||
|
fileprivate struct VMPlaceholderViewNew: View {
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Title()
|
||||||
|
Grid {
|
||||||
|
GridRow {
|
||||||
|
FirstRow()
|
||||||
}
|
}
|
||||||
TileButton(Label(String.support, systemImage: "questionmark.circle")) {
|
GridRow {
|
||||||
openURL(URL(string: "https://docs.getutm.app/")!)
|
SecondRow()
|
||||||
}
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
GridRow {
|
||||||
|
Button {
|
||||||
|
openWindow(id: "server")
|
||||||
|
} label: {
|
||||||
|
Label(String.server, systemImage: "server.rack")
|
||||||
|
}.buttonStyle(BigButtonStyle(width: nil, height: 50))
|
||||||
|
.gridCellColumns(2)
|
||||||
|
.gridCellUnsizedAxes(.horizontal)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate struct Title: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text("Welcome to UTM").font(.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct FirstRow: View {
|
||||||
|
@EnvironmentObject private var data: UTMData
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TileButton(Label(String.create, systemImage: "plus.circle")) {
|
||||||
|
data.newVM()
|
||||||
|
}
|
||||||
|
TileButton(Label(String.browse, systemImage: "arrow.down.circle")) {
|
||||||
|
openURL(URL(string: "https://mac.getutm.app/gallery/")!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct SecondRow: View {
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TileButton(Label(String.guide, systemImage: "book.circle")) {
|
||||||
|
openURL(URL(string: "https://docs.getutm.app/basics/basics/")!)
|
||||||
|
}
|
||||||
|
TileButton(Label(String.support, systemImage: "questionmark.circle")) {
|
||||||
|
openURL(URL(string: "https://docs.getutm.app/")!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fileprivate extension String {
|
fileprivate extension String {
|
||||||
static let create = NSLocalizedString("Create a New Virtual Machine", comment: "Welcome view")
|
static let create = NSLocalizedString("Create a New Virtual Machine", comment: "Welcome view")
|
||||||
static let browse = NSLocalizedString("Browse UTM Gallery", comment: "Welcome view")
|
static let browse = NSLocalizedString("Browse UTM Gallery", comment: "Welcome view")
|
||||||
static let guide = NSLocalizedString("User Guide", comment: "Welcome view")
|
static let guide = NSLocalizedString("User Guide", comment: "Welcome view")
|
||||||
static let support = NSLocalizedString("Support", comment: "Welcome view")
|
static let support = NSLocalizedString("Support", comment: "Welcome view")
|
||||||
|
static let server = NSLocalizedString("Server", comment: "Server view")
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TileButton: View {
|
private struct TileButton: View {
|
||||||
|
|
|
@ -26,8 +26,8 @@ struct VMRemovableDrivesView: View {
|
||||||
@State private var workaroundFileImporterBug: Bool = false
|
@State private var workaroundFileImporterBug: Bool = false
|
||||||
@State private var currentDrive: UTMQemuConfigurationDrive?
|
@State private var currentDrive: UTMQemuConfigurationDrive?
|
||||||
|
|
||||||
private var qemuVM: UTMQemuVirtualMachine! {
|
private var qemuVM: (any UTMSpiceVirtualMachine)! {
|
||||||
vm.wrapped as? UTMQemuVirtualMachine
|
vm.wrapped as? any UTMSpiceVirtualMachine
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileManager: FileManager {
|
var fileManager: FileManager {
|
||||||
|
@ -78,6 +78,7 @@ struct VMRemovableDrivesView: View {
|
||||||
}
|
}
|
||||||
ForEach(config.drives.filter { $0.isExternal }) { drive in
|
ForEach(config.drives.filter { $0.isExternal }) { drive in
|
||||||
HStack {
|
HStack {
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
// Drive menu
|
// Drive menu
|
||||||
Menu {
|
Menu {
|
||||||
// Browse button
|
// Browse button
|
||||||
|
@ -118,6 +119,9 @@ struct VMRemovableDrivesView: View {
|
||||||
} label: {
|
} label: {
|
||||||
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
|
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
|
||||||
}.disabled(vm.hasSuspendState)
|
}.disabled(vm.hasSuspendState)
|
||||||
|
#else
|
||||||
|
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
|
||||||
|
#endif
|
||||||
Spacer()
|
Spacer()
|
||||||
// Disk image path, or (empty)
|
// Disk image path, or (empty)
|
||||||
Text(pathFor(drive))
|
Text(pathFor(drive))
|
||||||
|
|
|
@ -51,6 +51,7 @@ struct VMToolbarModifier: ViewModifier {
|
||||||
UTMPreferenceButtonToolbarContent()
|
UTMPreferenceButtonToolbarContent()
|
||||||
#endif
|
#endif
|
||||||
ToolbarItemGroup(placement: buttonPlacement) {
|
ToolbarItemGroup(placement: buttonPlacement) {
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
if vm.isShortcut {
|
if vm.isShortcut {
|
||||||
DestructiveButton {
|
DestructiveButton {
|
||||||
confirmAction = .confirmDeleteShortcut
|
confirmAction = .confirmDeleteShortcut
|
||||||
|
@ -112,6 +113,7 @@ struct VMToolbarModifier: ViewModifier {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
#endif
|
||||||
if vm.hasSuspendState || !vm.isStopped {
|
if vm.hasSuspendState || !vm.isStopped {
|
||||||
Button {
|
Button {
|
||||||
confirmAction = .confirmStopVM
|
confirmAction = .confirmStopVM
|
||||||
|
@ -129,6 +131,7 @@ struct VMToolbarModifier: ViewModifier {
|
||||||
}.help("Run selected VM")
|
}.help("Run selected VM")
|
||||||
.padding(.leading, padding)
|
.padding(.leading, padding)
|
||||||
}
|
}
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
if bottom {
|
if bottom {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
@ -143,6 +146,7 @@ struct VMToolbarModifier: ViewModifier {
|
||||||
}.help("Edit selected VM")
|
}.help("Edit selected VM")
|
||||||
.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
|
.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
|
||||||
.padding(.leading, padding)
|
.padding(.leading, padding)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
|
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
|
||||||
|
|
|
@ -26,12 +26,12 @@ struct VMWizardStartView: View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
VZVirtualMachine.isSupported && !processIsTranslated()
|
VZVirtualMachine.isSupported && !processIsTranslated()
|
||||||
#else
|
#else
|
||||||
jb_has_hypervisor()
|
UTMCapabilities.current.contains(.hasHypervisorSupport)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEmulationSupported: Bool {
|
var isEmulationSupported: Bool {
|
||||||
#if WITH_QEMU_TCI
|
#if !WITH_JIT
|
||||||
true
|
true
|
||||||
#else
|
#else
|
||||||
Main.jitAvailable
|
Main.jitAvailable
|
||||||
|
|
|
@ -21,9 +21,19 @@ import AppKit
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#endif
|
#endif
|
||||||
#if canImport(AltKit) && !WITH_QEMU_TCI
|
#if canImport(AltKit) && WITH_JIT
|
||||||
import AltKit
|
import AltKit
|
||||||
#endif
|
#endif
|
||||||
|
#if WITH_SERVER
|
||||||
|
import Combine
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if WITH_REMOTE
|
||||||
|
import CocoaSpiceNoUsb
|
||||||
|
typealias ConcreteVirtualMachine = UTMRemoteSpiceVirtualMachine
|
||||||
|
#else
|
||||||
|
typealias ConcreteVirtualMachine = UTMQemuVirtualMachine
|
||||||
|
#endif
|
||||||
|
|
||||||
struct AlertMessage: Identifiable {
|
struct AlertMessage: Identifiable {
|
||||||
var message: String
|
var message: String
|
||||||
|
@ -88,7 +98,18 @@ struct AlertMessage: Identifiable {
|
||||||
nonisolated private var documentsURL: URL {
|
nonisolated private var documentsURL: URL {
|
||||||
UTMData.defaultStorageUrl
|
UTMData.defaultStorageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if WITH_SERVER
|
||||||
|
/// Remote access server
|
||||||
|
private(set) var remoteServer: UTMRemoteServer!
|
||||||
|
|
||||||
|
/// Listeners for remote access
|
||||||
|
private var remoteChangeListeners: [VMData: Set<AnyCancellable>] = [:]
|
||||||
|
|
||||||
|
/// Listener for list changes
|
||||||
|
private var listChangedListener: AnyCancellable?
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Queue to run `busyWork` tasks
|
/// Queue to run `busyWork` tasks
|
||||||
private var busyQueue: DispatchQueue
|
private var busyQueue: DispatchQueue
|
||||||
|
|
||||||
|
@ -100,6 +121,10 @@ struct AlertMessage: Identifiable {
|
||||||
self.virtualMachines = []
|
self.virtualMachines = []
|
||||||
self.pendingVMs = []
|
self.pendingVMs = []
|
||||||
self.selectedVM = nil
|
self.selectedVM = nil
|
||||||
|
#if WITH_SERVER
|
||||||
|
self.remoteServer = UTMRemoteServer(data: self)
|
||||||
|
beginObservingChanges()
|
||||||
|
#endif
|
||||||
listLoadFromDefaults()
|
listLoadFromDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,7 +158,7 @@ struct AlertMessage: Identifiable {
|
||||||
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
|
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guard UTMQemuVirtualMachine.isVirtualMachine(url: file) else {
|
guard ConcreteVirtualMachine.isVirtualMachine(url: file) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
|
@ -168,7 +193,7 @@ struct AlertMessage: Identifiable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load VM list (and order) from persistent storage
|
/// Load VM list (and order) from persistent storage
|
||||||
private func listLoadFromDefaults() {
|
fileprivate func listLoadFromDefaults() {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
guard defaults.object(forKey: "VMList") == nil else {
|
guard defaults.object(forKey: "VMList") == nil else {
|
||||||
listLegacyLoadFromDefaults()
|
listLegacyLoadFromDefaults()
|
||||||
|
@ -186,7 +211,7 @@ struct AlertMessage: Identifiable {
|
||||||
guard let list = defaults.stringArray(forKey: "VMEntryList") else {
|
guard let list = defaults.stringArray(forKey: "VMEntryList") else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
virtualMachines = list.uniqued().compactMap { uuidString in
|
let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in
|
||||||
guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
|
guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -198,6 +223,7 @@ struct AlertMessage: Identifiable {
|
||||||
}
|
}
|
||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
|
listReplace(with: virtualMachines)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load VM list (and order) from persistent storage (legacy)
|
/// Load VM list (and order) from persistent storage (legacy)
|
||||||
|
@ -205,7 +231,7 @@ struct AlertMessage: Identifiable {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
// legacy path list
|
// legacy path list
|
||||||
if let files = defaults.array(forKey: "VMList") as? [String] {
|
if let files = defaults.array(forKey: "VMList") as? [String] {
|
||||||
virtualMachines = files.uniqued().compactMap({ file in
|
let virtualMachines = files.uniqued().compactMap({ file in
|
||||||
let url = documentsURL.appendingPathComponent(file, isDirectory: true)
|
let url = documentsURL.appendingPathComponent(file, isDirectory: true)
|
||||||
if let vm = try? VMData(url: url) {
|
if let vm = try? VMData(url: url) {
|
||||||
return vm
|
return vm
|
||||||
|
@ -213,10 +239,11 @@ struct AlertMessage: Identifiable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
listReplace(with: virtualMachines)
|
||||||
}
|
}
|
||||||
// bookmark list
|
// bookmark list
|
||||||
if let list = defaults.array(forKey: "VMList") {
|
if let list = defaults.array(forKey: "VMList") {
|
||||||
virtualMachines = list.compactMap { item in
|
let virtualMachines = list.compactMap { item in
|
||||||
let vm: VMData?
|
let vm: VMData?
|
||||||
if let bookmark = item as? Data {
|
if let bookmark = item as? Data {
|
||||||
vm = VMData(bookmark: bookmark)
|
vm = VMData(bookmark: bookmark)
|
||||||
|
@ -228,6 +255,7 @@ struct AlertMessage: Identifiable {
|
||||||
try? vm?.load()
|
try? vm?.load()
|
||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
|
listReplace(with: virtualMachines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -238,8 +266,15 @@ struct AlertMessage: Identifiable {
|
||||||
defaults.set(wrappedVMs, forKey: "VMEntryList")
|
defaults.set(wrappedVMs, forKey: "VMEntryList")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func listReplace(with vms: [VMData]) {
|
/// Replace current VM list with a new list
|
||||||
|
/// - Parameter vms: List to replace with
|
||||||
|
fileprivate func listReplace(with vms: [VMData]) {
|
||||||
|
virtualMachines.forEach({ endObservingChanges(for: $0) })
|
||||||
virtualMachines = vms
|
virtualMachines = vms
|
||||||
|
vms.forEach({ beginObservingChanges(for: $0) })
|
||||||
|
if let vm = selectedVM, !vms.contains(where: { $0 == vm }) {
|
||||||
|
selectedVM = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add VM to list
|
/// Add VM to list
|
||||||
|
@ -254,6 +289,7 @@ struct AlertMessage: Identifiable {
|
||||||
} else {
|
} else {
|
||||||
virtualMachines.append(vm)
|
virtualMachines.append(vm)
|
||||||
}
|
}
|
||||||
|
beginObservingChanges(for: vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select VM in list
|
/// Select VM in list
|
||||||
|
@ -267,6 +303,7 @@ struct AlertMessage: Identifiable {
|
||||||
/// - Returns: Index of item removed or nil if already removed
|
/// - Returns: Index of item removed or nil if already removed
|
||||||
@discardableResult public func listRemove(vm: VMData) -> Int? {
|
@discardableResult public func listRemove(vm: VMData) -> Int? {
|
||||||
let index = virtualMachines.firstIndex(of: vm)
|
let index = virtualMachines.firstIndex(of: vm)
|
||||||
|
endObservingChanges(for: vm)
|
||||||
if let index = index {
|
if let index = index {
|
||||||
virtualMachines.remove(at: index)
|
virtualMachines.remove(at: index)
|
||||||
}
|
}
|
||||||
|
@ -316,7 +353,7 @@ struct AlertMessage: Identifiable {
|
||||||
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
|
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
|
||||||
for i in 1..<1000 {
|
for i in 1..<1000 {
|
||||||
let name = nameForId(i)
|
let name = nameForId(i)
|
||||||
let file = UTMQemuVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
|
let file = ConcreteVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
|
||||||
if !fileManager.fileExists(atPath: file.path) {
|
if !fileManager.fileExists(atPath: file.path) {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
@ -383,6 +420,13 @@ struct AlertMessage: Identifiable {
|
||||||
func save(vm: VMData) async throws {
|
func save(vm: VMData) async throws {
|
||||||
do {
|
do {
|
||||||
try await vm.save()
|
try await vm.save()
|
||||||
|
#if WITH_SERVER
|
||||||
|
if let qemuConfig = vm.config as? UTMQemuConfiguration {
|
||||||
|
await remoteServer.broadcast { remote in
|
||||||
|
try await remote.qemuConfigurationHasChanged(id: vm.id, configuration: qemuConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
// refresh the VM object as it is now stale
|
// refresh the VM object as it is now stale
|
||||||
let origError = error
|
let origError = error
|
||||||
|
@ -450,8 +494,8 @@ struct AlertMessage: Identifiable {
|
||||||
/// - Returns: The new VM
|
/// - Returns: The new VM
|
||||||
@discardableResult func clone(vm: VMData) async throws -> VMData {
|
@discardableResult func clone(vm: VMData) async throws -> VMData {
|
||||||
let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
|
let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
|
||||||
let newPath = UTMQemuVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
|
let newPath = ConcreteVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
|
||||||
|
|
||||||
try await copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
|
try await copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
|
||||||
guard let newVM = try? VMData(url: newPath) else {
|
guard let newVM = try? VMData(url: newPath) else {
|
||||||
throw UTMDataError.cloneFailed
|
throw UTMDataError.cloneFailed
|
||||||
|
@ -532,7 +576,7 @@ struct AlertMessage: Identifiable {
|
||||||
/// Calculate total size of VM and data
|
/// Calculate total size of VM and data
|
||||||
/// - Parameter vm: VM to calculate size
|
/// - Parameter vm: VM to calculate size
|
||||||
/// - Returns: Size in bytes
|
/// - Returns: Size in bytes
|
||||||
func computeSize(for vm: VMData) -> Int64 {
|
func computeSize(for vm: VMData) async -> Int64 {
|
||||||
let path = vm.pathUrl
|
let path = vm.pathUrl
|
||||||
guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
|
guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
|
||||||
logger.error("failed to create enumerator for \(path)")
|
logger.error("failed to create enumerator for \(path)")
|
||||||
|
@ -616,7 +660,7 @@ struct AlertMessage: Identifiable {
|
||||||
listSelect(vm: vm)
|
listSelect(vm: vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws {
|
private func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws {
|
||||||
try await Task.detached(priority: .userInitiated) {
|
try await Task.detached(priority: .userInitiated) {
|
||||||
let status = copyfile(srcURL.path, dstURL.path, nil, copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE | COPYFILE_DATA_SPARSE))
|
let status = copyfile(srcURL.path, dstURL.path, nil, copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE | COPYFILE_DATA_SPARSE))
|
||||||
if status < 0 {
|
if status < 0 {
|
||||||
|
@ -677,7 +721,10 @@ struct AlertMessage: Identifiable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
|
func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
|
||||||
|
guard let vm = vm as? any UTMSpiceVirtualMachine else {
|
||||||
|
throw UTMDataError.unsupportedBackend
|
||||||
|
}
|
||||||
let task = UTMDownloadSupportToolsTask(for: vm)
|
let task = UTMDownloadSupportToolsTask(for: vm)
|
||||||
if await task.hasExistingSupportTools {
|
if await task.hasExistingSupportTools {
|
||||||
vm.config.qemu.isGuestToolsInstallRequested = false
|
vm.config.qemu.isGuestToolsInstallRequested = false
|
||||||
|
@ -756,7 +803,60 @@ struct AlertMessage: Identifiable {
|
||||||
}
|
}
|
||||||
vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
|
vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Change listener
|
||||||
|
|
||||||
|
private func beginObservingChanges() {
|
||||||
|
#if WITH_SERVER
|
||||||
|
listChangedListener = $virtualMachines.sink { vms in
|
||||||
|
Task {
|
||||||
|
await self.remoteServer.broadcast { remote in
|
||||||
|
try await remote.listHasChanged(ids: vms.map({ $0.id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginObservingChanges(for vm: VMData) {
|
||||||
|
#if WITH_SERVER
|
||||||
|
var observers = Set<AnyCancellable>()
|
||||||
|
let registryEntry = vm.registryEntry
|
||||||
|
observers.insert(vm.objectWillChange.sink { [self] _ in
|
||||||
|
// reset observers when registry changes
|
||||||
|
if vm.registryEntry != registryEntry {
|
||||||
|
endObservingChanges(for: vm)
|
||||||
|
beginObservingChanges(for: vm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observers.insert(vm.$state.sink { state in
|
||||||
|
Task {
|
||||||
|
let isTakeoverAllowed = self.vmWindows[vm] is VMRemoteSessionState && (state == .started || state == .paused)
|
||||||
|
await self.remoteServer.broadcast { remote in
|
||||||
|
try await remote.virtualMachine(id: vm.id, didTransitionToState: state, isTakeoverAllowed: isTakeoverAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if let registryEntry = registryEntry {
|
||||||
|
observers.insert(registryEntry.externalDrivePublisher.sink { drives in
|
||||||
|
let mountedDrives = drives.mapValues({ $0.path })
|
||||||
|
Task {
|
||||||
|
await self.remoteServer.broadcast { remote in
|
||||||
|
try await remote.mountedDrivesHasChanged(id: vm.id, mountedDrives: mountedDrives)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
remoteChangeListeners[vm] = observers
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endObservingChanges(for vm: VMData) {
|
||||||
|
#if WITH_SERVER
|
||||||
|
remoteChangeListeners.removeValue(forKey: vm)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Other utility functions
|
// MARK: - Other utility functions
|
||||||
|
|
||||||
/// In some regions, iOS will prompt the user for network access
|
/// In some regions, iOS will prompt the user for network access
|
||||||
|
@ -790,16 +890,20 @@ struct AlertMessage: Identifiable {
|
||||||
|
|
||||||
/// Execute a task with spinning progress indicator (Swift concurrency version)
|
/// Execute a task with spinning progress indicator (Swift concurrency version)
|
||||||
/// - Parameter work: Function to execute
|
/// - Parameter work: Function to execute
|
||||||
func busyWorkAsync(_ work: @escaping @Sendable () async throws -> Void) {
|
@discardableResult
|
||||||
|
func busyWorkAsync<T>(_ work: @escaping @Sendable () async throws -> T) -> Task<T, any Error> {
|
||||||
Task.detached(priority: .userInitiated) {
|
Task.detached(priority: .userInitiated) {
|
||||||
await self.setBusyIndicator(true)
|
await self.setBusyIndicator(true)
|
||||||
do {
|
do {
|
||||||
try await work()
|
let result = try await work()
|
||||||
|
await self.setBusyIndicator(false)
|
||||||
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("\(error)")
|
logger.error("\(error)")
|
||||||
await self.showErrorAlert(message: error.localizedDescription)
|
await self.showErrorAlert(message: error.localizedDescription)
|
||||||
|
await self.setBusyIndicator(false)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
await self.setBusyIndicator(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -824,7 +928,7 @@ struct AlertMessage: Identifiable {
|
||||||
/// - vm: VM to send mouse/tablet coordinates to
|
/// - vm: VM to send mouse/tablet coordinates to
|
||||||
/// - components: Data (see UTM Wiki for details)
|
/// - components: Data (see UTM Wiki for details)
|
||||||
func automationSendMouse(to vm: VMData, urlComponents components: URLComponents) {
|
func automationSendMouse(to vm: VMData, urlComponents components: URLComponents) {
|
||||||
guard let qemuVm = vm.wrapped as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
|
guard let qemuVm = vm.wrapped as? any UTMSpiceVirtualMachine else { return } // FIXME: implement for Apple VM
|
||||||
guard !qemuVm.config.displays.isEmpty else { return }
|
guard !qemuVm.config.displays.isEmpty else { return }
|
||||||
guard let queryItems = components.queryItems else { return }
|
guard let queryItems = components.queryItems else { return }
|
||||||
/// Parse targeted position
|
/// Parse targeted position
|
||||||
|
@ -868,7 +972,7 @@ struct AlertMessage: Identifiable {
|
||||||
|
|
||||||
// MARK: - AltKit
|
// MARK: - AltKit
|
||||||
|
|
||||||
#if canImport(AltKit) && !WITH_QEMU_TCI
|
#if canImport(AltKit) && WITH_JIT
|
||||||
/// Detect if we are installed from AltStore and can use AltJIT
|
/// Detect if we are installed from AltStore and can use AltJIT
|
||||||
var isAltServerCompatible: Bool {
|
var isAltServerCompatible: Bool {
|
||||||
guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
|
guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
|
||||||
|
@ -968,6 +1072,8 @@ struct AlertMessage: Identifiable {
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
enum UTMDataError: Error {
|
enum UTMDataError: Error {
|
||||||
case virtualMachineAlreadyExists
|
case virtualMachineAlreadyExists
|
||||||
|
case virtualMachineUnavailable
|
||||||
|
case unsupportedBackend
|
||||||
case cloneFailed
|
case cloneFailed
|
||||||
case shortcutCreationFailed
|
case shortcutCreationFailed
|
||||||
case importFailed
|
case importFailed
|
||||||
|
@ -977,6 +1083,8 @@ enum UTMDataError: Error {
|
||||||
case jitStreamerDecodeFailed
|
case jitStreamerDecodeFailed
|
||||||
case jitStreamerAttachFailed
|
case jitStreamerAttachFailed
|
||||||
case jitStreamerUrlInvalid(String)
|
case jitStreamerUrlInvalid(String)
|
||||||
|
case notImplemented
|
||||||
|
case reconnectFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UTMDataError: LocalizedError {
|
extension UTMDataError: LocalizedError {
|
||||||
|
@ -984,6 +1092,10 @@ extension UTMDataError: LocalizedError {
|
||||||
switch self {
|
switch self {
|
||||||
case .virtualMachineAlreadyExists:
|
case .virtualMachineAlreadyExists:
|
||||||
return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
|
return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
|
||||||
|
case .virtualMachineUnavailable:
|
||||||
|
return NSLocalizedString("This virtual machine is currently unavailable, make sure it is not open in another session.", comment: "UTMData")
|
||||||
|
case .unsupportedBackend:
|
||||||
|
return NSLocalizedString("Operation not supported by the backend.", comment: "UTMData")
|
||||||
case .cloneFailed:
|
case .cloneFailed:
|
||||||
return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
|
return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
|
||||||
case .shortcutCreationFailed:
|
case .shortcutCreationFailed:
|
||||||
|
@ -1002,6 +1114,239 @@ extension UTMDataError: LocalizedError {
|
||||||
return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
|
return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
|
||||||
case .jitStreamerUrlInvalid(let urlString):
|
case .jitStreamerUrlInvalid(let urlString):
|
||||||
return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
|
return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
|
||||||
|
case .notImplemented:
|
||||||
|
return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
|
||||||
|
case .reconnectFailed:
|
||||||
|
return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Client
|
||||||
|
|
||||||
|
/// Declare host capabilities to any remote client
|
||||||
|
struct UTMCapabilities: OptionSet, Codable {
|
||||||
|
let rawValue: UInt
|
||||||
|
|
||||||
|
/// If set, no trick is needed to get JIT working as the process is entitled.
|
||||||
|
static let hasJitEntitlements = Self(rawValue: 1 << 0)
|
||||||
|
|
||||||
|
/// If set, virtualization is supported by this host.
|
||||||
|
static let hasHypervisorSupport = Self(rawValue: 1 << 1)
|
||||||
|
|
||||||
|
/// If set, host is aarch64
|
||||||
|
static let isAarch64 = Self(rawValue: 1 << 2)
|
||||||
|
|
||||||
|
/// If set, host is x86_64
|
||||||
|
static let isX86_64 = Self(rawValue: 1 << 3)
|
||||||
|
|
||||||
|
static fileprivate(set) var current: Self = {
|
||||||
|
var current = Self()
|
||||||
|
#if WITH_JIT
|
||||||
|
if jb_has_jit_entitlement() {
|
||||||
|
current.insert(.hasJitEntitlements)
|
||||||
|
}
|
||||||
|
if jb_has_hypervisor() {
|
||||||
|
current.insert(.hasHypervisorSupport)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if arch(arm64)
|
||||||
|
current.insert(.isAarch64)
|
||||||
|
#endif
|
||||||
|
#if arch(x86_64)
|
||||||
|
current.insert(.isX86_64)
|
||||||
|
#endif
|
||||||
|
return current
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if WITH_REMOTE
|
||||||
|
private let kReconnectTimeoutSeconds: UInt64 = 5
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class UTMRemoteData: UTMData {
|
||||||
|
/// Remote access client
|
||||||
|
private(set) var remoteClient: UTMRemoteClient!
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
self.remoteClient = UTMRemoteClient(data: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func listLoadFromDefaults() {
|
||||||
|
// do nothing since we do not load from VMList
|
||||||
|
}
|
||||||
|
|
||||||
|
override func listRefresh() async {
|
||||||
|
busyWorkAsync {
|
||||||
|
try await self.listRefreshFromRemote()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconnect(to server: UTMRemoteClient.State.SavedServer) async throws {
|
||||||
|
var reconnectTask: Task<UTMRemoteClient.Remote, any Error>?
|
||||||
|
let timeoutTask = Task {
|
||||||
|
try await Task.sleep(nanoseconds: kReconnectTimeoutSeconds * NSEC_PER_SEC)
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
}
|
||||||
|
reconnectTask = busyWorkAsync { [self] in
|
||||||
|
do {
|
||||||
|
try await remoteClient.connect(server)
|
||||||
|
} catch is CancellationError {
|
||||||
|
throw UTMDataError.reconnectFailed
|
||||||
|
}
|
||||||
|
timeoutTask.cancel()
|
||||||
|
try await listRefreshFromRemote()
|
||||||
|
return await remoteClient.server
|
||||||
|
}
|
||||||
|
// make all active sessions wait on the reconnect
|
||||||
|
for session in VMSessionState.allActiveSessions.values {
|
||||||
|
let vm = session.vm as! UTMRemoteSpiceVirtualMachine
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await vm.reconnectServer {
|
||||||
|
try await reconnectTask!.value
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
session.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = try await reconnectTask!.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listRefreshFromRemote() async throws {
|
||||||
|
if let capabilities = await self.remoteClient.server.capabilities {
|
||||||
|
UTMCapabilities.current = capabilities
|
||||||
|
}
|
||||||
|
let ids = try await remoteClient.server.listVirtualMachines()
|
||||||
|
let items = try await remoteClient.server.getVirtualMachineInformation(for: ids)
|
||||||
|
let openSessionVms = VMSessionState.allActiveSessions.values.map({ $0.vm })
|
||||||
|
let vms = items.map { item in
|
||||||
|
let wrapped = openSessionVms.first(where: { $0.id == item.id }) as? UTMRemoteSpiceVirtualMachine
|
||||||
|
return VMRemoteData(fromRemoteItem: item, existingWrapped: wrapped)
|
||||||
|
}
|
||||||
|
await loadVirtualMachines(vms)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadVirtualMachines(_ vms: [VMData]) async {
|
||||||
|
listReplace(with: vms)
|
||||||
|
for vm in vms {
|
||||||
|
let remoteVM = vm as! VMRemoteData
|
||||||
|
if remoteVM.isLoaded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await remoteVM.load(withRemoteServer: remoteClient.server)
|
||||||
|
} catch {
|
||||||
|
remoteVM.unavailableReason = error.localizedDescription
|
||||||
|
}
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteListHasChanged(ids: [UUID]) async {
|
||||||
|
var existing = virtualMachines.reduce(into: [:]) { partialResult, vm in
|
||||||
|
partialResult[vm.id] = vm
|
||||||
|
}
|
||||||
|
let new = ids.compactMap { id in
|
||||||
|
if existing[id] == nil {
|
||||||
|
return id
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !new.isEmpty, let newItems = try? await remoteClient.server.getVirtualMachineInformation(for: new) {
|
||||||
|
newItems.map({ VMRemoteData(fromRemoteItem: $0) }).forEach { vm in
|
||||||
|
existing[vm.id] = vm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let vms = ids.compactMap({ existing[$0] })
|
||||||
|
await loadVirtualMachines(vms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteQemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async {
|
||||||
|
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await vm.reloadConfiguration(withRemoteServer: remoteClient.server, config: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteMountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async {
|
||||||
|
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vm.updateMountedDrives(mountedDrives)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async {
|
||||||
|
guard let vm = virtualMachines.first(where: { $0.id == id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let remoteVM = vm as! VMRemoteData
|
||||||
|
let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine
|
||||||
|
remoteVM.isTakeoverAllowed = isTakeoverAllowed
|
||||||
|
await wrapped.updateRemoteState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteVirtualMachineDidError(id: UUID, message: String) async {
|
||||||
|
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == id }) {
|
||||||
|
session.nonfatalError = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func listMove(fromOffsets: IndexSet, toOffset: Int) {
|
||||||
|
let ids = fromOffsets.map({ virtualMachines[$0].id })
|
||||||
|
Task {
|
||||||
|
try await remoteClient.server.reorderVirtualMachines(fromIds: ids, toOffset: toOffset)
|
||||||
|
}
|
||||||
|
super.listMove(fromOffsets: fromOffsets, toOffset: toOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func save(vm: VMData) async throws {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
override func discardChanges(for vm: VMData) throws {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
override func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
override func delete(vm: VMData, alsoRegistry: Bool) async throws -> Int? {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
override func clone(vm: VMData) async throws -> VMData {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
override func export(vm: VMData, to url: URL) async throws {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
override func move(vm: VMData, to url: URL) async throws {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
override func template(vm: VMData) async throws {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
override func computeSize(for vm: VMData) async -> Int64 {
|
||||||
|
(try? await remoteClient.server.getPackageSize(for: vm.id)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func importUTM(from url: URL, asShortcut: Bool) async throws {
|
||||||
|
throw UTMDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
|
||||||
|
try await remoteClient.server.mountGuestToolsOnVirtualMachine(id: vm.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
|
@ -18,8 +18,8 @@ import Foundation
|
||||||
|
|
||||||
/// Downloads support tools ISO
|
/// Downloads support tools ISO
|
||||||
class UTMDownloadSupportToolsTask: UTMDownloadTask {
|
class UTMDownloadSupportToolsTask: UTMDownloadTask {
|
||||||
private let vm: UTMQemuVirtualMachine
|
private let vm: any UTMSpiceVirtualMachine
|
||||||
|
|
||||||
private static let supportToolsDownloadUrl = URL(string: "https://getutm.app/downloads/utm-guest-tools-latest.iso")!
|
private static let supportToolsDownloadUrl = URL(string: "https://getutm.app/downloads/utm-guest-tools-latest.iso")!
|
||||||
|
|
||||||
private var toolsUrl: URL {
|
private var toolsUrl: URL {
|
||||||
|
@ -42,7 +42,7 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(for vm: UTMQemuVirtualMachine) {
|
init(for vm: any UTMSpiceVirtualMachine) {
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask")
|
let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask")
|
||||||
super.init(for: Self.supportToolsDownloadUrl, named: name)
|
super.init(for: Self.supportToolsDownloadUrl, named: name)
|
||||||
|
|
|
@ -99,6 +99,10 @@ class UTMReleaseHelper: ObservableObject {
|
||||||
if platform == "iOS SE" {
|
if platform == "iOS SE" {
|
||||||
currentSection.body.append(description)
|
currentSection.body.append(description)
|
||||||
}
|
}
|
||||||
|
#elseif WITH_REMOTE
|
||||||
|
if platform == "iOS Remote" {
|
||||||
|
currentSection.body.append(description)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
if platform.hasPrefix("visionOS") {
|
if platform.hasPrefix("visionOS") {
|
||||||
|
|
|
@ -20,7 +20,7 @@ import SwiftUI
|
||||||
/// Model wrapping a single UTMVirtualMachine for use in views
|
/// Model wrapping a single UTMVirtualMachine for use in views
|
||||||
@MainActor class VMData: ObservableObject {
|
@MainActor class VMData: ObservableObject {
|
||||||
/// Underlying virtual machine
|
/// Underlying virtual machine
|
||||||
private(set) var wrapped: (any UTMVirtualMachine)? {
|
fileprivate(set) var wrapped: (any UTMVirtualMachine)? {
|
||||||
willSet {
|
willSet {
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
@ -53,8 +53,8 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registry entry before loading
|
/// Registry entry before loading
|
||||||
private var registryEntryWrapped: UTMRegistryEntry?
|
fileprivate var registryEntryWrapped: UTMRegistryEntry?
|
||||||
|
|
||||||
/// Set when we use a temporary UUID because we loaded a legacy entry
|
/// Set when we use a temporary UUID because we loaded a legacy entry
|
||||||
private var uuidUnknown: Bool = false
|
private var uuidUnknown: Bool = false
|
||||||
|
|
||||||
|
@ -67,14 +67,22 @@ import SwiftUI
|
||||||
@Published var state: UTMVirtualMachineState = .stopped
|
@Published var state: UTMVirtualMachineState = .stopped
|
||||||
|
|
||||||
/// Copy from wrapped VM
|
/// Copy from wrapped VM
|
||||||
@Published var screenshot: PlatformImage?
|
@Published var screenshot: UTMVirtualMachineScreenshot?
|
||||||
|
|
||||||
|
/// If true, it is possible to hijack the session.
|
||||||
|
@Published var isTakeoverAllowed: Bool = false
|
||||||
|
|
||||||
/// Allows changes in the config, registry, and VM to be reflected
|
/// Allows changes in the config, registry, and VM to be reflected
|
||||||
private var observers: [AnyCancellable] = []
|
private var observers: [AnyCancellable] = []
|
||||||
|
|
||||||
|
/// True if the .utm is loaded outside of the default storage
|
||||||
|
var isShortcut: Bool {
|
||||||
|
isShortcut(pathUrl)
|
||||||
|
}
|
||||||
|
|
||||||
/// No default init
|
/// No default init
|
||||||
private init() {
|
fileprivate init() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a VM from an existing object
|
/// Create a VM from an existing object
|
||||||
|
@ -129,9 +137,11 @@ import SwiftUI
|
||||||
/// - Parameter config: Configuration to create new VM
|
/// - Parameter config: Configuration to create new VM
|
||||||
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
|
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
|
||||||
self.init()
|
self.init()
|
||||||
|
#if !WITH_REMOTE
|
||||||
if let qemuConfig = config as? UTMQemuConfiguration {
|
if let qemuConfig = config as? UTMQemuConfiguration {
|
||||||
wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
|
wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if let appleConfig = config as? UTMAppleConfiguration {
|
if let appleConfig = config as? UTMAppleConfiguration {
|
||||||
wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
|
wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
|
||||||
|
@ -160,9 +170,11 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
var loaded: (any UTMVirtualMachine)?
|
var loaded: (any UTMVirtualMachine)?
|
||||||
let config = try UTMQemuConfiguration.load(from: url)
|
let config = try UTMQemuConfiguration.load(from: url)
|
||||||
|
#if !WITH_REMOTE
|
||||||
if let qemuConfig = config as? UTMQemuConfiguration {
|
if let qemuConfig = config as? UTMQemuConfiguration {
|
||||||
loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut(url))
|
loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut(url))
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if let appleConfig = config as? UTMAppleConfiguration {
|
if let appleConfig = config as? UTMAppleConfiguration {
|
||||||
loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut(url))
|
loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut(url))
|
||||||
|
@ -195,7 +207,7 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Listen to changes in the underlying object and propogate upwards
|
/// Listen to changes in the underlying object and propogate upwards
|
||||||
private func subscribeToChildren() {
|
fileprivate func subscribeToChildren() {
|
||||||
var s: [AnyCancellable] = []
|
var s: [AnyCancellable] = []
|
||||||
if let wrapped = wrapped {
|
if let wrapped = wrapped {
|
||||||
wrapped.onConfigurationChange = { [weak self] in
|
wrapped.onConfigurationChange = { [weak self] in
|
||||||
|
@ -205,10 +217,12 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapped.onStateChange = { [weak self] in
|
wrapped.onStateChange = { [weak self, weak wrapped] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.state = wrapped.state
|
if let wrapped = wrapped {
|
||||||
self?.screenshot = wrapped.screenshot
|
self?.state = wrapped.state
|
||||||
|
self?.screenshot = wrapped.screenshot
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -281,11 +295,6 @@ extension VMData: Hashable {
|
||||||
|
|
||||||
// MARK: - VM State
|
// MARK: - VM State
|
||||||
extension VMData {
|
extension VMData {
|
||||||
/// True if the .utm is loaded outside of the default storage
|
|
||||||
var isShortcut: Bool {
|
|
||||||
isShortcut(pathUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isShortcut(_ url: URL) -> Bool {
|
func isShortcut(_ url: URL) -> Bool {
|
||||||
let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
|
let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
|
||||||
let parentUrl = url.deletingLastPathComponent().standardizedFileURL
|
let parentUrl = url.deletingLastPathComponent().standardizedFileURL
|
||||||
|
@ -422,6 +431,98 @@ extension VMData {
|
||||||
|
|
||||||
/// If non-null, is the most recent screenshot image of the running VM
|
/// If non-null, is the most recent screenshot image of the running VM
|
||||||
var screenshotImage: PlatformImage? {
|
var screenshotImage: PlatformImage? {
|
||||||
wrapped?.screenshot
|
wrapped?.screenshot?.image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if WITH_REMOTE
|
||||||
|
@MainActor
|
||||||
|
class VMRemoteData: VMData {
|
||||||
|
private var backend: UTMBackend
|
||||||
|
private var _isShortcut: Bool
|
||||||
|
override var isShortcut: Bool {
|
||||||
|
_isShortcut
|
||||||
|
}
|
||||||
|
private var initialState: UTMVirtualMachineState
|
||||||
|
private var existingWrapped: UTMRemoteSpiceVirtualMachine?
|
||||||
|
|
||||||
|
/// Set by caller when VM is unavailable and there is a reason for it.
|
||||||
|
@Published var unavailableReason: String?
|
||||||
|
|
||||||
|
init(fromRemoteItem item: UTMRemoteMessageServer.VirtualMachineInformation, existingWrapped: UTMRemoteSpiceVirtualMachine? = nil) {
|
||||||
|
self.backend = item.backend
|
||||||
|
self._isShortcut = item.isShortcut
|
||||||
|
self.initialState = item.state
|
||||||
|
self.existingWrapped = existingWrapped
|
||||||
|
super.init()
|
||||||
|
self.isTakeoverAllowed = item.isTakeoverAllowed
|
||||||
|
self.registryEntryWrapped = UTMRegistry.shared.entry(uuid: item.id, name: item.name, path: item.path)
|
||||||
|
self.registryEntryWrapped!.isSuspended = item.isSuspended
|
||||||
|
self.registryEntryWrapped!.externalDrives = item.mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override func load() throws {
|
||||||
|
throw VMRemoteDataError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(withRemoteServer server: UTMRemoteClient.Remote) async throws {
|
||||||
|
guard backend == .qemu else {
|
||||||
|
throw VMRemoteDataError.backendNotSupported
|
||||||
|
}
|
||||||
|
let entry = registryEntryWrapped!
|
||||||
|
let config = try await server.getQEMUConfiguration(for: entry.uuid)
|
||||||
|
await loadCustomIcon(withRemoteServer: server, id: entry.uuid, config: config)
|
||||||
|
let vm: UTMRemoteSpiceVirtualMachine
|
||||||
|
if let existingWrapped = existingWrapped {
|
||||||
|
vm = existingWrapped
|
||||||
|
wrapped = vm
|
||||||
|
self.existingWrapped = nil
|
||||||
|
await reloadConfiguration(withRemoteServer: server, config: config)
|
||||||
|
vm.updateRegistry(entry)
|
||||||
|
} else {
|
||||||
|
vm = UTMRemoteSpiceVirtualMachine(forRemoteServer: server, remotePath: entry.package.path, entry: entry, config: config)
|
||||||
|
wrapped = vm
|
||||||
|
}
|
||||||
|
vm.updateConfigFromRegistry()
|
||||||
|
subscribeToChildren()
|
||||||
|
await vm.updateRemoteState(initialState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadConfiguration(withRemoteServer server: UTMRemoteClient.Remote, config: UTMQemuConfiguration) async {
|
||||||
|
let spiceVM = wrapped as! UTMRemoteSpiceVirtualMachine
|
||||||
|
await loadCustomIcon(withRemoteServer: server, id: spiceVM.id, config: config)
|
||||||
|
spiceVM.reload(usingConfiguration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCustomIcon(withRemoteServer server: UTMRemoteClient.Remote, id: UUID, config: UTMQemuConfiguration) async {
|
||||||
|
if config.information.isIconCustom, let iconUrl = config.information.iconURL {
|
||||||
|
if let iconUrl = try? await server.getPackageFile(for: id, relativePathComponents: [UTMQemuConfiguration.dataDirectoryName, iconUrl.lastPathComponent]) {
|
||||||
|
config.information.iconURL = iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMountedDrives(_ mountedDrives: [String: String]) {
|
||||||
|
guard let registryEntry = registryEntry else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registryEntry.externalDrives = mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VMRemoteDataError: Error {
|
||||||
|
case notImplemented
|
||||||
|
case backendNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VMRemoteDataError: LocalizedError {
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notImplemented:
|
||||||
|
return NSLocalizedString("This function is not implemented.", comment: "VMData")
|
||||||
|
case .backendNotSupported:
|
||||||
|
return NSLocalizedString("This VM is configured for a backend that does not support remote clients.", comment: "VMData")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
|
@ -129,7 +129,11 @@ NS_AVAILABLE_IOS(13.4)
|
||||||
- (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region {
|
- (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region {
|
||||||
// Hide cursor while hovering in VM view
|
// Hide cursor while hovering in VM view
|
||||||
if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
|
if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
|
||||||
|
#if TARGET_OS_VISION
|
||||||
|
return nil; // FIXME: hidden pointer seems to jump around due to following gaze
|
||||||
|
#else
|
||||||
return [UIPointerStyle hiddenPointerStyle];
|
return [UIPointerStyle hiddenPointerStyle];
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
@ -153,11 +157,13 @@ NS_AVAILABLE_IOS(13.4)
|
||||||
|
|
||||||
|
|
||||||
- (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion {
|
- (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion {
|
||||||
|
#if !TARGET_OS_VISION
|
||||||
if (@available(iOS 14.0, *)) {
|
if (@available(iOS 14.0, *)) {
|
||||||
if (self.prefersPointerLocked) {
|
if (self.prefersPointerLocked) {
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
// Requesting region for the VM display?
|
// Requesting region for the VM display?
|
||||||
if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
|
if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
|
||||||
// Then we need to find out if the pointer is in the actual display area or outside
|
// Then we need to find out if the pointer is in the actual display area or outside
|
||||||
|
|
|
@ -181,11 +181,15 @@ const CGFloat kScrollResistance = 10.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (VMMouseType)indirectMouseType {
|
- (VMMouseType)indirectMouseType {
|
||||||
|
#if TARGET_OS_VISION
|
||||||
|
return VMMouseTypeAbsolute;
|
||||||
|
#else
|
||||||
if (@available(iOS 14.0, *)) {
|
if (@available(iOS 14.0, *)) {
|
||||||
return VMMouseTypeRelative;
|
return VMMouseTypeRelative;
|
||||||
} else {
|
} else {
|
||||||
return VMMouseTypeAbsolute; // legacy iOS 13.4 mouse handling requires absolute
|
return VMMouseTypeAbsolute; // legacy iOS 13.4 mouse handling requires absolute
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Converting view points to VM display points
|
#pragma mark - Converting view points to VM display points
|
||||||
|
@ -635,7 +639,7 @@ static CGRect CGRectClipToBounds(CGRect rect1, CGRect rect2) {
|
||||||
VMMouseType type = [self touchTypeToMouseType:touch.type];
|
VMMouseType type = [self touchTypeToMouseType:touch.type];
|
||||||
#if TARGET_OS_VISION
|
#if TARGET_OS_VISION
|
||||||
if ([self isTouchGazeGesture:touch]) {
|
if ([self isTouchGazeGesture:touch]) {
|
||||||
type = self.indirectMouseType;
|
type = VMMouseTypeRelative;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
if ([self switchMouseType:type]) {
|
if ([self switchMouseType:type]) {
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
#import <UIKit/UIKit.h>
|
||||||
#import "VMDisplayViewController.h"
|
#import "VMDisplayViewController.h"
|
||||||
#if defined(WITH_QEMU_TCI)
|
#if !defined(WITH_USB)
|
||||||
@import CocoaSpiceNoUsb;
|
@import CocoaSpiceNoUsb;
|
||||||
#else
|
#else
|
||||||
@import CocoaSpice;
|
@import CocoaSpice;
|
||||||
|
@ -42,6 +42,8 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
@property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
|
@property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
|
||||||
|
|
||||||
|
@property (nonatomic) BOOL isDynamicResolutionSupported;
|
||||||
|
|
||||||
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
|
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
|
||||||
- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE;
|
- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE;
|
||||||
- (instancetype)initWithDisplay:(CSDisplay *)display input:(nullable CSInput *)input NS_DESIGNATED_INITIALIZER;
|
- (instancetype)initWithDisplay:(CSDisplay *)display input:(nullable CSInput *)input NS_DESIGNATED_INITIALIZER;
|
||||||
|
|
|
@ -29,11 +29,15 @@
|
||||||
#import "UTM-Swift.h"
|
#import "UTM-Swift.h"
|
||||||
@import CocoaSpiceRenderer;
|
@import CocoaSpiceRenderer;
|
||||||
|
|
||||||
|
static const NSInteger kResizeDebounceSecs = 1;
|
||||||
|
static const NSInteger kResizeTimeoutSecs = 5;
|
||||||
|
|
||||||
@interface VMDisplayMetalViewController ()
|
@interface VMDisplayMetalViewController ()
|
||||||
|
|
||||||
@property (nonatomic, nullable) CSMetalRenderer *renderer;
|
@property (nonatomic, nullable) CSMetalRenderer *renderer;
|
||||||
@property (nonatomic) CGFloat windowScaling;
|
@property (nonatomic, nullable) id debounceResize;
|
||||||
@property (nonatomic) CGPoint windowOrigin;
|
@property (nonatomic, nullable) id cancelResize;
|
||||||
|
@property (nonatomic) BOOL ignoreNextResize;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
@ -43,9 +47,6 @@
|
||||||
if (self = [super initWithNibName:nil bundle:nil]) {
|
if (self = [super initWithNibName:nil bundle:nil]) {
|
||||||
self.vmDisplay = display;
|
self.vmDisplay = display;
|
||||||
self.vmInput = input;
|
self.vmInput = input;
|
||||||
self.windowScaling = 1.0;
|
|
||||||
self.windowOrigin = CGPointZero;
|
|
||||||
[self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:NSKeyValueObservingOptionNew context:nil];
|
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
@ -120,19 +121,25 @@
|
||||||
- (void)viewWillAppear:(BOOL)animated {
|
- (void)viewWillAppear:(BOOL)animated {
|
||||||
[super viewWillAppear:animated];
|
[super viewWillAppear:animated];
|
||||||
self.prefersHomeIndicatorAutoHidden = YES;
|
self.prefersHomeIndicatorAutoHidden = YES;
|
||||||
|
#if !TARGET_OS_VISION
|
||||||
[self startGCMouse];
|
[self startGCMouse];
|
||||||
|
#endif
|
||||||
[self.vmDisplay addRenderer:self.renderer];
|
[self.vmDisplay addRenderer:self.renderer];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewWillDisappear:(BOOL)animated {
|
- (void)viewWillDisappear:(BOOL)animated {
|
||||||
[super viewWillDisappear:animated];
|
[super viewWillDisappear:animated];
|
||||||
|
#if !TARGET_OS_VISION
|
||||||
[self stopGCMouse];
|
[self stopGCMouse];
|
||||||
|
#endif
|
||||||
[self.vmDisplay removeRenderer:self.renderer];
|
[self.vmDisplay removeRenderer:self.renderer];
|
||||||
|
[self removeObserver:self forKeyPath:@"vmDisplay.displaySize"];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewDidAppear:(BOOL)animated {
|
- (void)viewDidAppear:(BOOL)animated {
|
||||||
[super viewDidAppear:animated];
|
[super viewDidAppear:animated];
|
||||||
self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size];
|
self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size];
|
||||||
|
[self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial) context:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
|
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
|
||||||
|
@ -140,10 +147,12 @@
|
||||||
[coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
[coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||||
self.delegate.displayViewSize = [self convertSizeToNative:size];
|
self.delegate.displayViewSize = [self convertSizeToNative:size];
|
||||||
[self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
|
[self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
|
||||||
|
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
|
||||||
|
if (!CGSizeEqualToSize(size, self.vmDisplay.displaySize)) {
|
||||||
|
[self requestResolutionChangeToSize:size];
|
||||||
|
}
|
||||||
|
}
|
||||||
}];
|
}];
|
||||||
if (self.delegate.qemuDisplayIsDynamicResolution) {
|
|
||||||
[self displayResize:size];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)enterSuspendedWithIsBusy:(BOOL)busy {
|
- (void)enterSuspendedWithIsBusy:(BOOL)busy {
|
||||||
|
@ -161,8 +170,8 @@
|
||||||
[super enterLive];
|
[super enterLive];
|
||||||
self.prefersPointerLocked = YES;
|
self.prefersPointerLocked = YES;
|
||||||
self.view.window.isIndirectPointerTouchIgnored = YES;
|
self.view.window.isIndirectPointerTouchIgnored = YES;
|
||||||
if (self.delegate.qemuDisplayIsDynamicResolution) {
|
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
|
||||||
[self displayResize:self.view.bounds.size];
|
[self requestResolutionChangeToSize:self.view.bounds.size];
|
||||||
}
|
}
|
||||||
if (self.delegate.qemuHasClipboardSharing) {
|
if (self.delegate.qemuHasClipboardSharing) {
|
||||||
[[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
|
[[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
|
||||||
|
@ -200,11 +209,21 @@
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)displayResize:(CGSize)size {
|
- (void)requestResolutionChangeToSize:(CGSize)size {
|
||||||
UTMLog(@"resizing to (%f, %f)", size.width, size.height);
|
self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
|
||||||
size = [self convertSizeToNative:size];
|
UTMLog(@"DISPLAY: requesting resolution (%f, %f)", size.width, size.height);
|
||||||
CGRect bounds = CGRectMake(0, 0, size.width, size.height);
|
CGSize newSize = [self convertSizeToNative:size];
|
||||||
[self.vmDisplay requestResolution:bounds];
|
CGRect bounds = CGRectMake(0, 0, newSize.width, newSize.height);
|
||||||
|
self.debounceResize = nil;
|
||||||
|
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
|
||||||
|
self.cancelResize = [self debounce:kResizeTimeoutSecs context:self.cancelResize action:^{
|
||||||
|
self.cancelResize = nil;
|
||||||
|
UTMLog(@"DISPLAY: requesting resolution cancelled");
|
||||||
|
[self resizeWindowToDisplaySize];
|
||||||
|
}];
|
||||||
|
#endif
|
||||||
|
[self.vmDisplay requestResolution:bounds];
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)setVmDisplay:(CSDisplay *)display {
|
- (void)setVmDisplay:(CSDisplay *)display {
|
||||||
|
@ -217,8 +236,6 @@
|
||||||
|
|
||||||
- (void)setDisplayScaling:(CGFloat)scaling origin:(CGPoint)origin {
|
- (void)setDisplayScaling:(CGFloat)scaling origin:(CGPoint)origin {
|
||||||
self.vmDisplay.viewportOrigin = origin;
|
self.vmDisplay.viewportOrigin = origin;
|
||||||
self.windowScaling = scaling;
|
|
||||||
self.windowOrigin = origin;
|
|
||||||
if (!self.delegate.qemuDisplayIsNativeResolution) {
|
if (!self.delegate.qemuDisplayIsNativeResolution) {
|
||||||
scaling = CGPointToPixel(scaling);
|
scaling = CGPointToPixel(scaling);
|
||||||
}
|
}
|
||||||
|
@ -229,25 +246,67 @@
|
||||||
|
|
||||||
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
|
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
|
||||||
if ([keyPath isEqualToString:@"vmDisplay.displaySize"]) {
|
if ([keyPath isEqualToString:@"vmDisplay.displaySize"]) {
|
||||||
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
|
UTMLog(@"DISPLAY: vmDisplay.displaySize changed");
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
if (self.cancelResize) {
|
||||||
CGSize minSize = self.vmDisplay.displaySize;
|
[self debounce:0 context:self.cancelResize action:^{}];
|
||||||
if (self.delegate.qemuDisplayIsNativeResolution) {
|
self.cancelResize = nil;
|
||||||
minSize.width = CGPixelToPoint(minSize.width);
|
}
|
||||||
minSize.height = CGPixelToPoint(minSize.height);
|
self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
|
||||||
}
|
[self resizeWindowToDisplaySize];
|
||||||
CGSize displaySize = CGSizeMake(minSize.width * self.windowScaling, minSize.height * self.windowScaling);
|
}];
|
||||||
CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
|
|
||||||
UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:displaySize];
|
|
||||||
geoPref.minimumSize = minSize;
|
|
||||||
geoPref.maximumSize = maxSize;
|
|
||||||
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
|
|
||||||
[self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
|
|
||||||
});
|
|
||||||
#else
|
|
||||||
[self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)setIsDynamicResolutionSupported:(BOOL)isDynamicResolutionSupported {
|
||||||
|
if (_isDynamicResolutionSupported != isDynamicResolutionSupported) {
|
||||||
|
_isDynamicResolutionSupported = isDynamicResolutionSupported;
|
||||||
|
UTMLog(@"DISPLAY: isDynamicResolutionSupported = %d", isDynamicResolutionSupported);
|
||||||
|
if (self.delegate.qemuDisplayIsDynamicResolution) {
|
||||||
|
if (isDynamicResolutionSupported) {
|
||||||
|
[self requestResolutionChangeToSize:self.view.bounds.size];
|
||||||
|
} else {
|
||||||
|
[self resizeWindowToDisplaySize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)resizeWindowToDisplaySize {
|
||||||
|
CGSize displaySize = self.vmDisplay.displaySize;
|
||||||
|
UTMLog(@"DISPLAY: request window resize to (%f, %f)", displaySize.width, displaySize.height);
|
||||||
|
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
|
||||||
|
CGSize minSize = displaySize;
|
||||||
|
if (self.delegate.qemuDisplayIsNativeResolution) {
|
||||||
|
minSize.width = CGPixelToPoint(minSize.width);
|
||||||
|
minSize.height = CGPixelToPoint(minSize.height);
|
||||||
|
}
|
||||||
|
CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
|
||||||
|
UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:minSize];
|
||||||
|
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
|
||||||
|
geoPref.minimumSize = CGSizeMake(800, 600);
|
||||||
|
geoPref.maximumSize = maxSize;
|
||||||
|
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsFreeform;
|
||||||
|
} else {
|
||||||
|
geoPref.minimumSize = minSize;
|
||||||
|
geoPref.maximumSize = maxSize;
|
||||||
|
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
|
||||||
|
}
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
CGSize currentViewSize = self.view.bounds.size;
|
||||||
|
UTMLog(@"DISPLAY: old view size = (%f, %f)", currentViewSize.width, currentViewSize.height);
|
||||||
|
if (CGSizeEqualToSize(minSize, currentViewSize)) {
|
||||||
|
// since `-viewWillTransitionToSize:withTransitionCoordinator:` is not called
|
||||||
|
self.delegate.displayViewSize = [self convertSizeToNative:currentViewSize];
|
||||||
|
[self.delegate display:self.vmDisplay didResizeTo:displaySize];
|
||||||
|
}
|
||||||
|
[self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
|
||||||
|
});
|
||||||
|
#else
|
||||||
|
if (CGSizeEqualToSize(displaySize, CGSizeZero)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self.delegate display:self.vmDisplay didResizeTo:displaySize];
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
|
@ -55,7 +55,7 @@ public extension VMDisplayViewController {
|
||||||
parent.setChildViewControllerForPointerLock(self)
|
parent.setChildViewControllerForPointerLock(self)
|
||||||
UIPress.pressResponderOverride = self
|
UIPress.pressResponderOverride = self
|
||||||
}
|
}
|
||||||
#if !os(visionOS)
|
#if !os(visionOS) && !WITH_REMOTE
|
||||||
if runInBackground {
|
if runInBackground {
|
||||||
logger.info("Start location tracking to enable running in background")
|
logger.info("Start location tracking to enable running in background")
|
||||||
UTMLocationManager.sharedInstance().startUpdatingLocation()
|
UTMLocationManager.sharedInstance().startUpdatingLocation()
|
||||||
|
@ -75,24 +75,6 @@ public extension VMDisplayViewController {
|
||||||
func enterLive() {
|
func enterLive() {
|
||||||
UIApplication.shared.isIdleTimerDisabled = disableIdleTimer
|
UIApplication.shared.isIdleTimerDisabled = disableIdleTimer
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suspend() {
|
|
||||||
// dummy function for selector
|
|
||||||
}
|
|
||||||
|
|
||||||
func terminateApplication() {
|
|
||||||
DispatchQueue.main.async { [self] in
|
|
||||||
// animate to home screen
|
|
||||||
let app = UIApplication.shared
|
|
||||||
app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
|
|
||||||
|
|
||||||
// wait 2 seconds while app is going background
|
|
||||||
Thread.sleep(forTimeInterval: 2)
|
|
||||||
|
|
||||||
// exit app when app is in background
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Toolbar hiding
|
// MARK: Toolbar hiding
|
||||||
|
@ -134,4 +116,15 @@ public extension VMDisplayViewController {
|
||||||
func integerForSetting(_ key: String) -> Int {
|
func integerForSetting(_ key: String) -> Int {
|
||||||
return UserDefaults.standard.integer(forKey: key)
|
return UserDefaults.standard.integer(forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func debounce(_ delaySeconds: Int, context: Any? = nil, action: @escaping () -> Void) -> Any {
|
||||||
|
if context != nil {
|
||||||
|
let previous = context as! DispatchWorkItem
|
||||||
|
previous.cancel()
|
||||||
|
}
|
||||||
|
let item = DispatchWorkItem(block: action)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(delaySeconds), execute: item)
|
||||||
|
return item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -370,7 +370,7 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
||||||
|
|
||||||
- (void)insertUTF8Sequence:(const char *)ctext {
|
- (void)insertUTF8Sequence:(const char *)ctext {
|
||||||
unsigned long ctext_len = strlen(ctext);
|
unsigned long ctext_len = strlen(ctext);
|
||||||
UTMLog(@"ctext length=%lu\n", ctext_len);
|
//UTMLog(@"ctext length=%lu\n", ctext_len);
|
||||||
unsigned char tc = ctext[0];
|
unsigned char tc = ctext[0];
|
||||||
|
|
||||||
int keycode = 0;
|
int keycode = 0;
|
||||||
|
@ -393,7 +393,7 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
||||||
|
|
||||||
switch (ctext_len) {
|
switch (ctext_len) {
|
||||||
case 1:
|
case 1:
|
||||||
UTMLog(@"char=%d\n", tc);
|
//UTMLog(@"char=%d\n", tc);
|
||||||
index = indexForChar(_map, _map_len, tc);
|
index = indexForChar(_map, _map_len, tc);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
keycode = _map[index].key;
|
keycode = _map[index].key;
|
||||||
|
@ -401,8 +401,8 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
UTMLog(@"char=%d\n", tc);
|
//UTMLog(@"char=%d\n", tc);
|
||||||
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
//UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
||||||
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], 0);
|
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], 0);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
keycode = _ext_map[index].key;
|
keycode = _ext_map[index].key;
|
||||||
|
@ -412,9 +412,9 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
UTMLog(@"char=%d\n", tc);
|
//UTMLog(@"char=%d\n", tc);
|
||||||
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
//UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
||||||
UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
|
//UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
|
||||||
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], ctext[2]);
|
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], ctext[2]);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
keycode = _ext_map[index].key;
|
keycode = _ext_map[index].key;
|
||||||
|
|
|
@ -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>
|
<string>RunInBackground</string>
|
||||||
<key>DefaultValue</key>
|
<key>DefaultValue</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
</array>
|
||||||
|
<key>Platform</key>
|
||||||
|
<string>iOS</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -31,6 +37,8 @@
|
||||||
<string>AutosaveBackground</string>
|
<string>AutosaveBackground</string>
|
||||||
<key>DefaultValue</key>
|
<key>DefaultValue</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>Platform</key>
|
||||||
|
<string>iOS</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -83,6 +91,11 @@
|
||||||
<string>NoUsbPrompt</string>
|
<string>NoUsbPrompt</string>
|
||||||
<key>DefaultValue</key>
|
<key>DefaultValue</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
<string>iOS-SE</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -99,6 +112,10 @@
|
||||||
<string>PSGroupSpecifier</string>
|
<string>PSGroupSpecifier</string>
|
||||||
<key>Title</key>
|
<key>Title</key>
|
||||||
<string>Graphics</string>
|
<string>Graphics</string>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -121,6 +138,10 @@
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
<integer>2</integer>
|
<integer>2</integer>
|
||||||
</array>
|
</array>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -155,6 +176,10 @@
|
||||||
<integer>105</integer>
|
<integer>105</integer>
|
||||||
<integer>120</integer>
|
<integer>120</integer>
|
||||||
</array>
|
</array>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -2789,6 +2814,11 @@
|
||||||
<string>PSGroupSpecifier</string>
|
<string>PSGroupSpecifier</string>
|
||||||
<key>Title</key>
|
<key>Title</key>
|
||||||
<string>JitStreamer</string>
|
<string>JitStreamer</string>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
<string>iOS-SE</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -2799,6 +2829,11 @@
|
||||||
<string>JitStreamerAttach</string>
|
<string>JitStreamerAttach</string>
|
||||||
<key>DefaultValue</key>
|
<key>DefaultValue</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
<string>iOS-SE</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
@ -2809,6 +2844,11 @@
|
||||||
<string>JitStreamerAddress</string>
|
<string>JitStreamerAddress</string>
|
||||||
<key>DefaultValue</key>
|
<key>DefaultValue</key>
|
||||||
<string>69.69.0.1</string>
|
<string>69.69.0.1</string>
|
||||||
|
<key>ExcludeTargets</key>
|
||||||
|
<array>
|
||||||
|
<string>iOS-Remote</string>
|
||||||
|
<string>iOS-SE</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Type</key>
|
<key>Type</key>
|
||||||
|
|
|
@ -19,15 +19,23 @@ import SwiftUI
|
||||||
|
|
||||||
extension UTMData {
|
extension UTMData {
|
||||||
func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
|
func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
|
||||||
|
#if WITH_SOLO_VM
|
||||||
guard VMSessionState.allActiveSessions.count == 0 else {
|
guard VMSessionState.allActiveSessions.count == 0 else {
|
||||||
logger.error("Session already started")
|
logger.error("Session already started")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
guard let wrapped = vm.wrapped else {
|
guard let wrapped = vm.wrapped else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let session = VMSessionState(for: wrapped as! UTMQemuVirtualMachine)
|
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) {
|
||||||
session.start()
|
session.showWindow()
|
||||||
|
} else if vm.isStopped || vm.isTakeoverAllowed {
|
||||||
|
let session = VMSessionState(for: wrapped as! (any UTMSpiceVirtualMachine))
|
||||||
|
session.start(options: options)
|
||||||
|
} else {
|
||||||
|
showErrorAlert(message: NSLocalizedString("This virtual machine is already running. In order to run it from this device, you must stop it first.", comment: "UTMDataExtension"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop(vm: VMData) {
|
func stop(vm: VMData) {
|
||||||
|
@ -37,6 +45,7 @@ extension UTMData {
|
||||||
if wrapped.registryEntry.isSuspended {
|
if wrapped.registryEntry.isSuspended {
|
||||||
wrapped.requestVmDeleteState()
|
wrapped.requestVmDeleteState()
|
||||||
}
|
}
|
||||||
|
wrapped.requestVmStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func close(vm: VMData) {
|
func close(vm: VMData) {
|
||||||
|
|
|
@ -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 {
|
struct UTMSettingsView: View {
|
||||||
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
|
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
|
||||||
|
|
||||||
|
private var hasContainer: Bool {
|
||||||
|
#if WITH_JIT
|
||||||
|
jb_has_container()
|
||||||
|
#else
|
||||||
|
true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
IASKAppSettings()
|
IASKAppSettings()
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.appSettingsShowPrivacyLink(jb_has_container())
|
.appSettingsShowPrivacyLink(hasContainer)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button("Close") {
|
Button("Close") {
|
||||||
|
|
|
@ -19,8 +19,12 @@ import SwiftUI
|
||||||
@MainActor
|
@MainActor
|
||||||
struct UTMSingleWindowView: View {
|
struct UTMSingleWindowView: View {
|
||||||
let isInteractive: Bool
|
let isInteractive: Bool
|
||||||
|
|
||||||
|
#if WITH_REMOTE
|
||||||
|
@State private var data: UTMRemoteData = UTMRemoteData()
|
||||||
|
#else
|
||||||
@State private var data: UTMData = UTMData()
|
@State private var data: UTMData = UTMData()
|
||||||
|
#endif
|
||||||
@State private var session: VMSessionState?
|
@State private var session: VMSessionState?
|
||||||
@State private var identifier: VMSessionState.WindowID?
|
@State private var identifier: VMSessionState.WindowID?
|
||||||
|
|
||||||
|
@ -36,7 +40,11 @@ struct UTMSingleWindowView: View {
|
||||||
if let session = session {
|
if let session = session {
|
||||||
VMWindowView(id: identifier!, isInteractive: isInteractive).environmentObject(session)
|
VMWindowView(id: identifier!, isInteractive: isInteractive).environmentObject(session)
|
||||||
} else if isInteractive {
|
} else if isInteractive {
|
||||||
|
#if WITH_REMOTE
|
||||||
|
RemoteContentView(remoteClientState: data.remoteClient.state).environmentObject(data)
|
||||||
|
#else
|
||||||
ContentView().environmentObject(data)
|
ContentView().environmentObject(data)
|
||||||
|
#endif
|
||||||
} else {
|
} else {
|
||||||
VStack {
|
VStack {
|
||||||
Text("Waiting for VM to connect to display...")
|
Text("Waiting for VM to connect to display...")
|
||||||
|
|
|
@ -19,7 +19,7 @@ import SwiftUI
|
||||||
|
|
||||||
struct VMDisplayHostedView: UIViewControllerRepresentable {
|
struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
internal class Coordinator: VMDisplayViewControllerDelegate {
|
internal class Coordinator: VMDisplayViewControllerDelegate {
|
||||||
let vm: UTMQemuVirtualMachine
|
let vm: any UTMSpiceVirtualMachine
|
||||||
let device: VMWindowState.Device
|
let device: VMWindowState.Device
|
||||||
@Binding var state: VMWindowState
|
@Binding var state: VMWindowState
|
||||||
var vmStateCancellable: AnyCancellable?
|
var vmStateCancellable: AnyCancellable?
|
||||||
|
@ -37,19 +37,19 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var qemuDisplayUpscaler: MTLSamplerMinMagFilter {
|
@MainActor var qemuDisplayUpscaler: MTLSamplerMinMagFilter {
|
||||||
vmConfig.displays[state.device!.configIndex].upscalingFilter.metalSamplerMinMagFilter
|
vmConfig.displays[device.configIndex].upscalingFilter.metalSamplerMinMagFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var qemuDisplayDownscaler: MTLSamplerMinMagFilter {
|
@MainActor var qemuDisplayDownscaler: MTLSamplerMinMagFilter {
|
||||||
vmConfig.displays[state.device!.configIndex].downscalingFilter.metalSamplerMinMagFilter
|
vmConfig.displays[device.configIndex].downscalingFilter.metalSamplerMinMagFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var qemuDisplayIsDynamicResolution: Bool {
|
@MainActor var qemuDisplayIsDynamicResolution: Bool {
|
||||||
vmConfig.displays[state.device!.configIndex].isDynamicResolution
|
vmConfig.displays[device.configIndex].isDynamicResolution
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var qemuDisplayIsNativeResolution: Bool {
|
@MainActor var qemuDisplayIsNativeResolution: Bool {
|
||||||
vmConfig.displays[state.device!.configIndex].isNativeResolution
|
vmConfig.displays[device.configIndex].isNativeResolution
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var qemuHasClipboardSharing: Bool {
|
@MainActor var qemuHasClipboardSharing: Bool {
|
||||||
|
@ -57,7 +57,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var qemuConsoleResizeCommand: String? {
|
@MainActor var qemuConsoleResizeCommand: String? {
|
||||||
vmConfig.serials[state.device!.configIndex].terminal?.resizeCommand
|
vmConfig.serials[device.configIndex].terminal?.resizeCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
var isViewportChanged: Bool {
|
var isViewportChanged: Bool {
|
||||||
|
@ -100,7 +100,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(with vm: UTMQemuVirtualMachine, device: VMWindowState.Device, state: Binding<VMWindowState>) {
|
init(with vm: any UTMSpiceVirtualMachine, device: VMWindowState.Device, state: Binding<VMWindowState>) {
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
self.device = device
|
self.device = device
|
||||||
self._state = state
|
self._state = state
|
||||||
|
@ -131,7 +131,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let vm: UTMQemuVirtualMachine
|
let vm: any UTMSpiceVirtualMachine
|
||||||
let device: VMWindowState.Device
|
let device: VMWindowState.Device
|
||||||
|
|
||||||
@Binding var state: VMWindowState
|
@Binding var state: VMWindowState
|
||||||
|
@ -168,7 +168,12 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
if let vc = uiViewController as? VMDisplayMetalViewController {
|
if let vc = uiViewController as? VMDisplayMetalViewController {
|
||||||
vc.vmInput = session.primaryInput
|
vc.vmInput = session.primaryInput
|
||||||
}
|
}
|
||||||
if state.isKeyboardShown != state.isKeyboardRequested {
|
#if os(visionOS)
|
||||||
|
let useSystemOsk = !(uiViewController is VMDisplayMetalViewController)
|
||||||
|
#else
|
||||||
|
let useSystemOsk = true
|
||||||
|
#endif
|
||||||
|
if useSystemOsk && state.isKeyboardShown != state.isKeyboardRequested {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if state.isKeyboardRequested {
|
if state.isKeyboardRequested {
|
||||||
uiViewController.showKeyboard()
|
uiViewController.showKeyboard()
|
||||||
|
@ -190,6 +195,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
// some obscure SwiftUI error means we cannot refer to Coordinator's state binding
|
// some obscure SwiftUI error means we cannot refer to Coordinator's state binding
|
||||||
vc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
|
vc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
|
||||||
|
vc.isDynamicResolutionSupported = state.isDynamicResolutionSupported
|
||||||
}
|
}
|
||||||
case .serial(let serial, _):
|
case .serial(let serial, _):
|
||||||
if let vc = uiViewController as? VMDisplayTerminalViewController {
|
if let vc = uiViewController as? VMDisplayTerminalViewController {
|
||||||
|
|
|
@ -37,21 +37,21 @@ import SwiftUI
|
||||||
|
|
||||||
let id: ID = ID()
|
let id: ID = ID()
|
||||||
|
|
||||||
let vm: UTMQemuVirtualMachine
|
let vm: any UTMSpiceVirtualMachine
|
||||||
|
|
||||||
var qemuConfig: UTMQemuConfiguration {
|
var qemuConfig: UTMQemuConfiguration {
|
||||||
vm.config
|
vm.config
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var vmState: UTMVirtualMachineState = .stopped
|
@Published var vmState: UTMVirtualMachineState = .stopped
|
||||||
|
|
||||||
@Published var fatalError: String?
|
|
||||||
|
|
||||||
@Published var nonfatalError: String?
|
@Published var nonfatalError: String?
|
||||||
|
|
||||||
|
@Published var fatalError: String?
|
||||||
|
|
||||||
@Published var primaryInput: CSInput?
|
@Published var primaryInput: CSInput?
|
||||||
|
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
private var primaryUsbManager: CSUSBManager?
|
private var primaryUsbManager: CSUSBManager?
|
||||||
|
|
||||||
private var usbManagerQueue = DispatchQueue(label: "USB Manager Queue", qos: .utility)
|
private var usbManagerQueue = DispatchQueue(label: "USB Manager Queue", qos: .utility)
|
||||||
|
@ -78,10 +78,12 @@ import SwiftUI
|
||||||
@Published var externalWindowBinding: Binding<VMWindowState>?
|
@Published var externalWindowBinding: Binding<VMWindowState>?
|
||||||
|
|
||||||
@Published var hasShownMemoryWarning: Bool = false
|
@Published var hasShownMemoryWarning: Bool = false
|
||||||
|
|
||||||
|
@Published var isDynamicResolutionSupported: Bool = false
|
||||||
|
|
||||||
private var hasAutosave: Bool = false
|
private var hasAutosave: Bool = false
|
||||||
|
|
||||||
init(for vm: UTMQemuVirtualMachine) {
|
init(for vm: any UTMSpiceVirtualMachine) {
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
super.init()
|
super.init()
|
||||||
vm.delegate = self
|
vm.delegate = self
|
||||||
|
@ -148,7 +150,7 @@ extension VMSessionState: UTMVirtualMachineDelegate {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
vmState = state
|
vmState = state
|
||||||
if state == .stopped {
|
if state == .stopped {
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
clearDevices()
|
clearDevices()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
@ -157,7 +159,7 @@ extension VMSessionState: UTMVirtualMachineDelegate {
|
||||||
|
|
||||||
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
fatalError = message
|
nonfatalError = message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -281,7 +283,7 @@ extension VMSessionState: UTMSpiceIODelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
nonisolated func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) {
|
nonisolated func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
primaryUsbManager?.delegate = nil
|
primaryUsbManager?.delegate = nil
|
||||||
|
@ -291,9 +293,21 @@ extension VMSessionState: UTMSpiceIODelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
nonisolated func spiceDynamicResolutionSupportDidChange(_ supported: Bool) {
|
||||||
|
Task { @MainActor in
|
||||||
|
isDynamicResolutionSupported = supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func spiceDidDisconnect() {
|
||||||
|
Task { @MainActor in
|
||||||
|
fatalError = NSLocalizedString("Connection to the server was lost.", comment: "VMSessionState")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
extension VMSessionState: CSUSBManagerDelegate {
|
extension VMSessionState: CSUSBManagerDelegate {
|
||||||
nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceError error: String, for device: CSUSBDevice) {
|
nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceError error: String, for device: CSUSBDevice) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
@ -419,10 +433,18 @@ extension VMSessionState {
|
||||||
logger.warning("Error starting audio session: \(error.localizedDescription)")
|
logger.warning("Error starting audio session: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
Self.allActiveSessions[id] = self
|
Self.allActiveSessions[id] = self
|
||||||
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
|
showWindow()
|
||||||
vm.requestVmStart(options: options)
|
if vm.state == .paused {
|
||||||
|
vm.requestVmResume()
|
||||||
|
} else {
|
||||||
|
vm.requestVmStart(options: options)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func showWindow() {
|
||||||
|
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func suspend() {
|
@objc private func suspend() {
|
||||||
// dummy function for selector
|
// dummy function for selector
|
||||||
}
|
}
|
||||||
|
@ -436,7 +458,9 @@ extension VMSessionState {
|
||||||
}
|
}
|
||||||
// tell other screens to shut down
|
// tell other screens to shut down
|
||||||
Self.allActiveSessions.removeValue(forKey: id)
|
Self.allActiveSessions.removeValue(forKey: id)
|
||||||
NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
|
closeWindows()
|
||||||
|
|
||||||
|
#if WITH_SOLO_VM
|
||||||
// animate to home screen
|
// animate to home screen
|
||||||
let app = UIApplication.shared
|
let app = UIApplication.shared
|
||||||
app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
|
app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
|
||||||
|
@ -446,12 +470,17 @@ extension VMSessionState {
|
||||||
|
|
||||||
// exit app when app is in background
|
// exit app when app is in background
|
||||||
exit(0)
|
exit(0)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func powerDown() {
|
func closeWindows() {
|
||||||
|
NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
|
||||||
|
}
|
||||||
|
|
||||||
|
func powerDown(isKill: Bool = false) {
|
||||||
Task {
|
Task {
|
||||||
try? await vm.deleteSnapshot(name: nil)
|
try? await vm.deleteSnapshot(name: nil)
|
||||||
try await vm.stop(usingMethod: .force)
|
try await vm.stop(usingMethod: isKill ? .kill : .force)
|
||||||
self.stop()
|
self.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -482,6 +511,7 @@ extension VMSessionState {
|
||||||
}
|
}
|
||||||
|
|
||||||
func didEnterBackground() {
|
func didEnterBackground() {
|
||||||
|
#if !os(visionOS)
|
||||||
logger.info("Entering background")
|
logger.info("Entering background")
|
||||||
let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
|
let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
|
||||||
if shouldAutosaveBackground && vmState == .started {
|
if shouldAutosaveBackground && vmState == .started {
|
||||||
|
@ -494,7 +524,7 @@ extension VMSessionState {
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await vm.saveSnapshot()
|
try await vm.saveSnapshot(name: nil)
|
||||||
self.hasAutosave = true
|
self.hasAutosave = true
|
||||||
logger.info("Save snapshot complete")
|
logger.info("Save snapshot complete")
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -504,14 +534,17 @@ extension VMSessionState {
|
||||||
task = .invalid
|
task = .invalid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func didEnterForeground() {
|
func didEnterForeground() {
|
||||||
|
#if !os(visionOS)
|
||||||
logger.info("Entering foreground!")
|
logger.info("Entering foreground!")
|
||||||
if (hasAutosave && vmState == .started) {
|
if (hasAutosave && vmState == .started) {
|
||||||
logger.info("Deleting snapshot")
|
logger.info("Deleting snapshot")
|
||||||
vm.requestVmDeleteState()
|
vm.requestVmDeleteState()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,7 @@ struct VMToolbarDriveMenuView: View {
|
||||||
}
|
}
|
||||||
ForEach(config.drives) { drive in
|
ForEach(config.drives) { drive in
|
||||||
if drive.isExternal {
|
if drive.isExternal {
|
||||||
|
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||||
Menu {
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
selectedDrive = drive
|
selectedDrive = drive
|
||||||
|
@ -68,6 +69,12 @@ struct VMToolbarDriveMenuView: View {
|
||||||
} label: {
|
} label: {
|
||||||
MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
|
MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
Button {
|
||||||
|
} label: {
|
||||||
|
MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
|
||||||
|
}.disabled(true)
|
||||||
|
#endif
|
||||||
} else if drive.imageType == .disk || drive.imageType == .cd {
|
} else if drive.imageType == .disk || drive.imageType == .cd {
|
||||||
Button {
|
Button {
|
||||||
} label: {
|
} label: {
|
||||||
|
|
|
@ -82,13 +82,17 @@ struct VMToolbarView: View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
Group {
|
Group {
|
||||||
Button {
|
Button {
|
||||||
if session.vm.state == .started {
|
if state.isRunning {
|
||||||
state.alert = .powerDown
|
state.alert = .powerDown
|
||||||
} else {
|
} else {
|
||||||
state.alert = .terminateApp
|
state.alert = .terminateApp
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
|
if state.isRunning {
|
||||||
|
Label("Power Off", systemImage: "power")
|
||||||
|
} else {
|
||||||
|
Label("Force Kill", systemImage: "xmark")
|
||||||
|
}
|
||||||
}.offset(offset(for: 8))
|
}.offset(offset(for: 8))
|
||||||
Button {
|
Button {
|
||||||
session.pauseResume()
|
session.pauseResume()
|
||||||
|
@ -110,7 +114,7 @@ struct VMToolbarView: View {
|
||||||
} label: {
|
} label: {
|
||||||
Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
|
Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
|
||||||
}.offset(offset(for: 5))
|
}.offset(offset(for: 5))
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
if session.vm.hasUsbRedirection {
|
if session.vm.hasUsbRedirection {
|
||||||
VMToolbarUSBMenuView()
|
VMToolbarUSBMenuView()
|
||||||
.offset(offset(for: 4))
|
.offset(offset(for: 4))
|
||||||
|
|
|
@ -71,6 +71,8 @@ struct VMWindowState: Identifiable {
|
||||||
var isRunning: Bool = false
|
var isRunning: Bool = false
|
||||||
|
|
||||||
var alert: Alert?
|
var alert: Alert?
|
||||||
|
|
||||||
|
var isDynamicResolutionSupported: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - VM action alerts
|
// MARK: - VM action alerts
|
||||||
|
@ -82,7 +84,7 @@ extension VMWindowState {
|
||||||
case .powerDown: return 0
|
case .powerDown: return 0
|
||||||
case .terminateApp: return 1
|
case .terminateApp: return 1
|
||||||
case .restart: return 2
|
case .restart: return 2
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
case .deviceConnected(_): return 3
|
case .deviceConnected(_): return 3
|
||||||
#endif
|
#endif
|
||||||
case .nonfatalError(_): return 4
|
case .nonfatalError(_): return 4
|
||||||
|
@ -94,7 +96,7 @@ extension VMWindowState {
|
||||||
case powerDown
|
case powerDown
|
||||||
case terminateApp
|
case terminateApp
|
||||||
case restart
|
case restart
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
case deviceConnected(CSUSBDevice)
|
case deviceConnected(CSUSBDevice)
|
||||||
#endif
|
#endif
|
||||||
case nonfatalError(String)
|
case nonfatalError(String)
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftUIVisualEffects
|
import SwiftUIVisualEffects
|
||||||
|
#if os(visionOS)
|
||||||
|
import VisionKeyboardKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct VMWindowView: View {
|
struct VMWindowView: View {
|
||||||
let id: VMSessionState.WindowID
|
let id: VMSessionState.WindowID
|
||||||
|
@ -24,7 +27,10 @@ struct VMWindowView: View {
|
||||||
@State private var state: VMWindowState
|
@State private var state: VMWindowState
|
||||||
@EnvironmentObject private var session: VMSessionState
|
@EnvironmentObject private var session: VMSessionState
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
#if os(visionOS)
|
||||||
|
@Environment(\.dismissWindow) private var dismissWindow
|
||||||
|
#endif
|
||||||
|
|
||||||
private let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
|
private let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
|
||||||
private let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
|
private let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
|
||||||
private let didReceiveMemoryWarningNotification = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
|
private let didReceiveMemoryWarningNotification = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
|
||||||
|
@ -108,13 +114,13 @@ struct VMWindowView: View {
|
||||||
}, secondaryButton: .cancel(Text("No")))
|
}, secondaryButton: .cancel(Text("No")))
|
||||||
case .terminateApp:
|
case .terminateApp:
|
||||||
return Alert(title: Text("Are you sure you want to exit UTM?"), primaryButton: .destructive(Text("Yes")) {
|
return Alert(title: Text("Are you sure you want to exit UTM?"), primaryButton: .destructive(Text("Yes")) {
|
||||||
session.stop()
|
session.powerDown(isKill: true)
|
||||||
}, secondaryButton: .cancel(Text("No")))
|
}, secondaryButton: .cancel(Text("No")))
|
||||||
case .restart:
|
case .restart:
|
||||||
return Alert(title: Text("Are you sure you want to reset this VM? Any unsaved changes will be lost."), primaryButton: .destructive(Text("Yes")) {
|
return Alert(title: Text("Are you sure you want to reset this VM? Any unsaved changes will be lost."), primaryButton: .destructive(Text("Yes")) {
|
||||||
session.reset()
|
session.reset()
|
||||||
}, secondaryButton: .cancel(Text("No")))
|
}, secondaryButton: .cancel(Text("No")))
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
case .deviceConnected(let device):
|
case .deviceConnected(let device):
|
||||||
return Alert(title: Text("Would you like to connect '\(device.name ?? device.description)' to this virtual machine?"), primaryButton: .default(Text("Yes")) {
|
return Alert(title: Text("Would you like to connect '\(device.name ?? device.description)' to this virtual machine?"), primaryButton: .default(Text("Yes")) {
|
||||||
session.mostRecentConnectedDevice = nil
|
session.mostRecentConnectedDevice = nil
|
||||||
|
@ -127,6 +133,8 @@ struct VMWindowView: View {
|
||||||
return Alert(title: Text(message), dismissButton: .cancel(Text("OK")) {
|
return Alert(title: Text(message), dismissButton: .cancel(Text("OK")) {
|
||||||
if case .fatalError(_) = type {
|
if case .fatalError(_) = type {
|
||||||
session.stop()
|
session.stop()
|
||||||
|
} else if session.vmState == .stopped {
|
||||||
|
session.stop()
|
||||||
} else {
|
} else {
|
||||||
session.nonfatalError = nil
|
session.nonfatalError = nil
|
||||||
}
|
}
|
||||||
|
@ -151,7 +159,7 @@ struct VMWindowView: View {
|
||||||
state.saveWindow(to: session.vm.registryEntry, device: oldDevice)
|
state.saveWindow(to: session.vm.registryEntry, device: oldDevice)
|
||||||
state.restoreWindow(from: session.vm.registryEntry, device: newDevice)
|
state.restoreWindow(from: session.vm.registryEntry, device: newDevice)
|
||||||
}
|
}
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
.onChange(of: session.mostRecentConnectedDevice) { newValue in
|
.onChange(of: session.mostRecentConnectedDevice) { newValue in
|
||||||
if session.activeWindow == state.id, let device = newValue {
|
if session.activeWindow == state.id, let device = newValue {
|
||||||
state.alert = .deviceConnected(device)
|
state.alert = .deviceConnected(device)
|
||||||
|
@ -171,6 +179,9 @@ struct VMWindowView: View {
|
||||||
.onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
|
.onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
|
||||||
vmStateUpdated(from: oldValue, to: newValue)
|
vmStateUpdated(from: oldValue, to: newValue)
|
||||||
}
|
}
|
||||||
|
.onChange(of: session.isDynamicResolutionSupported) { newValue in
|
||||||
|
state.isDynamicResolutionSupported = newValue
|
||||||
|
}
|
||||||
.onReceive(keyboardDidShowNotification) { _ in
|
.onReceive(keyboardDidShowNotification) { _ in
|
||||||
state.isKeyboardShown = true
|
state.isKeyboardShown = true
|
||||||
state.isKeyboardRequested = true
|
state.isKeyboardRequested = true
|
||||||
|
@ -202,12 +213,30 @@ struct VMWindowView: View {
|
||||||
if !isInteractive {
|
if !isInteractive {
|
||||||
session.externalWindowBinding = $state
|
session.externalWindowBinding = $state
|
||||||
}
|
}
|
||||||
|
state.isDynamicResolutionSupported = session.isDynamicResolutionSupported
|
||||||
|
// in case an alert appeared before we created the view
|
||||||
|
if session.activeWindow == state.id {
|
||||||
|
#if WITH_USB
|
||||||
|
if let device = session.mostRecentConnectedDevice {
|
||||||
|
state.alert = .deviceConnected(device)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if let nonfatalError = session.nonfatalError {
|
||||||
|
state.alert = .nonfatalError(nonfatalError)
|
||||||
|
}
|
||||||
|
if let fatalError = session.fatalError {
|
||||||
|
state.alert = .fatalError(fatalError)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
session.removeWindow(state.id)
|
session.removeWindow(state.id)
|
||||||
if !isInteractive {
|
if !isInteractive {
|
||||||
session.externalWindowBinding = nil
|
session.externalWindowBinding = nil
|
||||||
}
|
}
|
||||||
|
#if os(visionOS)
|
||||||
|
dismissWindow(keyboardFor: state.id)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,9 +250,12 @@ struct VMWindowView: View {
|
||||||
state.isBusy = false
|
state.isBusy = false
|
||||||
state.isRunning = false
|
state.isRunning = false
|
||||||
}
|
}
|
||||||
|
// do not close if we have a popup open
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||||
if session.vmState == .stopped && session.fatalError == nil {
|
if session.nonfatalError == nil && session.fatalError == nil {
|
||||||
session.stop()
|
if session.vmState == .stopped {
|
||||||
|
session.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
|
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
|
||||||
|
|
|
@ -44,7 +44,7 @@ class VMDisplayAppleTerminalWindowController: VMDisplayAppleWindowController, VM
|
||||||
private var isSizeChangeIgnored: Bool = true
|
private var isSizeChangeIgnored: Bool = true
|
||||||
@Setting("OptionAsMetaKey") var isOptionAsMetaKey: Bool = false
|
@Setting("OptionAsMetaKey") var isOptionAsMetaKey: Bool = false
|
||||||
|
|
||||||
convenience init(primaryForIndex index: Int, vm: UTMAppleVirtualMachine, onClose: ((Notification) -> Void)?) {
|
convenience init(primaryForIndex index: Int, vm: UTMAppleVirtualMachine, onClose: (() -> Void)?) {
|
||||||
self.init(vm: vm, onClose: onClose)
|
self.init(vm: vm, onClose: onClose)
|
||||||
self.index = index
|
self.index = index
|
||||||
}
|
}
|
||||||
|
|
|
@ -257,9 +257,9 @@ extension VMDisplayAppleWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMDisplayAppleWindowController: UTMScreenshotProvider {
|
extension VMDisplayAppleWindowController: UTMScreenshotProvider {
|
||||||
var screenshot: PlatformImage? {
|
var screenshot: UTMVirtualMachineScreenshot? {
|
||||||
if let image = mainView?.image() {
|
if let image = mainView?.image() {
|
||||||
return image
|
return UTMVirtualMachineScreenshot(wrapping: image)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -149,7 +149,7 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController {
|
||||||
override func enterSuspended(isBusy busy: Bool) {
|
override func enterSuspended(isBusy busy: Bool) {
|
||||||
if !busy {
|
if !busy {
|
||||||
metalView.isHidden = true
|
metalView.isHidden = true
|
||||||
screenshotView.image = vm.screenshot
|
screenshotView.image = vm.screenshot?.image
|
||||||
screenshotView.isHidden = false
|
screenshotView.isHidden = false
|
||||||
}
|
}
|
||||||
if vm.state == .stopped {
|
if vm.state == .stopped {
|
||||||
|
|
|
@ -38,7 +38,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
||||||
|
|
||||||
var shouldAutoStartVM: Bool = true
|
var shouldAutoStartVM: Bool = true
|
||||||
var vm: (any UTMVirtualMachine)!
|
var vm: (any UTMVirtualMachine)!
|
||||||
var onClose: ((Notification) -> Void)?
|
var onClose: (() -> Void)?
|
||||||
private(set) var secondaryWindows: [VMDisplayWindowController] = []
|
private(set) var secondaryWindows: [VMDisplayWindowController] = []
|
||||||
private(set) weak var primaryWindow: VMDisplayWindowController?
|
private(set) weak var primaryWindow: VMDisplayWindowController?
|
||||||
private var preventIdleSleepAssertion: IOPMAssertionID?
|
private var preventIdleSleepAssertion: IOPMAssertionID?
|
||||||
|
@ -60,7 +60,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(vm: any UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
|
convenience init(vm: any UTMVirtualMachine, onClose: (() -> Void)?) {
|
||||||
self.init(window: nil)
|
self.init(window: nil)
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
self.onClose = onClose
|
self.onClose = onClose
|
||||||
|
@ -236,7 +236,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
||||||
|
|
||||||
func registerSecondaryWindow(_ secondaryWindow: VMDisplayWindowController, at index: Int? = nil) {
|
func registerSecondaryWindow(_ secondaryWindow: VMDisplayWindowController, at index: Int? = nil) {
|
||||||
secondaryWindows.insert(secondaryWindow, at: index ?? secondaryWindows.endIndex)
|
secondaryWindows.insert(secondaryWindow, at: index ?? secondaryWindows.endIndex)
|
||||||
secondaryWindow.onClose = { [weak self] _ in
|
secondaryWindow.onClose = { [weak self] in
|
||||||
self?.secondaryWindows.removeAll(where: { $0 == secondaryWindow })
|
self?.secondaryWindows.removeAll(where: { $0 == secondaryWindow })
|
||||||
}
|
}
|
||||||
secondaryWindow.primaryWindow = self
|
secondaryWindow.primaryWindow = self
|
||||||
|
@ -367,7 +367,7 @@ extension VMDisplayWindowController: NSWindowDelegate {
|
||||||
IOPMAssertionRelease(preventIdleSleepAssertion)
|
IOPMAssertionRelease(preventIdleSleepAssertion)
|
||||||
}
|
}
|
||||||
isFinalizing = true
|
isFinalizing = true
|
||||||
onClose?(notification)
|
onClose?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowDidBecomeKey(_ notification: Notification) {
|
func windowDidBecomeKey(_ notification: Notification) {
|
||||||
|
|
|
@ -37,6 +37,10 @@ struct SettingsView: View {
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Input", systemImage: "keyboard")
|
Label("Input", systemImage: "keyboard")
|
||||||
}
|
}
|
||||||
|
ServerSettingsView().padding()
|
||||||
|
.tabItem {
|
||||||
|
Label("Server", systemImage: "server.rack")
|
||||||
|
}
|
||||||
}.frame(minWidth: 600, minHeight: 350, alignment: .topLeading)
|
}.frame(minWidth: 600, minHeight: 350, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -181,6 +185,65 @@ struct InputSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ServerSettingsView: View {
|
||||||
|
private let defaultPort = 21589
|
||||||
|
|
||||||
|
@AppStorage("ServerAutostart") var isServerAutostart: Bool = false
|
||||||
|
@AppStorage("ServerExternal") var isServerExternal: Bool = false
|
||||||
|
@AppStorage("ServerAutoblock") var isServerAutoblock: Bool = false
|
||||||
|
@AppStorage("ServerPort") var serverPort: Int = 0
|
||||||
|
@AppStorage("ServerPasswordRequired") var isServerPasswordRequired: Bool = false
|
||||||
|
@AppStorage("ServerPassword") var serverPassword: String = ""
|
||||||
|
|
||||||
|
// note it is okay to store the server password in plaintext in the settings plist because if the attacker is able to see the password,
|
||||||
|
// they can gain execution in UTM application context... which is the context needed to read the password.
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Startup")) {
|
||||||
|
Toggle("Automatically start UTM server", isOn: $isServerAutostart)
|
||||||
|
}
|
||||||
|
Section(header: Text("Network")) {
|
||||||
|
Toggle("Reject unknown connections by default", isOn: $isServerAutoblock)
|
||||||
|
.help("If checked, you will not be prompted about any unknown connection and they will be rejected.")
|
||||||
|
Toggle("Allow access from external clients", isOn: $isServerExternal)
|
||||||
|
.help("By default, the server is only available on LAN but setting this will use UPnP/NAT-PMP to port forward to WAN.")
|
||||||
|
.onChange(of: isServerExternal) { newValue in
|
||||||
|
if newValue {
|
||||||
|
if serverPort == 0 {
|
||||||
|
serverPort = defaultPort
|
||||||
|
}
|
||||||
|
if !isServerPasswordRequired {
|
||||||
|
isServerPasswordRequired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NumberTextField("", number: $serverPort, prompt: "Any")
|
||||||
|
.frame(width: 80)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.help("Specify a port number to listen on. This is required if external clients are permitted.")
|
||||||
|
.onChange(of: serverPort) { newValue in
|
||||||
|
if serverPort == 0 {
|
||||||
|
isServerExternal = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Authentication")) {
|
||||||
|
Toggle("Require Password", isOn: $isServerPasswordRequired)
|
||||||
|
.disabled(isServerExternal)
|
||||||
|
.help("If enabled, clients must enter a password. This is required if you want to access the server externally.")
|
||||||
|
.onChange(of: isServerPasswordRequired) { newValue in
|
||||||
|
if newValue && serverPassword.count == 0 {
|
||||||
|
serverPassword = .random(length: 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextField("Password", text: $serverPassword)
|
||||||
|
.disabled(!isServerPasswordRequired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension UserDefaults {
|
extension UserDefaults {
|
||||||
@objc dynamic var KeepRunningAfterLastWindowClosed: Bool { false }
|
@objc dynamic var KeepRunningAfterLastWindowClosed: Bool { false }
|
||||||
@objc dynamic var ShowMenuIcon: Bool { false }
|
@objc dynamic var ShowMenuIcon: Bool { false }
|
||||||
|
|
|
@ -58,6 +58,9 @@ struct UTMApp: App {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
UTMMenuBarExtraScene(data: data)
|
UTMMenuBarExtraScene(data: data)
|
||||||
|
Window("UTM Server", id: "server") {
|
||||||
|
UTMServerView().environmentObject(data.remoteServer.state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HACK: SwiftUI doesn't provide if-statement support in SceneBuilder
|
// HACK: SwiftUI doesn't provide if-statement support in SceneBuilder
|
||||||
|
|
|
@ -22,7 +22,7 @@ extension UTMData {
|
||||||
func run(vm: VMData, options: UTMVirtualMachineStartOptions = [], startImmediately: Bool = true) {
|
func run(vm: VMData, options: UTMVirtualMachineStartOptions = [], startImmediately: Bool = true) {
|
||||||
var window: Any? = vmWindows[vm]
|
var window: Any? = vmWindows[vm]
|
||||||
if window == nil {
|
if window == nil {
|
||||||
let close = { (notification: Notification) -> Void in
|
let close = {
|
||||||
self.vmWindows.removeValue(forKey: vm)
|
self.vmWindows.removeValue(forKey: vm)
|
||||||
window = nil
|
window = nil
|
||||||
}
|
}
|
||||||
|
@ -76,6 +76,37 @@ extension UTMData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a remote session and return SPICE server port.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - vm: VM to start
|
||||||
|
/// - options: Start options
|
||||||
|
/// - server: Remote server
|
||||||
|
/// - Returns: Port number to SPICE server
|
||||||
|
func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> UTMRemoteMessageServer.StartVirtualMachine.ServerInformation {
|
||||||
|
guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else {
|
||||||
|
throw UTMDataError.unsupportedBackend
|
||||||
|
}
|
||||||
|
if let existingSession = vmWindows[vm] as? VMRemoteSessionState, let spiceServerInfo = wrapped.spiceServerInfo {
|
||||||
|
if wrapped.state == .paused {
|
||||||
|
try await wrapped.resume()
|
||||||
|
}
|
||||||
|
existingSession.client = client
|
||||||
|
return spiceServerInfo
|
||||||
|
}
|
||||||
|
guard vmWindows[vm] == nil else {
|
||||||
|
throw UTMDataError.virtualMachineUnavailable
|
||||||
|
}
|
||||||
|
let session = VMRemoteSessionState(for: wrapped, client: client) {
|
||||||
|
self.vmWindows.removeValue(forKey: vm)
|
||||||
|
}
|
||||||
|
try await wrapped.start(options: options.union(.remoteSession))
|
||||||
|
vmWindows[vm] = session
|
||||||
|
guard let spiceServerInfo = wrapped.spiceServerInfo else {
|
||||||
|
throw UTMDataError.unsupportedBackend
|
||||||
|
}
|
||||||
|
return spiceServerInfo
|
||||||
|
}
|
||||||
|
|
||||||
func stop(vm: VMData) {
|
func stop(vm: VMData) {
|
||||||
guard let wrapped = vm.wrapped else {
|
guard let wrapped = vm.wrapped else {
|
||||||
return
|
return
|
||||||
|
|
|
@ -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
|
import IOKit.pwr_mgt
|
||||||
|
|
||||||
/// Represents the UI state for a single headless VM session.
|
/// Represents the UI state for a single headless VM session.
|
||||||
@MainActor class VMHeadlessSessionState: NSObject, ObservableObject {
|
@MainActor class VMHeadlessSessionState: NSObject, ObservableObject, UTMVirtualMachineDelegate {
|
||||||
let vm: any UTMVirtualMachine
|
let vm: any UTMVirtualMachine
|
||||||
var onStop: ((Notification) -> Void)?
|
var onStop: (() -> Void)?
|
||||||
|
|
||||||
@Published var vmState: UTMVirtualMachineState = .stopped
|
@Published var vmState: UTMVirtualMachineState = .stopped
|
||||||
|
|
||||||
@Published var fatalError: String?
|
|
||||||
|
|
||||||
private var hasStarted: Bool = false
|
private var hasStarted: Bool = false
|
||||||
private var preventIdleSleepAssertion: IOPMAssertionID?
|
private var preventIdleSleepAssertion: IOPMAssertionID?
|
||||||
|
|
||||||
@Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
|
@Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
|
||||||
|
|
||||||
init(for vm: any UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
|
init(for vm: any UTMVirtualMachine, onStop: (() -> Void)?) {
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
self.onStop = onStop
|
self.onStop = onStop
|
||||||
super.init()
|
super.init()
|
||||||
|
@ -42,9 +40,7 @@ import IOKit.pwr_mgt
|
||||||
deinit {
|
deinit {
|
||||||
NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
|
NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
|
|
||||||
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
vmState = state
|
vmState = state
|
||||||
|
@ -63,7 +59,6 @@ extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
|
||||||
|
|
||||||
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
fatalError = message
|
|
||||||
NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
|
NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
|
||||||
if !hasStarted {
|
if !hasStarted {
|
||||||
// if we got an error and haven't started, then cleanup
|
// if we got an error and haven't started, then cleanup
|
||||||
|
@ -101,6 +96,7 @@ extension VMHeadlessSessionState {
|
||||||
if let preventIdleSleepAssertion = preventIdleSleepAssertion {
|
if let preventIdleSleepAssertion = preventIdleSleepAssertion {
|
||||||
IOPMAssertionRelease(preventIdleSleepAssertion)
|
IOPMAssertionRelease(preventIdleSleepAssertion)
|
||||||
}
|
}
|
||||||
|
onStop?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
@ -14,6 +18,8 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.temporary-exception.sbpl</key>
|
<key>com.apple.security.temporary-exception.sbpl</key>
|
||||||
<array>
|
<array>
|
||||||
<string>(allow network-outbound)</string>
|
<string>(allow network-outbound)</string>
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.vm.device-access</key>
|
<key>com.apple.vm.device-access</key>
|
||||||
|
|
|
@ -15,23 +15,40 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import VisionKeyboardKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct UTMApp: App {
|
struct UTMApp: App {
|
||||||
|
#if WITH_REMOTE
|
||||||
|
@State private var data: UTMRemoteData = UTMRemoteData()
|
||||||
|
#else
|
||||||
@State private var data: UTMData = UTMData()
|
@State private var data: UTMData = UTMData()
|
||||||
|
#endif
|
||||||
@Environment(\.openWindow) private var openWindow
|
@Environment(\.openWindow) private var openWindow
|
||||||
@Environment(\.dismissWindow) private var dismissWindow
|
@Environment(\.dismissWindow) private var dismissWindow
|
||||||
|
|
||||||
private let vmSessionCreatedNotification = NotificationCenter.default.publisher(for: .vmSessionCreated)
|
private let vmSessionCreatedNotification = NotificationCenter.default.publisher(for: .vmSessionCreated)
|
||||||
private let vmSessionEndedNotification = NotificationCenter.default.publisher(for: .vmSessionEnded)
|
private let vmSessionEndedNotification = NotificationCenter.default.publisher(for: .vmSessionEnded)
|
||||||
|
|
||||||
|
private var contentView: some View {
|
||||||
|
#if WITH_REMOTE
|
||||||
|
RemoteContentView(remoteClientState: data.remoteClient.state)
|
||||||
|
#else
|
||||||
|
ContentView()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup(id: "home") {
|
WindowGroup(id: "home") {
|
||||||
ContentView()
|
contentView
|
||||||
.environmentObject(data)
|
.environmentObject(data)
|
||||||
.onReceive(vmSessionCreatedNotification) { output in
|
.onReceive(vmSessionCreatedNotification) { output in
|
||||||
let newSession = output.userInfo!["Session"] as! VMSessionState
|
let newSession = output.userInfo!["Session"] as! VMSessionState
|
||||||
openWindow(value: newSession.newWindow())
|
if let window = newSession.windows.first {
|
||||||
|
openWindow(value: window)
|
||||||
|
} else {
|
||||||
|
openWindow(value: newSession.newWindow())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onReceive(vmSessionEndedNotification) { output in
|
.onReceive(vmSessionEndedNotification) { output in
|
||||||
let endedSession = output.userInfo!["Session"] as! VMSessionState
|
let endedSession = output.userInfo!["Session"] as! VMSessionState
|
||||||
|
@ -46,12 +63,17 @@ struct UTMApp: App {
|
||||||
WindowGroup(for: VMSessionState.GlobalWindowID.self) { $globalID in
|
WindowGroup(for: VMSessionState.GlobalWindowID.self) { $globalID in
|
||||||
if let globalID = globalID, let session = VMSessionState.allActiveSessions[globalID.sessionID] {
|
if let globalID = globalID, let session = VMSessionState.allActiveSessions[globalID.sessionID] {
|
||||||
VMWindowView(id: globalID.windowID).environmentObject(session)
|
VMWindowView(id: globalID.windowID).environmentObject(session)
|
||||||
|
.glassBackgroundEffect(in: .rect(cornerRadius: 15))
|
||||||
|
#if WITH_SOLO_VM
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// currently we only support one session, so close the home window
|
// currently we only support one session, so close the home window
|
||||||
dismissWindow(id: "home")
|
dismissWindow(id: "home")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.windowStyle(.plain)
|
||||||
.windowResizability(.contentMinSize)
|
.windowResizability(.contentMinSize)
|
||||||
|
KeyboardWindowGroup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,23 +15,35 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import VisionKeyboardKit
|
||||||
|
#if !WITH_USB
|
||||||
|
import CocoaSpiceNoUsb
|
||||||
|
#else
|
||||||
|
import CocoaSpice
|
||||||
|
#endif
|
||||||
|
|
||||||
struct VMToolbarOrnamentModifier: ViewModifier {
|
struct VMToolbarOrnamentModifier: ViewModifier {
|
||||||
@Binding var state: VMWindowState
|
@Binding var state: VMWindowState
|
||||||
@EnvironmentObject private var session: VMSessionState
|
@EnvironmentObject private var session: VMSessionState
|
||||||
@AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
|
@AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@Environment(\.dismissWindow) private var dismissWindow
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.ornament(visibility: isCollapsed ? .hidden : .visible, attachmentAnchor: .scene(.top)) {
|
content.ornament(visibility: isCollapsed ? .hidden : .visible, attachmentAnchor: .scene(.top)) {
|
||||||
HStack {
|
HStack {
|
||||||
Button {
|
Button {
|
||||||
if session.vm.state == .started {
|
if state.isRunning {
|
||||||
state.alert = .powerDown
|
state.alert = .powerDown
|
||||||
} else {
|
} else {
|
||||||
state.alert = .terminateApp
|
state.alert = .terminateApp
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
|
if state.isRunning {
|
||||||
|
Label("Power Off", systemImage: "power")
|
||||||
|
} else {
|
||||||
|
Label("Force Kill", systemImage: "xmark")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(state.isBusy)
|
.disabled(state.isBusy)
|
||||||
Button {
|
Button {
|
||||||
|
@ -56,7 +68,7 @@ struct VMToolbarOrnamentModifier: ViewModifier {
|
||||||
}
|
}
|
||||||
.disabled(state.isBusy)
|
.disabled(state.isBusy)
|
||||||
}
|
}
|
||||||
#if !WITH_QEMU_TCI
|
#if WITH_USB
|
||||||
if session.vm.hasUsbRedirection {
|
if session.vm.hasUsbRedirection {
|
||||||
VMToolbarUSBMenuView()
|
VMToolbarUSBMenuView()
|
||||||
.disabled(state.isBusy)
|
.disabled(state.isBusy)
|
||||||
|
@ -67,11 +79,39 @@ struct VMToolbarOrnamentModifier: ViewModifier {
|
||||||
VMToolbarDisplayMenuView(state: $state)
|
VMToolbarDisplayMenuView(state: $state)
|
||||||
.disabled(state.isBusy)
|
.disabled(state.isBusy)
|
||||||
Button {
|
Button {
|
||||||
state.isKeyboardRequested = true
|
if case .display(_, _) = state.device {
|
||||||
|
state.isKeyboardRequested = !state.isKeyboardShown
|
||||||
|
} else {
|
||||||
|
state.isKeyboardRequested = true
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Keyboard", systemImage: "keyboard")
|
Label("Keyboard", systemImage: "keyboard")
|
||||||
}
|
}
|
||||||
.disabled(state.isBusy)
|
.disabled(state.isBusy)
|
||||||
|
.onChange(of: state.isKeyboardRequested) { _, newValue in
|
||||||
|
guard case .display(_, _) = state.device else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newValue {
|
||||||
|
openWindow(keyboardFor: state.id)
|
||||||
|
} else {
|
||||||
|
dismissWindow(keyboardFor: state.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(KeyboardEvent.publisher(for: state.id)) { event in
|
||||||
|
switch event {
|
||||||
|
case .keyboardDidAppear:
|
||||||
|
state.isKeyboardShown = true
|
||||||
|
state.isKeyboardRequested = true
|
||||||
|
case .keyboardDidDisappear:
|
||||||
|
state.isKeyboardShown = false
|
||||||
|
state.isKeyboardRequested = false
|
||||||
|
case .keyUp(let keyCode, let modifier):
|
||||||
|
handleKeyEvent(keyCode, modifier: modifier, isKeyDown: false)
|
||||||
|
case .keyDown(let keyCode, let modifier):
|
||||||
|
handleKeyEvent(keyCode, modifier: modifier, isKeyDown: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button {
|
Button {
|
||||||
isCollapsed = true
|
isCollapsed = true
|
||||||
|
@ -90,6 +130,18 @@ struct VMToolbarOrnamentModifier: ViewModifier {
|
||||||
.modifier(ToolbarOrnamentViewModifier())
|
.modifier(ToolbarOrnamentViewModifier())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleKeyEvent(_ keyCode: KeyboardKeyCode, modifier: KeyboardModifier, isKeyDown: Bool) {
|
||||||
|
guard let primaryInput = session.primaryInput else {
|
||||||
|
logger.debug("ignoring key event because input channel is not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var scanCode = keyCode.ps2Set1ScanMake(modifier).reduce(Int32(0), { ($0 << 8) | Int32($1) })
|
||||||
|
if ((scanCode & 0xFF00) == 0xE000) {
|
||||||
|
scanCode = 0x100 | (scanCode & 0xFF);
|
||||||
|
}
|
||||||
|
primaryInput.send(isKeyDown ? .press : .release, code: scanCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the following was suggested by Apple via Feedback to look close to .toolbar() with .bottomOrnament
|
// the following was suggested by Apple via Feedback to look close to .toolbar() with .bottomOrnament
|
||||||
|
|
|
@ -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+Sharing.h"
|
||||||
#include "UTMLegacyQemuConfiguration+System.h"
|
#include "UTMLegacyQemuConfiguration+System.h"
|
||||||
#include "UTMLegacyQemuConfigurationPortForward.h"
|
#include "UTMLegacyQemuConfigurationPortForward.h"
|
||||||
|
#include "UTMLogging.h"
|
||||||
|
#if !defined(WITH_REMOTE)
|
||||||
#include "UTMProcess.h"
|
#include "UTMProcess.h"
|
||||||
#include "UTMQemuSystem.h"
|
#include "UTMQemuSystem.h"
|
||||||
#include "UTMJailbreak.h"
|
#include "UTMJailbreak.h"
|
||||||
#include "UTMLogging.h"
|
#else
|
||||||
|
#include "UTMQemuSystemBackends.h"
|
||||||
|
#endif
|
||||||
#include "UTMLegacyViewState.h"
|
#include "UTMLegacyViewState.h"
|
||||||
#include "UTMSpiceIO.h"
|
#include "UTMSpiceIO.h"
|
||||||
|
#include "GenerateKey.h"
|
||||||
#if TARGET_OS_IPHONE
|
#if TARGET_OS_IPHONE
|
||||||
|
#if !defined(WITH_REMOTE)
|
||||||
#include "UTMLocationManager.h"
|
#include "UTMLocationManager.h"
|
||||||
|
#endif
|
||||||
#include "VMDisplayViewController.h"
|
#include "VMDisplayViewController.h"
|
||||||
//#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION
|
//#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION
|
||||||
#include "VMDisplayMetalViewController.h"
|
#include "VMDisplayMetalViewController.h"
|
||||||
|
|
|
@ -40,6 +40,10 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||||
var supportsRecoveryMode: Bool {
|
var supportsRecoveryMode: Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var supportsRemoteSession: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static let capabilities = Capabilities()
|
static let capabilities = Capabilities()
|
||||||
|
@ -85,7 +89,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var screenshot: PlatformImage? {
|
private(set) var screenshot: UTMVirtualMachineScreenshot? {
|
||||||
willSet {
|
willSet {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
@ -474,7 +478,11 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||||
screenshot = screenshotDelegate?.screenshot
|
screenshot = screenshotDelegate?.screenshot
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reloadScreenshotFromFile() {
|
||||||
|
screenshot = loadScreenshot()
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor private func createAppleVM() throws {
|
@MainActor private func createAppleVM() throws {
|
||||||
for i in config.serials.indices {
|
for i in config.serials.indices {
|
||||||
let (fd, sfd, name) = try createPty()
|
let (fd, sfd, name) = try createPty()
|
||||||
|
@ -721,7 +729,7 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol UTMScreenshotProvider: AnyObject {
|
protocol UTMScreenshotProvider: AnyObject {
|
||||||
var screenshot: PlatformImage? { get }
|
var screenshot: UTMVirtualMachineScreenshot? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UTMAppleVirtualMachineError: Error {
|
enum UTMAppleVirtualMachineError: Error {
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import Network
|
||||||
|
|
||||||
extension Optional where Wrapped == String {
|
extension Optional where Wrapped == String {
|
||||||
var _bound: String? {
|
var _bound: String? {
|
||||||
|
@ -383,4 +384,44 @@ extension String {
|
||||||
}
|
}
|
||||||
return Int(numeric)
|
return Int(numeric)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func random(length: Int) -> String {
|
||||||
|
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
return String((0..<length).map{ _ in letters.randomElement()! })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Encodable {
|
||||||
|
func propertyList() throws -> Any {
|
||||||
|
let encoder = PropertyListEncoder()
|
||||||
|
encoder.outputFormat = .xml
|
||||||
|
let xml = try encoder.encode(self)
|
||||||
|
return try PropertyListSerialization.propertyList(from: xml, format: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Decodable {
|
||||||
|
init(fromPropertyList propertyList: Any) throws {
|
||||||
|
let data = try PropertyListSerialization.data(fromPropertyList: propertyList, format: .xml, options: 0)
|
||||||
|
let decoder = PropertyListDecoder()
|
||||||
|
self = try decoder.decode(Self.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NWEndpoint {
|
||||||
|
var hostname: String? {
|
||||||
|
if case .hostPort(let host, _) = self {
|
||||||
|
switch host {
|
||||||
|
case .name(let hostname, _):
|
||||||
|
return hostname
|
||||||
|
case .ipv4(let address):
|
||||||
|
return "\(address)"
|
||||||
|
case .ipv6(let address):
|
||||||
|
return "\(address)"
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,7 +65,7 @@ typedef struct memorystatus_memlimit_properties {
|
||||||
|
|
||||||
int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, user_addr_t buffer, size_t buffersize);
|
int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, user_addr_t buffer, size_t buffersize);
|
||||||
|
|
||||||
#if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
|
#if !TARGET_OS_OSX && defined(WITH_JIT)
|
||||||
extern int csops(pid_t pid, unsigned int ops, void * useraddr, size_t usersize);
|
extern int csops(pid_t pid, unsigned int ops, void * useraddr, size_t usersize);
|
||||||
extern boolean_t exc_server(mach_msg_header_t *, mach_msg_header_t *);
|
extern boolean_t exc_server(mach_msg_header_t *, mach_msg_header_t *);
|
||||||
extern int ptrace(int request, pid_t pid, caddr_t addr, int data);
|
extern int ptrace(int request, pid_t pid, caddr_t addr, int data);
|
||||||
|
@ -100,7 +100,7 @@ static bool jb_has_debugger_attached(void) {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
bool jb_has_cs_disabled(void) {
|
bool jb_has_cs_disabled(void) {
|
||||||
#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
|
#if TARGET_OS_OSX || !defined(WITH_JIT)
|
||||||
return false;
|
return false;
|
||||||
#else
|
#else
|
||||||
int flags;
|
int flags;
|
||||||
|
@ -236,7 +236,7 @@ static bool is_device_A12_or_newer(void) {
|
||||||
bool jb_has_jit_entitlement(void) {
|
bool jb_has_jit_entitlement(void) {
|
||||||
#if TARGET_OS_OSX
|
#if TARGET_OS_OSX
|
||||||
return true;
|
return true;
|
||||||
#elif defined(WITH_QEMU_TCI)
|
#elif !defined(WITH_JIT)
|
||||||
return false;
|
return false;
|
||||||
#else
|
#else
|
||||||
NSDictionary *entitlements = cached_app_entitlements();
|
NSDictionary *entitlements = cached_app_entitlements();
|
||||||
|
@ -330,7 +330,7 @@ bool jb_has_cs_execseg_allow_unsigned(void) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool jb_enable_ptrace_hack(void) {
|
bool jb_enable_ptrace_hack(void) {
|
||||||
#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
|
#if TARGET_OS_OSX || !defined(WITH_JIT)
|
||||||
return false;
|
return false;
|
||||||
#else
|
#else
|
||||||
bool debugged = jb_has_debugger_attached();
|
bool debugged = jb_has_debugger_attached();
|
||||||
|
@ -380,7 +380,7 @@ bool jb_increase_memlimit(void) {
|
||||||
return ret1 == 0 && ret2 == 0;
|
return ret1 == 0 && ret2 == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
|
#if !TARGET_OS_OSX && defined(WITH_JIT)
|
||||||
extern const char *environ[];
|
extern const char *environ[];
|
||||||
|
|
||||||
static char *childArgv[] = {NULL, "debugme", NULL};
|
static char *childArgv[] = {NULL, "debugme", NULL};
|
||||||
|
|
|
@ -15,7 +15,9 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
#import "UTMLogging.h"
|
#import "UTMLogging.h"
|
||||||
|
#if !defined(WITH_REMOTE)
|
||||||
@import QEMUKitInternal;
|
@import QEMUKitInternal;
|
||||||
|
#endif
|
||||||
|
|
||||||
static UTMLogging *gLoggingInstance;
|
static UTMLogging *gLoggingInstance;
|
||||||
|
|
||||||
|
@ -42,7 +44,11 @@ void UTMLog(NSString *format, ...) {
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)writeLine:(NSString *)line {
|
- (void)writeLine:(NSString *)line {
|
||||||
|
#if defined(WITH_REMOTE)
|
||||||
|
NSLog(@"%@", line);
|
||||||
|
#else
|
||||||
[QEMULogging.sharedInstance writeLine:line];
|
[QEMULogging.sharedInstance writeLine:line];
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
|
@ -26,7 +26,7 @@ typealias SystemPasteboardType = NSPasteboard.PasteboardType
|
||||||
#else
|
#else
|
||||||
#error("Neither UIKit nor AppKit found!")
|
#error("Neither UIKit nor AppKit found!")
|
||||||
#endif
|
#endif
|
||||||
#if WITH_QEMU_TCI
|
#if !WITH_USB
|
||||||
import CocoaSpiceNoUsb
|
import CocoaSpiceNoUsb
|
||||||
#else
|
#else
|
||||||
import CocoaSpice
|
import CocoaSpice
|
||||||
|
|
|
@ -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
|
import QEMUKitInternal
|
||||||
#if WITH_QEMU_TCI
|
#if !WITH_USB
|
||||||
import CocoaSpiceNoUsb
|
import CocoaSpiceNoUsb
|
||||||
#else
|
#else
|
||||||
import CocoaSpice
|
import CocoaSpice
|
||||||
|
|
|
@ -15,24 +15,9 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
#import "UTMProcess.h"
|
#import "UTMProcess.h"
|
||||||
|
#import "UTMQemuSystemBackends.h"
|
||||||
@import QEMUKitInternal;
|
@import QEMUKitInternal;
|
||||||
|
|
||||||
/// Specify the backend renderer for this VM
|
|
||||||
typedef NS_ENUM(NSInteger, UTMQEMURendererBackend) {
|
|
||||||
kQEMURendererBackendDefault = 0,
|
|
||||||
kQEMURendererBackendAngleGL = 1,
|
|
||||||
kQEMURendererBackendAngleMetal = 2,
|
|
||||||
kQEMURendererBackendMax = 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Specify the sound backend for this VM
|
|
||||||
typedef NS_ENUM(NSInteger, UTMQEMUSoundBackend) {
|
|
||||||
kQEMUSoundBackendDefault = 0,
|
|
||||||
kQEMUSoundBackendSPICE = 1,
|
|
||||||
kQEMUSoundBackendCoreAudio = 2,
|
|
||||||
kQEMUSoundBackendMax = 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
@interface UTMQemuSystem : UTMProcess <QEMULauncher>
|
@interface UTMQemuSystem : UTMProcess <QEMULauncher>
|
||||||
|
|
|
@ -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 Foundation
|
||||||
import QEMUKit
|
import QEMUKit
|
||||||
|
#if os(macOS)
|
||||||
|
import SwiftPortmap
|
||||||
|
#endif
|
||||||
|
|
||||||
private var SpiceIoServiceGuestAgentContext = 0
|
private var SpiceIoServiceGuestAgentContext = 0
|
||||||
private let kSuspendSnapshotName = "suspend"
|
private let kSuspendSnapshotName = "suspend"
|
||||||
private let kProbeSuspendDelay = 1*NSEC_PER_SEC
|
private let kProbeSuspendDelay = 1*NSEC_PER_SEC
|
||||||
|
|
||||||
/// QEMU backend virtual machine
|
/// QEMU backend virtual machine
|
||||||
final class UTMQemuVirtualMachine: UTMVirtualMachine {
|
final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine {
|
||||||
struct Capabilities: UTMVirtualMachineCapabilities {
|
struct Capabilities: UTMVirtualMachineCapabilities {
|
||||||
var supportsProcessKill: Bool {
|
var supportsProcessKill: Bool {
|
||||||
true
|
true
|
||||||
|
@ -43,6 +46,10 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
|
||||||
var supportsRecoveryMode: Bool {
|
var supportsRecoveryMode: Bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var supportsRemoteSession: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static let capabilities = Capabilities()
|
static let capabilities = Capabilities()
|
||||||
|
@ -88,7 +95,7 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var screenshot: PlatformImage? {
|
var screenshot: UTMVirtualMachineScreenshot? {
|
||||||
willSet {
|
willSet {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
@ -117,6 +124,9 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pipe interface (alternative to UTMSpiceIO)
|
||||||
|
private var pipeInterface: UTMPipeInterface?
|
||||||
|
|
||||||
private let qemuVM = QEMUVirtualMachine()
|
private let qemuVM = QEMUVirtualMachine()
|
||||||
|
|
||||||
private var system: UTMQemuSystem? {
|
private var system: UTMQemuSystem? {
|
||||||
|
@ -144,7 +154,13 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine {
|
||||||
private var swtpm: UTMSWTPM?
|
private var swtpm: UTMSWTPM?
|
||||||
|
|
||||||
private var changeCursorRequestInProgress: Bool = false
|
private var changeCursorRequestInProgress: Bool = false
|
||||||
|
|
||||||
|
#if WITH_SERVER
|
||||||
|
@Setting("ServerPort") private var serverPort: Int = 0
|
||||||
|
private var spicePort: SwiftPortmap.Port?
|
||||||
|
private(set) var spiceServerInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation?
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool = false) throws {
|
@MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool = false) throws {
|
||||||
self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
|
self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
|
||||||
// load configuration
|
// load configuration
|
||||||
|
@ -267,10 +283,24 @@ extension UTMQemuVirtualMachine {
|
||||||
await qemuVM.setRedirectLog(url: nil)
|
await qemuVM.setRedirectLog(url: nil)
|
||||||
}
|
}
|
||||||
let isRunningAsDisposible = options.contains(.bootDisposibleMode)
|
let isRunningAsDisposible = options.contains(.bootDisposibleMode)
|
||||||
|
let isRemoteSession = options.contains(.remoteSession)
|
||||||
|
#if WITH_SERVER
|
||||||
|
let spicePassword = isRemoteSession ? String.random(length: 32) : nil
|
||||||
|
let spicePort = isRemoteSession ? try SwiftPortmap.Port.TCP(unusedPortStartingAt: UInt16(serverPort)) : nil
|
||||||
|
#else
|
||||||
|
if isRemoteSession {
|
||||||
|
throw UTMVirtualMachineError.notImplemented
|
||||||
|
}
|
||||||
|
#endif
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
config.qemu.isDisposable = isRunningAsDisposible
|
config.qemu.isDisposable = isRunningAsDisposible
|
||||||
|
#if WITH_SERVER
|
||||||
|
config.qemu.spiceServerPort = spicePort?.internalPort
|
||||||
|
config.qemu.spiceServerPassword = spicePassword
|
||||||
|
config.qemu.isSpiceServerTlsEnabled = true
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// start TPM
|
// start TPM
|
||||||
if await config.qemu.hasTPMDevice {
|
if await config.qemu.hasTPMDevice {
|
||||||
let swtpm = UTMSWTPM()
|
let swtpm = UTMSWTPM()
|
||||||
|
@ -280,12 +310,12 @@ extension UTMQemuVirtualMachine {
|
||||||
try await swtpm.start()
|
try await swtpm.start()
|
||||||
self.swtpm = swtpm
|
self.swtpm = swtpm
|
||||||
}
|
}
|
||||||
|
|
||||||
let allArguments = await config.allArguments
|
let allArguments = await config.allArguments
|
||||||
let arguments = allArguments.map({ $0.string })
|
let arguments = allArguments.map({ $0.string })
|
||||||
let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
|
let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
|
||||||
let remoteBookmarks = await remoteBookmarks
|
let remoteBookmarks = await remoteBookmarks
|
||||||
|
|
||||||
let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
|
let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
|
||||||
system.resources = resources
|
system.resources = resources
|
||||||
system.currentDirectoryUrl = await config.socketURL
|
system.currentDirectoryUrl = await config.socketURL
|
||||||
|
@ -295,12 +325,12 @@ extension UTMQemuVirtualMachine {
|
||||||
system.hasDebugLog = hasDebugLog
|
system.hasDebugLog = hasDebugLog
|
||||||
#endif
|
#endif
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
if isShortcut {
|
if isShortcut {
|
||||||
try await accessShortcut()
|
try await accessShortcut()
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
}
|
}
|
||||||
|
|
||||||
var options = UTMSpiceIOOptions()
|
var options = UTMSpiceIOOptions()
|
||||||
if await !config.sound.isEmpty {
|
if await !config.sound.isEmpty {
|
||||||
options.insert(.hasAudio)
|
options.insert(.hasAudio)
|
||||||
|
@ -317,14 +347,41 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
let spiceSocketUrl = await config.spiceSocketURL
|
let spiceSocketUrl = await config.spiceSocketURL
|
||||||
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
|
let interface: any QEMUInterface
|
||||||
ioService.logHandler = { [weak system] (line: String) -> Void in
|
let spicePublicKey: Data?
|
||||||
guard !line.contains("spice_make_scancode") else {
|
if isRemoteSession {
|
||||||
return // do not log key presses for privacy reasons
|
let pipeInterface = UTMPipeInterface()
|
||||||
|
await MainActor.run {
|
||||||
|
pipeInterface.monitorInPipeURL = config.monitorPipeURL.appendingPathExtension("in")
|
||||||
|
pipeInterface.monitorOutPipeURL = config.monitorPipeURL.appendingPathExtension("out")
|
||||||
|
pipeInterface.guestAgentInPipeURL = config.guestAgentPipeURL.appendingPathExtension("in")
|
||||||
|
pipeInterface.guestAgentOutPipeURL = config.guestAgentPipeURL.appendingPathExtension("out")
|
||||||
}
|
}
|
||||||
system?.logging?.writeLine(line)
|
try pipeInterface.start()
|
||||||
|
interface = pipeInterface
|
||||||
|
// generate a TLS key for this session
|
||||||
|
guard let key = GenerateRSACertificate("UTM Remote SPICE Server" as CFString,
|
||||||
|
"UTM" as CFString,
|
||||||
|
Int.random(in: 1..<CLong.max) as CFNumber,
|
||||||
|
1 as CFNumber,
|
||||||
|
false as CFBoolean)?.takeUnretainedValue() as? [Data] else {
|
||||||
|
throw UTMQemuVirtualMachineError.keyGenerationFailed
|
||||||
|
}
|
||||||
|
try await key[1].write(to: config.spiceTlsKeyUrl)
|
||||||
|
try await key[2].write(to: config.spiceTlsCertUrl)
|
||||||
|
spicePublicKey = key[3]
|
||||||
|
} else {
|
||||||
|
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
|
||||||
|
ioService.logHandler = { [weak system] (line: String) -> Void in
|
||||||
|
guard !line.contains("spice_make_scancode") else {
|
||||||
|
return // do not log key presses for privacy reasons
|
||||||
|
}
|
||||||
|
system?.logging?.writeLine(line)
|
||||||
|
}
|
||||||
|
try ioService.start()
|
||||||
|
interface = ioService
|
||||||
|
spicePublicKey = nil
|
||||||
}
|
}
|
||||||
try ioService.start()
|
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
// create EFI variables for legacy config as well as handle UEFI resets
|
// create EFI variables for legacy config as well as handle UEFI resets
|
||||||
|
@ -333,7 +390,7 @@ extension UTMQemuVirtualMachine {
|
||||||
|
|
||||||
// start QEMU
|
// start QEMU
|
||||||
await qemuVM.setDelegate(self)
|
await qemuVM.setDelegate(self)
|
||||||
try await qemuVM.start(launcher: system, interface: ioService)
|
try await qemuVM.start(launcher: system, interface: interface)
|
||||||
let monitor = await monitor!
|
let monitor = await monitor!
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
@ -346,7 +403,11 @@ extension UTMQemuVirtualMachine {
|
||||||
|
|
||||||
// set up SPICE sharing and removable drives
|
// set up SPICE sharing and removable drives
|
||||||
try await self.restoreExternalDrives(withMounting: !isSuspended)
|
try await self.restoreExternalDrives(withMounting: !isSuspended)
|
||||||
try await self.restoreSharedDirectory(for: ioService)
|
if let ioService = interface as? UTMSpiceIO {
|
||||||
|
try await self.restoreSharedDirectory(for: ioService)
|
||||||
|
} else {
|
||||||
|
// TODO: implement shared directory in remote interface
|
||||||
|
}
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
// continue VM boot
|
// continue VM boot
|
||||||
|
@ -358,11 +419,24 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// save ioService and let it set the delegate
|
// save ioService and let it set the delegate
|
||||||
self.ioService = ioService
|
self.ioService = interface as? UTMSpiceIO
|
||||||
|
self.pipeInterface = interface as? UTMPipeInterface
|
||||||
self.isRunningAsDisposible = isRunningAsDisposible
|
self.isRunningAsDisposible = isRunningAsDisposible
|
||||||
|
|
||||||
// test out snapshots
|
// test out snapshots
|
||||||
self.snapshotUnsupportedError = await determineSnapshotSupport()
|
self.snapshotUnsupportedError = await determineSnapshotSupport()
|
||||||
|
|
||||||
|
#if WITH_SERVER
|
||||||
|
// save server details
|
||||||
|
if let spicePort = spicePort, let spicePublicKey = spicePublicKey, let spicePassword = spicePassword {
|
||||||
|
self.spiceServerInfo = .init(spicePortInternal: spicePort.internalPort,
|
||||||
|
spicePortExternal: try? await spicePort.externalPort,
|
||||||
|
spiceHostExternal: try? await spicePort.externalIpv4Address,
|
||||||
|
spicePublicKey: spicePublicKey,
|
||||||
|
spicePassword: spicePassword)
|
||||||
|
self.spicePort = spicePort
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func start(options: UTMVirtualMachineStartOptions = []) async throws {
|
func start(options: UTMVirtualMachineStartOptions = []) async throws {
|
||||||
|
@ -379,7 +453,7 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
try await startTask!.value
|
try await startTask!.value
|
||||||
state = .started
|
state = .started
|
||||||
if screenshotTimer == nil {
|
if screenshotTimer == nil && !options.contains(.remoteSession) {
|
||||||
screenshotTimer = startScreenshotTimer()
|
screenshotTimer = startScreenshotTimer()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -584,10 +658,16 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
|
func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
|
||||||
|
#if WITH_SERVER
|
||||||
|
spicePort = nil
|
||||||
|
spiceServerInfo = nil
|
||||||
|
#endif
|
||||||
swtpm?.stop()
|
swtpm?.stop()
|
||||||
swtpm = nil
|
swtpm = nil
|
||||||
ioService = nil
|
ioService = nil
|
||||||
ioServiceDelegate = nil
|
ioServiceDelegate = nil
|
||||||
|
pipeInterface?.disconnect()
|
||||||
|
pipeInterface = nil
|
||||||
snapshotUnsupportedError = nil
|
snapshotUnsupportedError = nil
|
||||||
try? saveScreenshot()
|
try? saveScreenshot()
|
||||||
state = .stopped
|
state = .stopped
|
||||||
|
@ -621,11 +701,27 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
|
||||||
|
|
||||||
// MARK: - Input device switching
|
// MARK: - Input device switching
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
func requestInputTablet(_ tablet: Bool) {
|
func changeInputTablet(_ tablet: Bool) async throws {
|
||||||
guard !changeCursorRequestInProgress else {
|
defer {
|
||||||
|
changeCursorRequestInProgress = false
|
||||||
|
}
|
||||||
|
guard state == .started else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let spiceIO = ioService else {
|
guard let monitor = await monitor else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let index = try await monitor.mouseIndex(forAbsolute: tablet)
|
||||||
|
try await monitor.mouseSelect(index)
|
||||||
|
ioService?.primaryInput?.requestMouseMode(!tablet)
|
||||||
|
} catch {
|
||||||
|
logger.error("Error changing mouse mode: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestInputTablet(_ tablet: Bool) {
|
||||||
|
guard !changeCursorRequestInProgress else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changeCursorRequestInProgress = true
|
changeCursorRequestInProgress = true
|
||||||
|
@ -633,40 +729,11 @@ extension UTMQemuVirtualMachine {
|
||||||
defer {
|
defer {
|
||||||
changeCursorRequestInProgress = false
|
changeCursorRequestInProgress = false
|
||||||
}
|
}
|
||||||
guard state == .started else {
|
try await changeInputTablet(tablet)
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let monitor = await monitor else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let index = try await monitor.mouseIndex(forAbsolute: tablet)
|
|
||||||
try await monitor.mouseSelect(index)
|
|
||||||
spiceIO.primaryInput?.requestMouseMode(!tablet)
|
|
||||||
} catch {
|
|
||||||
logger.error("Error changing mouse mode: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - USB redirection
|
|
||||||
extension UTMQemuVirtualMachine {
|
|
||||||
var hasUsbRedirection: Bool {
|
|
||||||
return jb_has_usb_entitlement()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Screenshot
|
|
||||||
extension UTMQemuVirtualMachine {
|
|
||||||
@MainActor @discardableResult
|
|
||||||
func takeScreenshot() async -> Bool {
|
|
||||||
let screenshot = await ioService?.screenshot()
|
|
||||||
self.screenshot = screenshot?.image
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Architecture supported
|
// MARK: - Architecture supported
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
/// Check if a QEMU target is supported
|
/// Check if a QEMU target is supported
|
||||||
|
@ -695,7 +762,11 @@ extension UTMQemuVirtualMachine {
|
||||||
|
|
||||||
// MARK: - External drives
|
// MARK: - External drives
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
func eject(_ drive: UTMQemuConfigurationDrive, isForced: Bool = false) async throws {
|
func eject(_ drive: UTMQemuConfigurationDrive) async throws {
|
||||||
|
try await eject(drive, isForced: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eject(_ drive: UTMQemuConfigurationDrive, isForced: Bool) async throws {
|
||||||
guard drive.isExternal else {
|
guard drive.isExternal else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -707,8 +778,12 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
await registryEntry.removeExternalDrive(forId: drive.id)
|
await registryEntry.removeExternalDrive(forId: drive.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL, isAccessOnly: Bool = false) async throws {
|
func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL) async throws {
|
||||||
|
try await changeMedium(drive, to: url, isAccessOnly: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL, isAccessOnly: Bool) async throws {
|
||||||
_ = url.startAccessingSecurityScopedResource()
|
_ = url.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
url.stopAccessingSecurityScopedResource()
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
@ -719,7 +794,7 @@ extension UTMQemuVirtualMachine {
|
||||||
await registryEntry.setExternalDrive(file, forId: drive.id)
|
await registryEntry.setExternalDrive(file, forId: drive.id)
|
||||||
try await changeMedium(drive, with: tempBookmark, url: url, isSecurityScoped: false, isAccessOnly: isAccessOnly)
|
try await changeMedium(drive, with: tempBookmark, url: url, isSecurityScoped: false, isAccessOnly: isAccessOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func changeMedium(_ drive: UTMQemuConfigurationDrive, with bookmark: Data, url: URL?, isSecurityScoped: Bool, isAccessOnly: Bool) async throws {
|
private func changeMedium(_ drive: UTMQemuConfigurationDrive, with bookmark: Data, url: URL?, isSecurityScoped: Bool, isAccessOnly: Bool) async throws {
|
||||||
let system = await system ?? UTMProcess()
|
let system = await system ?? UTMProcess()
|
||||||
let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
|
let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
|
||||||
|
@ -731,8 +806,8 @@ extension UTMQemuVirtualMachine {
|
||||||
try qemu.changeMedium(forDrive: "drive\(drive.id)", path: path)
|
try qemu.changeMedium(forDrive: "drive\(drive.id)", path: path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreExternalDrives(withMounting isMounting: Bool) async throws {
|
private func restoreExternalDrives(withMounting isMounting: Bool) async throws {
|
||||||
guard await system != nil else {
|
guard await system != nil else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
|
@ -754,43 +829,14 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor func externalImageURL(for drive: UTMQemuConfigurationDrive) -> URL? {
|
|
||||||
registryEntry.externalDrives[drive.id]?.url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shared directory
|
// MARK: - Shared directory
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
@MainActor var sharedDirectoryURL: URL? {
|
func stopAccessingPath(_ path: String) async {
|
||||||
registryEntry.sharedDirectories.first?.url
|
await system?.stopAccessingPath(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSharedDirectory() async {
|
|
||||||
if let oldPath = await registryEntry.sharedDirectories.first?.path {
|
|
||||||
await system?.stopAccessingPath(oldPath)
|
|
||||||
}
|
|
||||||
await registryEntry.removeAllSharedDirectories()
|
|
||||||
}
|
|
||||||
|
|
||||||
func changeSharedDirectory(to url: URL) async throws {
|
|
||||||
await clearSharedDirectory()
|
|
||||||
_ = url.startAccessingSecurityScopedResource()
|
|
||||||
defer {
|
|
||||||
url.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
let file = try await UTMRegistryEntry.File(url: url, isReadOnly: config.sharing.isDirectoryShareReadOnly)
|
|
||||||
await registryEntry.setSingleSharedDirectory(file)
|
|
||||||
if await config.sharing.directoryShareMode == .webdav {
|
|
||||||
if let ioService = ioService {
|
|
||||||
ioService.changeSharedDirectory(url)
|
|
||||||
}
|
|
||||||
} else if await config.sharing.directoryShareMode == .virtfs {
|
|
||||||
let tempBookmark = try url.bookmarkData()
|
|
||||||
try await changeVirtfsSharedDirectory(with: tempBookmark, isSecurityScoped: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
|
func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
|
||||||
let system = await system ?? UTMProcess()
|
let system = await system ?? UTMProcess()
|
||||||
let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
|
let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
|
||||||
|
@ -799,61 +845,10 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
await registryEntry.updateSingleSharedDirectoryRemoteBookmark(bookmark)
|
await registryEntry.updateSingleSharedDirectoryRemoteBookmark(bookmark)
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreSharedDirectory(for ioService: UTMSpiceIO) async throws {
|
|
||||||
guard let share = await registryEntry.sharedDirectories.first else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if await config.sharing.directoryShareMode == .virtfs {
|
|
||||||
if let bookmark = share.remoteBookmark {
|
|
||||||
// a share bookmark was saved while QEMU was running
|
|
||||||
try await changeVirtfsSharedDirectory(with: bookmark, isSecurityScoped: true)
|
|
||||||
} else {
|
|
||||||
// a share bookmark was saved while QEMU was NOT running
|
|
||||||
let url = try URL(resolvingPersistentBookmarkData: share.bookmark)
|
|
||||||
try await changeSharedDirectory(to: url)
|
|
||||||
}
|
|
||||||
} else if await config.sharing.directoryShareMode == .webdav {
|
|
||||||
ioService.changeSharedDirectory(share.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Registry syncing
|
// MARK: - Registry syncing
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
@MainActor func updateRegistryFromConfig() async throws {
|
|
||||||
// save a copy to not collide with updateConfigFromRegistry()
|
|
||||||
let configShare = config.sharing.directoryShareUrl
|
|
||||||
let configDrives = config.drives
|
|
||||||
try await updateRegistryBasics()
|
|
||||||
for drive in configDrives {
|
|
||||||
if drive.isExternal, let url = drive.imageURL {
|
|
||||||
try await changeMedium(drive, to: url)
|
|
||||||
} else if drive.isExternal {
|
|
||||||
try await eject(drive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let url = configShare {
|
|
||||||
try await changeSharedDirectory(to: url)
|
|
||||||
} else {
|
|
||||||
await clearSharedDirectory()
|
|
||||||
}
|
|
||||||
// remove any unreferenced drives
|
|
||||||
registryEntry.externalDrives = registryEntry.externalDrives.filter({ element in
|
|
||||||
configDrives.contains(where: { $0.id == element.key && $0.isExternal })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor func updateConfigFromRegistry() {
|
|
||||||
config.sharing.directoryShareUrl = sharedDirectoryURL
|
|
||||||
for i in config.drives.indices {
|
|
||||||
let id = config.drives[i].id
|
|
||||||
if config.drives[i].isExternal {
|
|
||||||
config.drives[i].imageURL = registryEntry.externalDrives[id]?.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
|
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
|
||||||
config.information.uuid = uuid
|
config.information.uuid = uuid
|
||||||
if let name = name {
|
if let name = name {
|
||||||
|
@ -864,7 +859,7 @@ extension UTMQemuVirtualMachine {
|
||||||
registryEntry.update(copying: entry)
|
registryEntry.update(copying: entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var remoteBookmarks: [URL: Data] {
|
@MainActor var remoteBookmarks: [URL: Data] {
|
||||||
var dict = [URL: Data]()
|
var dict = [URL: Data]()
|
||||||
for file in registryEntry.externalDrives.values {
|
for file in registryEntry.externalDrives.values {
|
||||||
|
@ -889,6 +884,7 @@ enum UTMQemuVirtualMachineError: Error {
|
||||||
case accessShareFailed
|
case accessShareFailed
|
||||||
case invalidVmState
|
case invalidVmState
|
||||||
case saveSnapshotFailed(Error)
|
case saveSnapshotFailed(Error)
|
||||||
|
case keyGenerationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UTMQemuVirtualMachineError: LocalizedError {
|
extension UTMQemuVirtualMachineError: LocalizedError {
|
||||||
|
@ -905,6 +901,8 @@ extension UTMQemuVirtualMachineError: LocalizedError {
|
||||||
case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
|
case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
|
||||||
case .saveSnapshotFailed(let error):
|
case .saveSnapshotFailed(let error):
|
||||||
return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
|
return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
|
||||||
|
case .keyGenerationFailed:
|
||||||
|
return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ class UTMRegistry: NSObject {
|
||||||
super.init()
|
super.init()
|
||||||
if let newEntries = try? serializedEntries.mapValues({ value in
|
if let newEntries = try? serializedEntries.mapValues({ value in
|
||||||
let dict = value as! [String: Any]
|
let dict = value as! [String: Any]
|
||||||
return try UTMRegistryEntry(from: dict)
|
return try UTMRegistryEntry(fromPropertyList: dict)
|
||||||
}) {
|
}) {
|
||||||
entries = newEntries
|
entries = newEntries
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
|
@objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
|
||||||
/// Empty registry entry used only as a workaround for object initialization
|
/// Empty registry entry used only as a workaround for object initialization
|
||||||
|
@ -61,7 +62,7 @@ import Foundation
|
||||||
} else {
|
} else {
|
||||||
package = nil
|
package = nil
|
||||||
}
|
}
|
||||||
_package = package ?? File(path: path)
|
_package = package ?? File(dummyFromPath: path)
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
_isSuspended = false
|
_isSuspended = false
|
||||||
_externalDrives = [:]
|
_externalDrives = [:]
|
||||||
|
@ -109,11 +110,7 @@ import Foundation
|
||||||
}
|
}
|
||||||
|
|
||||||
func asDictionary() throws -> [String: Any] {
|
func asDictionary() throws -> [String: Any] {
|
||||||
let encoder = PropertyListEncoder()
|
return try propertyList() as! [String: Any]
|
||||||
encoder.outputFormat = .xml
|
|
||||||
let xml = try encoder.encode(self)
|
|
||||||
let dict = try PropertyListSerialization.propertyList(from: xml, format: nil)
|
|
||||||
return dict as! [String: Any]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the UUID
|
/// Update the UUID
|
||||||
|
@ -128,13 +125,6 @@ import Foundation
|
||||||
|
|
||||||
protocol UTMRegistryEntryDecodable: Decodable {}
|
protocol UTMRegistryEntryDecodable: Decodable {}
|
||||||
extension UTMRegistryEntry: UTMRegistryEntryDecodable {}
|
extension UTMRegistryEntry: UTMRegistryEntryDecodable {}
|
||||||
extension UTMRegistryEntryDecodable {
|
|
||||||
init(from dictionary: [String: Any]) throws {
|
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: dictionary, format: .xml, options: 0)
|
|
||||||
let decoder = PropertyListDecoder()
|
|
||||||
self = try decoder.decode(Self.self, from: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Accessors
|
// MARK: - Accessors
|
||||||
@MainActor extension UTMRegistryEntry {
|
@MainActor extension UTMRegistryEntry {
|
||||||
|
@ -177,7 +167,11 @@ extension UTMRegistryEntryDecodable {
|
||||||
_externalDrives = newValue
|
_externalDrives = newValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var externalDrivePublisher: Published<[String: File]>.Publisher {
|
||||||
|
$_externalDrives
|
||||||
|
}
|
||||||
|
|
||||||
var sharedDirectories: [File] {
|
var sharedDirectories: [File] {
|
||||||
get {
|
get {
|
||||||
_sharedDirectories
|
_sharedDirectories
|
||||||
|
@ -308,7 +302,7 @@ extension UTMRegistryEntry {
|
||||||
}
|
}
|
||||||
for drive in viewState.allDrives() {
|
for drive in viewState.allDrives() {
|
||||||
if let bookmark = viewState.bookmark(forRemovableDrive: drive), let path = viewState.path(forRemovableDrive: drive) {
|
if let bookmark = viewState.bookmark(forRemovableDrive: drive), let path = viewState.path(forRemovableDrive: drive) {
|
||||||
let file = File(path: path, remoteBookmark: bookmark)
|
let file = File(dummyFromPath: path, remoteBookmark: bookmark)
|
||||||
_externalDrives[drive] = file
|
_externalDrives[drive] = file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -393,7 +387,7 @@ extension UTMRegistryEntry {
|
||||||
self.isValid = true
|
self.isValid = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate init(path: String, remoteBookmark: Data = Data()) {
|
init(dummyFromPath path: String, remoteBookmark: Data = Data()) {
|
||||||
self.path = path
|
self.path = path
|
||||||
self.bookmark = Data()
|
self.bookmark = Data()
|
||||||
self.isReadOnly = false
|
self.isReadOnly = false
|
||||||
|
|
|
@ -16,8 +16,12 @@
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
#import "UTMSpiceIODelegate.h"
|
#import "UTMSpiceIODelegate.h"
|
||||||
|
#if defined(WITH_REMOTE)
|
||||||
|
#import "UTMRemoteConnectInterface.h"
|
||||||
|
#else
|
||||||
@import QEMUKitInternal;
|
@import QEMUKitInternal;
|
||||||
#if defined(WITH_QEMU_TCI)
|
#endif
|
||||||
|
#if !defined(WITH_USB)
|
||||||
@import CocoaSpiceNoUsb;
|
@import CocoaSpiceNoUsb;
|
||||||
#else
|
#else
|
||||||
@import CocoaSpice;
|
@import CocoaSpice;
|
||||||
|
@ -34,14 +38,18 @@ typedef NS_OPTIONS(NSUInteger, UTMSpiceIOOptions) {
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
#if defined(WITH_REMOTE)
|
||||||
|
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, UTMRemoteConnectInterface>
|
||||||
|
#else
|
||||||
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
|
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
|
||||||
|
#endif
|
||||||
|
|
||||||
@property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
|
@property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
|
||||||
@property (nonatomic, readonly, nullable) CSInput *primaryInput;
|
@property (nonatomic, readonly, nullable) CSInput *primaryInput;
|
||||||
@property (nonatomic, readonly, nullable) CSPort *primarySerial;
|
@property (nonatomic, readonly, nullable) CSPort *primarySerial;
|
||||||
@property (nonatomic, readonly) NSArray<CSDisplay *> *displays;
|
@property (nonatomic, readonly) NSArray<CSDisplay *> *displays;
|
||||||
@property (nonatomic, readonly) NSArray<CSPort *> *serials;
|
@property (nonatomic, readonly) NSArray<CSPort *> *serials;
|
||||||
#if !defined(WITH_QEMU_TCI)
|
#if defined(WITH_USB)
|
||||||
@property (nonatomic, readonly, nullable) CSUSBManager *primaryUsbManager;
|
@property (nonatomic, readonly, nullable) CSUSBManager *primaryUsbManager;
|
||||||
#endif
|
#endif
|
||||||
@property (nonatomic, weak, nullable) id<UTMSpiceIODelegate> delegate;
|
@property (nonatomic, weak, nullable) id<UTMSpiceIODelegate> delegate;
|
||||||
|
@ -50,6 +58,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
- (instancetype)init NS_UNAVAILABLE;
|
- (instancetype)init NS_UNAVAILABLE;
|
||||||
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
|
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
|
||||||
|
- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey password:(NSString *)password options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
|
||||||
- (void)changeSharedDirectory:(NSURL *)url;
|
- (void)changeSharedDirectory:(NSURL *)url;
|
||||||
|
|
||||||
- (BOOL)startWithError:(NSError * _Nullable *)error;
|
- (BOOL)startWithError:(NSError * _Nullable *)error;
|
||||||
|
|
|
@ -22,20 +22,23 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
|
|
||||||
@interface UTMSpiceIO ()
|
@interface UTMSpiceIO ()
|
||||||
|
|
||||||
@property (nonatomic) NSURL *socketUrl;
|
@property (nonatomic, nullable) NSURL *socketUrl;
|
||||||
|
@property (nonatomic, nullable) NSString *host;
|
||||||
|
@property (nonatomic) NSInteger tlsPort;
|
||||||
|
@property (nonatomic, nullable) NSData *serverPublicKey;
|
||||||
|
@property (nonatomic, nullable) NSString *password;
|
||||||
@property (nonatomic) UTMSpiceIOOptions options;
|
@property (nonatomic) UTMSpiceIOOptions options;
|
||||||
@property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
|
@property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
|
||||||
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
|
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
|
||||||
@property (nonatomic, readwrite, nullable) CSInput *primaryInput;
|
@property (nonatomic, readwrite, nullable) CSInput *primaryInput;
|
||||||
@property (nonatomic, readwrite, nullable) CSPort *primarySerial;
|
@property (nonatomic, readwrite, nullable) CSPort *primarySerial;
|
||||||
@property (nonatomic) NSMutableArray<CSPort *> *mutableSerials;
|
@property (nonatomic) NSMutableArray<CSPort *> *mutableSerials;
|
||||||
#if !defined(WITH_QEMU_TCI)
|
#if defined(WITH_USB)
|
||||||
@property (nonatomic, readwrite, nullable) CSUSBManager *primaryUsbManager;
|
@property (nonatomic, readwrite, nullable) CSUSBManager *primaryUsbManager;
|
||||||
#endif
|
#endif
|
||||||
@property (nonatomic, nullable) CSConnection *spiceConnection;
|
@property (nonatomic, nullable) CSConnection *spiceConnection;
|
||||||
@property (nonatomic, nullable) CSMain *spice;
|
@property (nonatomic, nullable) CSMain *spice;
|
||||||
@property (nonatomic, nullable, copy) NSURL *sharedDirectory;
|
@property (nonatomic, nullable, copy) NSURL *sharedDirectory;
|
||||||
@property (nonatomic) NSInteger port;
|
|
||||||
@property (nonatomic) BOOL dynamicResolutionSupported;
|
@property (nonatomic) BOOL dynamicResolutionSupported;
|
||||||
@property (nonatomic, readwrite) BOOL isConnected;
|
@property (nonatomic, readwrite) BOOL isConnected;
|
||||||
|
|
||||||
|
@ -72,10 +75,29 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey password:(NSString *)password options:(UTMSpiceIOOptions)options {
|
||||||
|
if (self = [super init]) {
|
||||||
|
self.host = host;
|
||||||
|
self.tlsPort = tlsPort;
|
||||||
|
self.serverPublicKey = serverPublicKey;
|
||||||
|
self.password = password;
|
||||||
|
self.options = options;
|
||||||
|
self.mutableDisplays = [NSMutableArray array];
|
||||||
|
self.mutableSerials = [NSMutableArray array];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
- (void)initializeSpiceIfNeeded {
|
- (void)initializeSpiceIfNeeded {
|
||||||
if (!self.spiceConnection) {
|
if (!self.spiceConnection) {
|
||||||
NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
|
if (self.socketUrl) {
|
||||||
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
|
NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
|
||||||
|
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
|
||||||
|
} else {
|
||||||
|
self.spiceConnection = [[CSConnection alloc] initWithHost:self.host tlsPort:[@(self.tlsPort) stringValue] serverPublicKey:self.serverPublicKey];
|
||||||
|
self.spiceConnection.password = self.password;
|
||||||
|
}
|
||||||
self.spiceConnection.delegate = self;
|
self.spiceConnection.delegate = self;
|
||||||
self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
|
self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
|
||||||
self.spiceConnection.session.shareClipboard = (self.options & UTMSpiceIOOptionsHasClipboardSharing) == UTMSpiceIOOptionsHasClipboardSharing;
|
self.spiceConnection.session.shareClipboard = (self.options & UTMSpiceIOOptionsHasClipboardSharing) == UTMSpiceIOOptionsHasClipboardSharing;
|
||||||
|
@ -94,13 +116,15 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
}
|
}
|
||||||
// do not need to encode/decode audio locally
|
// do not need to encode/decode audio locally
|
||||||
g_setenv("SPICE_DISABLE_OPUS", "1", YES);
|
g_setenv("SPICE_DISABLE_OPUS", "1", YES);
|
||||||
// need to chdir to workaround AF_UNIX sun_len limitations
|
if (self.socketUrl) {
|
||||||
NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path;
|
// need to chdir to workaround AF_UNIX sun_len limitations
|
||||||
if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) {
|
NSString *curdir = self.socketUrl.URLByDeletingLastPathComponent.path;
|
||||||
if (error) {
|
if (!curdir || ![NSFileManager.defaultManager changeCurrentDirectoryPath:curdir]) {
|
||||||
*error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to change current directory.", "UTMSpiceIO")}];
|
if (error) {
|
||||||
|
*error = [NSError errorWithDomain:kUTMErrorDomain code:-1 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Failed to change current directory.", "UTMSpiceIO")}];
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
}
|
}
|
||||||
return NO;
|
|
||||||
}
|
}
|
||||||
if (![self.spice spiceStart]) {
|
if (![self.spice spiceStart]) {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -135,7 +159,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
self.primaryInput = nil;
|
self.primaryInput = nil;
|
||||||
self.primarySerial = nil;
|
self.primarySerial = nil;
|
||||||
[self.mutableSerials removeAllObjects];
|
[self.mutableSerials removeAllObjects];
|
||||||
#if !defined(WITH_QEMU_TCI)
|
#if defined(WITH_USB)
|
||||||
self.primaryUsbManager = nil;
|
self.primaryUsbManager = nil;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
@ -154,10 +178,13 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
- (void)spiceConnected:(CSConnection *)connection {
|
- (void)spiceConnected:(CSConnection *)connection {
|
||||||
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
||||||
self.isConnected = YES;
|
self.isConnected = YES;
|
||||||
#if !defined(WITH_QEMU_TCI)
|
#if defined(WITH_USB)
|
||||||
self.primaryUsbManager = connection.usbManager;
|
self.primaryUsbManager = connection.usbManager;
|
||||||
[self.delegate spiceDidChangeUsbManager:connection.usbManager];
|
[self.delegate spiceDidChangeUsbManager:connection.usbManager];
|
||||||
#endif
|
#endif
|
||||||
|
#if defined(WITH_REMOTE)
|
||||||
|
[self.connectDelegate remoteInterfaceDidConnect:self];
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)spiceInputAvailable:(CSConnection *)connection input:(CSInput *)input {
|
- (void)spiceInputAvailable:(CSConnection *)connection input:(CSInput *)input {
|
||||||
|
@ -177,12 +204,17 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
- (void)spiceDisconnected:(CSConnection *)connection {
|
- (void)spiceDisconnected:(CSConnection *)connection {
|
||||||
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
||||||
self.isConnected = NO;
|
self.isConnected = NO;
|
||||||
|
[self.delegate spiceDidDisconnect];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)spiceError:(CSConnection *)connection code:(CSConnectionError)code message:(nullable NSString *)message {
|
- (void)spiceError:(CSConnection *)connection code:(CSConnectionError)code message:(nullable NSString *)message {
|
||||||
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
||||||
self.isConnected = NO;
|
self.isConnected = NO;
|
||||||
|
#if defined(WITH_REMOTE)
|
||||||
|
[self.connectDelegate remoteInterface:self didErrorWithMessage:message];
|
||||||
|
#else
|
||||||
[self.connectDelegate qemuInterface:self didErrorWithMessage:message];
|
[self.connectDelegate qemuInterface:self didErrorWithMessage:message];
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)spiceDisplayCreated:(CSConnection *)connection display:(CSDisplay *)display {
|
- (void)spiceDisplayCreated:(CSConnection *)connection display:(CSDisplay *)display {
|
||||||
|
@ -202,6 +234,9 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
- (void)spiceDisplayDestroyed:(CSConnection *)connection display:(CSDisplay *)display {
|
- (void)spiceDisplayDestroyed:(CSConnection *)connection display:(CSDisplay *)display {
|
||||||
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
NSAssert(connection == self.spiceConnection, @"Unknown connection");
|
||||||
[self.mutableDisplays removeObject:display];
|
[self.mutableDisplays removeObject:display];
|
||||||
|
if (self.primaryDisplay == display) {
|
||||||
|
self.primaryDisplay = nil;
|
||||||
|
}
|
||||||
[self.delegate spiceDidDestroyDisplay:display];
|
[self.delegate spiceDidDestroyDisplay:display];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,12 +250,16 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
|
|
||||||
- (void)spiceForwardedPortOpened:(CSConnection *)connection port:(CSPort *)port {
|
- (void)spiceForwardedPortOpened:(CSConnection *)connection port:(CSPort *)port {
|
||||||
if ([port.name isEqualToString:@"org.qemu.monitor.qmp.0"]) {
|
if ([port.name isEqualToString:@"org.qemu.monitor.qmp.0"]) {
|
||||||
|
#if !defined(WITH_REMOTE)
|
||||||
UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
|
UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
|
||||||
[self.connectDelegate qemuInterface:self didCreateMonitorPort:qemuPort];
|
[self.connectDelegate qemuInterface:self didCreateMonitorPort:qemuPort];
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
|
if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
|
||||||
|
#if !defined(WITH_REMOTE)
|
||||||
UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
|
UTMQemuPort *qemuPort = [[UTMQemuPort alloc] initFrom:port];
|
||||||
[self.connectDelegate qemuInterface:self didCreateGuestAgentPort:qemuPort];
|
[self.connectDelegate qemuInterface:self didCreateGuestAgentPort:qemuPort];
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
if ([port.name isEqualToString:@"com.utmapp.terminal.0"]) {
|
if ([port.name isEqualToString:@"com.utmapp.terminal.0"]) {
|
||||||
self.primarySerial = port;
|
self.primarySerial = port;
|
||||||
|
@ -236,11 +275,11 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
}
|
}
|
||||||
if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
|
if ([port.name isEqualToString:@"org.qemu.guest_agent.0"]) {
|
||||||
}
|
}
|
||||||
if ([port.name isEqualToString:@"com.utmapp.terminal.0"]) {
|
|
||||||
self.primarySerial = port;
|
|
||||||
}
|
|
||||||
if ([port.name hasPrefix:@"com.utmapp.terminal."]) {
|
if ([port.name hasPrefix:@"com.utmapp.terminal."]) {
|
||||||
[self.mutableSerials removeObject:port];
|
[self.mutableSerials removeObject:port];
|
||||||
|
if (self.primarySerial == port) {
|
||||||
|
self.primarySerial = nil;
|
||||||
|
}
|
||||||
[self.delegate spiceDidDestroySerial:port];
|
[self.delegate spiceDidDestroySerial:port];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -285,7 +324,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
if (self.primarySerial) {
|
if (self.primarySerial) {
|
||||||
[self.delegate spiceDidCreateSerial:self.primarySerial];
|
[self.delegate spiceDidCreateSerial:self.primarySerial];
|
||||||
}
|
}
|
||||||
#if !defined(WITH_QEMU_TCI)
|
#if defined(WITH_USB)
|
||||||
if (self.primaryUsbManager) {
|
if (self.primaryUsbManager) {
|
||||||
[self.delegate spiceDidChangeUsbManager:self.primaryUsbManager];
|
[self.delegate spiceDidChangeUsbManager:self.primaryUsbManager];
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,12 +32,13 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
- (void)spiceDidUpdateDisplay:(CSDisplay *)display NS_SWIFT_NAME(spiceDidUpdateDisplay(_:));
|
- (void)spiceDidUpdateDisplay:(CSDisplay *)display NS_SWIFT_NAME(spiceDidUpdateDisplay(_:));
|
||||||
- (void)spiceDidCreateSerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidCreateSerial(_:));
|
- (void)spiceDidCreateSerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidCreateSerial(_:));
|
||||||
- (void)spiceDidDestroySerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidDestroySerial(_:));
|
- (void)spiceDidDestroySerial:(CSPort *)serial NS_SWIFT_NAME(spiceDidDestroySerial(_:));
|
||||||
#if !defined(WITH_QEMU_TCI)
|
#if defined(WITH_USB)
|
||||||
- (void)spiceDidChangeUsbManager:(nullable CSUSBManager *)usbManager NS_SWIFT_NAME(spiceDidChangeUsbManager(_:));
|
- (void)spiceDidChangeUsbManager:(nullable CSUSBManager *)usbManager NS_SWIFT_NAME(spiceDidChangeUsbManager(_:));
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@optional
|
@optional
|
||||||
- (void)spiceDynamicResolutionSupportDidChange:(BOOL)supported;
|
- (void)spiceDynamicResolutionSupportDidChange:(BOOL)supported;
|
||||||
|
- (void)spiceDidDisconnect;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
|
@ -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 kUTMBundleExtension = "utm"
|
||||||
private let kScreenshotPeriodSeconds = 60.0
|
private let kScreenshotPeriodSeconds = 60.0
|
||||||
private let kUTMBundleScreenshotFilename = "screenshot.png"
|
let kUTMBundleScreenshotFilename = "screenshot.png"
|
||||||
private let kUTMBundleViewFilename = "view.plist"
|
private let kUTMBundleViewFilename = "view.plist"
|
||||||
|
|
||||||
/// UTM virtual machine backend
|
/// UTM virtual machine backend
|
||||||
|
@ -66,8 +66,8 @@ protocol UTMVirtualMachine: AnyObject, Identifiable {
|
||||||
var state: UTMVirtualMachineState { get }
|
var state: UTMVirtualMachineState { get }
|
||||||
|
|
||||||
/// If non-null, is the most recent screenshot of the running VM
|
/// If non-null, is the most recent screenshot of the running VM
|
||||||
var screenshot: PlatformImage? { get }
|
var screenshot: UTMVirtualMachineScreenshot? { get }
|
||||||
|
|
||||||
/// If non-null, `saveSnapshot` and `restoreSnapshot` will not work due to the reason specified
|
/// If non-null, `saveSnapshot` and `restoreSnapshot` will not work due to the reason specified
|
||||||
var snapshotUnsupportedError: Error? { get }
|
var snapshotUnsupportedError: Error? { get }
|
||||||
|
|
||||||
|
@ -149,6 +149,9 @@ protocol UTMVirtualMachine: AnyObject, Identifiable {
|
||||||
/// Request a screenshot of the primary graphics device
|
/// Request a screenshot of the primary graphics device
|
||||||
/// - Returns: true if successful and the screenshot will be in `screenshot`
|
/// - Returns: true if successful and the screenshot will be in `screenshot`
|
||||||
@discardableResult func takeScreenshot() async -> Bool
|
@discardableResult func takeScreenshot() async -> Bool
|
||||||
|
|
||||||
|
/// If screenshot is modified externally, this must be called
|
||||||
|
func reloadScreenshotFromFile() throws
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supported capabilities for a UTM backend
|
/// Supported capabilities for a UTM backend
|
||||||
|
@ -167,6 +170,9 @@ protocol UTMVirtualMachineCapabilities {
|
||||||
|
|
||||||
/// The backend supports booting into recoveryOS.
|
/// The backend supports booting into recoveryOS.
|
||||||
var supportsRecoveryMode: Bool { get }
|
var supportsRecoveryMode: Bool { get }
|
||||||
|
|
||||||
|
/// The backend supports remote sessions.
|
||||||
|
var supportsRemoteSession: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delegate for UTMVirtualMachine events
|
/// Delegate for UTMVirtualMachine events
|
||||||
|
@ -201,7 +207,7 @@ protocol UTMVirtualMachineDelegate: AnyObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Virtual machine state
|
/// Virtual machine state
|
||||||
enum UTMVirtualMachineState {
|
enum UTMVirtualMachineState: Codable {
|
||||||
case stopped
|
case stopped
|
||||||
case starting
|
case starting
|
||||||
case started
|
case started
|
||||||
|
@ -214,17 +220,19 @@ enum UTMVirtualMachineState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Additional options for VM start
|
/// Additional options for VM start
|
||||||
struct UTMVirtualMachineStartOptions: OptionSet {
|
struct UTMVirtualMachineStartOptions: OptionSet, Codable {
|
||||||
let rawValue: UInt
|
let rawValue: UInt
|
||||||
|
|
||||||
/// Boot without persisting any changes.
|
/// Boot without persisting any changes.
|
||||||
static let bootDisposibleMode = Self(rawValue: 1 << 0)
|
static let bootDisposibleMode = Self(rawValue: 1 << 0)
|
||||||
/// Boot into recoveryOS (when supported).
|
/// Boot into recoveryOS (when supported).
|
||||||
static let bootRecovery = Self(rawValue: 1 << 1)
|
static let bootRecovery = Self(rawValue: 1 << 1)
|
||||||
|
/// Start VDI session where a remote client will connect to.
|
||||||
|
static let remoteSession = Self(rawValue: 1 << 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Method to stop the VM
|
/// Method to stop the VM
|
||||||
enum UTMVirtualMachineStopMethod {
|
enum UTMVirtualMachineStopMethod: Codable {
|
||||||
/// Sends a request to the guest to shut down gracefully.
|
/// Sends a request to the guest to shut down gracefully.
|
||||||
case request
|
case request
|
||||||
/// Sends a hardware power down signal.
|
/// Sends a hardware power down signal.
|
||||||
|
@ -282,6 +290,43 @@ extension UTMVirtualMachine {
|
||||||
|
|
||||||
// MARK: - Screenshot
|
// MARK: - Screenshot
|
||||||
|
|
||||||
|
struct UTMVirtualMachineScreenshot {
|
||||||
|
let image: PlatformImage
|
||||||
|
let pngData: Data?
|
||||||
|
|
||||||
|
init?(contentsOfURL url: URL) {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
guard let image = NSImage(contentsOf: url) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
guard let image = UIImage(contentsOfURL: url) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
self.image = image
|
||||||
|
self.pngData = Self.createData(from: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(wrapping image: PlatformImage) {
|
||||||
|
self.image = image
|
||||||
|
self.pngData = Self.createData(from: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createData(from image: PlatformImage) -> Data? {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
guard let cgref = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let newrep = NSBitmapImageRep(cgImage: cgref)
|
||||||
|
newrep.size = image.size
|
||||||
|
return newrep.representation(using: .png, properties: [:])
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
return image.pngData()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension UTMVirtualMachine {
|
extension UTMVirtualMachine {
|
||||||
private var isScreenshotSaveEnabled: Bool {
|
private var isScreenshotSaveEnabled: Bool {
|
||||||
!UserDefaults.standard.bool(forKey: "NoSaveScreenshot")
|
!UserDefaults.standard.bool(forKey: "NoSaveScreenshot")
|
||||||
|
@ -311,12 +356,8 @@ extension UTMVirtualMachine {
|
||||||
return timer
|
return timer
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadScreenshot() -> PlatformImage? {
|
func loadScreenshot() -> UTMVirtualMachineScreenshot? {
|
||||||
#if canImport(AppKit)
|
UTMVirtualMachineScreenshot(contentsOfURL: screenshotUrl)
|
||||||
return NSImage(contentsOf: screenshotUrl)
|
|
||||||
#elseif canImport(UIKit)
|
|
||||||
return UIImage(contentsOfURL: screenshotUrl)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveScreenshot() throws {
|
func saveScreenshot() throws {
|
||||||
|
@ -326,17 +367,7 @@ extension UTMVirtualMachine {
|
||||||
guard let screenshot = screenshot else {
|
guard let screenshot = screenshot else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#if canImport(AppKit)
|
try screenshot.pngData?.write(to: screenshotUrl)
|
||||||
guard let cgref = screenshot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let newrep = NSBitmapImageRep(cgImage: cgref)
|
|
||||||
newrep.size = screenshot.size
|
|
||||||
let pngdata = newrep.representation(using: .png, properties: [:])
|
|
||||||
try pngdata?.write(to: screenshotUrl)
|
|
||||||
#elseif canImport(UIKit)
|
|
||||||
try screenshot.pngData()?.write(to: screenshotUrl)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteScreenshot() throws {
|
func deleteScreenshot() throws {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -15,7 +15,16 @@
|
||||||
"location" : "https://github.com/utmapp/CocoaSpice.git",
|
"location" : "https://github.com/utmapp/CocoaSpice.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "visionos",
|
"branch" : "visionos",
|
||||||
"revision" : "4529c9686259e8d1e94d6253ad2e3a563fd1498d"
|
"revision" : "9fd682e0f78c884036609d4a19db2cfb3ed50c33"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "cod",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/saagarjha/Cod.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "c359a08accfb49662a17cdfc5e333c7b4e5c2c56"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -63,13 +72,31 @@
|
||||||
"version" : "1.5.3"
|
"version" : "1.5.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftconnect",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/utmapp/SwiftConnect",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "af855e47ca222da163cc7f4f185230f36ba8694a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftportmap",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/osy/SwiftPortmap.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "72782141ab6f6f6db58bd16bac96d4e7ce901e9a"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftterm",
|
"identity" : "swiftterm",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/osy/SwiftTerm.git",
|
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "visionos",
|
"branch" : "main",
|
||||||
"revision" : "8b0900a4c516eb8c87813f11e797f349e7fca014"
|
"revision" : "ea0f681b25c8385b4a5a48d435e61d11392216e0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -81,6 +108,15 @@
|
||||||
"version" : "1.0.3"
|
"version" : "1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "visionkeyboardkit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/utmapp/VisionKeyboardKit.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "0804e4d64267acc8d08fb23160f5b6ac6134414f"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "zipfoundation",
|
"identity" : "zipfoundation",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
|
@ -409,6 +409,8 @@ build_angle () {
|
||||||
pwd="$(pwd)"
|
pwd="$(pwd)"
|
||||||
cd "$BUILD_DIR/WebKit.git/Source/ThirdParty/ANGLE"
|
cd "$BUILD_DIR/WebKit.git/Source/ThirdParty/ANGLE"
|
||||||
xcodebuild archive -archivePath "ANGLE" -scheme "ANGLE" -sdk $SDK -arch $ARCH -configuration Release WEBCORE_LIBRARY_DIR="/usr/local/lib" IPHONEOS_DEPLOYMENT_TARGET="14.0" MACOSX_DEPLOYMENT_TARGET="11.0" XROS_DEPLOYMENT_TARGET="1.0"
|
xcodebuild archive -archivePath "ANGLE" -scheme "ANGLE" -sdk $SDK -arch $ARCH -configuration Release WEBCORE_LIBRARY_DIR="/usr/local/lib" IPHONEOS_DEPLOYMENT_TARGET="14.0" MACOSX_DEPLOYMENT_TARGET="11.0" XROS_DEPLOYMENT_TARGET="1.0"
|
||||||
|
# strip broken entitlements from signature
|
||||||
|
find "ANGLE.xcarchive/Products/usr/local/lib/" -name '*.dylib' -exec codesign -fs - \{\} \;
|
||||||
rsync -a "ANGLE.xcarchive/Products/usr/local/lib/" "$PREFIX/lib"
|
rsync -a "ANGLE.xcarchive/Products/usr/local/lib/" "$PREFIX/lib"
|
||||||
rsync -a "include/" "$PREFIX/include"
|
rsync -a "include/" "$PREFIX/include"
|
||||||
cd "$pwd"
|
cd "$pwd"
|
||||||
|
|
|
@ -7,11 +7,12 @@ command -v realpath >/dev/null 2>&1 || realpath() {
|
||||||
BASEDIR="$(dirname "$(realpath $0)")"
|
BASEDIR="$(dirname "$(realpath $0)")"
|
||||||
|
|
||||||
usage () {
|
usage () {
|
||||||
echo "Usage: $(basename $0) [-t teamid] [-p platform] [-a architecture] [-t targetversion] [-o output]"
|
echo "Usage: $(basename $0) [-t teamid] [-p platform] [-s scheme] [-a architecture] [-t targetversion] [-o output]"
|
||||||
echo ""
|
echo ""
|
||||||
echo " -t teamid Team Identifier for app groups. Optional for iOS. Required for macOS."
|
echo " -t teamid Team Identifier for app groups. Optional for iOS. Required for macOS."
|
||||||
echo " -p platform Target platform. Default ios. [ios|ios_simulator|ios-tci|ios_simulator-tci|macos|visionos|visionos_simulator]"
|
echo " -k sdk Target SDK. Default iphoneos. [iphoneos|iphonesimulator|xros|xrsimulator|macosx]"
|
||||||
echo " -a architecture Target architecture. Default arm64. [armv7|armv7s|arm64|i386|x86_64]"
|
echo " -s scheme Target scheme. Default iOS/macOS depending on platform. [iOS|iOS-TCI|iOS-Remote|macOS]"
|
||||||
|
echo " -a architecture Target architecture. Default arm64. [arm64|x86_64]"
|
||||||
echo " -o output Output archive path. Default is current directory."
|
echo " -o output Output archive path. Default is current directory."
|
||||||
echo ""
|
echo ""
|
||||||
exit 1
|
exit 1
|
||||||
|
@ -20,9 +21,8 @@ usage () {
|
||||||
PRODUCT_BUNDLE_PREFIX="com.utmapp"
|
PRODUCT_BUNDLE_PREFIX="com.utmapp"
|
||||||
TEAM_IDENTIFIER=
|
TEAM_IDENTIFIER=
|
||||||
ARCH=arm64
|
ARCH=arm64
|
||||||
PLATFORM=ios
|
|
||||||
OUTPUT=$PWD
|
OUTPUT=$PWD
|
||||||
SDK=
|
SDK=iphoneos
|
||||||
SCHEME=
|
SCHEME=
|
||||||
|
|
||||||
while [ "x$1" != "x" ]; do
|
while [ "x$1" != "x" ]; do
|
||||||
|
@ -35,8 +35,12 @@ while [ "x$1" != "x" ]; do
|
||||||
ARCH=$2
|
ARCH=$2
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-p )
|
-k )
|
||||||
PLATFORM=$2
|
SDK=$2
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-s )
|
||||||
|
SCHEME=$2
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-o )
|
-o )
|
||||||
|
@ -50,39 +54,14 @@ while [ "x$1" != "x" ]; do
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
case $PLATFORM in
|
case $SDK in
|
||||||
*-tci )
|
|
||||||
SCHEME="iOS-TCI"
|
|
||||||
;;
|
|
||||||
ios* | visionos* )
|
|
||||||
SCHEME="iOS"
|
|
||||||
;;
|
|
||||||
macos )
|
macos )
|
||||||
SCHEME="macOS"
|
SCHEME="macOS"
|
||||||
;;
|
;;
|
||||||
* )
|
* )
|
||||||
usage
|
if [ -z "$SCHEME" ]; then
|
||||||
;;
|
SCHEME="iOS"
|
||||||
esac
|
fi
|
||||||
|
|
||||||
case $PLATFORM in
|
|
||||||
visionos_simulator* )
|
|
||||||
SDK=xrsimulator
|
|
||||||
;;
|
|
||||||
visionos* )
|
|
||||||
SDK=xros
|
|
||||||
;;
|
|
||||||
ios_simulator* )
|
|
||||||
SDK=iphonesimulator
|
|
||||||
;;
|
|
||||||
ios* )
|
|
||||||
SDK=iphoneos
|
|
||||||
;;
|
|
||||||
macos )
|
|
||||||
SDK=macosx
|
|
||||||
;;
|
|
||||||
* )
|
|
||||||
usage
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
@ -94,8 +73,7 @@ fi
|
||||||
xcodebuild archive -archivePath "$OUTPUT" -scheme "$SCHEME" -sdk "$SDK" $ARCH_ARGS -configuration Release CODE_SIGNING_ALLOWED=NO $TEAM_IDENTIFIER_PREFIX
|
xcodebuild archive -archivePath "$OUTPUT" -scheme "$SCHEME" -sdk "$SDK" $ARCH_ARGS -configuration Release CODE_SIGNING_ALLOWED=NO $TEAM_IDENTIFIER_PREFIX
|
||||||
BUILT_PATH=$(find $OUTPUT.xcarchive -name '*.app' -type d | head -1)
|
BUILT_PATH=$(find $OUTPUT.xcarchive -name '*.app' -type d | head -1)
|
||||||
# Only retain the target architecture to address < iOS 15 crash & save disk space
|
# Only retain the target architecture to address < iOS 15 crash & save disk space
|
||||||
case $PLATFORM in
|
if [ "$SDK" == "iphoneos" ]; then
|
||||||
ios | ios-tci )
|
|
||||||
find "$BUILT_PATH" -type f -path '*/Frameworks/*.dylib' | while read FILE; do
|
find "$BUILT_PATH" -type f -path '*/Frameworks/*.dylib' | while read FILE; do
|
||||||
if [[ $(lipo -info "$FILE") =~ "Architectures in the fat file" ]]; then
|
if [[ $(lipo -info "$FILE") =~ "Architectures in the fat file" ]]; then
|
||||||
lipo -thin $ARCH "$FILE" -output "$FILE"
|
lipo -thin $ARCH "$FILE" -output "$FILE"
|
||||||
|
@ -107,10 +85,9 @@ ios | ios-tci )
|
||||||
lipo -thin $ARCH "$FILE" -output "$FILE"
|
lipo -thin $ARCH "$FILE" -output "$FILE"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
;;
|
fi
|
||||||
esac
|
|
||||||
find "$BUILT_PATH" -type d -path '*/Frameworks/*.framework' -exec codesign --force --sign - --timestamp=none \{\} \;
|
find "$BUILT_PATH" -type d -path '*/Frameworks/*.framework' -exec codesign --force --sign - --timestamp=none \{\} \;
|
||||||
if [ "$PLATFORM" == "macos" ]; then
|
if [ "$SDK" == "macosx" ]; then
|
||||||
# always build with vm entitlements, package_mac.sh can strip it later
|
# always build with vm entitlements, package_mac.sh can strip it later
|
||||||
# this way we can import into Xcode and re-sign from there
|
# this way we can import into Xcode and re-sign from there
|
||||||
UTM_ENTITLEMENTS="/tmp/utm.$$.entitlements"
|
UTM_ENTITLEMENTS="/tmp/utm.$$.entitlements"
|
||||||
|
|
|
@ -12,7 +12,8 @@ usage() {
|
||||||
echo " MODE is one of:"
|
echo " MODE is one of:"
|
||||||
echo " deb (Cydia DEB)"
|
echo " deb (Cydia DEB)"
|
||||||
echo " ipa (unsigned IPA of full build with all entitlements)"
|
echo " ipa (unsigned IPA of full build with all entitlements)"
|
||||||
echo " ipa-se (unsigned IPA of TCI build)"
|
echo " ipa-se (unsigned IPA of SE build)"
|
||||||
|
echo " ipa-remote (unsigned IPA of Remote build)"
|
||||||
echo " ipa-hv (unsigned IPA of full build without JIT entitlement)"
|
echo " ipa-hv (unsigned IPA of full build without JIT entitlement)"
|
||||||
echo " ipa-signed (developer signed IPA with valid PROFILE_NAME and TEAM_ID)"
|
echo " ipa-signed (developer signed IPA with valid PROFILE_NAME and TEAM_ID)"
|
||||||
echo " inputXcarchive is path to UTM.xcarchive"
|
echo " inputXcarchive is path to UTM.xcarchive"
|
||||||
|
@ -42,6 +43,11 @@ ipa-se )
|
||||||
BUNDLE_ID="com.utmapp.UTM-SE"
|
BUNDLE_ID="com.utmapp.UTM-SE"
|
||||||
INPUT_APP="$INPUT/Products/Applications/UTM SE.app"
|
INPUT_APP="$INPUT/Products/Applications/UTM SE.app"
|
||||||
;;
|
;;
|
||||||
|
ipa-remote )
|
||||||
|
NAME="UTM Remote"
|
||||||
|
BUNDLE_ID="com.utmapp.UTM-Remote"
|
||||||
|
INPUT_APP="$INPUT/Products/Applications/UTM Remote.app"
|
||||||
|
;;
|
||||||
* )
|
* )
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
|
@ -298,7 +304,7 @@ EOL
|
||||||
create_fake_ipa "$NAME" "$BUNDLE_ID" "$INPUT" "$OUTPUT" "$FAKEENT"
|
create_fake_ipa "$NAME" "$BUNDLE_ID" "$INPUT" "$OUTPUT" "$FAKEENT"
|
||||||
rm "$FAKEENT"
|
rm "$FAKEENT"
|
||||||
;;
|
;;
|
||||||
ipa-se )
|
ipa-se | ipa-remote )
|
||||||
FAKEENT="/tmp/fakeent.$$.plist"
|
FAKEENT="/tmp/fakeent.$$.plist"
|
||||||
cat >"$FAKEENT" <<EOL
|
cat >"$FAKEENT" <<EOL
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
Loading…
Reference in New Issue