Revert "Merge branch 'main' into show-main-window-macos"

This reverts commit ef97098631.
This commit is contained in:
Vincenzo Garambone 2024-02-27 19:02:24 +01:00
parent ef97098631
commit e955faba4f
115 changed files with 747 additions and 10788 deletions

View File

@ -23,7 +23,7 @@ on:
default: 'false' default: 'false'
env: env:
BUILD_XCODE_PATH: /Applications/Xcode_15.2.app BUILD_XCODE_PATH: /Applications/Xcode_15.1.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, ios_simulator-tci, macos, visionos, visionos_simulator, visionos-tci, visionos_simulator-tci] platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-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: ${{ endsWith(matrix.platform, '-tci') && '4' || '0' }} # limit 4 CPU for TCI build due to memory issues, 0 = unlimited for other builds NCPU: ${{ matrix.platform == 'ios-tci' && '2' || '0' }} # limit 2 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,16 +152,14 @@ jobs:
needs: [configuration, build-sysroot] needs: [configuration, build-sysroot]
strategy: strategy:
matrix: matrix:
configuration: [ arch: [arm64]
{arch: "arm64", sdk: "iphoneos", platform: "ios", scheme: "iOS"}, platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci]
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-SE"}, include:
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-Remote"}, # x86_64 supported only for macOS and simulators
{arch: "arm64", sdk: "xros", platform: "visionos", scheme: "iOS"}, - arch: x86_64
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-SE"}, platform: macos
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-Remote"}, - arch: x86_64
{arch: "arm64", sdk: "macosx", platform: "macos", scheme: "macOS"}, platform: ios_simulator
{arch: "x86_64", sdk: "macosx", platform: "macos", scheme: "macOS"},
]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -171,8 +169,8 @@ jobs:
id: cache-sysroot id: cache-sysroot
uses: osy/actions-cache@v3 uses: osy/actions-cache@v3
with: with:
path: sysroot-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }} path: sysroot-${{ matrix.platform }}-${{ matrix.arch }}
key: ${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }} key: ${{ matrix.platform }}-${{ matrix.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
@ -184,12 +182,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 -k ${{ matrix.configuration.sdk }} -s ${{ matrix.configuration.scheme }} -a ${{ matrix.configuration.arch }} -o UTM ./scripts/build_utm.sh -p ${{ matrix.platform }} -a ${{ matrix.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.configuration.scheme }}-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }} name: UTM-${{ matrix.platform }}-${{ matrix.arch }}
path: UTM.xcarchive.tgz path: UTM.xcarchive.tgz
build-universal: build-universal:
name: Build UTM (Universal Mac) name: Build UTM (Universal Mac)
@ -217,7 +215,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" -k macosx -s macOS -a "arm64 x86_64" -o UTM ./scripts/build_utm.sh -t "$SIGNING_TEAM_ID" -p 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 }}
@ -233,14 +231,12 @@ jobs:
strategy: strategy:
matrix: matrix:
configuration: [ configuration: [
{platform: "ios", scheme: "iOS", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"}, {platform: "ios", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
{platform: "ios-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"}, {platform: "ios-tci", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
{platform: "ios", scheme: "iOS", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"}, {platform: "ios", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
{platform: "ios", scheme: "iOS", mode: "deb", name: "UTM.deb", path: "UTM.deb"}, {platform: "ios", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
{platform: "visionos", scheme: "iOS", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"}, {platform: "visionos", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
{platform: "visionos-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"}, {platform: "visionos-tci", 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:
@ -249,7 +245,7 @@ jobs:
- name: Download Artifact - name: Download Artifact
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-arm64 name: UTM-${{ 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
@ -319,9 +315,7 @@ jobs:
LAUNCHER_PROFILE_DATA: ${{ vars.LAUNCHER_PROFILE_DATA }} LAUNCHER_PROFILE_DATA: ${{ vars.LAUNCHER_PROFILE_DATA }}
LAUNCHER_PROFILE_UUID: ${{ vars.LAUNCHER_PROFILE_UUID }} LAUNCHER_PROFILE_UUID: ${{ vars.LAUNCHER_PROFILE_UUID }}
- name: Install appdmg - name: Install appdmg
run: | run: npm install -g appdmg
python3 -m pip install setuptools
npm install -g appdmg
- name: Download Artifact - name: Download Artifact
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:

View File

@ -17,8 +17,8 @@
// Configuration settings file format documentation can be found at: // Configuration settings file format documentation can be found at:
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 4.4.5 MARKETING_VERSION = 4.4.4
CURRENT_PROJECT_VERSION = 94 CURRENT_PROJECT_VERSION = 92
// Codesigning settings defined optionally, see Documentation/iOSDevelopment.md // Codesigning settings defined optionally, see Documentation/iOSDevelopment.md
#include? "CodeSigning.xcconfig" #include? "CodeSigning.xcconfig"

View File

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

View File

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

View File

@ -15,6 +15,7 @@
// //
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 {
@ -100,17 +101,13 @@ extension UTMConfigurationDrive {
try handle.close() try handle.close()
}.value }.value
} }
private func createQcow2Image(at newURL: URL, size sizeMib: Int) async throws { private func createQcow2Image(at newURL: URL, size sizeMib: Int) async throws {
#if WITH_REMOTE
fatalError("Not implemented")
#else
try await Task.detached { try await Task.detached {
if !QEMUGenerateDefaultQcow2File(newURL as CFURL, sizeMib) { if !QEMUGenerateDefaultQcow2File(newURL as CFURL, sizeMib) {
throw UTMConfigurationError.cannotCreateDiskImage throw UTMConfigurationError.cannotCreateDiskImage
} }
}.value }.value
#endif
} }
#if os(macOS) #if os(macOS)

View File

@ -61,26 +61,6 @@ 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
@ -129,48 +109,16 @@ import Virtualization // for getting network interfaces
@QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] { @QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
f("-spice") f("-spice")
if let port = qemu.spiceServerPort { "unix=on"
if qemu.isSpiceServerTlsEnabled { "addr=\(spiceSocketURL.lastPathComponent)"
"tls-port=\(port)" "disable-ticketing=on"
"tls-channel=default" "image-compression=off"
"x509-key-file=" "playback-compression=off"
spiceTlsKeyUrl "streaming-video=off"
"x509-cert-file=" "gl=\(isGLOn ? "on" : "off")"
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")
if isRemoteSpice { f("spiceport,id=org.qemu.monitor.qmp,name=org.qemu.monitor.qmp.0")
"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
@ -180,28 +128,8 @@ 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")
@ -215,7 +143,7 @@ import Virtualization // for getting network interfaces
} else { } else {
for display in displays { for display in displays {
f("-device") f("-device")
filterDisplayIfRemote(display.hardware) display.hardware
if let vgaRamSize = displays[0].vgaRamMib { if let vgaRamSize = displays[0].vgaRamMib {
"vgamem_mb=\(vgaRamSize)" "vgamem_mb=\(vgaRamSize)"
} }
@ -224,7 +152,7 @@ import Virtualization // for getting network interfaces
} }
} }
private var isGLSupported: Bool { private var isGLOn: 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")
} }
@ -233,11 +161,7 @@ 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")
@ -394,9 +318,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_JIT #if !WITH_QEMU_TCI
// use mirror mapping when we don't have JIT entitlements // use mirror mapping when we don't have JIT entitlements
if !UTMCapabilities.current.contains(.hasJitEntitlements) { if !jb_has_jit_entitlement() {
"split-wx=on" "split-wx=on"
} }
#endif #endif
@ -509,10 +433,6 @@ 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" }) {
@ -751,7 +671,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_USB #if !WITH_QEMU_TCI
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 {
@ -939,16 +859,7 @@ 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")
if isRemoteSpice { f("spiceport,id=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
"pipe"
"path="
guestAgentPipeURL
} else {
"spiceport"
"name=org.qemu.guest_agent.0"
}
"id=org.qemu.guest_agent"
f()
} }
if isSpiceAgentUsed { if isSpiceAgentUsed {
f("-device") f("-device")

View File

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

View File

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

View File

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

View File

@ -20,11 +20,8 @@ import UniformTypeIdentifiers
import IQKeyboardManagerSwift import IQKeyboardManagerSwift
#endif #endif
// on visionOS, there is no text to show more than UTM #if WITH_QEMU_TCI
#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
@ -36,8 +33,7 @@ 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()))
@ -71,11 +67,6 @@ 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()
@ -87,7 +78,7 @@ struct ContentView: View {
#if !os(visionOS) #if !os(visionOS)
IQKeyboardManager.shared.enable = true IQKeyboardManager.shared.enable = true
#endif #endif
#if WITH_JIT #if !WITH_QEMU_TCI
if !Main.jitAvailable { if !Main.jitAvailable {
data.busyWorkAsync { data.busyWorkAsync {
let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach") let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach")
@ -104,7 +95,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 !UTMCapabilities.current.contains(.hasHypervisorSupport) { if !jb_has_hypervisor() {
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")
} }
} }
@ -172,7 +163,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? (any UTMSpiceVirtualMachine) { if let vm = vm.wrapped as? UTMQemuVirtualMachine {
shouldSaveOnPause = !vm.isRunningAsDisposible shouldSaveOnPause = !vm.isRunningAsDisposible
} else { } else {
shouldSaveOnPause = true shouldSaveOnPause = true

View File

@ -1,111 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import UniformTypeIdentifiers
struct MacDeviceLabel<Title>: View where Title : StringProtocol {
let title: Title
let device: MacDevice
init(_ title: Title, device macDevice: MacDevice) {
self.title = title
self.device = macDevice
}
var body: some View {
Label(title, systemImage: device.symbolName)
}
}
// credits: https://adamdemasi.com/2023/04/15/mac-device-icon-by-device-class.html
private extension UTTagClass {
static let deviceModelCode = UTTagClass(rawValue: "com.apple.device-model-code")
}
private extension UTType {
static let macBook = UTType("com.apple.mac.laptop")
static let macBookWithNotch = UTType("com.apple.mac.notched-laptop")
static let macMini = UTType("com.apple.macmini")
static let macStudio = UTType("com.apple.macstudio")
static let iMac = UTType("com.apple.imac")
static let macPro = UTType("com.apple.macpro")
static let macPro2013 = UTType("com.apple.macpro-cylinder")
static let macPro2019 = UTType("com.apple.macpro-2019")
}
struct MacDevice {
let model: String
let symbolName: String
#if os(macOS)
static let current: Self = {
let key = "hw.model"
var size = size_t()
sysctlbyname(key, nil, &size, nil, 0)
let value = malloc(size)
defer {
value?.deallocate()
}
sysctlbyname(key, value, &size, nil, 0)
guard let cChar = value?.bindMemory(to: CChar.self, capacity: size) else {
return Self(model: "Unknown")
}
return Self(model: String(cString: cChar))
}()
#endif
init(model: String?) {
self.model = model ?? "Unknown"
self.symbolName = Self.symbolName(from: self.model)
}
private static func checkModel(_ model: String, conformsTo type: UTType?) -> Bool {
guard let type else {
return false
}
return UTType(tag: model, tagClass: .deviceModelCode, conformingTo: nil)?.conforms(to: type) ?? false
}
private static func symbolName(from model: String) -> String {
if checkModel(model, conformsTo: .macBookWithNotch),
#available(macOS 14, iOS 17, macCatalyst 17, tvOS 17, watchOS 10, *) {
// macbook.gen2 was added with SF Symbols 5.0 (macOS Sonoma, 2023), but MacBooks with a notch
// were released in 2021!
return "macbook.gen2"
} else if checkModel(model, conformsTo: .macBook) {
return "laptopcomputer"
} else if checkModel(model, conformsTo: .macMini) {
return "macmini"
} else if checkModel(model, conformsTo: .macStudio) {
return "macstudio"
} else if checkModel(model, conformsTo: .iMac) {
return "desktopcomputer"
} else if checkModel(model, conformsTo: .macPro2019) {
return "macpro.gen3"
} else if checkModel(model, conformsTo: .macPro2013) {
return "macpro.gen2"
} else if checkModel(model, conformsTo: .macPro) {
return "macpro"
}
return "display"
}
}
#Preview {
MacDeviceLabel("MacBook", device: MacDevice(model: "Mac14,6"))
}

View File

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

View File

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

View File

@ -21,7 +21,6 @@ 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…")
@ -30,7 +29,6 @@ struct VMCommands: Commands {
Text("Open…") Text("Open…")
}).keyboardShortcut(KeyEquivalent("o")) }).keyboardShortcut(KeyEquivalent("o"))
} }
#endif
SidebarCommands() SidebarCommands()
ToolbarCommands() ToolbarCommands()
CommandGroup(replacing: .windowList, addition: { CommandGroup(replacing: .windowList, addition: {

View File

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

View File

@ -101,7 +101,7 @@ struct VMConfigSystemView: View {
} }
#endif #endif
let actualJitSizeMib = jitSizeMib == 0 ? memorySizeMib / 4 : jitSizeMib let actualJitSizeMib = jitSizeMib == 0 ? memorySizeMib / 4 : jitSizeMib
let jitMirrorMultiplier = UTMCapabilities.current.contains(.hasJitEntitlements) ? 1 : 2; let jitMirrorMultiplier = jb_has_jit_entitlement() ? 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 = ConcreteVirtualMachine.isSupported(systemArchitecture: newValue) isArchitectureSupported = UTMQemuVirtualMachine.isSupported(systemArchitecture: newValue)
if newValue != architecture { if newValue != architecture {
architecture = newValue architecture = newValue
} }

View File

@ -61,7 +61,6 @@ 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)
@ -69,7 +68,6 @@ 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
@ -101,7 +99,7 @@ struct VMContextMenuModifier: ViewModifier {
} }
#endif #endif
if let _ = vm.config as? UTMQemuConfiguration { if let _ = vm.wrapped as? UTMQemuVirtualMachine {
Button { Button {
data.run(vm: vm, options: .bootDisposibleMode) data.run(vm: vm, options: .bootDisposibleMode)
} label: { } label: {
@ -122,7 +120,6 @@ struct VMContextMenuModifier: ViewModifier {
Divider() Divider()
} }
#if !WITH_REMOTE // FIXME: implement remote feature
Button { Button {
shareItem = .utmCopy(vm) shareItem = .utmCopy(vm)
showSharePopup.toggle() showSharePopup.toggle()
@ -167,7 +164,6 @@ 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) {
@ -179,7 +175,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!) try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)
} }
} }
} }

View File

@ -29,10 +29,9 @@ 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)
} }
@ -71,8 +70,8 @@ struct VMDetailsView: View {
.padding([.leading, .trailing, .bottom]) .padding([.leading, .trailing, .bottom])
} }
#else #else
let qemuConfig = vm.config as! UTMQemuConfiguration let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: vm, config: qemuConfig) VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom]) .padding([.leading, .trailing, .bottom])
#endif #endif
} else { } else {
@ -90,8 +89,8 @@ struct VMDetailsView: View {
VMRemovableDrivesView(vm: vm, config: qemuVM.config) VMRemovableDrivesView(vm: vm, config: qemuVM.config)
} }
#else #else
let qemuConfig = vm.config as! UTMQemuConfiguration let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: vm, config: qemuConfig) VMRemovableDrivesView(vm: vm, config: qemuVM.config)
#endif #endif
}.padding([.leading, .trailing, .bottom]) }.padding([.leading, .trailing, .bottom])
} }
@ -110,16 +109,6 @@ 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
}
}
} }
} }
} }
@ -162,7 +151,7 @@ struct Screenshot: View {
.blendMode(.hardLight) .blendMode(.hardLight)
#if os(visionOS) #if os(visionOS)
.overlay { .overlay {
if vm.isStopped || vm.isTakeoverAllowed { if vm.isStopped {
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.resizable() .resizable()
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
@ -175,7 +164,7 @@ struct Screenshot: View {
#endif #endif
if vm.isBusy { if vm.isBusy {
Spinner(size: .large) Spinner(size: .large)
} else if vm.isStopped || vm.isTakeoverAllowed { } else if vm.isStopped {
#if !os(visionOS) #if !os(visionOS)
Button(action: { data.run(vm: vm) }, label: { Button(action: { data.run(vm: vm) }, label: {
Label("Run", systemImage: "play.circle.fill") Label("Run", systemImage: "play.circle.fill")

View File

@ -66,10 +66,8 @@ 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
@ -121,12 +119,10 @@ private struct VMListModifier: ViewModifier {
newButton newButton
} }
#else #else
#if !WITH_REMOTE // FIXME: implement remote feature
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
newButton newButton
} }
#endif #if !os(visionOS)
#if !os(visionOS) && !WITH_REMOTE
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Settings") { Button("Settings") {
settingsPresented.toggle() settingsPresented.toggle()
@ -144,9 +140,7 @@ private struct VMListModifier: ViewModifier {
if data.showNewVMSheet { if data.showNewVMSheet {
VMWizardView() VMWizardView()
} else if settingsPresented { } else if settingsPresented {
#if !WITH_REMOTE
UTMSettingsView() UTMSettingsView()
#endif
} }
} }
.onChange(of: data.showNewVMSheet) { newValue in .onChange(of: data.showNewVMSheet) { newValue in

View File

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

View File

@ -26,8 +26,8 @@ struct VMRemovableDrivesView: View {
@State private var workaroundFileImporterBug: Bool = false @State private var workaroundFileImporterBug: Bool = false
@State private var currentDrive: UTMQemuConfigurationDrive? @State private var currentDrive: UTMQemuConfigurationDrive?
private var qemuVM: (any UTMSpiceVirtualMachine)! { private var qemuVM: UTMQemuVirtualMachine! {
vm.wrapped as? any UTMSpiceVirtualMachine vm.wrapped as? UTMQemuVirtualMachine
} }
var fileManager: FileManager { var fileManager: FileManager {
@ -78,7 +78,6 @@ 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
@ -119,9 +118,6 @@ struct VMRemovableDrivesView: View {
} label: { } label: {
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil) DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
}.disabled(vm.hasSuspendState) }.disabled(vm.hasSuspendState)
#else
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
#endif
Spacer() Spacer()
// Disk image path, or (empty) // Disk image path, or (empty)
Text(pathFor(drive)) Text(pathFor(drive))

View File

@ -51,7 +51,6 @@ 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
@ -113,7 +112,6 @@ struct VMToolbarModifier: ViewModifier {
Spacer() Spacer()
} }
#endif #endif
#endif
if vm.hasSuspendState || !vm.isStopped { if vm.hasSuspendState || !vm.isStopped {
Button { Button {
confirmAction = .confirmStopVM confirmAction = .confirmStopVM
@ -131,7 +129,6 @@ 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()
@ -146,7 +143,6 @@ struct VMToolbarModifier: ViewModifier {
}.help("Edit selected VM") }.help("Edit selected VM")
.disabled(vm.hasSuspendState || !vm.isModifyAllowed) .disabled(vm.hasSuspendState || !vm.isModifyAllowed)
.padding(.leading, padding) .padding(.leading, padding)
#endif
} }
} }
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem)) .modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))

View File

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

View File

@ -21,19 +21,9 @@ import AppKit
import UIKit import UIKit
import SwiftUI import SwiftUI
#endif #endif
#if canImport(AltKit) && WITH_JIT #if canImport(AltKit) && !WITH_QEMU_TCI
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
@ -98,18 +88,7 @@ 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
@ -121,10 +100,6 @@ 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()
} }
@ -158,7 +133,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 ConcreteVirtualMachine.isVirtualMachine(url: file) else { guard UTMQemuVirtualMachine.isVirtualMachine(url: file) else {
continue continue
} }
await Task.yield() await Task.yield()
@ -193,7 +168,7 @@ struct AlertMessage: Identifiable {
} }
/// Load VM list (and order) from persistent storage /// Load VM list (and order) from persistent storage
fileprivate func listLoadFromDefaults() { private 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()
@ -211,7 +186,7 @@ struct AlertMessage: Identifiable {
guard let list = defaults.stringArray(forKey: "VMEntryList") else { guard let list = defaults.stringArray(forKey: "VMEntryList") else {
return return
} }
let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in virtualMachines = 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
} }
@ -223,7 +198,6 @@ 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)
@ -231,7 +205,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] {
let virtualMachines = files.uniqued().compactMap({ file in 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
@ -239,11 +213,10 @@ 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") {
let virtualMachines = list.compactMap { item in 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)
@ -255,7 +228,6 @@ struct AlertMessage: Identifiable {
try? vm?.load() try? vm?.load()
return vm return vm
} }
listReplace(with: virtualMachines)
} }
} }
@ -266,15 +238,8 @@ struct AlertMessage: Identifiable {
defaults.set(wrappedVMs, forKey: "VMEntryList") defaults.set(wrappedVMs, forKey: "VMEntryList")
} }
/// Replace current VM list with a new list private func listReplace(with vms: [VMData]) {
/// - 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
@ -289,7 +254,6 @@ struct AlertMessage: Identifiable {
} else { } else {
virtualMachines.append(vm) virtualMachines.append(vm)
} }
beginObservingChanges(for: vm)
} }
/// Select VM in list /// Select VM in list
@ -303,7 +267,6 @@ 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)
} }
@ -353,7 +316,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 = ConcreteVirtualMachine.virtualMachinePath(for: name, in: documentsURL) let file = UTMQemuVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
if !fileManager.fileExists(atPath: file.path) { if !fileManager.fileExists(atPath: file.path) {
return name return name
} }
@ -420,13 +383,6 @@ 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
@ -494,8 +450,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 = ConcreteVirtualMachine.virtualMachinePath(for: newName, in: documentsURL) let newPath = UTMQemuVirtualMachine.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
@ -576,7 +532,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) async -> Int64 { func computeSize(for vm: VMData) -> 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)")
@ -660,7 +616,7 @@ struct AlertMessage: Identifiable {
listSelect(vm: vm) listSelect(vm: vm)
} }
private func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws { 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 {
@ -721,10 +677,7 @@ struct AlertMessage: Identifiable {
} }
} }
func mountSupportTools(for vm: any UTMVirtualMachine) async throws { func mountSupportTools(for vm: UTMQemuVirtualMachine) 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
@ -803,60 +756,7 @@ 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
@ -890,20 +790,16 @@ 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
@discardableResult func busyWorkAsync(_ work: @escaping @Sendable () async throws -> Void) {
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 {
let result = try await work() 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)
} }
} }
@ -928,7 +824,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? any UTMSpiceVirtualMachine else { return } // FIXME: implement for Apple VM guard let qemuVm = vm.wrapped as? UTMQemuVirtualMachine 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
@ -972,7 +868,7 @@ struct AlertMessage: Identifiable {
// MARK: - AltKit // MARK: - AltKit
#if canImport(AltKit) && WITH_JIT #if canImport(AltKit) && !WITH_QEMU_TCI
/// 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 {
@ -1072,8 +968,6 @@ 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
@ -1083,8 +977,6 @@ 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 {
@ -1092,10 +984,6 @@ 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:
@ -1114,239 +1002,6 @@ extension UTMDataError: LocalizedError {
return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData") return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
case .jitStreamerUrlInvalid(let urlString): case .jitStreamerUrlInvalid(let urlString):
return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString) return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
case .notImplemented:
return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
case .reconnectFailed:
return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
} }
} }
} }
// MARK: - Remote Client
/// Declare host capabilities to any remote client
struct UTMCapabilities: OptionSet, Codable {
let rawValue: UInt
/// If set, no trick is needed to get JIT working as the process is entitled.
static let hasJitEntitlements = Self(rawValue: 1 << 0)
/// If set, virtualization is supported by this host.
static let hasHypervisorSupport = Self(rawValue: 1 << 1)
/// If set, host is aarch64
static let isAarch64 = Self(rawValue: 1 << 2)
/// If set, host is x86_64
static let isX86_64 = Self(rawValue: 1 << 3)
static fileprivate(set) var current: Self = {
var current = Self()
#if WITH_JIT
if jb_has_jit_entitlement() {
current.insert(.hasJitEntitlements)
}
if jb_has_hypervisor() {
current.insert(.hasHypervisorSupport)
}
#endif
#if arch(arm64)
current.insert(.isAarch64)
#endif
#if arch(x86_64)
current.insert(.isX86_64)
#endif
return current
}()
}
#if WITH_REMOTE
private let kReconnectTimeoutSeconds: UInt64 = 5
@MainActor
class UTMRemoteData: UTMData {
/// Remote access client
private(set) var remoteClient: UTMRemoteClient!
override init() {
super.init()
self.remoteClient = UTMRemoteClient(data: self)
}
override func listLoadFromDefaults() {
// do nothing since we do not load from VMList
}
override func listRefresh() async {
busyWorkAsync {
try await self.listRefreshFromRemote()
}
}
func reconnect(to server: UTMRemoteClient.State.SavedServer) async throws {
var reconnectTask: Task<UTMRemoteClient.Remote, any Error>?
let timeoutTask = Task {
try await Task.sleep(nanoseconds: kReconnectTimeoutSeconds * NSEC_PER_SEC)
reconnectTask?.cancel()
}
reconnectTask = busyWorkAsync { [self] in
do {
try await remoteClient.connect(server)
} catch is CancellationError {
throw UTMDataError.reconnectFailed
}
timeoutTask.cancel()
try await listRefreshFromRemote()
return await remoteClient.server
}
// make all active sessions wait on the reconnect
for session in VMSessionState.allActiveSessions.values {
let vm = session.vm as! UTMRemoteSpiceVirtualMachine
Task {
do {
try await vm.reconnectServer {
try await reconnectTask!.value
}
} catch {
session.stop()
}
}
}
_ = try await reconnectTask!.value
}
private func listRefreshFromRemote() async throws {
if let capabilities = await self.remoteClient.server.capabilities {
UTMCapabilities.current = capabilities
}
let ids = try await remoteClient.server.listVirtualMachines()
let items = try await remoteClient.server.getVirtualMachineInformation(for: ids)
let openSessionVms = VMSessionState.allActiveSessions.values.map({ $0.vm })
let vms = items.map { item in
let wrapped = openSessionVms.first(where: { $0.id == item.id }) as? UTMRemoteSpiceVirtualMachine
return VMRemoteData(fromRemoteItem: item, existingWrapped: wrapped)
}
await loadVirtualMachines(vms)
}
private func loadVirtualMachines(_ vms: [VMData]) async {
listReplace(with: vms)
for vm in vms {
let remoteVM = vm as! VMRemoteData
if remoteVM.isLoaded {
continue
}
do {
try await remoteVM.load(withRemoteServer: remoteClient.server)
} catch {
remoteVM.unavailableReason = error.localizedDescription
}
await Task.yield()
}
}
func remoteListHasChanged(ids: [UUID]) async {
var existing = virtualMachines.reduce(into: [:]) { partialResult, vm in
partialResult[vm.id] = vm
}
let new = ids.compactMap { id in
if existing[id] == nil {
return id
} else {
return nil
}
}
if !new.isEmpty, let newItems = try? await remoteClient.server.getVirtualMachineInformation(for: new) {
newItems.map({ VMRemoteData(fromRemoteItem: $0) }).forEach { vm in
existing[vm.id] = vm
}
}
let vms = ids.compactMap({ existing[$0] })
await loadVirtualMachines(vms)
}
func remoteQemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async {
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
return
}
await vm.reloadConfiguration(withRemoteServer: remoteClient.server, config: configuration)
}
func remoteMountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async {
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
return
}
vm.updateMountedDrives(mountedDrives)
}
func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async {
guard let vm = virtualMachines.first(where: { $0.id == id }) else {
return
}
let remoteVM = vm as! VMRemoteData
let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine
remoteVM.isTakeoverAllowed = isTakeoverAllowed
await wrapped.updateRemoteState(state)
}
func remoteVirtualMachineDidError(id: UUID, message: String) async {
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == id }) {
session.nonfatalError = message
}
}
override func listMove(fromOffsets: IndexSet, toOffset: Int) {
let ids = fromOffsets.map({ virtualMachines[$0].id })
Task {
try await remoteClient.server.reorderVirtualMachines(fromIds: ids, toOffset: toOffset)
}
super.listMove(fromOffsets: fromOffsets, toOffset: toOffset)
}
override func save(vm: VMData) async throws {
throw UTMDataError.notImplemented
}
override func discardChanges(for vm: VMData) throws {
throw UTMDataError.notImplemented
}
override func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
throw UTMDataError.notImplemented
}
@discardableResult
override func delete(vm: VMData, alsoRegistry: Bool) async throws -> Int? {
throw UTMDataError.notImplemented
}
@discardableResult
override func clone(vm: VMData) async throws -> VMData {
throw UTMDataError.notImplemented
}
override func export(vm: VMData, to url: URL) async throws {
throw UTMDataError.notImplemented
}
override func move(vm: VMData, to url: URL) async throws {
throw UTMDataError.notImplemented
}
override func template(vm: VMData) async throws {
throw UTMDataError.notImplemented
}
override func computeSize(for vm: VMData) async -> Int64 {
(try? await remoteClient.server.getPackageSize(for: vm.id)) ?? 0
}
override func importUTM(from url: URL, asShortcut: Bool) async throws {
throw UTMDataError.notImplemented
}
override func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
try await remoteClient.server.mountGuestToolsOnVirtualMachine(id: vm.id)
}
}
#endif

View File

@ -18,8 +18,8 @@ import Foundation
/// Downloads support tools ISO /// Downloads support tools ISO
class UTMDownloadSupportToolsTask: UTMDownloadTask { class UTMDownloadSupportToolsTask: UTMDownloadTask {
private let vm: any UTMSpiceVirtualMachine private let vm: UTMQemuVirtualMachine
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: any UTMSpiceVirtualMachine) { init(for vm: UTMQemuVirtualMachine) {
self.vm = vm self.vm = vm
let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask") let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask")
super.init(for: Self.supportToolsDownloadUrl, named: name) super.init(for: Self.supportToolsDownloadUrl, named: name)

View File

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

View File

@ -20,7 +20,7 @@ import SwiftUI
/// Model wrapping a single UTMVirtualMachine for use in views /// Model wrapping a single UTMVirtualMachine for use in views
@MainActor class VMData: ObservableObject { @MainActor class VMData: ObservableObject {
/// Underlying virtual machine /// Underlying virtual machine
fileprivate(set) var wrapped: (any UTMVirtualMachine)? { private(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
fileprivate var registryEntryWrapped: UTMRegistryEntry? private 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,22 +67,14 @@ import SwiftUI
@Published var state: UTMVirtualMachineState = .stopped @Published var state: UTMVirtualMachineState = .stopped
/// Copy from wrapped VM /// Copy from wrapped VM
@Published var screenshot: UTMVirtualMachineScreenshot? @Published var screenshot: PlatformImage?
/// 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
fileprivate init() { private init() {
} }
/// Create a VM from an existing object /// Create a VM from an existing object
@ -137,11 +129,9 @@ 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)
@ -170,11 +160,9 @@ 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))
@ -207,7 +195,7 @@ import SwiftUI
} }
/// Listen to changes in the underlying object and propogate upwards /// Listen to changes in the underlying object and propogate upwards
fileprivate func subscribeToChildren() { private 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
@ -217,12 +205,10 @@ import SwiftUI
} }
} }
wrapped.onStateChange = { [weak self, weak wrapped] in wrapped.onStateChange = { [weak self] in
Task { @MainActor in Task { @MainActor in
if let wrapped = wrapped { self?.state = wrapped.state
self?.state = wrapped.state self?.screenshot = wrapped.screenshot
self?.screenshot = wrapped.screenshot
}
} }
} }
} }
@ -295,6 +281,11 @@ 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
@ -431,98 +422,6 @@ 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?.image wrapped?.screenshot
} }
} }
#if WITH_REMOTE
@MainActor
class VMRemoteData: VMData {
private var backend: UTMBackend
private var _isShortcut: Bool
override var isShortcut: Bool {
_isShortcut
}
private var initialState: UTMVirtualMachineState
private var existingWrapped: UTMRemoteSpiceVirtualMachine?
/// Set by caller when VM is unavailable and there is a reason for it.
@Published var unavailableReason: String?
init(fromRemoteItem item: UTMRemoteMessageServer.VirtualMachineInformation, existingWrapped: UTMRemoteSpiceVirtualMachine? = nil) {
self.backend = item.backend
self._isShortcut = item.isShortcut
self.initialState = item.state
self.existingWrapped = existingWrapped
super.init()
self.isTakeoverAllowed = item.isTakeoverAllowed
self.registryEntryWrapped = UTMRegistry.shared.entry(uuid: item.id, name: item.name, path: item.path)
self.registryEntryWrapped!.isSuspended = item.isSuspended
self.registryEntryWrapped!.externalDrives = item.mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
}
override func load() throws {
throw VMRemoteDataError.notImplemented
}
func load(withRemoteServer server: UTMRemoteClient.Remote) async throws {
guard backend == .qemu else {
throw VMRemoteDataError.backendNotSupported
}
let entry = registryEntryWrapped!
let config = try await server.getQEMUConfiguration(for: entry.uuid)
await loadCustomIcon(withRemoteServer: server, id: entry.uuid, config: config)
let vm: UTMRemoteSpiceVirtualMachine
if let existingWrapped = existingWrapped {
vm = existingWrapped
wrapped = vm
self.existingWrapped = nil
await reloadConfiguration(withRemoteServer: server, config: config)
vm.updateRegistry(entry)
} else {
vm = UTMRemoteSpiceVirtualMachine(forRemoteServer: server, remotePath: entry.package.path, entry: entry, config: config)
wrapped = vm
}
vm.updateConfigFromRegistry()
subscribeToChildren()
await vm.updateRemoteState(initialState)
}
func reloadConfiguration(withRemoteServer server: UTMRemoteClient.Remote, config: UTMQemuConfiguration) async {
let spiceVM = wrapped as! UTMRemoteSpiceVirtualMachine
await loadCustomIcon(withRemoteServer: server, id: spiceVM.id, config: config)
spiceVM.reload(usingConfiguration: config)
}
private func loadCustomIcon(withRemoteServer server: UTMRemoteClient.Remote, id: UUID, config: UTMQemuConfiguration) async {
if config.information.isIconCustom, let iconUrl = config.information.iconURL {
if let iconUrl = try? await server.getPackageFile(for: id, relativePathComponents: [UTMQemuConfiguration.dataDirectoryName, iconUrl.lastPathComponent]) {
config.information.iconURL = iconUrl
}
}
}
func updateMountedDrives(_ mountedDrives: [String: String]) {
guard let registryEntry = registryEntry else {
return
}
registryEntry.externalDrives = mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
}
}
enum VMRemoteDataError: Error {
case notImplemented
case backendNotSupported
}
extension VMRemoteDataError: LocalizedError {
var errorDescription: String? {
switch self {
case .notImplemented:
return NSLocalizedString("This function is not implemented.", comment: "VMData")
case .backendNotSupported:
return NSLocalizedString("This VM is configured for a backend that does not support remote clients.", comment: "VMData")
}
}
}
#endif

View File

@ -129,11 +129,7 @@ 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;
} }
@ -157,13 +153,11 @@ NS_AVAILABLE_IOS(13.4)
- (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion { - (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion {
#if !TARGET_OS_VISION
if (@available(iOS 14.0, *)) { if (@available(iOS 14.0, *)) {
if (self.prefersPointerLocked) { if (self.prefersPointerLocked) {
return nil; return nil;
} }
} }
#endif
// Requesting region for the VM display? // Requesting region for the VM display?
if (interaction.view == self.mtkView && self.hasTouchpadPointer) { if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
// Then we need to find out if the pointer is in the actual display area or outside // Then we need to find out if the pointer is in the actual display area or outside

View File

@ -181,15 +181,11 @@ 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
@ -639,7 +635,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 = VMMouseTypeRelative; type = self.indirectMouseType;
} }
#endif #endif
if ([self switchMouseType:type]) { if ([self switchMouseType:type]) {

View File

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

View File

@ -29,15 +29,11 @@
#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, nullable) id debounceResize; @property (nonatomic) CGFloat windowScaling;
@property (nonatomic, nullable) id cancelResize; @property (nonatomic) CGPoint windowOrigin;
@property (nonatomic) BOOL ignoreNextResize;
@end @end
@ -47,6 +43,9 @@ static const NSInteger kResizeTimeoutSecs = 5;
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;
} }
@ -121,25 +120,19 @@ static const NSInteger kResizeTimeoutSecs = 5;
- (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 {
@ -147,12 +140,10 @@ static const NSInteger kResizeTimeoutSecs = 5;
[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 {
@ -170,8 +161,8 @@ static const NSInteger kResizeTimeoutSecs = 5;
[super enterLive]; [super enterLive];
self.prefersPointerLocked = YES; self.prefersPointerLocked = YES;
self.view.window.isIndirectPointerTouchIgnored = YES; self.view.window.isIndirectPointerTouchIgnored = YES;
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) { if (self.delegate.qemuDisplayIsDynamicResolution) {
[self requestResolutionChangeToSize:self.view.bounds.size]; [self displayResize:self.view.bounds.size];
} }
if (self.delegate.qemuHasClipboardSharing) { if (self.delegate.qemuHasClipboardSharing) {
[[UTMPasteboard generalPasteboard] requestPollingModeForObject:self]; [[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
@ -209,21 +200,11 @@ static const NSInteger kResizeTimeoutSecs = 5;
return size; return size;
} }
- (void)requestResolutionChangeToSize:(CGSize)size { - (void)displayResize:(CGSize)size {
self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{ UTMLog(@"resizing to (%f, %f)", size.width, size.height);
UTMLog(@"DISPLAY: requesting resolution (%f, %f)", size.width, size.height); size = [self convertSizeToNative:size];
CGSize newSize = [self convertSizeToNative:size]; CGRect bounds = CGRectMake(0, 0, size.width, size.height);
CGRect bounds = CGRectMake(0, 0, newSize.width, newSize.height); [self.vmDisplay requestResolution:bounds];
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 {
@ -236,6 +217,8 @@ static const NSInteger kResizeTimeoutSecs = 5;
- (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);
} }
@ -246,67 +229,25 @@ static const NSInteger kResizeTimeoutSecs = 5;
- (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"]) {
UTMLog(@"DISPLAY: vmDisplay.displaySize changed");
if (self.cancelResize) {
[self debounce:0 context:self.cancelResize action:^{}];
self.cancelResize = nil;
}
self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
[self resizeWindowToDisplaySize];
}];
}
}
- (void)setIsDynamicResolutionSupported:(BOOL)isDynamicResolutionSupported {
if (_isDynamicResolutionSupported != isDynamicResolutionSupported) {
_isDynamicResolutionSupported = isDynamicResolutionSupported;
UTMLog(@"DISPLAY: isDynamicResolutionSupported = %d", isDynamicResolutionSupported);
if (self.delegate.qemuDisplayIsDynamicResolution) {
if (isDynamicResolutionSupported) {
[self requestResolutionChangeToSize:self.view.bounds.size];
} else {
[self resizeWindowToDisplaySize];
}
}
}
}
- (void)resizeWindowToDisplaySize {
CGSize displaySize = self.vmDisplay.displaySize;
UTMLog(@"DISPLAY: request window resize to (%f, %f)", displaySize.width, displaySize.height);
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION #if defined(TARGET_OS_VISION) && TARGET_OS_VISION
CGSize minSize = displaySize; dispatch_async(dispatch_get_main_queue(), ^{
if (self.delegate.qemuDisplayIsNativeResolution) { CGSize minSize = self.vmDisplay.displaySize;
minSize.width = CGPixelToPoint(minSize.width); if (self.delegate.qemuDisplayIsNativeResolution) {
minSize.height = CGPixelToPoint(minSize.height); minSize.width = CGPixelToPoint(minSize.width);
} minSize.height = CGPixelToPoint(minSize.height);
CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference); }
UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:minSize]; CGSize displaySize = CGSizeMake(minSize.width * self.windowScaling, minSize.height * self.windowScaling);
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) { CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
geoPref.minimumSize = CGSizeMake(800, 600); UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:displaySize];
geoPref.maximumSize = maxSize; geoPref.minimumSize = minSize;
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsFreeform; geoPref.maximumSize = maxSize;
} else { geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
geoPref.minimumSize = minSize; [self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
geoPref.maximumSize = maxSize; });
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
}
dispatch_async(dispatch_get_main_queue(), ^{
CGSize currentViewSize = self.view.bounds.size;
UTMLog(@"DISPLAY: old view size = (%f, %f)", currentViewSize.width, currentViewSize.height);
if (CGSizeEqualToSize(minSize, currentViewSize)) {
// since `-viewWillTransitionToSize:withTransitionCoordinator:` is not called
self.delegate.displayViewSize = [self convertSizeToNative:currentViewSize];
[self.delegate display:self.vmDisplay didResizeTo:displaySize];
}
[self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
});
#else #else
if (CGSizeEqualToSize(displaySize, CGSizeZero)) { [self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
return;
}
[self.delegate display:self.vmDisplay didResizeTo:displaySize];
#endif #endif
}
} }
@end @end

View File

@ -55,7 +55,7 @@ public extension VMDisplayViewController {
parent.setChildViewControllerForPointerLock(self) parent.setChildViewControllerForPointerLock(self)
UIPress.pressResponderOverride = self UIPress.pressResponderOverride = self
} }
#if !os(visionOS) && !WITH_REMOTE #if !os(visionOS)
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,6 +75,24 @@ 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
@ -116,15 +134,4 @@ public extension VMDisplayViewController {
func integerForSetting(_ key: String) -> Int { func integerForSetting(_ key: String) -> Int {
return UserDefaults.standard.integer(forKey: key) return UserDefaults.standard.integer(forKey: key)
} }
@discardableResult
func debounce(_ delaySeconds: Int, context: Any? = nil, action: @escaping () -> Void) -> Any {
if context != nil {
let previous = context as! DispatchWorkItem
previous.cancel()
}
let item = DispatchWorkItem(block: action)
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(delaySeconds), execute: item)
return item
}
} }

View File

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

View File

@ -95,7 +95,7 @@
"LU6-kH-vN3.accessibilityLabel" = "主页"; "LU6-kH-vN3.accessibilityLabel" = "主页";
/* Class = "UIButton"; normalTitle = "Home"; ObjectID = "LU6-kH-vN3"; */ /* Class = "UIButton"; normalTitle = "Home"; ObjectID = "LU6-kH-vN3"; */
"LU6-kH-vN3.normalTitle" = "Home"; "LU6-kH-vN3.normalTitle" = "主页";
/* Class = "UIButton"; accessibilityLabel = "Escape"; ObjectID = "n12-9R-99C"; */ /* Class = "UIButton"; accessibilityLabel = "Escape"; ObjectID = "n12-9R-99C"; */
"n12-9R-99C.accessibilityLabel" = "Esc"; "n12-9R-99C.accessibilityLabel" = "Esc";

View File

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

View File

@ -1,32 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct RemoteContentView: View {
@ObservedObject var remoteClientState: UTMRemoteClient.State
@EnvironmentObject private var data: UTMRemoteData
var body: some View {
if remoteClientState.isConnected {
ContentView()
.environmentObject(data as UTMData)
} else {
UTMRemoteConnectView(remoteClientState: remoteClientState)
.transition(.move(edge: .leading))
}
}
}

View File

@ -21,12 +21,6 @@
<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>
@ -37,8 +31,6 @@
<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>
@ -91,11 +83,6 @@
<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>
@ -112,10 +99,6 @@
<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>
@ -138,10 +121,6 @@
<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>
@ -176,10 +155,6 @@
<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>
@ -2814,11 +2789,6 @@
<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>
@ -2829,11 +2799,6 @@
<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>
@ -2844,11 +2809,6 @@
<string>JitStreamerAddress</string> <string>JitStreamerAddress</string>
<key>DefaultValue</key> <key>DefaultValue</key>
<string>69.69.0.1</string> <string>69.69.0.1</string>
<key>ExcludeTargets</key>
<array>
<string>iOS-Remote</string>
<string>iOS-SE</string>
</array>
</dict> </dict>
<dict> <dict>
<key>Type</key> <key>Type</key>

View File

@ -45,9 +45,6 @@
"Drag cursor" = "カーソルをドラッグ"; "Drag cursor" = "カーソルをドラッグ";
"Touch mode (always show cursor)" = "タッチモード(常にカーソルを表示)"; "Touch mode (always show cursor)" = "タッチモード(常にカーソルを表示)";
"Touch mode (try hiding cursor)" = "タッチモード(カーソルの非表示を試みる)"; "Touch mode (try hiding cursor)" = "タッチモード(カーソルの非表示を試みる)";
"Visibility" = "可視性";
"Always show cursor" = "常にカーソルを表示";
"Try hiding cursor" = "カーソルの非表示を試みる";
"Apple Pencil Input" = "Apple Pencil入力"; "Apple Pencil Input" = "Apple Pencil入力";
"Tablet mode (always show cursor)" = "タブレットモード(常にカーソルを表示)"; "Tablet mode (always show cursor)" = "タブレットモード(常にカーソルを表示)";
"Tablet mode (try hiding cursor)" = "タブレットモード(カーソルの非表示を試みる)"; "Tablet mode (try hiding cursor)" = "タブレットモード(カーソルの非表示を試みる)";

View File

@ -26,16 +26,16 @@
"Cursor" = "指標"; "Cursor" = "指標";
/* (No Comment) */ /* (No Comment) */
"D-DOWN" = "向鍵"; "D-DOWN" = "下方向鍵";
/* (No Comment) */ /* (No Comment) */
"D-LEFT" = "向鍵"; "D-LEFT" = "左方向鍵";
/* (No Comment) */ /* (No Comment) */
"D-RIGHT" = "向鍵"; "D-RIGHT" = "右方向鍵";
/* (No Comment) */ /* (No Comment) */
"D-UP" = "向鍵"; "D-UP" = "上方向鍵";
/* (No Comment) */ /* (No Comment) */
"Disabled" = "已禁用"; "Disabled" = "已禁用";

View File

@ -5,7 +5,7 @@
"Auto save on background" = "在后台运行时自动保存"; "Auto save on background" = "在后台运行时自动保存";
/* (No Comment) */ /* (No Comment) */
"Auto save on low memory" = "在内存不足时自动保存"; "Auto save on low memory" = "在内存时自动保存";
/* (No Comment) */ /* (No Comment) */
"Background" = "后台"; "Background" = "后台";
@ -17,7 +17,7 @@
"Caps" = "大写锁定"; "Caps" = "大写锁定";
/* (No Comment) */ /* (No Comment) */
"Click & Hold" = "击并按住"; "Click & Hold" = "击并按住";
/* (No Comment) */ /* (No Comment) */
"Continue running VM in the background" = "在后台继续运行虚拟机"; "Continue running VM in the background" = "在后台继续运行虚拟机";
@ -89,7 +89,7 @@
"Mouse Wheel" = "鼠标滚轮"; "Mouse Wheel" = "鼠标滚轮";
/* (No Comment) */ /* (No Comment) */
"Mouse Wheel (per swipe)" = "鼠标滚轮 (每次滚动)"; "Mouse Wheel (per swipe)" = "鼠标滚轮";
/* (No Comment) */ /* (No Comment) */
"Move Screen" = "移动显示屏"; "Move Screen" = "移动显示屏";
@ -101,7 +101,7 @@
"none given" = "未指定"; "none given" = "未指定";
/* (No Comment) */ /* (No Comment) */
"Right" = "右"; "Right" = "方向键右";
/* (No Comment) */ /* (No Comment) */
"Right Click" = "右键单击"; "Right Click" = "右键单击";
@ -110,28 +110,28 @@
"Space" = "空格"; "Space" = "空格";
/* (No Comment) */ /* (No Comment) */
"Tablet mode (always show cursor)" = "平板模式 (总是显示光标)"; "Tablet mode (always show cursor)" = "平板模式 (显示光标)";
/* (No Comment) */ /* (No Comment) */
"Tablet mode (try hiding cursor)" = "平板模式 (尝试隐藏光标)"; "Tablet mode (try hiding cursor)" = "平板模式 (隐藏光标)";
/* (No Comment) */ /* (No Comment) */
"Three Finger Pan" = "三指拖"; "Three Finger Pan" = "三指拖";
/* (No Comment) */ /* (No Comment) */
"Touch Input" = "触摸输入"; "Touch Input" = "触摸输入";
/* (No Comment) */ /* (No Comment) */
"Touch mode (always show cursor)" = "触摸模式 (总是显示光标)"; "Touch mode (always show cursor)" = "触摸模式 (显示光标)";
/* (No Comment) */ /* (No Comment) */
"Touch mode (try hiding cursor)" = "触摸模式 (尝试隐藏光标)"; "Touch mode (try hiding cursor)" = "触摸模式 (隐藏光标)";
/* (No Comment) */ /* (No Comment) */
"Touchpad/Mouse Input" = "触控板/鼠标输入"; "Touchpad/Mouse Input" = "触控板/鼠标输入";
/* (No Comment) */ /* (No Comment) */
"Two Finger Pan" = "双指拖"; "Two Finger Pan" = "双指拖";
/* (No Comment) */ /* (No Comment) */
"Two Finger Scroll" = "双指滚动"; "Two Finger Scroll" = "双指滚动";

View File

@ -19,23 +19,15 @@ 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
} }
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) { let session = VMSessionState(for: wrapped as! UTMQemuVirtualMachine)
session.showWindow() session.start()
} 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) {
@ -45,7 +37,6 @@ extension UTMData {
if wrapped.registryEntry.isSuspended { if wrapped.registryEntry.isSuspended {
wrapped.requestVmDeleteState() wrapped.requestVmDeleteState()
} }
wrapped.requestVmStop()
} }
func close(vm: VMData) { func close(vm: VMData) {

View File

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

View File

@ -19,20 +19,12 @@ 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(hasContainer) .appSettingsShowPrivacyLink(jb_has_container())
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Close") { Button("Close") {

View File

@ -19,12 +19,8 @@ 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?
@ -40,11 +36,7 @@ struct UTMSingleWindowView: View {
if let session = session { if let session = session {
VMWindowView(id: identifier!, isInteractive: isInteractive).environmentObject(session) VMWindowView(id: identifier!, isInteractive: isInteractive).environmentObject(session)
} else if isInteractive { } else if isInteractive {
#if WITH_REMOTE
RemoteContentView(remoteClientState: data.remoteClient.state).environmentObject(data)
#else
ContentView().environmentObject(data) ContentView().environmentObject(data)
#endif
} else { } else {
VStack { VStack {
Text("Waiting for VM to connect to display...") Text("Waiting for VM to connect to display...")

View File

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

View File

@ -37,21 +37,21 @@ import SwiftUI
let id: ID = ID() let id: ID = ID()
let vm: any UTMSpiceVirtualMachine let vm: UTMQemuVirtualMachine
var qemuConfig: UTMQemuConfiguration { var qemuConfig: UTMQemuConfiguration {
vm.config vm.config
} }
@Published var vmState: UTMVirtualMachineState = .stopped @Published var vmState: UTMVirtualMachineState = .stopped
@Published var nonfatalError: String?
@Published var fatalError: String? @Published var fatalError: String?
@Published var nonfatalError: String?
@Published var primaryInput: CSInput? @Published var primaryInput: CSInput?
#if WITH_USB #if !WITH_QEMU_TCI
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,12 +78,10 @@ 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: any UTMSpiceVirtualMachine) { init(for vm: UTMQemuVirtualMachine) {
self.vm = vm self.vm = vm
super.init() super.init()
vm.delegate = self vm.delegate = self
@ -150,7 +148,7 @@ extension VMSessionState: UTMVirtualMachineDelegate {
Task { @MainActor in Task { @MainActor in
vmState = state vmState = state
if state == .stopped { if state == .stopped {
#if WITH_USB #if !WITH_QEMU_TCI
clearDevices() clearDevices()
#endif #endif
} }
@ -159,7 +157,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
nonfatalError = message fatalError = message
} }
} }
@ -283,7 +281,7 @@ extension VMSessionState: UTMSpiceIODelegate {
} }
} }
#if WITH_USB #if !WITH_QEMU_TCI
nonisolated func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) { nonisolated func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) {
Task { @MainActor in Task { @MainActor in
primaryUsbManager?.delegate = nil primaryUsbManager?.delegate = nil
@ -293,21 +291,9 @@ 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_USB #if !WITH_QEMU_TCI
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
@ -433,18 +419,10 @@ extension VMSessionState {
logger.warning("Error starting audio session: \(error.localizedDescription)") logger.warning("Error starting audio session: \(error.localizedDescription)")
} }
Self.allActiveSessions[id] = self Self.allActiveSessions[id] = self
showWindow()
if vm.state == .paused {
vm.requestVmResume()
} else {
vm.requestVmStart(options: options)
}
}
func showWindow() {
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self]) NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
vm.requestVmStart(options: options)
} }
@objc private func suspend() { @objc private func suspend() {
// dummy function for selector // dummy function for selector
} }
@ -458,9 +436,7 @@ extension VMSessionState {
} }
// tell other screens to shut down // tell other screens to shut down
Self.allActiveSessions.removeValue(forKey: id) Self.allActiveSessions.removeValue(forKey: id)
closeWindows() NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
#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)
@ -470,17 +446,12 @@ extension VMSessionState {
// exit app when app is in background // exit app when app is in background
exit(0) exit(0)
#endif
} }
func closeWindows() { func powerDown() {
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: isKill ? .kill : .force) try await vm.stop(usingMethod: .force)
self.stop() self.stop()
} }
} }
@ -511,7 +482,6 @@ 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 {
@ -524,7 +494,7 @@ extension VMSessionState {
} }
Task { Task {
do { do {
try await vm.saveSnapshot(name: nil) try await vm.saveSnapshot()
self.hasAutosave = true self.hasAutosave = true
logger.info("Save snapshot complete") logger.info("Save snapshot complete")
} catch { } catch {
@ -534,17 +504,14 @@ extension VMSessionState {
task = .invalid task = .invalid
} }
} }
#endif
} }
func didEnterForeground() { func didEnterForeground() {
#if !os(visionOS)
logger.info("Entering foreground!") logger.info("Entering foreground!")
if (hasAutosave && vmState == .started) { if (hasAutosave && vmState == .started) {
logger.info("Deleting snapshot") logger.info("Deleting snapshot")
vm.requestVmDeleteState() vm.requestVmDeleteState()
} }
#endif
} }
} }

View File

@ -52,7 +52,6 @@ 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
@ -69,12 +68,6 @@ struct VMToolbarDriveMenuView: View {
} label: { } label: {
MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill") MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
} }
#else
Button {
} label: {
MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
}.disabled(true)
#endif
} else if drive.imageType == .disk || drive.imageType == .cd { } else if drive.imageType == .disk || drive.imageType == .cd {
Button { Button {
} label: { } label: {

View File

@ -82,17 +82,13 @@ struct VMToolbarView: View {
GeometryReader { geometry in GeometryReader { geometry in
Group { Group {
Button { Button {
if state.isRunning { if session.vm.state == .started {
state.alert = .powerDown state.alert = .powerDown
} else { } else {
state.alert = .terminateApp state.alert = .terminateApp
} }
} label: { } label: {
if state.isRunning { Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
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()
@ -114,7 +110,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_USB #if !WITH_QEMU_TCI
if session.vm.hasUsbRedirection { if session.vm.hasUsbRedirection {
VMToolbarUSBMenuView() VMToolbarUSBMenuView()
.offset(offset(for: 4)) .offset(offset(for: 4))

View File

@ -71,8 +71,6 @@ 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
@ -84,7 +82,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_USB #if !WITH_QEMU_TCI
case .deviceConnected(_): return 3 case .deviceConnected(_): return 3
#endif #endif
case .nonfatalError(_): return 4 case .nonfatalError(_): return 4
@ -96,7 +94,7 @@ extension VMWindowState {
case powerDown case powerDown
case terminateApp case terminateApp
case restart case restart
#if WITH_USB #if !WITH_QEMU_TCI
case deviceConnected(CSUSBDevice) case deviceConnected(CSUSBDevice)
#endif #endif
case nonfatalError(String) case nonfatalError(String)

View File

@ -16,9 +16,6 @@
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
@ -27,10 +24,7 @@ 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)
@ -114,13 +108,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.powerDown(isKill: true) session.stop()
}, 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_USB #if !WITH_QEMU_TCI
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
@ -133,8 +127,6 @@ 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
} }
@ -159,7 +151,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_USB #if !WITH_QEMU_TCI
.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)
@ -179,9 +171,6 @@ 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
@ -213,30 +202,12 @@ 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
} }
} }
@ -250,12 +221,9 @@ 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.nonfatalError == nil && session.fatalError == nil { if session.vmState == .stopped && session.fatalError == nil {
if session.vmState == .stopped { session.stop()
session.stop()
}
} }
} }
case .pausing, .stopping, .starting, .resuming, .saving, .restoring: case .pausing, .stopping, .starting, .resuming, .saving, .restoring:

View File

@ -1 +0,0 @@
"" = "";

View File

@ -1,20 +0,0 @@
/* Bundle name */
"CFBundleName" = "UTM";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "La macchina virtuale può accedere alla rete locale. Inoltre, UTM, si avvale della rete locale per comunicare con AltServer";
/* Privacy - Location Always and When In Use Usage Description */
"NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM richiede il permesso di accedere alla posizione periodicamente per assicurarsi che il sistema mantenga il processo in background. I dati raccolti sulla posizione non lasceranno il tuo dispositivo.";
/* Privacy - Location Always Usage Description */
"NSLocationAlwaysUsageDescription" = "UTM richiede il permesso di accedere alla posizione periodicamente per assicurarsi che il sistema mantenga il processo in background. I dati raccolti sulla posizione non lasceranno il tuo dispositivo.";
/* Privacy - Location When In Use Usage Description */
"NSLocationWhenInUseUsageDescription" = "UTM richiede il permesso di accedere alla posizione periodicamente per assicurarsi che il sistema mantenga il processo in background. I dati raccolti sulla posizione non lasceranno il tuo dispositivo.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "Permette alle Macchine Virtuali di accedere al Microfono";
/* (No Comment) */
"UTM virtual machine" = "Macchina Virtuale UTM";

View File

@ -1,9 +0,0 @@
/* Bundle name */
"CFBundleName" = "UTMリモート";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "UTMはローカルネットワークを使用してUTMリモートサーバを検索し、接続します。";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "仮想マシンがマイクから録音するには、アクセス許可が必要です。";

View File

@ -1,17 +1,17 @@
/* Bundle name */ /* Bundle name */
"CFBundleName" = "UTM"; "CFBundleName" = "UTM SE";
/* Privacy - Local Network Usage Description */ /* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "虛擬電腦可以訪問本地網絡。UTM 還會使用本地網絡與 AltServer 進行通信。"; "NSLocalNetworkUsageDescription" = "虛擬電腦可以訪問本地網絡。UTM 還會使用本地網絡與 AltServer 進行通信。";
/* Privacy - Location Always and When In Use Usage Description */ /* Privacy - Location Always and When In Use Usage Description */
"NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。"; "NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。";
/* Privacy - Location Always Usage Description */ /* Privacy - Location Always Usage Description */
"NSLocationAlwaysUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。"; "NSLocationAlwaysUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。";
/* Privacy - Location When In Use Usage Description */ /* Privacy - Location When In Use Usage Description */
"NSLocationWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。"; "NSLocationWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永離開設備。";
/* Privacy - Microphone Usage Description */ /* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "任何虛擬電腦都需要許可才能由咪高風進行錄製。"; "NSMicrophoneUsageDescription" = "任何虛擬電腦都需要許可才能由咪高風進行錄製。";

View File

@ -1,8 +1,8 @@
/* Bundle name */ /* Bundle name */
"CFBundleName" = "UTM"; "CFBundleName" = "UTM SE";
/* Privacy - Local Network Usage Description */ /* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "虚拟机可能会访问本地网络。UTM 还使用本地网络与 AltServer 通信。"; "NSLocalNetworkUsageDescription" = "虚拟机可访问本地网络。UTM 还使用本地网络与 AltServer 通信。";
/* Privacy - Location Always and When In Use Usage Description */ /* Privacy - Location Always and When In Use Usage Description */
"NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM 定期请求位置数据,以确保系统保持后台进程处于活动状态。位置数据永远不会离开设备。"; "NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM 定期请求位置数据,以确保系统保持后台进程处于活动状态。位置数据永远不会离开设备。";

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
<?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>%lld Cores</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@cores@</string>
<key>cores</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string>%lld Core</string>
<key>other</key>
<string>%lld Core</string>
</dict>
</dict>
</dict>
</plist>

View File

@ -48,10 +48,8 @@
// UTMAppleConfigurationVirtualization.swift // UTMAppleConfigurationVirtualization.swift
"Disabled" = "無効"; "Disabled" = "無効";
"Generic Mouse" = "汎用マウス"; "Mouse" = "マウス";
"Mac Trackpad (macOS 13+)" = "MacトラックパッドmacOS 13以降"; "Trackpad" = "トラックパッド";
"Generic USB" = "汎用USB";
"Mac Keyboard (macOS 14+)" = "MacキーボードmacOS 14以降";
// UTMQemuConfiguration.swift // UTMQemuConfiguration.swift
"Failed to migrate configuration from a previous UTM version." = "以前のUTMバージョンからの構成の移行に失敗しました。"; "Failed to migrate configuration from a previous UTM version." = "以前のUTMバージョンからの構成の移行に失敗しました。";
@ -94,17 +92,13 @@
/* Services */ /* Services */
// UTMPipeInterface.swift // UTMQemu.m
"Failed to create pipe for communications." = "通信用パイプの作成に失敗しました。";
// UTMProcess.m
"Internal error has occurred." = "内部エラーが発生しました。"; "Internal error has occurred." = "内部エラーが発生しました。";
// UTMQemuImage.swift // UTMQemuImage.swift
"An unknown QEMU error has occurred." = "不明なQEMUエラーが発生しました。"; "An unknown QEMU error has occurred." = "不明なQEMUエラーが発生しました。";
// UTMSpiceIO.m // UTMSpiceIO.m
"Failed to change current directory." = "作業ディレクトリの変更に失敗しました。";
"Failed to start SPICE client." = "SPICEクライアントの開始に失敗しました。"; "Failed to start SPICE client." = "SPICEクライアントの開始に失敗しました。";
"Internal error trying to connect to SPICE server." = "SPICEサーバへの接続試行中に内部エラーが発生しました。"; "Internal error trying to connect to SPICE server." = "SPICEサーバへの接続試行中に内部エラーが発生しました。";
@ -128,46 +122,22 @@
"Failed to access shared directory." = "共有ディレクトリのアクセスに失敗しました。"; "Failed to access shared directory." = "共有ディレクトリのアクセスに失敗しました。";
"The virtual machine is in an invalid state." = "仮想マシンが無効な状態です。"; "The virtual machine is in an invalid state." = "仮想マシンが無効な状態です。";
"Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@" = "仮想マシンのスナップショットの保存に失敗しました。これは、通常1台以上のデバイスがスナップショットに対応していないことを意味します。%@"; "Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@" = "仮想マシンのスナップショットの保存に失敗しました。これは、通常1台以上のデバイスがスナップショットに対応していないことを意味します。%@";
"Failed to generate TLS key for server." = "サーバ用のTLS鍵の生成に失敗しました。";
/* Platform/iOS */ /* Platform/iOS */
// UTMDataExtension.swift // UTMMainView.swift
"This virtual machine is already running. In order to run it from this device, you must stop it first." = "この仮想マシンはすでに実行されています。このデバイスから実行するには、まず停止する必要があります。";
// UTMSingleWindowView.swift
"Waiting for VM to connect to display..." = "仮想マシンがディスプレイに接続するのを待機中…"; "Waiting for VM to connect to display..." = "仮想マシンがディスプレイに接続するのを待機中…";
// UTMRemoteConnectView.swift
"Select a UTM Server" = "UTMサーバを選択してください";
"Help" = "ヘルプ";
"New Connection" = "新規接続";
"Saved" = "保存済み";
"Edit…" = "編集…";
"Delete" = "削除";
"Discovered" = "検出";
"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." = "最新バージョンのUTMがMac上で実行されており、UTMサーバが有効になっていることを確認してください。UTMはMac App Storeからダウンロードできます。";
"Name (optional)" = "名前(オプション)";
"Hostname or IP address" = "ホスト名またはIPアドレス";
"Port" = "ポート";
"Host" = "ホスト";
"Fingerprint" = "指紋";
"Password" = "パスワード";
"Save Password" = "パスワードを保存";
"Close" = "閉じる";
"Cancel" = "キャンセル";
"Trust" = "信頼";
"Connect" = "接続";
"Timed out trying to connect." = "接続試行中にタイムアウトになりました。";
// UTMSettingsView.swift // UTMSettingsView.swift
"Settings" = "設定"; "Settings" = "設定";
"Close" = "閉じる";
// VMConfigNetworkPortForwardView.swift // VMConfigNetworkPortForwardView.swift
"Port Forward" = "ポート転送"; "Port Forward" = "ポート転送";
"%@ ➡️ %@" = "%1$@ ➡️ %2$@"; "%@ ➡️ %@" = "%1$@ ➡️ %2$@";
"New" = "新規"; "New" = "新規";
"Delete" = "削除";
"Save" = "保存"; "Save" = "保存";
// VMDrivesSettingsView.swift // VMDrivesSettingsView.swift
@ -175,6 +145,7 @@
"Are you sure you want to permanently delete this disk image?" = "このディスクイメージを完全に削除してもよろしいですか?"; "Are you sure you want to permanently delete this disk image?" = "このディスクイメージを完全に削除してもよろしいですか?";
"EFI Variables" = "EFI変数"; "EFI Variables" = "EFI変数";
"%@ Drive" = "%@ドライブ"; "%@ Drive" = "%@ドライブ";
"Cancel" = "キャンセル";
"Done" = "完了"; "Done" = "完了";
// VMSettingsView.swift // VMSettingsView.swift
@ -195,7 +166,7 @@
// VMToolbarView.swift // VMToolbarView.swift
"Power Off" = "電源オフ"; "Power Off" = "電源オフ";
"Force Kill" = "強制終了"; "Quit" = "終了";
"Pause" = "一時停止"; "Pause" = "一時停止";
"Play" = "再生"; "Play" = "再生";
"Restart" = "再起動"; "Restart" = "再起動";
@ -342,20 +313,9 @@
"If enabled, num lock will always be on to the guest. Note this may make your keyboard's num lock indicator out of sync." = "有効にすると、ゲストに対してNumLockが常にオンになります。これにより、キーボードのNumLockインジケータが同期しなくなる可能性があることに注意してください。"; "If enabled, num lock will always be on to the guest. Note this may make your keyboard's num lock indicator out of sync." = "有効にすると、ゲストに対してNumLockが常にオンになります。これにより、キーボードのNumLockインジケータが同期しなくなる可能性があることに注意してください。";
"QEMU USB" = "QEMU USB"; "QEMU USB" = "QEMU USB";
"Do not show prompt when USB device is plugged in" = "USBデバイス挿入時にプロンプトを表示しない"; "Do not show prompt when USB device is plugged in" = "USBデバイス挿入時にプロンプトを表示しない";
"Startup" = "起動時";
"Automatically start UTM server" = "UTMサーバを自動的に起動";
"Reject unknown connections by default" = "デフォルトで不明な接続を拒否";
"If checked, you will not be prompted about any unknown connection and they will be rejected." = "チェックを入れると、不明な接続についてのプロンプトは表示されず拒否されます。";
"Allow access from external clients" = "外部クライアントからのアクセスを許可";
"By default, the server is only available on LAN but setting this will use UPnP/NAT-PMP to port forward to WAN." = "デフォルトでは、サーバはLAN上でのみ利用可能ですが、これを設定すると、UPnP/NAT-PMPを使用してWANにポート転送します。";
"Specify a port number to listen on. This is required if external clients are permitted." = "外部からの接続を受け入れるポート番号を指定します。これは、外部クライアントが許可されている場合に必要です。";
"Authentication" = "認証";
"Require Password" = "パスワードが必要";
"If enabled, clients must enter a password. This is required if you want to access the server externally." = "有効にすると、クライアントはパスワードを入力する必要があります。これは、サーバに外部からアクセスする場合に必要です。";
// UTMApp.swift // UTMApp.swift
"UTM" = "UTM"; "UTM" = "UTM";
"UTM Server" = "UTMサーバ";
// UTMDataExtension.swift // UTMDataExtension.swift
"This virtual machine cannot be run on this machine." = "この仮想マシンはこのマシンでは実行できません。"; "This virtual machine cannot be run on this machine." = "この仮想マシンはこのマシンでは実行できません。";
@ -366,7 +326,6 @@
"Hide dock icon on next launch" = "次回起動時にDockアイコンを非表示にする"; "Hide dock icon on next launch" = "次回起動時にDockアイコンを非表示にする";
"Requires restarting UTM to take affect." = "変更を適用するには、UTMを再起動する必要があります。"; "Requires restarting UTM to take affect." = "変更を適用するには、UTMを再起動する必要があります。";
"No virtual machines found." = "仮想マシンが見つかりません。"; "No virtual machines found." = "仮想マシンが見つかりません。";
"Quit" = "終了";
"Terminate UTM and stop all running VMs." = "UTMを終了し、すべての実行中の仮想マシンを停止します。"; "Terminate UTM and stop all running VMs." = "UTMを終了し、すべての実行中の仮想マシンを停止します。";
"Start" = "開始"; "Start" = "開始";
"Stop" = "停止"; "Stop" = "停止";
@ -374,22 +333,6 @@
"Reset" = "リセット"; "Reset" = "リセット";
"Busy…" = "処理中…"; "Busy…" = "処理中…";
// UTMServer.swift
"Enable UTM Server" = "UTMサーバを有効にする";
"Reset Identity" = "IDをリセット";
"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." = "すべてのクライアントを削除して、新しいサーバIDを生成しますか? 以前このサーバとペアリングしていたクライアントは、再度接続する前に手動でこのサーバとのペアリングを解除するよう指示されます。";
"Server IP: %s, Port: %s" = "サーバIP: %1$s、ポート: %2$s";
"Running" = "動作中";
"Name" = "名前";
"Last Seen" = "最終接続日時";
"Status" = "状態";
"Connected" = "接続済み";
"Blocked" = "ブロック済み";
"Approve" = "承認";
"Block" = "ブロック";
"Disconnect" = "切断";
"Do you want to forget the selected client(s)?" = "選択中のクライアントを削除しますか?";
// VMConfigAppleBootView.swift // VMConfigAppleBootView.swift
"Operating System" = "オペレーティングシステム"; "Operating System" = "オペレーティングシステム";
"Bootloader" = "ブートローダ"; "Bootloader" = "ブートローダ";
@ -421,6 +364,7 @@
// VMConfigAppleDriveDetailsView.swift // VMConfigAppleDriveDetailsView.swift
"Removable Drive" = "リムーバブルドライブ"; "Removable Drive" = "リムーバブルドライブ";
"Name" = "名前";
"(New Drive)" = "(新規ドライブ)"; "(New Drive)" = "(新規ドライブ)";
"Read Only?" = "読み出しのみ"; "Read Only?" = "読み出しのみ";
"Delete Drive" = "ドライブを削除"; "Delete Drive" = "ドライブを削除";
@ -453,7 +397,8 @@
"Enable Balloon Device" = "バルーンデバイスを有効にする"; "Enable Balloon Device" = "バルーンデバイスを有効にする";
"Enable Entropy Device" = "エントロピデバイスを有効にする"; "Enable Entropy Device" = "エントロピデバイスを有効にする";
"Enable Sound" = "サウンドを有効にする"; "Enable Sound" = "サウンドを有効にする";
"Pointer" = "ポインタ"; "Enable Keyboard" = "キーボードを有効にする";
"Enable Pointer" = "ポインタを有効にする";
"Use Trackpad" = "トラックパッドを使用"; "Use Trackpad" = "トラックパッドを使用";
"Allows passing through additional input from trackpads. Only supported on macOS 13+ guests." = "トラックパッドからの追加の入力をパススルーできるようになります。macOS 13以降のゲストでのみ対応しています。"; "Allows passing through additional input from trackpads. Only supported on macOS 13+ guests." = "トラックパッドからの追加の入力をパススルーできるようになります。macOS 13以降のゲストでのみ対応しています。";
"Enable Rosetta on Linux (x86_64 Emulation)" = "Linux上でRosettaを有効にするx86_64エミュレーション"; "Enable Rosetta on Linux (x86_64 Emulation)" = "Linux上でRosettaを有効にするx86_64エミュレーション";
@ -467,11 +412,9 @@
"Guest Port" = "ゲストポート"; "Guest Port" = "ゲストポート";
"Host Address" = "ホストアドレス"; "Host Address" = "ホストアドレス";
"Host Port" = "ホストポート"; "Host Port" = "ホストポート";
"Edit…" = "編集…";
"New…" = "新規…"; "New…" = "新規…";
// VMSessionState.swift
"Connection to the server was lost." = "サーバへの接続が切断されました。";
// VMConfigQEMUArgumentsView.swift // VMConfigQEMUArgumentsView.swift
"Arguments" = "引数"; "Arguments" = "引数";
"Export QEMU Command…" = "QEMUコマンドを書き出す…"; "Export QEMU Command…" = "QEMUコマンドを書き出す…";
@ -511,13 +454,6 @@
"Select where to export QEMU command:" = "QEMUコマンドの書き出し先を選択してください:"; "Select where to export QEMU command:" = "QEMUコマンドの書き出し先を選択してください:";
/* Platform/visionOS */
// VMToolbarOrnamentModifier.swift
"Hide Controls" = "コントロールを非表示";
"Show Controls" = "コントロールを表示";
/* Platform/Shared */ /* Platform/Shared */
// DestructiveButton.swift // DestructiveButton.swift
@ -679,6 +615,7 @@
"Emulated Serial Device" = "仮想シリアルデバイス"; "Emulated Serial Device" = "仮想シリアルデバイス";
"TCP" = "TCP"; "TCP" = "TCP";
"Server Address" = "サーバアドレス"; "Server Address" = "サーバアドレス";
"Port" = "ポート";
"The target does not support hardware emulated serial connections." = "このターゲットはハードウェア仮想シリアル接続に対応していません。"; "The target does not support hardware emulated serial connections." = "このターゲットはハードウェア仮想シリアル接続に対応していません。";
// VMConfigSharingView.swift // VMConfigSharingView.swift
@ -763,7 +700,6 @@
"Browse UTM Gallery" = "UTMギャラリーをブラウズ"; "Browse UTM Gallery" = "UTMギャラリーをブラウズ";
"User Guide" = "ユーザガイド"; "User Guide" = "ユーザガイド";
"Support" = "サポート"; "Support" = "サポート";
"Server" = "サーバ";
// VMRemovableDrivesView.swift // VMRemovableDrivesView.swift
"%@ %@" = "%1$@ %2$@"; "%@ %@" = "%1$@ %2$@";
@ -784,8 +720,6 @@
"Stop selected VM" = "選択した仮想マシンを停止します"; "Stop selected VM" = "選択した仮想マシンを停止します";
"Run selected VM" = "選択した仮想マシンを実行します"; "Run selected VM" = "選択した仮想マシンを実行します";
"Edit selected VM" = "選択した仮想マシンを編集します"; "Edit selected VM" = "選択した仮想マシンを編集します";
"Preferences" = "環境設定";
"Show UTM preferences" = "UTM環境設定を表示します";
// VMWizardDrivesView.swift // VMWizardDrivesView.swift
"Storage" = "ストレージ"; "Storage" = "ストレージ";
@ -793,7 +727,7 @@
// VMWizardHardwareView.swift // VMWizardHardwareView.swift
"Hardware OpenGL Acceleration" = "ハードウェアOpenGLアクセラレーション"; "Hardware OpenGL Acceleration" = "ハードウェアOpenGLアクセラレーション";
"There are known issues in some newer Linux drivers including black screen, broken compositing, and apps failing to render." = "一部の新しいLinuxドライバの中には、画面が黒くなる、表示が乱れる、アプリのレンダリングに失敗するといった既知の問題があります。"; "There are known issues in some newer Linux drivers including black screen, broken compositing, and apps failing to render." = "一部の新しいLinuxドライバの中には、画面が黒くなる、表示が乱れる、Appのレンダリングに失敗するといった既知の問題があります。";
"Enable hardware OpenGL acceleration" = "ハードウェアOpenGLアクセラレーションを有効にする"; "Enable hardware OpenGL acceleration" = "ハードウェアOpenGLアクセラレーションを有効にする";
// VMWizardOSLinuxView.swift // VMWizardOSLinuxView.swift
@ -906,7 +840,6 @@
// UTMData.swift // UTMData.swift
"An existing virtual machine already exists with this name." = "この名前の仮想マシンがすでに存在します。"; "An existing virtual machine already exists with this name." = "この名前の仮想マシンがすでに存在します。";
"This virtual machine is currently unavailable, make sure it is not open in another session." = "この仮想マシンは現在使用できません。別のセッションで開かれていないことを確認してください。";
"Failed to clone VM." = "仮想マシンの複製に失敗しました。"; "Failed to clone VM." = "仮想マシンの複製に失敗しました。";
"Unable to add a shortcut to the new location." = "新しい場所にショートカットを追加できません。"; "Unable to add a shortcut to the new location." = "新しい場所にショートカットを追加できません。";
"Cannot import this VM. Either the configuration is invalid, created in a newer version of UTM, or on a platform that is incompatible with this version of UTM." = "この仮想マシンは読み込めません。構成が無効であるか、新しいバージョンのUTM、またはこのバージョンのUTMと互換性のないプラットフォームで作成されています。"; "Cannot import this VM. Either the configuration is invalid, created in a newer version of UTM, or on a platform that is incompatible with this version of UTM." = "この仮想マシンは読み込めません。構成が無効であるか、新しいバージョンのUTM、またはこのバージョンのUTMと互換性のないプラットフォームで作成されています。";
@ -918,8 +851,6 @@
"Failed to decode JitStreamer response." = "JitStreamerの応答のデコードに失敗しました。"; "Failed to decode JitStreamer response." = "JitStreamerの応答のデコードに失敗しました。";
"Failed to attach to JitStreamer." = "JitStreamerへのアタッチに失敗しました。"; "Failed to attach to JitStreamer." = "JitStreamerへのアタッチに失敗しました。";
"Invalid JitStreamer attach URL:\n%@" = "JitStreamerアタッチURLが無効です:\n%@"; "Invalid JitStreamer attach URL:\n%@" = "JitStreamerアタッチURLが無効です:\n%@";
"This functionality is not yet implemented." = "この機能はまだ実装されていません。";
"Failed to reconnect to the server." = "サーバへの再接続に失敗しました。";
// UTMDownloadVMTask.swift // UTMDownloadVMTask.swift
"There is no UTM file in the downloaded ZIP archive." = "ダウンロードされたZIPアーカイブ内にUTMファイルがありません。"; "There is no UTM file in the downloaded ZIP archive." = "ダウンロードされたZIPアーカイブ内にUTMファイルがありません。";
@ -950,51 +881,8 @@
"Restoring" = "復元中"; "Restoring" = "復元中";
/* Remote */
// UTMRemoteKeyManager.swift
"Failed to generate a key pair." = "鍵ペアの生成に失敗しました。";
"Failed to parse generated key pair." = "生成された鍵ペアの解析に失敗しました。";
"Failed to import generated key." = "生成された鍵の読み込みに失敗しました。";
// UTMRemoteClient.swift
"Failed to determine host name." = "ホスト名の特定に失敗しました。";
"Failed to get host fingerprint." = "ホストの指紋の取得に失敗しました。";
"Password is required." = "パスワードが必要です。";
"Password is incorrect." = "パスワードが間違っています。";
"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." = "このホストはまだ信頼されていません。指紋がホストに表示されているものと一致していることを確認し、“信頼”を選択して続ける必要があります。";
"The server interface version does not match the client." = "サーバインターフェイスのバージョンがクライアントと一致しません。";
// UTMRemoteSpiceVirtualMachine.swift
"Failed to connect to SPICE: %@" = "SPICEへの接続に失敗しました: %@";
"An operation is already in progress." = "操作はすでに進行中です。";
// UTMRemoteServer.swift
"Allow" = "許可";
"Deny" = "拒否";
"Disconnect" = "切断";
"New unknown remote client connection." = "新しい不明なリモートクライアントからの接続がありました。";
"New trusted remote client connection." = "新しい信頼済みリモートクライアントからの接続がありました。";
"Unknown Remote Client" = "不明なリモートクライアント";
"A client with fingerprint '%@' is attempting to connect." = "指紋“%@”のクライアントが接続しようとしています。";
"Remote Client Connected" = "リモートクライアント接続済み";
"Established connection from %@." = "%@からの接続を確立しました。";
"UTM Remote Server Error" = "UTMリモートサーバエラー";
"Cannot reserve port '%@' for external access from NAT. Make sure no other device on the network has reserved it." = "NATからの外部アクセス用のポート“%@”を予約できません。ネットワーク上のほかのデバイスが予約していないことを確認してください。";
"Not authenticated." = "認証されていません。";
"The client interface version does not match the server." = "クライアントインターフェイスのバージョンがサーバと一致しません。";
"Cannot find VM with ID: %@" = "指定されたIDの仮想マシンが見つかりません: %@";
"Invalid backend." = "バックエンドが無効です。";
"Failed to access file." = "ファイルへのアクセスに失敗しました。";
/* Scripting */ /* Scripting */
// UTMScriptingUSBDeviceImpl.swift
"UTM is not ready to accept commands." = "UTMはコマンドを受け入れる準備ができていません。";
"The device cannot be found." = "デバイスが見つかりません。";
"The device is not currently connected." = "デバイスが現在接続されていません。";
// UTMScriptingVirtualMachineImpl.swift // UTMScriptingVirtualMachineImpl.swift
"Operation not available." = "操作は利用できません。"; "Operation not available." = "操作は利用できません。";
"Operation not supported by the backend." = "操作はバックエンドが対応していません。"; "Operation not supported by the backend." = "操作はバックエンドが対応していません。";
@ -1010,6 +898,7 @@
"This device is not supported by the target." = "このデバイスはターゲットが対応していません。"; "This device is not supported by the target." = "このデバイスはターゲットが対応していません。";
// UTMScriptingCreateCommand.swift // UTMScriptingCreateCommand.swift
"UTM is not ready to accept commands." = "UTMはコマンドを受け入れる準備ができていません。";
"A valid backend must be specified." = "有効なバックエンドを指定する必要があります。"; "A valid backend must be specified." = "有効なバックエンドを指定する必要があります。";
"This backend is not supported on your machine." = "このバックエンドはお使いのマシンでは対応していません。"; "This backend is not supported on your machine." = "このバックエンドはお使いのマシンでは対応していません。";
"A valid configuration must be specified." = "有効な構成を指定する必要があります。"; "A valid configuration must be specified." = "有効な構成を指定する必要があります。";

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
var shouldAutoStartVM: Bool = true var shouldAutoStartVM: Bool = true
var vm: (any UTMVirtualMachine)! var vm: (any UTMVirtualMachine)!
var onClose: (() -> Void)? var onClose: ((Notification) -> 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: (() -> Void)?) { convenience init(vm: any UTMVirtualMachine, onClose: ((Notification) -> 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?() onClose?(notification)
} }
func windowDidBecomeKey(_ notification: Notification) { func windowDidBecomeKey(_ notification: Notification) {

View File

@ -1,93 +0,0 @@
/* Class = "NSToolbarItem"; label = "Shared Folder"; ObjectID = "7EC-GE-fIl"; */
"7EC-GE-fIl.label" = "Cartella Condivisa";
/* Class = "NSToolbarItem"; paletteLabel = "Shared Folder"; ObjectID = "7EC-GE-fIl"; */
"7EC-GE-fIl.paletteLabel" = "Cartella Condivisa";
/* Class = "NSToolbarItem"; toolTip = "Shared folder"; ObjectID = "7EC-GE-fIl"; */
"7EC-GE-fIl.toolTip" = "Cartella Condivisa";
/* Class = "NSToolbarItem"; label = "Stop"; ObjectID = "Bkx-Ph-j0D"; */
"Bkx-Ph-j0D.label" = "Arresta";
/* Class = "NSToolbarItem"; paletteLabel = "Stop"; ObjectID = "Bkx-Ph-j0D"; */
"Bkx-Ph-j0D.paletteLabel" = "Arresta";
/* Class = "NSToolbarItem"; toolTip = "Shuts down and stops the VM"; ObjectID = "Bkx-Ph-j0D"; */
"Bkx-Ph-j0D.toolTip" = "Spegne e Arresta VM";
/* Class = "NSToolbarItem"; label = "Toolbar Item"; ObjectID = "C8Y-BQ-Y6m"; */
"C8Y-BQ-Y6m.label" = "Elemento della Barra degli Strumenti";
/* Class = "NSToolbarItem"; paletteLabel = "Toolbar Item"; ObjectID = "C8Y-BQ-Y6m"; */
"C8Y-BQ-Y6m.paletteLabel" = "Elemento della Barra degli Strumenti";
/* Class = "NSToolbarItem"; label = "Capture Input"; ObjectID = "FN7-zs-mWC"; */
"FN7-zs-mWC.label" = "Cattura Input";
/* Class = "NSToolbarItem"; paletteLabel = "Capture Input"; ObjectID = "FN7-zs-mWC"; */
"FN7-zs-mWC.paletteLabel" = "Cattura Input";
/* Class = "NSToolbarItem"; toolTip = "Capture input devices"; ObjectID = "FN7-zs-mWC"; */
"FN7-zs-mWC.toolTip" = "Cattura Dispositivi di Input";
/* Class = "NSToolbarItem"; label = "Restart"; ObjectID = "G7P-HJ-bcy"; */
"G7P-HJ-bcy.label" = "Riavvia";
/* Class = "NSToolbarItem"; paletteLabel = "Restart"; ObjectID = "G7P-HJ-bcy"; */
"G7P-HJ-bcy.paletteLabel" = "Riavvia";
/* Class = "NSToolbarItem"; toolTip = "Restarts the VM"; ObjectID = "G7P-HJ-bcy"; */
"G7P-HJ-bcy.toolTip" = "Riavvia la VM";
/* Class = "NSToolbarItem"; label = "Windows"; ObjectID = "MQ2-L1-yl7"; */
"MQ2-L1-yl7.label" = "Finestre";
/* Class = "NSToolbarItem"; paletteLabel = "Windows"; ObjectID = "MQ2-L1-yl7"; */
"MQ2-L1-yl7.paletteLabel" = "Finestre";
/* Class = "NSToolbarItem"; toolTip = "Windows"; ObjectID = "MQ2-L1-yl7"; */
"MQ2-L1-yl7.toolTip" = "Finestre";
/* Class = "NSWindow"; title = "UTM"; ObjectID = "QvC-M9-y7g"; */
"QvC-M9-y7g.title" = "UTM";
/* Class = "NSToolbarItem"; label = "Resize Console"; ObjectID = "Ulf-oT-4cP"; */
"Ulf-oT-4cP.label" = "Ridimensiona Console";
/* Class = "NSToolbarItem"; paletteLabel = "Resize Console"; ObjectID = "Ulf-oT-4cP"; */
"Ulf-oT-4cP.paletteLabel" = "Ridimensiona Console";
/* Class = "NSToolbarItem"; toolTip = "Send console resize command"; ObjectID = "Ulf-oT-4cP"; */
"Ulf-oT-4cP.toolTip" = "Invia il comando Ridimensiona alla Console";
/* Class = "NSButton"; ibShadowedToolTip = "Starts/resumes the VM"; ObjectID = "ZTi-Hs-ge6"; */
"ZTi-Hs-ge6.ibShadowedToolTip" = "Avvia/Riprende l'esecuzione della VM";
/* Class = "NSToolbarItem"; label = "Drives"; ObjectID = "bKL-Th-FFw"; */
"bKL-Th-FFw.label" = "Dischi";
/* Class = "NSToolbarItem"; paletteLabel = "Drives"; ObjectID = "bKL-Th-FFw"; */
"bKL-Th-FFw.paletteLabel" = "Dischi";
/* Class = "NSToolbarItem"; toolTip = "Drive image options"; ObjectID = "bKL-Th-FFw"; */
"bKL-Th-FFw.toolTip" = "Opzioni Immagine Disco";
/* Class = "NSToolbarItem"; label = "Start/Pause"; ObjectID = "kT2-2U-cYm"; */
"kT2-2U-cYm.label" = "Avvia/Metti in Pausa";
/* Class = "NSToolbarItem"; paletteLabel = "Start/Pause"; ObjectID = "kT2-2U-cYm"; */
"kT2-2U-cYm.paletteLabel" = "Avvia/Metti in Pausa";
/* Class = "NSToolbarItem"; toolTip = "Start/pause the VM"; ObjectID = "kT2-2U-cYm"; */
"kT2-2U-cYm.toolTip" = "Avvia/Metti in Pausa la VM";
/* Class = "NSToolbarItem"; label = "USB"; ObjectID = "tlw-Fb-ne3"; */
"tlw-Fb-ne3.label" = "USB";
/* Class = "NSToolbarItem"; paletteLabel = "USB"; ObjectID = "tlw-Fb-ne3"; */
"tlw-Fb-ne3.paletteLabel" = "USB";
/* Class = "NSToolbarItem"; toolTip = "USB devices"; ObjectID = "tlw-Fb-ne3"; */
"tlw-Fb-ne3.toolTip" = "Dispositivi USB";

View File

@ -1,11 +1,11 @@
/* Class = "NSToolbarItem"; label = "Shared Folder"; ObjectID = "7EC-GE-fIl"; */ /* Class = "NSToolbarItem"; label = "Shared Folder"; ObjectID = "7EC-GE-fIl"; */
"7EC-GE-fIl.label" = "享資料夾"; "7EC-GE-fIl.label" = "享資料夾";
/* Class = "NSToolbarItem"; paletteLabel = "Shared Folder"; ObjectID = "7EC-GE-fIl"; */ /* Class = "NSToolbarItem"; paletteLabel = "Shared Folder"; ObjectID = "7EC-GE-fIl"; */
"7EC-GE-fIl.paletteLabel" = "享資料夾"; "7EC-GE-fIl.paletteLabel" = "享資料夾";
/* Class = "NSToolbarItem"; toolTip = "Shared folder"; ObjectID = "7EC-GE-fIl"; */ /* Class = "NSToolbarItem"; toolTip = "Shared folder"; ObjectID = "7EC-GE-fIl"; */
"7EC-GE-fIl.toolTip" = "享資料夾"; "7EC-GE-fIl.toolTip" = "享資料夾";
/* Class = "NSToolbarItem"; label = "Drives"; ObjectID = "bKL-Th-FFw"; */ /* Class = "NSToolbarItem"; label = "Drives"; ObjectID = "bKL-Th-FFw"; */
"bKL-Th-FFw.label" = "磁碟"; "bKL-Th-FFw.label" = "磁碟";
@ -32,13 +32,13 @@
"C8Y-BQ-Y6m.paletteLabel" = "工具列項目"; "C8Y-BQ-Y6m.paletteLabel" = "工具列項目";
/* Class = "NSToolbarItem"; label = "Capture Input"; ObjectID = "FN7-zs-mWC"; */ /* Class = "NSToolbarItem"; label = "Capture Input"; ObjectID = "FN7-zs-mWC"; */
"FN7-zs-mWC.label" = "擷取輸入"; "FN7-zs-mWC.label" = "捕獲輸入";
/* Class = "NSToolbarItem"; paletteLabel = "Capture Input"; ObjectID = "FN7-zs-mWC"; */ /* Class = "NSToolbarItem"; paletteLabel = "Capture Input"; ObjectID = "FN7-zs-mWC"; */
"FN7-zs-mWC.paletteLabel" = "擷取輸入"; "FN7-zs-mWC.paletteLabel" = "捕獲輸入";
/* Class = "NSToolbarItem"; toolTip = "Capture input devices"; ObjectID = "FN7-zs-mWC"; */ /* Class = "NSToolbarItem"; toolTip = "Capture input devices"; ObjectID = "FN7-zs-mWC"; */
"FN7-zs-mWC.toolTip" = "擷取輸入裝置"; "FN7-zs-mWC.toolTip" = "捕獲輸入裝置";
/* Class = "NSToolbarItem"; label = "Restart"; ObjectID = "G7P-HJ-bcy"; */ /* Class = "NSToolbarItem"; label = "Restart"; ObjectID = "G7P-HJ-bcy"; */
"G7P-HJ-bcy.label" = "重新啟動"; "G7P-HJ-bcy.label" = "重新啟動";

View File

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

View File

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

View File

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

View File

@ -1,173 +0,0 @@
//
// Copyright © 2023 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(macOS 13, *)
struct UTMServerView: View {
@EnvironmentObject private var remoteServer: UTMRemoteServer.State
@State private var isDeletingAll: Bool = false
var body: some View {
VStack(alignment: .leading) {
HStack {
Toggle("Enable UTM Server", isOn: Binding<Bool>(get: {
remoteServer.isServerActive
}, set: { value in
if value {
remoteServer.requestServerAction(.start)
} else {
remoteServer.requestServerAction(.stop)
}
}))
Spacer()
Button {
isDeletingAll = true
} label: {
Text("Reset Identity")
}
.alert("Confirmation", isPresented: $isDeletingAll) {
Button(role: .destructive) {
remoteServer.allClients.removeAll()
remoteServer.requestServerAction(.reset)
} label: {
Text("Reset Identity")
}.keyboardShortcut(.defaultAction)
} message: {
Text("Do you want to forget all clients and generate a new server identity? Any clients that previously paired with this server will be instructed to manually unpair with this server before they can connect again.")
}
}.padding([.top, .leading, .trailing])
ServerOverview()
Divider()
HStack {
if let address = remoteServer.externalIPAddress, let port = remoteServer.externalPort {
Text("Server IP: \(address), Port: \(String(port))")
.textSelection(.enabled)
}
Spacer()
if remoteServer.isServerActive {
Image(systemName: "circle.fill")
.foregroundStyle(.green)
Text("Running")
} else {
Image(systemName: "circle.fill")
.foregroundStyle(.red)
Text("Stopped")
}
}.padding([.bottom, .leading, .trailing])
}.disabled(remoteServer.isBusy)
}
}
@available(macOS 13, *)
fileprivate struct ServerOverview: View {
@EnvironmentObject private var remoteServer: UTMRemoteServer.State
@State private var sortOrder = [KeyPathComparator(\UTMRemoteServer.State.Client.name)]
@State private var selectedFingerprints = Set<UTMRemoteServer.State.ClientFingerprint>()
@State private var isDeleting: Bool = false
var body: some View {
Table(remoteServer.allClients, selection: $selectedFingerprints, sortOrder: $sortOrder) {
TableColumn("") { client in
if remoteServer.isConnected(client.fingerprint) {
Image(systemName: "circle.fill")
.foregroundStyle(.green)
}
}.width(16)
TableColumn("Name", value: \.name)
.width(ideal: 200)
TableColumn("Fingerprint") { client in
Text((client.fingerprint ^ remoteServer.serverFingerprint).hexString())
}.width(ideal: 300)
TableColumn("Last Seen", value: \.lastSeen) { client in
Text(DateFormatter.localizedString(from: client.lastSeen, dateStyle: .short, timeStyle: .short))
}.width(ideal: 150)
TableColumn("Status") { client in
if remoteServer.isConnected(client.fingerprint) {
Text("Connected")
} else if remoteServer.isBlocked(client.fingerprint) {
Text("Blocked")
} else if !remoteServer.isApproved(client.fingerprint) {
HStack {
Button {
remoteServer.approve(client.fingerprint)
} label: {
Text("Approve")
}.buttonStyle(.bordered)
Button {
remoteServer.block(client.fingerprint)
} label: {
Text("Block")
}.buttonStyle(.bordered)
}
}
}.width(ideal: 140)
}
.contextMenu(forSelectionType: UTMRemoteServer.State.ClientFingerprint.self) { items in
if items.count == 1 {
if remoteServer.isConnected(items.first!) {
Button {
remoteServer.disconnect(items.first!)
} label: {
Text("Disconnect")
}
}
if !remoteServer.isApproved(items.first!) {
Button {
remoteServer.approve(items.first!)
} label: {
Text("Approve")
}
}
if !remoteServer.isBlocked(items.first!) {
Button {
remoteServer.block(items.first!)
} label: {
Text("Block")
}
}
}
if items.count > 0 {
Button {
isDeleting = true
selectedFingerprints = items
} label: {
Text("Delete")
}
}
}
.onChange(of: sortOrder) {
remoteServer.allClients.sort(using: $0)
}
.onDeleteCommand {
isDeleting = true
}
.alert("Confirmation", isPresented: $isDeleting) {
Button(role: .destructive) {
remoteServer.allClients.removeAll(where: { selectedFingerprints.contains($0.fingerprint) })
} label: {
Text("Delete")
}.keyboardShortcut(.defaultAction)
} message: {
Text("Do you want to forget the selected client(s)?")
}
}
}
@available(macOS 13, *)
#Preview {
UTMServerView()
}

View File

@ -18,18 +18,20 @@ 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, UTMVirtualMachineDelegate { @MainActor class VMHeadlessSessionState: NSObject, ObservableObject {
let vm: any UTMVirtualMachine let vm: any UTMVirtualMachine
var onStop: (() -> Void)? var onStop: ((Notification) -> 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: (() -> Void)?) { init(for vm: any UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
self.vm = vm self.vm = vm
self.onStop = onStop self.onStop = onStop
super.init() super.init()
@ -40,7 +42,9 @@ 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
@ -59,6 +63,7 @@ import IOKit.pwr_mgt
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
@ -96,7 +101,6 @@ extension VMHeadlessSessionState {
if let preventIdleSleepAssertion = preventIdleSleepAssertion { if let preventIdleSleepAssertion = preventIdleSleepAssertion {
IOPMAssertionRelease(preventIdleSleepAssertion) IOPMAssertionRelease(preventIdleSleepAssertion)
} }
onStop?()
} }
} }

View File

@ -1,35 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import IOKit.pwr_mgt
/// Represents the UI state for a single headless VM session.
class VMRemoteSessionState: VMHeadlessSessionState {
public weak var client: UTMRemoteServer.Remote?
init(for vm: any UTMVirtualMachine, client: UTMRemoteServer.Remote, onStop: (() -> Void)?) {
self.client = client
super.init(for: vm, onStop: onStop)
}
override func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
Task {
try? await client?.virtualMachine(id: vm.id, didErrorWithMessage: message)
super.virtualMachine(vm, didErrorWithMessage: message)
}
}
}

View File

@ -1,8 +0,0 @@
/* Bundle name */
"CFBundleName" = "UTM";
/* (No Comment) */
"UTM virtual machine" = "Macchina Virtuale UTM";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "Permette alle Macchine Virtuali di accedere al Microfono";

View File

@ -4,10 +4,6 @@
<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>
@ -18,8 +14,6 @@
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.temporary-exception.sbpl</key> <key>com.apple.security.temporary-exception.sbpl</key>
<array> <array>
<string>(allow network-outbound)</string> <string>(allow network-outbound)</string>

View File

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

View File

@ -73,7 +73,6 @@
"TCP Client Connection" = "Połączenie kilent TCP"; "TCP Client Connection" = "Połączenie kilent TCP";
"TCP Server Connection" = "Połączenie serwer TCP"; "TCP Server Connection" = "Połączenie serwer TCP";
"Automatic Serial Device (max 4)" = "Automatyczne urządzenie szeregowe (maks. 4)"; "Automatic Serial Device (max 4)" = "Automatyczne urządzenie szeregowe (maks. 4)";
"Automatic" = "Automatyczny";
"Manual Serial Device (advanced)" = "Manualne urządzenie szeregowe (zaawansowane)"; "Manual Serial Device (advanced)" = "Manualne urządzenie szeregowe (zaawansowane)";
"GDB Debug Stub" = "GDB Debug Stub"; "GDB Debug Stub" = "GDB Debug Stub";
"QEMU Monitor (HMP)" = "Monitor QEMU (HMP)"; "QEMU Monitor (HMP)" = "Monitor QEMU (HMP)";
@ -179,7 +178,7 @@
"Information" = "Informacje"; "Information" = "Informacje";
"System" = "System"; "System" = "System";
"QEMU" = "QEMU"; "QEMU" = "QEMU";
"Input" = "Urządzenie peryferyjne"; "Input" = "Urządzenie WE/WY";
"Sharing" = "Współdzielenie"; "Sharing" = "Współdzielenie";
"Devices" = "Urządzenia"; "Devices" = "Urządzenia";
"Display" = "Monitor"; "Display" = "Monitor";
@ -296,12 +295,10 @@
"Sound Backend" = "Tryb dźwięku"; "Sound Backend" = "Tryb dźwięku";
"SPICE with GStreamer (Input & Output)" = "SPICE z GStreamerem (WE/WY)"; "SPICE with GStreamer (Input & Output)" = "SPICE z GStreamerem (WE/WY)";
"CoreAudio (Output Only)" = "CoreAudio (tylko wyjście)"; "CoreAudio (Output Only)" = "CoreAudio (tylko wyjście)";
"Mouse/Keyboard" = "Klawiatura/mysz";
"Capture input automatically when entering full screen" = "Przechwytuj mysz automatycznie w trybie pełnoekranowym";
"Console" = "Konsola"; "Console" = "Konsola";
"Option (⌥) is Meta key" = "Option to klawisz Meta"; "Option (⌥) is Meta key" = "Option to klawisz Meta";
"If enabled, Option will be mapped to the Meta key which can be useful for emacs. Otherwise, option will work as the system intended (such as for entering international text)." = "Jeśli włączone, klawisz Option będzie zmapowany jako klawisz Meta, który może być przydatny dla eMacków. W innym wypadku, ta opcja będzie działać jak system przewiduje (tj: wpisywanie międzynarodowego tekstu)."; "If enabled, Option will be mapped to the Meta key which can be useful for emacs. Otherwise, option will work as the system intended (such as for entering international text)." = "Jeśli włączone, klawisz Option będzie zmapowany jako klawisz Meta, który może być przydatny dla eMacków. W innym wypadku, ta opcja będzie działać jak system przewiduje (tj: wpisywanie międzynarodowego tekstu).";
"QEMU Pointer" = "Mysz QEMU"; "QEMU Pointer" = "Wskaźnik QEMU";
"Hold Control (⌃) for right click" = "Przytrzymaj Control (⌃) aby wykonać prawe kliknięcie"; "Hold Control (⌃) for right click" = "Przytrzymaj Control (⌃) aby wykonać prawe kliknięcie";
"Invert scrolling" = "Odwróć przewijanie"; "Invert scrolling" = "Odwróć przewijanie";
"If enabled, scroll wheel input will be inverted." = "Jeśli włączone, przewijanie będzie odwrócone"; "If enabled, scroll wheel input will be inverted." = "Jeśli włączone, przewijanie będzie odwrócone";
@ -520,8 +517,7 @@
"Guest Network (IPv6)" = "Sieć gościa (IPv6)"; "Guest Network (IPv6)" = "Sieć gościa (IPv6)";
"Host Address" = "Adres hosta"; "Host Address" = "Adres hosta";
"Host Address (IPv6)" = "Adres hostaIPv6)"; "Host Address (IPv6)" = "Adres hostaIPv6)";
"DHCP Start" = "Pierwszy adres zakresu DHCP"; "DHCP Start" = "Start DHCP";
"DHCP End" = "Ostatni adres zakresu DHCP";
"DHCP Domain Name" = "Nazwa domeny DHCP"; "DHCP Domain Name" = "Nazwa domeny DHCP";
"DNS Server" = "Serwer DNS"; "DNS Server" = "Serwer DNS";
"DNS Server (IPv6)" = "Serwer DNSIPv6"; "DNS Server (IPv6)" = "Serwer DNSIPv6";
@ -554,7 +550,7 @@
"Instantiate PS/2 controller even when USB input is supported. Required for older Windows." = "Utwórz instancję kontrolera PS/2 nawet jeśli kontroler USB jest wspierany. Wymagane dla starszych wersji systemu Windows."; "Instantiate PS/2 controller even when USB input is supported. Required for older Windows." = "Utwórz instancję kontrolera PS/2 nawet jeśli kontroler USB jest wspierany. Wymagane dla starszych wersji systemu Windows.";
"QEMU Machine Properties" = "Właściwości maszyny QEMU"; "QEMU Machine Properties" = "Właściwości maszyny QEMU";
"This is appended to the -machine argument." = "To jest dołączone do argumentu -machine."; "This is appended to the -machine argument." = "To jest dołączone do argumentu -machine.";
"QEMU Arguments" = "Argumenty rozruchu QEMU"; "QEMU Arguments" = "Argumenty dla QEMU";
"Export QEMU Command…" = "Eksportuj komendę QEMU…"; "Export QEMU Command…" = "Eksportuj komendę QEMU…";
"(Delete)" = "Usuń"; "(Delete)" = "Usuń";

View File

@ -15,40 +15,23 @@
// //
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
if let window = newSession.windows.first { openWindow(value: newSession.newWindow())
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
@ -63,17 +46,12 @@ struct UTMApp: App {
WindowGroup(for: VMSessionState.GlobalWindowID.self) { $globalID in WindowGroup(for: VMSessionState.GlobalWindowID.self) { $globalID in
if let globalID = globalID, let session = VMSessionState.allActiveSessions[globalID.sessionID] { if let globalID = globalID, let session = VMSessionState.allActiveSessions[globalID.sessionID] {
VMWindowView(id: globalID.windowID).environmentObject(session) VMWindowView(id: globalID.windowID).environmentObject(session)
.glassBackgroundEffect(in: .rect(cornerRadius: 15))
#if WITH_SOLO_VM
.onAppear { .onAppear {
// currently we only support one session, so close the home window // currently we only support one session, so close the home window
dismissWindow(id: "home") dismissWindow(id: "home")
} }
#endif
} }
} }
.windowStyle(.plain)
.windowResizability(.contentMinSize) .windowResizability(.contentMinSize)
KeyboardWindowGroup()
} }
} }

View File

@ -15,35 +15,23 @@
// //
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 state.isRunning { if session.vm.state == .started {
state.alert = .powerDown state.alert = .powerDown
} else { } else {
state.alert = .terminateApp state.alert = .terminateApp
} }
} label: { } label: {
if state.isRunning { Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
Label("Power Off", systemImage: "power")
} else {
Label("Force Kill", systemImage: "xmark")
}
} }
.disabled(state.isBusy) .disabled(state.isBusy)
Button { Button {
@ -68,7 +56,7 @@ struct VMToolbarOrnamentModifier: ViewModifier {
} }
.disabled(state.isBusy) .disabled(state.isBusy)
} }
#if WITH_USB #if !WITH_QEMU_TCI
if session.vm.hasUsbRedirection { if session.vm.hasUsbRedirection {
VMToolbarUSBMenuView() VMToolbarUSBMenuView()
.disabled(state.isBusy) .disabled(state.isBusy)
@ -79,39 +67,11 @@ struct VMToolbarOrnamentModifier: ViewModifier {
VMToolbarDisplayMenuView(state: $state) VMToolbarDisplayMenuView(state: $state)
.disabled(state.isBusy) .disabled(state.isBusy)
Button { Button {
if case .display(_, _) = state.device { state.isKeyboardRequested = true
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
@ -130,30 +90,6 @@ 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
private struct ToolbarOrnamentViewModifier: ViewModifier {
func body(content: Content) -> some View {
content
.buttonBorderShape(.capsule)
.buttonStyle(.borderless)
.labelStyle(.iconOnly)
.padding(12)
.glassBackgroundEffect()
}
} }
// 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
/* Bundle display name */
"CFBundleDisplayName" = "QEMUHelper";
/* Bundle name */
"CFBundleName" = "QEMUHelper";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2020 osy. Tutti i diritti riservati.";

View File

@ -1,9 +0,0 @@
/* QEMUHelper */
"Cannot find QEMU support libraries." = "Impossibile trovare le librerie di supporto di QEMU";
/* QEMUHelper */
"Error starting QEMU." = "Erorre durante l'avvio di QUEMU";
/* QEMUHelper */
"QEMU exited unexpectedly." = "QEMU si è interrotto inaspettatamente";

View File

@ -1,78 +0,0 @@
# UTM
[![Build](https://github.com/utmapp/UTM/workflows/Build/badge.svg?branch=master&event=push)][1]
> 계산 가능한 수열을 계산하는 단일 기계를 발명할 수 있습니다.(It is possible to invent a single machine which can be used to compute any computable sequence.)
-- <cite>엘런 튜링, 1936</cite>
UTM은 iOS와 macOS를 위한 완전한 시스템 에뮬레이터, 가상머신입니다. 이것은 QEMU를 기반으로 합니다. 요컨데 당신은 이것을 통해, Windows나 Linux와 같은 운영체제들을 Mac, iPhone, iPad 등에서 구동할 수 있습니다. 자세한 내용은 https://getutm.app/ 와 https://mac.getutm.app/ 를 읽어주세요.
<p align="center">
<img width="450px" alt="iPhone에서 동작하는 UTM" src="screen.png">
<br>
<img width="450px" alt="MacBook에서 동작하는 UTM" src="screenmac.png">
</p>
## 주요기능
* QMEU를 활용한 완전한 시스템 에뮬레이션(MMU, 기타 기기들)
* x86_64, ARM64, and RISC-V를 포함한 30가지 이상의 프로세서 지원
* SPICE와 QXL을 활용한 VGA 그래픽 모드
* 텍스트 터미널 모드
* USB 장치들
* QEMU TCG를 활용한 JIT 기반 가속
* 초기부터 macOS 11과 iOS 11+를 위해 디자인된, 최신 및 최고의 API를 활용한 프론트엔드
* 당신의 기기에서 바로 가상머신을 생성하고, 관리하고, 구동하기
## macOS 추가 기능
* Hypervisor.framework와 QEMU를 활용한 하드웨어 가속 가상화
* macOS 12+에서 Virtualization.framework를 통해 macOS 게스트 구동
## UTM SE
UTM/QEMU이 최고의 성능을 내기 위해서는 동적 코드 생성이(JIT) 필요합니다. iOS 기기에서 JIT는 jailbroken를 요구하거나, 특정 iOS 버전에서 발견된 다양한 해결책 중 하나를 필요로 합니다.(자세한 내용은 "설치" 부분을 참고해주세요."
UTM SE("slow edition")은 [threaded interpreter][3]를 사용합니다. 이는 전통적인 인터프리터보다는 좋지만, 그래도 여전히 JIT보다는 느립니다. 이 기술은 [iSH][4]가 동적 실행을 위해 하는 일과 유사한데요. 결과적으로 UTM SE는 탈옥이나 JIT 해결책을 요구하진 않고, 정규 앱으로 나란히 메모리에 적재될 수 있습니다.
빌드 시간과 크기를 최적하기 위해서, UTM SE에는 ARM, PPC, RISC-V, x86(32bit와 64bit 변종 모두) 아키텍처들만이 포함되어 있습니다.
## 설치
iOS를 위한 UTM (SE): https://getutm.app/install/
macOS를 위한 UTM: https://mac.getutm.app/
## 개발
### [macOS 개발](Documentation/MacDevelopment.md)
### [iOS 개발](Documentation/iOSDevelopment.md)
## 관련사항
* [iSH][4]: iOS에서 x86 Linux 앱을 실행하기 위해, 사용자 모드 Linux 터미널 인터페이스를 에뮬레이트
* [a-shell][5]: 기본적으로 iOS용으로 구축되면서, 터미널 인터페이스를 통해 액세스할 수 있는, 범용 유닉스 명령 및 유틸리티 패키지
## 라이센스
UTM은 permissive Apache 2.0 license를 따르며 배포되었습니다. 하지만 몇몇 (L)GPL 컴포넌트들을 사용하는데요. 대부분은 동적으로 연결되어있지만, gstreamer 플러그인은 정적으로 연결되어 있고, 일부 코드는 qemu에서 가져왔습니다. 이 앱을 재배포 하려는 경우 꼭 이에 유의하시길 바랍니다.
일부 아이콘은 [www.flaticon.com](https://www.flaticon.com/)에서 [Freepik](https://www.freepik.com)를 통해 만들어졌습니다.
추가적으로 UTM 프론트엔드는 아래의 MIT/BSD 라이센스를 사용하는 컴포넌트들에 의존하고 있습니다.
* [IQKeyboardManager](https://github.com/hackiftekhar/IQKeyboardManager)
* [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm)
* [ZIP Foundation](https://github.com/weichsel/ZIPFoundation)
* [InAppSettingsKit](https://github.com/futuretap/InAppSettingsKit)
지속 통합 호스팅은 다음을 통해 제공됩니다. [MacStadium](https://www.macstadium.com/opensource)
[<img src="https://uploads-ssl.webflow.com/5ac3c046c82724970fc60918/5c019d917bba312af7553b49_MacStadium-developerlogo.png" alt="MacStadium logo" width="250">](https://www.macstadium.com)
[1]: https://github.com/utmapp/UTM/actions?query=event%3Arelease+workflow%3ABuild
[2]: screen.png
[3]: https://github.com/ktemkin/qemu/blob/with_tcti/tcg/aarch64-tcti/README.md
[4]: https://github.com/ish-app/ish
[5]: https://github.com/holzschu/a-shell

View File

@ -4,7 +4,7 @@
> 發明一台可用於計算任何可計算序列的機器是可行的。 > 發明一台可用於計算任何可計算序列的機器是可行的。
-- <cite>艾倫·圖靈Alan Turing, 1936 年</cite> -- <cite>艾倫·圖靈Alan Turing, 1936 年</cite>
UTM 是一個功能完备的系統模擬工具與虛擬电脑主機,適用於 iOS 和 macOS。它基於 QEMU。簡言之,它允許你在 Mac、iPhone 和 iPad 上執行 Windows、Linux 等。更多訊息請 https://getutm.app/ 與 https://mac.getutm.app/。 UTM 是一個功能完备的系統模擬工具和虛擬电脑主機,適用於 iOS 和 macOS。它以 QEMU 為基礎。簡言之,它允許你在 Mac、iPhone 和 iPad 上執行 Windows、Linux 等。更多訊息請參閱 https://getutm.app/ 與 https://mac.getutm.app/。
<p align="center"> <p align="center">
<img width="450px" alt=「在 iPhone 上執行 UTM" src="screen.png"> <img width="450px" alt=「在 iPhone 上執行 UTM" src="screen.png">
@ -14,7 +14,7 @@ UTM 是一個功能完备的系統模擬工具與虛擬电脑主機,適用於
## 特性 ## 特性
* 使用 QEMU 進行全作業系統模擬MMU、裝置等) * 使用 QEMU 進行全作業系統模擬MMU、設備等)
* 支援逾三十種體系結構 CPU包括 x86_64、ARM64 和 RISC-V * 支援逾三十種體系結構 CPU包括 x86_64、ARM64 和 RISC-V
* 使用 SPICE 與 QXL 的 VGA 圖形模式 * 使用 SPICE 與 QXL 的 VGA 圖形模式
* 文本終端機模式 * 文本終端機模式
@ -23,14 +23,14 @@ UTM 是一個功能完备的系統模擬工具與虛擬电脑主機,適用於
* 採用了最新最靚的 API從零開始設計前端支援 macOS 11+ 與 iOS 11+ * 採用了最新最靚的 API從零開始設計前端支援 macOS 11+ 與 iOS 11+
* 從你的裝置上直接製作、管理和執行虛擬機 * 從你的裝置上直接製作、管理和執行虛擬機
## macOS 的附加功能 ## macOS 的附加功能
* 使用 Hypervisor.framework 與 QEMU 實現硬件加速虛擬化 * 使用 Hypervisor.framework 與 QEMU 實現硬件加速虛擬化
* 在 macOS 12+ 上使用 Virtualization.framework 來啓動 macOS 客戶端 * 在 macOS 12+ 上使用 Virtualization.framework 來啓動 macOS 客戶端
## UTM SE ## UTM SE
UTM/QEMU 需要動態程式碼生成JIT以得到最大性能。iOS 上的 JIT 需要已經越獄Jailbreak的裝置iOS 11.0~14.3 需越獄iOS 14.4+ 需要),或者為特定版本的 iOS 找到其他變通方法之一(有關更多詳細訊息,請「安裝」)。 UTM/QEMU 需要動態程式碼生成JIT以得到最大性能。iOS 上的 JIT 需要已經越獄Jailbreak的裝置iOS 11.0~14.3 需越獄iOS 14.4+ 需要),或者為特定版本的 iOS 找到其他變通方法之一(有關更多詳細訊息,請參閱「安裝」)。
UTM SE「較慢版」使用了「[執行緒解釋器][3]」,其性能優於傳統解釋器,但仍然比 JIT 要慢。此種技術類似於 [iSH][4] 的動態執行。因此UTM SE 無需越獄或任何 JIT 的變通方法可以作為常規應用程式側載Sideload UTM SE「較慢版」使用了「[執行緒解釋器][3]」,其性能優於傳統解釋器,但仍然比 JIT 要慢。此種技術類似於 [iSH][4] 的動態執行。因此UTM SE 無需越獄或任何 JIT 的變通方法可以作為常規應用程式側載Sideload
@ -55,7 +55,7 @@ UTM 同時支援 macOShttps://mac.getutm.app/
## 許可證 ## 許可證
UTM 於 Apache 2.0 許可證下發佈,但它採用了若干 GPL 與 LGPL 元件。這其中,大多數元件為動態連接,但 gstreamer 元件為靜態連接,部分程式碼來自 QEMU。如你打算重新分發此應用程式請務必緊記這一點。 UTM 於 Apache 2.0 許可證下發佈,但它採用了若干 GPL 與 LGPL 元件。這其中,大多數元件是動態連接的,但 gstreamer 元件是靜態連接的,部分程式碼來自 QEMU。如果你打算重新分發此應用程式請務必謹記這一點。
某些图示由 [Freepik](https://www.freepik.com) 從 [www.flaticon.com](https://www.flaticon.com/) 製作。 某些图示由 [Freepik](https://www.freepik.com) 從 [www.flaticon.com](https://www.flaticon.com/) 製作。

View File

@ -1,276 +0,0 @@
//
// 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;
}

View File

@ -1,33 +0,0 @@
//
// Copyright © 2023 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
#ifndef GenerateKey_h
#define GenerateKey_h
#include <CoreFoundation/CoreFoundation.h>
/// Generate a RSA-4096 key and return a PKCS#12 encoded data
///
/// The password of the blob is `password`. Returns NULL on error.
/// - Parameters:
/// - commonName: CN field of the certificate, max length is 1024 bytes
/// - organizationName: O field of the certificate, max length is 1024 bytes
/// - serial: Serial number of the certificate
/// - days: Validity in days from today
/// - isClient: If 0 then a TLS Server certificate is generated, otherwise a TLS Client certificate is generated
_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
#endif /* GenerateKey_h */

View File

@ -1,588 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Network
import SwiftConnect
let service = "_utm_server._tcp"
actor UTMRemoteClient {
let state: State
private let keyManager = UTMRemoteKeyManager(forClient: true)
private let connectionQueue = DispatchQueue(label: "UTM Remote Client Connection")
private var local: Local
private var scanTask: Task<Void, Error>?
private(set) var server: Remote!
nonisolated var fingerprint: [UInt8] {
keyManager.fingerprint ?? []
}
@MainActor
init(data: UTMRemoteData) {
self.state = State()
self.local = Local(data: data)
}
private func withErrorAlert(_ body: () async throws -> Void) async {
do {
try await body()
} catch {
await state.showErrorAlert(error.localizedDescription)
}
}
func startScanning() {
scanTask = Task {
await withErrorAlert {
for try await results in Connection.browse(forServiceType: service) {
await self.didFindResults(results)
}
}
}
}
func stopScanning() {
scanTask?.cancel()
scanTask = nil
}
func refresh() {
stopScanning()
startScanning()
}
func didFindResults(_ results: Set<NWBrowser.Result>) async {
let servers = results.compactMap { result in
let model: String?
if case .bonjour(let txtRecord) = result.metadata,
case .string(let value) = txtRecord.getEntry(for: "Model") {
model = value
} else {
model = nil
}
switch result.endpoint {
case .service(let name, _, _, _):
return State.DiscoveredServer(hostname: result.endpoint.debugDescription, model: model, name: name, endpoint: result.endpoint)
default:
return nil
}
}
await state.updateFoundServers(servers)
}
func connect(_ server: State.SavedServer) async throws {
var isSuccessful = false
let endpoint = server.endpoint ?? NWEndpoint.hostPort(host: .init(server.hostname), port: .init(integerLiteral: UInt16(server.port ?? 0)))
try await keyManager.load()
let connection = try await Connection(endpoint: endpoint, connectionQueue: connectionQueue, identity: keyManager.identity) { connection, error in
Task {
do {
try await self.local.data.reconnect(to: server)
} catch {
// reconnect failed
await self.state.setConnected(false)
await self.state.showErrorAlert(error.localizedDescription)
}
}
}
defer {
if !isSuccessful {
connection.close()
}
}
guard let host = connection.connection.currentPath?.remoteEndpoint?.hostname else {
throw ConnectionError.cannotDetermineHost
}
guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else {
throw ConnectionError.cannotFindFingerprint
}
if server.fingerprint.isEmpty {
throw ConnectionError.fingerprintUntrusted(fingerprint)
} else if server.fingerprint != fingerprint {
throw ConnectionError.fingerprintMismatch(fingerprint)
}
try Task.checkCancellation()
let peer = Peer(connection: connection, localInterface: local)
let remote = Remote(peer: peer, host: host)
let (isAuthenticated, device) = try await remote.handshake(password: server.password)
if !isAuthenticated {
if server.password == nil {
throw ConnectionError.passwordRequired
} else {
throw ConnectionError.passwordInvalid
}
}
self.server = remote
var server = server
await state.setConnected(true)
if !server.shouldSavePassword {
server.password = nil
}
if server.name.isEmpty {
server.name = server.hostname
}
server.lastSeen = Date()
server.model = device.model
await state.save(server: server)
isSuccessful = true
}
}
extension UTMRemoteClient {
@MainActor
class State: ObservableObject {
typealias ServerFingerprint = [UInt8]
struct DiscoveredServer: Identifiable {
let hostname: String
var model: String?
var name: String
var endpoint: NWEndpoint
var id: String {
hostname
}
}
struct SavedServer: Codable, Identifiable {
var fingerprint: ServerFingerprint
var hostname: String
var port: Int?
var model: String?
var name: String
var lastSeen: Date
var password: String?
var endpoint: NWEndpoint?
var shouldSavePassword: Bool = false
private enum CodingKeys: String, CodingKey {
case fingerprint, hostname, port, model, name, lastSeen, password
}
var id: ServerFingerprint {
fingerprint
}
var isAvailable: Bool {
endpoint != nil || (port != nil && port != 0)
}
init() {
self.hostname = ""
self.name = ""
self.lastSeen = Date()
self.fingerprint = []
}
init(from discovered: DiscoveredServer) {
self.hostname = discovered.hostname
self.model = discovered.model
self.name = discovered.name
self.lastSeen = Date()
self.endpoint = discovered.endpoint
self.fingerprint = []
}
}
struct AlertMessage: Identifiable {
let id = UUID()
let message: String
}
@Published var savedServers: [SavedServer] {
didSet {
UserDefaults.standard.setValue(try! savedServers.propertyList(), forKey: "TrustedServers")
}
}
@Published var foundServers: [DiscoveredServer] = []
@Published var isScanning: Bool = false
@Published private(set) var isConnected: Bool = false
@Published var alertMessage: AlertMessage?
init() {
var _savedServers = Array<SavedServer>()
if let array = UserDefaults.standard.array(forKey: "TrustedServers") {
if let servers = try? Array<SavedServer>(fromPropertyList: array) {
_savedServers = servers
}
}
self.savedServers = _savedServers
}
func showErrorAlert(_ message: String) {
alertMessage = AlertMessage(message: message)
}
func updateFoundServers(_ servers: [DiscoveredServer]) {
for idx in savedServers.indices {
savedServers[idx].endpoint = nil
}
foundServers = servers.filter { server in
if let idx = savedServers.firstIndex(where: { $0.port == nil && $0.hostname == server.hostname }) {
savedServers[idx].endpoint = server.endpoint
return false
} else {
return true
}
}
}
func save(server: SavedServer) {
if let idx = savedServers.firstIndex(where: { $0.fingerprint == server.fingerprint }) {
savedServers[idx] = server
} else {
savedServers.append(server)
}
}
func delete(server: SavedServer) {
savedServers.removeAll(where: { $0.fingerprint == server.fingerprint })
}
fileprivate func setConnected(_ connected: Bool) {
isConnected = connected
}
}
}
extension UTMRemoteClient {
class Local: LocalInterface {
typealias M = UTMRemoteMessageClient
fileprivate let data: UTMRemoteData
init(data: UTMRemoteData) {
self.data = data
}
func handle(message: M, data: Data) async throws -> Data {
switch message {
case .clientHandshake:
return try await _handshake(parameters: .decode(data)).encode()
case .listHasChanged:
return try await _listHasChanged(parameters: .decode(data)).encode()
case .qemuConfigurationHasChanged:
return try await _qemuConfigurationHasChanged(parameters: .decode(data)).encode()
case .mountedDrivesHasChanged:
return try await _mountedDrivesHasChanged(parameters: .decode(data)).encode()
case .virtualMachineDidTransition:
return try await _virtualMachineDidTransition(parameters: .decode(data)).encode()
case .virtualMachineDidError:
return try await _virtualMachineDidError(parameters: .decode(data)).encode()
}
}
private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
return .init(version: UTMRemoteMessageClient.version, capabilities: .current)
}
private func _listHasChanged(parameters: M.ListHasChanged.Request) async throws -> M.ListHasChanged.Reply {
await data.remoteListHasChanged(ids: parameters.ids)
return .init()
}
private func _qemuConfigurationHasChanged(parameters: M.QEMUConfigurationHasChanged.Request) async throws -> M.QEMUConfigurationHasChanged.Reply {
await data.remoteQemuConfigurationHasChanged(id: parameters.id, configuration: parameters.configuration)
return .init()
}
private func _mountedDrivesHasChanged(parameters: M.MountedDrivesHasChanged.Request) async throws -> M.MountedDrivesHasChanged.Reply {
await data.remoteMountedDrivesHasChanged(id: parameters.id, mountedDrives: parameters.mountedDrives)
return .init()
}
private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
await data.remoteVirtualMachineDidTransition(id: parameters.id, state: parameters.state, isTakeoverAllowed: parameters.isTakeoverAllowed)
return .init()
}
private func _virtualMachineDidError(parameters: M.VirtualMachineDidError.Request) async throws -> M.VirtualMachineDidError.Reply {
await data.remoteVirtualMachineDidError(id: parameters.id, message: parameters.errorMessage)
return .init()
}
}
}
extension UTMRemoteClient {
class Remote {
typealias M = UTMRemoteMessageServer
private let peer: Peer<UTMRemoteMessageClient>
let host: String
private(set) var capabilities: UTMCapabilities?
init(peer: Peer<UTMRemoteMessageClient>, host: String) {
self.peer = peer
self.host = host
}
func close() {
peer.close()
}
func handshake(password: String?) async throws -> (isAuthenticated: Bool, device: MacDevice) {
let reply = try await _handshake(parameters: .init(version: UTMRemoteMessageServer.version, password: password))
guard reply.version == UTMRemoteMessageServer.version else {
throw ClientError.versionMismatch
}
capabilities = reply.capabilities
return (isAuthenticated: reply.isAuthenticated, device: MacDevice(model: reply.model))
}
func listVirtualMachines() async throws -> [UUID] {
try await _listVirtualMachines(parameters: .init()).ids
}
func reorderVirtualMachines(fromIds ids: [UUID], toOffset offset: Int) async throws {
try await _reorderVirtualMachines(parameters: .init(ids: ids, offset: offset))
}
func getVirtualMachineInformation(for ids: [UUID]) async throws -> [M.VirtualMachineInformation] {
try await _getVirtualMachineInformation(parameters: .init(ids: ids)).informations
}
func getQEMUConfiguration(for id: UUID) async throws -> UTMQemuConfiguration {
try await _getQEMUConfiguration(parameters: .init(id: id)).configuration
}
func getPackageSize(for id: UUID) async throws -> Int64 {
try await _getPackageSize(parameters: .init(id: id)).size
}
func getPackageFile(for id: UUID, relativePathComponents: [String]) async throws -> URL {
let fm = FileManager.default
let packageUrl = try packageUrl(for: id)
let fileUrl = packageUrl.appendingPathComponent(relativePathComponents.joined(separator: "_"))
var lastModified: Date?
if fm.fileExists(atPath: fileUrl.path) {
lastModified = try? fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date
}
let reply = try await _getPackageFile(parameters: .init(id: id, relativePathComponents: relativePathComponents, lastModified: lastModified))
if let data = reply.data {
fm.createFile(atPath: fileUrl.path, contents: data, attributes: [.modificationDate: reply.lastModified])
}
return fileUrl
}
func sendPackageFile(for id: UUID, relativePathComponents: [String], data: Data) async throws {
let fm = FileManager.default
let packageUrl = try packageUrl(for: id)
let fileUrl = packageUrl.appendingPathComponent(relativePathComponents.joined(separator: "_"))
guard fm.createFile(atPath: fileUrl.path, contents: data) else {
throw ConnectionError.failedToAccessFile
}
guard let lastModified = try fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date else {
throw ConnectionError.failedToAccessFile
}
try await _sendPackageFile(parameters: .init(id: id, relativePathComponents: relativePathComponents, lastModified: lastModified, data: data))
}
func deletePackageFile(for id: UUID, relativePathComponents: [String]) async throws {
let fm = FileManager.default
let packageUrl = try packageUrl(for: id)
let fileUrl = packageUrl.appendingPathComponent(relativePathComponents.joined(separator: "_"))
try fm.removeItem(at: fileUrl)
try await _deletePackageFile(parameters: .init(id: id, relativePathComponents: relativePathComponents))
}
func mountGuestToolsOnVirtualMachine(id: UUID) async throws {
try await _mountGuestToolsOnVirtualMachine(parameters: .init(id: id))
}
func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> UTMRemoteMessageServer.StartVirtualMachine.ServerInformation {
return try await _startVirtualMachine(parameters: .init(id: id, options: options)).serverInfo
}
func stopVirtualMachine(id: UUID, method: UTMVirtualMachineStopMethod) async throws {
try await _stopVirtualMachine(parameters: .init(id: id, method: method))
}
func restartVirtualMachine(id: UUID) async throws {
try await _restartVirtualMachine(parameters: .init(id: id))
}
func pauseVirtualMachine(id: UUID) async throws {
try await _pauseVirtualMachine(parameters: .init(id: id))
}
func resumeVirtualMachine(id: UUID) async throws {
try await _resumeVirtualMachine(parameters: .init(id: id))
}
func saveSnapshotVirtualMachine(id: UUID, name: String?) async throws {
try await _saveSnapshotVirtualMachine(parameters: .init(id: id, name: name))
}
func deleteSnapshotVirtualMachine(id: UUID, name: String?) async throws {
try await _deleteSnapshotVirtualMachine(parameters: .init(id: id, name: name))
}
func restoreSnapshotVirtualMachine(id: UUID, name: String?) async throws {
try await _restoreSnapshotVirtualMachine(parameters: .init(id: id, name: name))
}
func changePointerTypeVirtualMachine(id: UUID, toTabletMode tablet: Bool) async throws {
try await _changePointerTypeVirtualMachine(parameters: .init(id: id, isTabletMode: tablet))
}
private func packageUrl(for id: UUID) throws -> URL {
let fm = FileManager.default
let cacheUrl = try fm.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let packageUrl = cacheUrl.appendingPathComponent(id.uuidString)
if !fm.fileExists(atPath: packageUrl.path) {
try fm.createDirectory(at: packageUrl, withIntermediateDirectories: false)
}
return packageUrl
}
private func _handshake(parameters: M.ServerHandshake.Request) async throws -> M.ServerHandshake.Reply {
try await M.ServerHandshake.send(parameters, to: peer)
}
private func _listVirtualMachines(parameters: M.ListVirtualMachines.Request) async throws -> M.ListVirtualMachines.Reply {
try await M.ListVirtualMachines.send(parameters, to: peer)
}
@discardableResult
private func _reorderVirtualMachines(parameters: M.ReorderVirtualMachines.Request) async throws -> M.ReorderVirtualMachines.Reply {
try await M.ReorderVirtualMachines.send(parameters, to: peer)
}
private func _getVirtualMachineInformation(parameters: M.GetVirtualMachineInformation.Request) async throws -> M.GetVirtualMachineInformation.Reply {
try await M.GetVirtualMachineInformation.send(parameters, to: peer)
}
private func _getQEMUConfiguration(parameters: M.GetQEMUConfiguration.Request) async throws -> M.GetQEMUConfiguration.Reply {
try await M.GetQEMUConfiguration.send(parameters, to: peer)
}
private func _getPackageSize(parameters: M.GetPackageSize.Request) async throws -> M.GetPackageSize.Reply {
try await M.GetPackageSize.send(parameters, to: peer)
}
private func _getPackageFile(parameters: M.GetPackageFile.Request) async throws -> M.GetPackageFile.Reply {
try await M.GetPackageFile.send(parameters, to: peer)
}
@discardableResult
private func _sendPackageFile(parameters: M.SendPackageFile.Request) async throws -> M.SendPackageFile.Reply {
try await M.SendPackageFile.send(parameters, to: peer)
}
@discardableResult
private func _deletePackageFile(parameters: M.DeletePackageFile.Request) async throws -> M.DeletePackageFile.Reply {
try await M.DeletePackageFile.send(parameters, to: peer)
}
@discardableResult
private func _mountGuestToolsOnVirtualMachine(parameters: M.MountGuestToolsOnVirtualMachine.Request) async throws -> M.MountGuestToolsOnVirtualMachine.Reply {
try await M.MountGuestToolsOnVirtualMachine.send(parameters, to: peer)
}
private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
try await M.StartVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply {
try await M.StopVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _restartVirtualMachine(parameters: M.RestartVirtualMachine.Request) async throws -> M.RestartVirtualMachine.Reply {
try await M.RestartVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _pauseVirtualMachine(parameters: M.PauseVirtualMachine.Request) async throws -> M.PauseVirtualMachine.Reply {
try await M.PauseVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _resumeVirtualMachine(parameters: M.ResumeVirtualMachine.Request) async throws -> M.ResumeVirtualMachine.Reply {
try await M.ResumeVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _saveSnapshotVirtualMachine(parameters: M.SaveSnapshotVirtualMachine.Request) async throws -> M.SaveSnapshotVirtualMachine.Reply {
try await M.SaveSnapshotVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _deleteSnapshotVirtualMachine(parameters: M.DeleteSnapshotVirtualMachine.Request) async throws -> M.DeleteSnapshotVirtualMachine.Reply {
try await M.DeleteSnapshotVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _restoreSnapshotVirtualMachine(parameters: M.RestoreSnapshotVirtualMachine.Request) async throws -> M.RestoreSnapshotVirtualMachine.Reply {
try await M.RestoreSnapshotVirtualMachine.send(parameters, to: peer)
}
@discardableResult
private func _changePointerTypeVirtualMachine(parameters: M.ChangePointerTypeVirtualMachine.Request) async throws -> M.ChangePointerTypeVirtualMachine.Reply {
try await M.ChangePointerTypeVirtualMachine.send(parameters, to: peer)
}
}
}
extension UTMRemoteClient {
enum ConnectionError: LocalizedError {
case cannotDetermineHost
case cannotFindFingerprint
case passwordRequired
case passwordInvalid
case fingerprintUntrusted(State.ServerFingerprint)
case fingerprintMismatch(State.ServerFingerprint)
case failedToAccessFile
var errorDescription: String? {
switch self {
case .cannotDetermineHost:
return NSLocalizedString("Failed to determine host name.", comment: "UTMRemoteClient")
case .cannotFindFingerprint:
return NSLocalizedString("Failed to get host fingerprint.", comment: "UTMRemoteClient")
case .passwordRequired:
return NSLocalizedString("Password is required.", comment: "UTMRemoteClient")
case .passwordInvalid:
return NSLocalizedString("Password is incorrect.", comment: "UTMRemoteClient")
case .fingerprintUntrusted(_):
return NSLocalizedString("This host is not yet trusted. You should verify that the fingerprints match what is displayed on the host and then select Trust to continue.", comment: "UTMRemoteClient")
case .fingerprintMismatch(_):
return String.localizedStringWithFormat(NSLocalizedString("The host fingerprint does not match the saved value. This means that UTM Server was reset, a different host is using the same name, or an attacker is pretending to be the host. For your protection, you need to delete this saved host to continue.", comment: "UTMRemoteClient"))
case .failedToAccessFile:
return NSLocalizedString("Failed to access file.", comment: "UTMRemoteClient")
}
}
}
enum ClientError: LocalizedError {
case versionMismatch
var errorDescription: String? {
switch self {
case .versionMismatch:
return NSLocalizedString("The server interface version does not match the client.", comment: "UTMRemoteClient")
}
}
}
}

View File

@ -1,39 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
#import <Foundation/Foundation.h>
@protocol UTMRemoteConnectDelegate;
NS_ASSUME_NONNULL_BEGIN
@protocol UTMRemoteConnectInterface <NSObject>
@property (nonatomic, weak) id<UTMRemoteConnectDelegate> connectDelegate;
- (BOOL)connectWithError:(NSError * _Nullable *)error;
- (void)disconnect;
@end
@protocol UTMRemoteConnectDelegate <NSObject>
- (void)remoteInterface:(id<UTMRemoteConnectInterface>)remoteInterface didErrorWithMessage:(NSString *)message;
- (void)remoteInterfaceDidConnect:(id<UTMRemoteConnectInterface>)remoteInterface;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,196 +0,0 @@
//
// Copyright © 2023 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Security
import CryptoKit
#if os(macOS)
import SystemConfiguration
#endif
class UTMRemoteKeyManager {
let isClient: Bool
private(set) var isLoaded: Bool = false
private(set) var identity: SecIdentity!
private(set) var fingerprint: [UInt8]?
init(forClient client: Bool) {
self.isClient = client
}
private var certificateCommonNamePrefix: String {
"UTM Remote \(isClient ? "Client" : "Server")"
}
private lazy var certificateCommonName: String = {
#if os(macOS)
let deviceName = SCDynamicStoreCopyComputerName(nil, nil) as? String ?? "macOS"
#else
let deviceName = UIDevice.current.name
#endif
return "\(certificateCommonNamePrefix) (\(deviceName))"
}()
private func generateKey() throws -> SecIdentity {
let commonName = certificateCommonName as CFString
let organizationName = "UTM" as CFString
let serialNumber = Int.random(in: 1..<CLong.max) as CFNumber
let days = 3650 as CFNumber
guard let data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() as? [CFData] else {
throw UTMRemoteKeyManagerError.generateKeyFailure
}
let importOptions = [ kSecImportExportPassphrase as String: "password" ] as CFDictionary
var rawItems: CFArray?
try withSecurityThrow(SecPKCS12Import(data[0], importOptions, &rawItems))
guard let items = (rawItems! as! [[String: Any]]).first else {
throw UTMRemoteKeyManagerError.parseKeyFailure
}
return items[kSecImportItemIdentity as String] as! SecIdentity
}
private func importIdentity(_ identity: SecIdentity) throws {
let attributes = [
kSecValueRef as String: identity,
] as CFDictionary
try withSecurityThrow(SecItemAdd(attributes, nil))
}
private func loadIdentity() throws -> SecIdentity? {
var query = [
kSecClass as String: kSecClassIdentity,
kSecReturnRef as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecMatchPolicy as String: SecPolicyCreateSSL(!isClient, nil),
] as [String : Any]
#if os(macOS)
query[kSecMatchSubjectStartsWith as String] = certificateCommonNamePrefix
#endif
var copyResult: AnyObject? = nil
let result = SecItemCopyMatching(query as CFDictionary, &copyResult)
if result == errSecItemNotFound {
return nil
}
try withSecurityThrow(result)
return (copyResult as! SecIdentity)
}
private func deleteIdentity(_ identity: SecIdentity) throws {
let query = [
kSecClass as String: kSecClassIdentity,
kSecMatchItemList as String: [identity],
] as CFDictionary
try withSecurityThrow(SecItemDelete(query))
}
private func withSecurityThrow(_ block: @autoclosure () -> OSStatus) throws {
let err = block()
if err != errSecSuccess && err != errSecDuplicateItem {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(err), userInfo: nil)
}
}
}
extension UTMRemoteKeyManager {
func load() async throws {
guard !isLoaded else {
return
}
let identity = try await Task.detached { [self] in
if let identity = try loadIdentity() {
return identity
} else {
let identity = try generateKey()
try importIdentity(identity)
return identity
}
}.value
var certificate: SecCertificate?
try withSecurityThrow(SecIdentityCopyCertificate(identity, &certificate))
self.identity = identity
self.fingerprint = certificate!.fingerprint()
self.isLoaded = true
}
func reset() async throws {
try await Task.detached { [self] in
if let identity = try loadIdentity() {
try deleteIdentity(identity)
}
}.value
if isLoaded {
isLoaded = false
try await load()
}
}
}
extension SecCertificate {
func fingerprint() -> [UInt8] {
let data = SecCertificateCopyData(self)
return SHA256.hash(data: data as Data).map({ $0 })
}
}
extension Array where Element == UInt8 {
func hexString() -> String {
self.map({ String(format: "%02X", $0) }).joined(separator: ":")
}
init?(hexString: String) {
let cleanString = hexString.replacingOccurrences(of: ":", with: "")
guard cleanString.count % 2 == 0 else {
return nil
}
var byteArray = [UInt8]()
var index = cleanString.startIndex
while index < cleanString.endIndex {
let nextIndex = cleanString.index(index, offsetBy: 2)
if let byte = UInt8(cleanString[index..<nextIndex], radix: 16) {
byteArray.append(byte)
} else {
return nil // Invalid hex character
}
index = nextIndex
}
self = byteArray
}
static func ^(lhs: Self, rhs: Self) -> Self {
let length = Swift.min(lhs.count, rhs.count)
return (0..<length).map({ lhs[$0] ^ rhs[$0] })
}
}
enum UTMRemoteKeyManagerError: Error {
case generateKeyFailure
case parseKeyFailure
case importKeyFailure
}
extension UTMRemoteKeyManagerError: LocalizedError {
var errorDescription: String? {
switch self {
case .generateKeyFailure:
return NSLocalizedString("Failed to generate a key pair.", comment: "UTMRemoteKeyManager")
case .parseKeyFailure:
return NSLocalizedString("Failed to parse generated key pair.", comment: "UTMRemoteKeyManager")
case .importKeyFailure:
return NSLocalizedString("Failed to import generated key.", comment: "UTMRemoteKeyManager")
}
}
}

View File

@ -1,380 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftConnect
enum UTMRemoteMessageServer: UInt8, MessageID {
static let version = 1
case serverHandshake
case listVirtualMachines
case reorderVirtualMachines
case getVirtualMachineInformation
case getQEMUConfiguration
case getPackageSize
case getPackageFile
case sendPackageFile
case deletePackageFile
case mountGuestToolsOnVirtualMachine
case startVirtualMachine
case stopVirtualMachine
case restartVirtualMachine
case pauseVirtualMachine
case resumeVirtualMachine
case saveSnapshotVirtualMachine
case deleteSnapshotVirtualMachine
case restoreSnapshotVirtualMachine
case changePointerTypeVirtualMachine
}
enum UTMRemoteMessageClient: UInt8, MessageID {
static let version = 1
case clientHandshake
case listHasChanged
case qemuConfigurationHasChanged
case mountedDrivesHasChanged
case virtualMachineDidTransition
case virtualMachineDidError
}
extension UTMRemoteMessageServer {
struct ServerHandshake: Message {
static let id = UTMRemoteMessageServer.serverHandshake
struct Request: Serializable, Codable {
let version: Int
let password: String?
}
struct Reply: Serializable, Codable {
let version: Int
let isAuthenticated: Bool
let capabilities: UTMCapabilities
let model: String
}
}
struct VirtualMachineInformation: Serializable, Codable {
let id: UUID
let name: String
let path: String
let isShortcut: Bool
let isSuspended: Bool
let isTakeoverAllowed: Bool
let backend: UTMBackend
let state: UTMVirtualMachineState
let mountedDrives: [String: String]
}
struct ListVirtualMachines: Message {
static let id = UTMRemoteMessageServer.listVirtualMachines
struct Request: Serializable, Codable {}
struct Reply: Serializable, Codable {
let ids: [UUID]
}
}
struct ReorderVirtualMachines: Message {
static let id = UTMRemoteMessageServer.reorderVirtualMachines
struct Request: Serializable, Codable {
let ids: [UUID]
let offset: Int
}
struct Reply: Serializable, Codable {}
}
struct GetVirtualMachineInformation: Message {
static let id = UTMRemoteMessageServer.getVirtualMachineInformation
struct Request: Serializable, Codable {
let ids: [UUID]
}
struct Reply: Serializable, Codable {
let informations: [VirtualMachineInformation]
}
}
struct GetQEMUConfiguration: Message {
static let id = UTMRemoteMessageServer.getQEMUConfiguration
struct Request: Serializable, Codable {
let id: UUID
}
struct Reply: Serializable, Codable {
let configuration: UTMQemuConfiguration
}
}
struct GetPackageSize: Message {
static let id = UTMRemoteMessageServer.getPackageSize
struct Request: Serializable, Codable {
let id: UUID
}
struct Reply: Serializable, Codable {
let size: Int64
}
}
struct GetPackageFile: Message {
static let id = UTMRemoteMessageServer.getPackageFile
struct Request: Serializable, Codable {
let id: UUID
let relativePathComponents: [String]
let lastModified: Date?
}
struct Reply: Serializable, Codable {
let data: Data?
let lastModified: Date
}
}
struct SendPackageFile: Message {
static let id = UTMRemoteMessageServer.sendPackageFile
struct Request: Serializable, Codable {
let id: UUID
let relativePathComponents: [String]
let lastModified: Date
let data: Data
}
struct Reply: Serializable, Codable {}
}
struct DeletePackageFile: Message {
static let id = UTMRemoteMessageServer.deletePackageFile
struct Request: Serializable, Codable {
let id: UUID
let relativePathComponents: [String]
}
struct Reply: Serializable, Codable {}
}
struct MountGuestToolsOnVirtualMachine: Message {
static let id = UTMRemoteMessageServer.mountGuestToolsOnVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
}
struct Reply: Serializable, Codable {}
}
struct StartVirtualMachine: Message {
static let id = UTMRemoteMessageServer.startVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
let options: UTMVirtualMachineStartOptions
}
struct ServerInformation: Serializable, Codable {
let spicePortInternal: UInt16
let spicePortExternal: UInt16?
let spiceHostExternal: String?
let spicePublicKey: Data
let spicePassword: String
}
struct Reply: Serializable, Codable {
let serverInfo: ServerInformation
}
}
struct StopVirtualMachine: Message {
static let id = UTMRemoteMessageServer.stopVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
let method: UTMVirtualMachineStopMethod
}
struct Reply: Serializable, Codable {}
}
struct RestartVirtualMachine: Message {
static let id = UTMRemoteMessageServer.restartVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
}
struct Reply: Serializable, Codable {}
}
struct PauseVirtualMachine: Message {
static let id = UTMRemoteMessageServer.pauseVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
}
struct Reply: Serializable, Codable {}
}
struct ResumeVirtualMachine: Message {
static let id = UTMRemoteMessageServer.resumeVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
}
struct Reply: Serializable, Codable {}
}
struct SaveSnapshotVirtualMachine: Message {
static let id = UTMRemoteMessageServer.saveSnapshotVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
let name: String?
}
struct Reply: Serializable, Codable {}
}
struct DeleteSnapshotVirtualMachine: Message {
static let id = UTMRemoteMessageServer.deleteSnapshotVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
let name: String?
}
struct Reply: Serializable, Codable {}
}
struct RestoreSnapshotVirtualMachine: Message {
static let id = UTMRemoteMessageServer.restoreSnapshotVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
let name: String?
}
struct Reply: Serializable, Codable {}
}
struct ChangePointerTypeVirtualMachine: Message {
static let id = UTMRemoteMessageServer.changePointerTypeVirtualMachine
struct Request: Serializable, Codable {
let id: UUID
let isTabletMode: Bool
}
struct Reply: Serializable, Codable {}
}
}
extension Serializable where Self == UTMRemoteMessageServer.GetQEMUConfiguration.Reply {
static func decode(_ data: Data) throws -> Self {
let decoder = Decoder()
decoder.userInfo[.dataURL] = URL(fileURLWithPath: "/")
return try decoder.decode(Self.self, from: data)
}
}
extension Serializable where Self == UTMRemoteMessageClient.QEMUConfigurationHasChanged.Request {
static func decode(_ data: Data) throws -> Self {
let decoder = Decoder()
decoder.userInfo[.dataURL] = URL(fileURLWithPath: "/")
return try decoder.decode(Self.self, from: data)
}
}
extension UTMRemoteMessageClient {
struct ClientHandshake: Message {
static let id = UTMRemoteMessageClient.clientHandshake
struct Request: Serializable, Codable {
let version: Int
}
struct Reply: Serializable, Codable {
let version: Int
let capabilities: UTMCapabilities
}
}
struct ListHasChanged: Message {
static let id = UTMRemoteMessageClient.listHasChanged
struct Request: Serializable, Codable {
let ids: [UUID]
}
struct Reply: Serializable, Codable {}
}
struct QEMUConfigurationHasChanged: Message {
static let id = UTMRemoteMessageClient.qemuConfigurationHasChanged
struct Request: Serializable, Codable {
let id: UUID
let configuration: UTMQemuConfiguration
}
struct Reply: Serializable, Codable {}
}
struct MountedDrivesHasChanged: Message {
static let id = UTMRemoteMessageClient.mountedDrivesHasChanged
struct Request: Serializable, Codable {
let id: UUID
let mountedDrives: [String: String]
}
struct Reply: Serializable, Codable {}
}
struct VirtualMachineDidTransition: Message {
static let id = UTMRemoteMessageClient.virtualMachineDidTransition
struct Request: Serializable, Codable {
let id: UUID
let state: UTMVirtualMachineState
let isTakeoverAllowed: Bool
}
struct Reply: Serializable, Codable {}
}
struct VirtualMachineDidError: Message {
static let id = UTMRemoteMessageClient.virtualMachineDidError
struct Request: Serializable, Codable {
let id: UUID
let errorMessage: String
}
struct Reply: Serializable, Codable {}
}
}

View File

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

View File

@ -1,424 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
final class UTMRemoteSpiceVirtualMachine: UTMSpiceVirtualMachine {
struct Capabilities: UTMVirtualMachineCapabilities {
var supportsProcessKill: Bool {
true
}
var supportsSnapshots: Bool {
true
}
var supportsScreenshots: Bool {
true
}
var supportsDisposibleMode: Bool {
true
}
var supportsRecoveryMode: Bool {
false
}
var supportsRemoteSession: Bool {
false
}
}
static let capabilities = Capabilities()
private var server: UTMRemoteClient.Remote
init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool) throws {
throw UTMVirtualMachineError.notImplemented
}
init(forRemoteServer server: UTMRemoteClient.Remote, remotePath: String, entry: UTMRegistryEntry, config: UTMQemuConfiguration) {
self.pathUrl = URL(fileURLWithPath: remotePath)
self.config = config
self.registryEntry = entry
self.server = server
_state = State(vm: self)
}
private(set) var pathUrl: URL
private(set) var isShortcut: Bool = false
private(set) var isRunningAsDisposible: Bool = false
weak var delegate: (UTMVirtualMachineDelegate)?
var onConfigurationChange: (() -> Void)?
var onStateChange: (() -> Void)?
private(set) var config: UTMQemuConfiguration {
willSet {
onConfigurationChange?()
}
}
private(set) var registryEntry: UTMRegistryEntry {
willSet {
onConfigurationChange?()
}
}
private var _state: State!
private(set) var state: UTMVirtualMachineState = .stopped {
willSet {
onStateChange?()
}
didSet {
if state == .stopped {
virtualMachineDidStop()
}
delegate?.virtualMachine(self, didTransitionToState: state)
}
}
var screenshot: UTMVirtualMachineScreenshot? {
willSet {
onStateChange?()
}
}
private(set) var snapshotUnsupportedError: Error?
weak var ioServiceDelegate: UTMSpiceIODelegate? {
didSet {
if let ioService = ioService {
ioService.delegate = ioServiceDelegate
}
}
}
private(set) var ioService: UTMSpiceIO? {
didSet {
oldValue?.delegate = nil
ioService?.delegate = ioServiceDelegate
}
}
var changeCursorRequestInProgress: Bool = false
private weak var screenshotTimer: Timer?
func reload(from packageUrl: URL?) throws {
throw UTMVirtualMachineError.notImplemented
}
@MainActor
func reload(usingConfiguration config: UTMQemuConfiguration) {
self.config = config
updateConfigFromRegistry()
}
@MainActor
func updateRegistry(_ entry: UTMRegistryEntry) {
self.registryEntry = entry
}
func updateConfigFromRegistry() {
// not needed
}
func changeUuid(to uuid: UUID, name: String?, copyingEntry entry: UTMRegistryEntry?) {
// not needed
}
func reconnectServer(_ body: () async throws -> UTMRemoteClient.Remote) async throws {
try await _state.operation(during: .resuming) {
self.server = try await body()
}
}
}
extension UTMRemoteSpiceVirtualMachine {
private class ConnectCoordinator: NSObject, UTMRemoteConnectDelegate {
var continuation: CheckedContinuation<Void, Error>?
func remoteInterface(_ remoteInterface: UTMRemoteConnectInterface, didErrorWithMessage message: String) {
remoteInterface.connectDelegate = nil
continuation?.resume(throwing: VMError.spiceConnectError(message))
continuation = nil
}
func remoteInterfaceDidConnect(_ remoteInterface: UTMRemoteConnectInterface) {
remoteInterface.connectDelegate = nil
continuation?.resume()
continuation = nil
}
}
}
extension UTMRemoteSpiceVirtualMachine {
private func connect(_ serverInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation, options: UTMSpiceIOOptions, remoteConnection: Bool) async throws -> UTMSpiceIO {
let ioService = UTMSpiceIO(host: remoteConnection ? serverInfo.spiceHostExternal! : server.host,
tlsPort: Int(remoteConnection ? serverInfo.spicePortExternal! : serverInfo.spicePortInternal),
serverPublicKey: serverInfo.spicePublicKey,
password: serverInfo.spicePassword,
options: options)
ioService.logHandler = { (line: String) -> Void in
guard !line.contains("spice_make_scancode") else {
return // do not log key presses for privacy reasons
}
NSLog("%@", line) // FIXME: log to file
}
try ioService.start()
let coordinator = ConnectCoordinator()
try await withCheckedThrowingContinuation { continuation in
coordinator.continuation = continuation
ioService.connectDelegate = coordinator
do {
try ioService.connect()
} catch {
ioService.connectDelegate = nil
continuation.resume(throwing: error)
}
}
return ioService
}
func start(options: UTMVirtualMachineStartOptions) async throws {
try await _state.operation(before: [.stopped, .started, .paused], during: .starting, after: .started) {
let spiceServer = try await server.startVirtualMachine(id: id, options: options)
var options = UTMSpiceIOOptions()
if await !config.sound.isEmpty {
options.insert(.hasAudio)
}
if await config.sharing.hasClipboardSharing {
options.insert(.hasClipboardSharing)
}
if await config.sharing.isDirectoryShareReadOnly {
options.insert(.isShareReadOnly)
}
#if false // FIXME: verbose logging is broken on iOS
if hasDebugLog {
options.insert(.hasDebugLog)
}
#endif
do {
self.ioService = try await connect(spiceServer, options: options, remoteConnection: false)
} catch {
if spiceServer.spiceHostExternal != nil && spiceServer.spicePortExternal != nil {
// retry with external port
self.ioService = try await connect(spiceServer, options: options, remoteConnection: true)
} else {
throw error
}
}
if screenshotTimer == nil {
screenshotTimer = startScreenshotTimer()
}
}
}
func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
try await _state.operation(before: [.started, .paused], during: .stopping, after: .stopped) {
await saveScreenshot()
try await server.stopVirtualMachine(id: id, method: method)
}
}
func restart() async throws {
try await _state.operation(before: [.started, .paused], during: .stopping, after: .started) {
try await server.restartVirtualMachine(id: id)
}
}
func pause() async throws {
try await _state.operation(before: .started, during: .pausing, after: .paused) {
try await server.pauseVirtualMachine(id: id)
}
}
func resume() async throws {
if ioService == nil {
return try await start(options: [])
} else {
try await _state.operation(before: .paused, during: .resuming, after: .started) {
try await server.resumeVirtualMachine(id: id)
}
}
}
func saveSnapshot(name: String?) async throws {
try await _state.operation(before: [.started, .paused], during: .saving) {
await saveScreenshot()
try await server.saveSnapshotVirtualMachine(id: id, name: name)
}
}
func deleteSnapshot(name: String?) async throws {
try await server.deleteSnapshotVirtualMachine(id: id, name: name)
}
func restoreSnapshot(name: String?) async throws {
try await _state.operation(before: [.started, .paused], during: .saving) {
try await server.restoreSnapshotVirtualMachine(id: id, name: name)
}
}
func loadScreenshotFromServer() async {
if let url = try? await server.getPackageFile(for: id, relativePathComponents: [kUTMBundleScreenshotFilename]) {
loadScreenshot(from: url)
}
}
func loadScreenshot(from url: URL) {
screenshot = UTMVirtualMachineScreenshot(contentsOfURL: url)
}
func saveScreenshot() async {
if let data = screenshot?.pngData {
try? await server.sendPackageFile(for: id, relativePathComponents: [kUTMBundleScreenshotFilename], data: data)
}
}
private func virtualMachineDidStop() {
ioService = nil
}
}
extension UTMRemoteSpiceVirtualMachine {
actor State {
private weak var vm: UTMRemoteSpiceVirtualMachine?
private var isInOperation: Bool = false
private(set) var state: UTMVirtualMachineState = .stopped {
didSet {
vm?.state = state
}
}
private var remoteState: UTMVirtualMachineState?
init(vm: UTMRemoteSpiceVirtualMachine) {
self.vm = vm
}
func operation(before: UTMVirtualMachineState, during: UTMVirtualMachineState, after: UTMVirtualMachineState? = nil, body: () async throws -> Void) async throws {
try await operation(before: [before], during: during, after: after, body: body)
}
func operation(before: Set<UTMVirtualMachineState>? = nil, during: UTMVirtualMachineState, after: UTMVirtualMachineState? = nil, body: () async throws -> Void) async throws {
while isInOperation {
await Task.yield()
}
if let before = before {
guard before.contains(state) else {
throw VMError.operationInProgress
}
}
isInOperation = true
remoteState = nil
defer {
isInOperation = false
if let remoteState = remoteState {
state = remoteState
}
}
let previous = state
state = during
do {
try await body()
} catch {
state = previous
throw error
}
state = after ?? previous
}
func updateRemoteState(_ state: UTMVirtualMachineState) {
self.remoteState = state
if !isInOperation && self.state != state {
self.state = state
}
}
}
func updateRemoteState(_ state: UTMVirtualMachineState) async {
await _state.updateRemoteState(state)
}
}
extension UTMRemoteSpiceVirtualMachine {
static func isSupported(systemArchitecture: QEMUArchitecture) -> Bool {
true // FIXME: somehow determine which architectures are supported
}
}
extension UTMRemoteSpiceVirtualMachine {
func requestInputTablet(_ tablet: Bool) {
guard !changeCursorRequestInProgress else {
return
}
changeCursorRequestInProgress = true
Task {
defer {
changeCursorRequestInProgress = false
}
try await server.changePointerTypeVirtualMachine(id: id, toTabletMode: tablet)
ioService?.primaryInput?.requestMouseMode(!tablet)
}
}
}
extension UTMRemoteSpiceVirtualMachine {
func eject(_ drive: UTMQemuConfigurationDrive) async throws {
// FIXME: implement remote feature
throw UTMVirtualMachineError.notImplemented
}
func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL) async throws {
// FIXME: implement remote feature
throw UTMVirtualMachineError.notImplemented
}
}
extension UTMRemoteSpiceVirtualMachine {
func stopAccessingPath(_ path: String) async {
// not needed
}
func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
throw UTMVirtualMachineError.notImplemented
}
}
extension UTMRemoteSpiceVirtualMachine {
enum VMError: LocalizedError {
case spiceConnectError(String)
case operationInProgress
var errorDescription: String? {
switch self {
case .spiceConnectError(let message):
return String.localizedStringWithFormat(NSLocalizedString("Failed to connect to SPICE: %@", comment: "UTMRemoteSpiceVirtualMachine"), message)
case .operationInProgress:
return NSLocalizedString("An operation is already in progress.", comment: "UTMRemoteSpiceVirtualMachine")
}
}
}
}

View File

@ -25,21 +25,14 @@
#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"
#else #include "UTMLogging.h"
#include "UTMQemuSystemBackends.h"
#endif
#include "UTMLegacyViewState.h" #include "UTMLegacyViewState.h"
#include "UTMSpiceIO.h" #include "UTMSpiceIO.h"
#include "GenerateKey.h"
#if TARGET_OS_IPHONE #if TARGET_OS_IPHONE
#if !defined(WITH_REMOTE)
#include "UTMLocationManager.h" #include "UTMLocationManager.h"
#endif
#include "VMDisplayViewController.h" #include "VMDisplayViewController.h"
//#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION //#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION
#include "VMDisplayMetalViewController.h" #include "VMDisplayMetalViewController.h"

View File

@ -40,10 +40,6 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
var supportsRecoveryMode: Bool { var supportsRecoveryMode: Bool {
true true
} }
var supportsRemoteSession: Bool {
false
}
} }
static let capabilities = Capabilities() static let capabilities = Capabilities()
@ -89,7 +85,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
} }
} }
private(set) var screenshot: UTMVirtualMachineScreenshot? { private(set) var screenshot: PlatformImage? {
willSet { willSet {
onStateChange?() onStateChange?()
} }
@ -478,11 +474,7 @@ 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()
@ -729,7 +721,7 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
} }
protocol UTMScreenshotProvider: AnyObject { protocol UTMScreenshotProvider: AnyObject {
var screenshot: UTMVirtualMachineScreenshot? { get } var screenshot: PlatformImage? { get }
} }
enum UTMAppleVirtualMachineError: Error { enum UTMAppleVirtualMachineError: Error {

View File

@ -16,7 +16,6 @@
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? {
@ -384,44 +383,4 @@ extension String {
} }
return Int(numeric) return Int(numeric)
} }
static func random(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return String((0..<length).map{ _ in letters.randomElement()! })
}
}
extension Encodable {
func propertyList() throws -> Any {
let encoder = PropertyListEncoder()
encoder.outputFormat = .xml
let xml = try encoder.encode(self)
return try PropertyListSerialization.propertyList(from: xml, format: nil)
}
}
extension Decodable {
init(fromPropertyList propertyList: Any) throws {
let data = try PropertyListSerialization.data(fromPropertyList: propertyList, format: .xml, options: 0)
let decoder = PropertyListDecoder()
self = try decoder.decode(Self.self, from: data)
}
}
extension NWEndpoint {
var hostname: String? {
if case .hostPort(let host, _) = self {
switch host {
case .name(let hostname, _):
return hostname
case .ipv4(let address):
return "\(address)"
case .ipv6(let address):
return "\(address)"
@unknown default:
break
}
}
return nil
}
} }

View File

@ -65,7 +65,7 @@ typedef struct memorystatus_memlimit_properties {
int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, user_addr_t buffer, size_t buffersize); int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, user_addr_t buffer, size_t buffersize);
#if !TARGET_OS_OSX && defined(WITH_JIT) #if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
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_JIT) #if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
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_JIT) #elif defined(WITH_QEMU_TCI)
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_JIT) #if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
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_JIT) #if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
extern const char *environ[]; extern const char *environ[];
static char *childArgv[] = {NULL, "debugme", NULL}; static char *childArgv[] = {NULL, "debugme", NULL};
@ -397,7 +397,7 @@ bool jb_spawn_ptrace_child(int argc, char **argv) {
return false; return false;
} }
childArgv[0] = argv[0]; childArgv[0] = argv[0];
if ((ret = posix_spawnp(&pid, argv[0], NULL, NULL, (void *)childArgv, NULL)) != 0) { if ((ret = posix_spawnp(&pid, argv[0], NULL, NULL, (void *)childArgv, (void *)environ)) != 0) {
return false; return false;
} }
return true; return true;

View File

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

View File

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

View File

@ -1,152 +0,0 @@
//
// Copyright © 2024 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import QEMUKit
class UTMPipeInterface: NSObject, QEMUInterface {
weak var connectDelegate: QEMUInterfaceConnectDelegate?
var monitorOutPipeURL: URL!
var monitorInPipeURL: URL!
var guestAgentOutPipeURL: URL!
var guestAgentInPipeURL: URL!
private var pipeIOQueue = DispatchQueue(label: "UTMPipeInterface")
private var qemuMonitorPort: Port!
private var qemuGuestAgentPort: Port!
func start() throws {
try initializePipe(at: monitorOutPipeURL)
try initializePipe(at: monitorInPipeURL)
try initializePipe(at: guestAgentOutPipeURL)
try initializePipe(at: guestAgentInPipeURL)
}
func connect() throws {
pipeIOQueue.async { [self] in
do {
try openQemuPipes()
connectDelegate?.qemuInterface(self, didCreateMonitorPort: qemuMonitorPort)
connectDelegate?.qemuInterface(self, didCreateGuestAgentPort: qemuGuestAgentPort)
} catch {
connectDelegate?.qemuInterface(self, didErrorWithMessage: error.localizedDescription)
}
}
}
func disconnect() {
cleanupPipes()
}
}
extension UTMPipeInterface {
class Port: NSObject, QEMUPort {
let readPipe: FileHandle
let writePipe: FileHandle
var readDataHandler: readDataHandler_t?
var errorHandler: errorHandler_t?
var disconnectHandler: disconnectHandler_t?
let isOpen: Bool = true
init(readPipe: FileHandle, writePipe: FileHandle) {
self.readPipe = readPipe
self.writePipe = writePipe
super.init()
readPipe.readabilityHandler = { fileHandle in
self.readDataHandler?(fileHandle.availableData)
}
}
func write(_ data: Data) {
writePipe.write(data)
}
}
private var fileManager: FileManager {
FileManager.default
}
private func initializePipe(at url: URL) throws {
if fileManager.fileExists(atPath: url.path) {
try fileManager.removeItem(at: url)
}
guard mkfifo(url.path, S_IRUSR | S_IWUSR) == 0 else {
throw ServerError.failedToCreatePipe(errno)
}
}
private func openPipe(at url: URL, forReading isRead: Bool) throws -> FileHandle {
let fileHandle: FileHandle
if isRead {
fileHandle = try FileHandle(forReadingFrom: url)
} else {
fileHandle = try FileHandle(forWritingTo: url)
}
return fileHandle
}
private func cleanupPipes() {
// unblock any un-opened pipes
_ = try? FileHandle(forUpdating: monitorOutPipeURL)
_ = try? FileHandle(forUpdating: monitorInPipeURL)
_ = try? FileHandle(forUpdating: guestAgentOutPipeURL)
_ = try? FileHandle(forUpdating: guestAgentInPipeURL)
pipeIOQueue.sync {
if let monitorOutPipeURL = monitorOutPipeURL {
try? fileManager.removeItem(at: monitorOutPipeURL)
}
if let monitorInPipeURL = monitorInPipeURL {
try? fileManager.removeItem(at: monitorInPipeURL)
}
if let guestAgentOutPipeURL = guestAgentOutPipeURL {
try? fileManager.removeItem(at: guestAgentOutPipeURL)
}
if let guestAgentInPipeURL = guestAgentInPipeURL {
try? fileManager.removeItem(at: guestAgentInPipeURL)
}
qemuMonitorPort = nil
qemuGuestAgentPort = nil
}
}
private func openQemuPipes() throws {
let qmpReadPipe = try openPipe(at: monitorOutPipeURL, forReading: true)
let qmpWritePipe = try openPipe(at: monitorInPipeURL, forReading: false)
qemuMonitorPort = Port(readPipe: qmpReadPipe, writePipe: qmpWritePipe)
let qgaReadPipe = try openPipe(at: guestAgentOutPipeURL, forReading: true)
let qgaWritePipe = try openPipe(at: guestAgentInPipeURL, forReading: false)
qemuGuestAgentPort = Port(readPipe: qgaReadPipe, writePipe: qgaWritePipe)
}
}
extension UTMPipeInterface {
enum ServerError: LocalizedError {
case failedToCreatePipe(Int32)
var errorDescription: String? {
switch self {
case .failedToCreatePipe(_):
return NSLocalizedString("Failed to create pipe for communications.", comment: "UTMPipeInterface")
}
}
}
}

View File

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

View File

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

View File

@ -1,36 +0,0 @@
//
// 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 */

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