Revert "Merge branch 'main' into show-main-window-macos"
This reverts commit ef97098631
.
This commit is contained in:
parent
ef97098631
commit
e955faba4f
|
@ -23,7 +23,7 @@ on:
|
|||
default: 'false'
|
||||
|
||||
env:
|
||||
BUILD_XCODE_PATH: /Applications/Xcode_15.2.app
|
||||
BUILD_XCODE_PATH: /Applications/Xcode_15.1.app
|
||||
RUNNER_IMAGE: macos-13
|
||||
|
||||
jobs:
|
||||
|
@ -53,7 +53,7 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
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:
|
||||
# x86_64 supported only for macOS and simulators
|
||||
- arch: x86_64
|
||||
|
@ -91,7 +91,7 @@ jobs:
|
|||
if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event.inputs.rebuild_sysroot == 'true'
|
||||
run: ./scripts/build_dependencies.sh -p ${{ matrix.platform }} -a ${{ matrix.arch }}
|
||||
env:
|
||||
NCPU: ${{ 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
|
||||
if: steps.cache-sysroot.outputs.cache-hit != 'true' || github.event_name == 'release' || github.event.inputs.test_release == 'true'
|
||||
run: tar -acf sysroot.tgz sysroot*
|
||||
|
@ -152,16 +152,14 @@ jobs:
|
|||
needs: [configuration, build-sysroot]
|
||||
strategy:
|
||||
matrix:
|
||||
configuration: [
|
||||
{arch: "arm64", sdk: "iphoneos", platform: "ios", scheme: "iOS"},
|
||||
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-SE"},
|
||||
{arch: "arm64", sdk: "iphoneos", platform: "ios-tci", scheme: "iOS-Remote"},
|
||||
{arch: "arm64", sdk: "xros", platform: "visionos", scheme: "iOS"},
|
||||
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-SE"},
|
||||
{arch: "arm64", sdk: "xros", platform: "visionos-tci", scheme: "iOS-Remote"},
|
||||
{arch: "arm64", sdk: "macosx", platform: "macos", scheme: "macOS"},
|
||||
{arch: "x86_64", sdk: "macosx", platform: "macos", scheme: "macOS"},
|
||||
]
|
||||
arch: [arm64]
|
||||
platform: [ios, ios_simulator, ios-tci, macos, visionos, visionos_simulator, visionos-tci]
|
||||
include:
|
||||
# x86_64 supported only for macOS and simulators
|
||||
- arch: x86_64
|
||||
platform: macos
|
||||
- arch: x86_64
|
||||
platform: ios_simulator
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
@ -171,8 +169,8 @@ jobs:
|
|||
id: cache-sysroot
|
||||
uses: osy/actions-cache@v3
|
||||
with:
|
||||
path: sysroot-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
|
||||
key: ${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
|
||||
path: sysroot-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
|
||||
- name: Check Cache
|
||||
if: steps.cache-sysroot.outputs.cache-hit != 'true'
|
||||
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 }}"
|
||||
- name: Build UTM
|
||||
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
|
||||
- name: Upload UTM
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
|
||||
name: UTM-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: UTM.xcarchive.tgz
|
||||
build-universal:
|
||||
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 }}"
|
||||
- name: Build UTM
|
||||
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
|
||||
env:
|
||||
SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
|
||||
|
@ -233,14 +231,12 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
configuration: [
|
||||
{platform: "ios", scheme: "iOS", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
|
||||
{platform: "ios-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
|
||||
{platform: "ios", scheme: "iOS", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
|
||||
{platform: "ios", scheme: "iOS", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
|
||||
{platform: "visionos", scheme: "iOS", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
|
||||
{platform: "visionos-tci", scheme: "iOS-SE", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"},
|
||||
{platform: "ios-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote.ipa", path: "UTM Remote.ipa"},
|
||||
{platform: "visionos-tci", scheme: "iOS-Remote", mode: "ipa-remote", name: "UTM-Remote-visionOS.ipa", path: "UTM Remote.ipa"},
|
||||
{platform: "ios", mode: "ipa", name: "UTM.ipa", path: "UTM.ipa"},
|
||||
{platform: "ios-tci", mode: "ipa-se", name: "UTM-SE.ipa", path: "UTM SE.ipa"},
|
||||
{platform: "ios", mode: "ipa-hv", name: "UTM-HV.ipa", path: "UTM.ipa"},
|
||||
{platform: "ios", mode: "deb", name: "UTM.deb", path: "UTM.deb"},
|
||||
{platform: "visionos", mode: "ipa", name: "UTM-visionOS.ipa", path: "UTM.ipa"},
|
||||
{platform: "visionos-tci", mode: "ipa-se", name: "UTM-SE-visionOS.ipa", path: "UTM SE.ipa"}
|
||||
]
|
||||
if: github.event_name == 'release' || github.event.inputs.test_release == 'true'
|
||||
steps:
|
||||
|
@ -249,7 +245,7 @@ jobs:
|
|||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-arm64
|
||||
name: UTM-${{ matrix.configuration.platform }}-arm64
|
||||
- name: Install ldid + dpkg
|
||||
run: brew install ldid dpkg
|
||||
- name: Fakesign IPA
|
||||
|
@ -319,9 +315,7 @@ jobs:
|
|||
LAUNCHER_PROFILE_DATA: ${{ vars.LAUNCHER_PROFILE_DATA }}
|
||||
LAUNCHER_PROFILE_UUID: ${{ vars.LAUNCHER_PROFILE_UUID }}
|
||||
- name: Install appdmg
|
||||
run: |
|
||||
python3 -m pip install setuptools
|
||||
npm install -g appdmg
|
||||
run: npm install -g appdmg
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
// Configuration settings file format documentation can be found at:
|
||||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
MARKETING_VERSION = 4.4.5
|
||||
CURRENT_PROJECT_VERSION = 94
|
||||
MARKETING_VERSION = 4.4.4
|
||||
CURRENT_PROJECT_VERSION = 92
|
||||
|
||||
// Codesigning settings defined optionally, see Documentation/iOSDevelopment.md
|
||||
#include? "CodeSigning.xcconfig"
|
||||
|
|
|
@ -426,16 +426,16 @@ extension QEMUArchitecture {
|
|||
}
|
||||
|
||||
var hasHypervisorSupport: Bool {
|
||||
guard UTMCapabilities.current.contains(.hasHypervisorSupport) else {
|
||||
return false
|
||||
}
|
||||
if UTMCapabilities.current.contains(.isAarch64) {
|
||||
return self == .aarch64
|
||||
} else if UTMCapabilities.current.contains(.isX86_64) {
|
||||
return self == .x86_64
|
||||
} else {
|
||||
guard jb_has_hypervisor() else {
|
||||
return false
|
||||
}
|
||||
#if arch(arm64)
|
||||
return self == .aarch64
|
||||
#elseif arch(x86_64)
|
||||
return self == .x86_64
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// TSO is supported on jailbroken iOS devices with Hypervisor support
|
||||
|
|
|
@ -120,7 +120,7 @@ extension UTMConfiguration {
|
|||
#endif
|
||||
// is it a legacy QEMU config?
|
||||
let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
|
||||
let name = ConcreteVirtualMachine.virtualMachineName(for: packageURL)
|
||||
let name = UTMQemuVirtualMachine.virtualMachineName(for: packageURL)
|
||||
let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
|
||||
return UTMQemuConfiguration(migrating: legacy)
|
||||
} else if stub.backend == .qemu {
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import QEMUKitInternal
|
||||
|
||||
/// Settings for single disk device
|
||||
protocol UTMConfigurationDrive: Codable, Hashable, Identifiable {
|
||||
|
@ -102,15 +103,11 @@ extension UTMConfigurationDrive {
|
|||
}
|
||||
|
||||
private func createQcow2Image(at newURL: URL, size sizeMib: Int) async throws {
|
||||
#if WITH_REMOTE
|
||||
fatalError("Not implemented")
|
||||
#else
|
||||
try await Task.detached {
|
||||
if !QEMUGenerateDefaultQcow2File(newURL as CFURL, sizeMib) {
|
||||
throw UTMConfigurationError.cannotCreateDiskImage
|
||||
}
|
||||
}.value
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
|
|
@ -61,26 +61,6 @@ import Virtualization // for getting network interfaces
|
|||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("swtpm")
|
||||
}
|
||||
|
||||
/// Used only if in remote sever mode.
|
||||
var monitorPipeURL: URL {
|
||||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qmp")
|
||||
}
|
||||
|
||||
/// Used only if in remote sever mode.
|
||||
var guestAgentPipeURL: URL {
|
||||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qga")
|
||||
}
|
||||
|
||||
/// Used only if in remote sever mode.
|
||||
var spiceTlsKeyUrl: URL {
|
||||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("pem")
|
||||
}
|
||||
|
||||
/// Used only if in remote sever mode.
|
||||
var spiceTlsCertUrl: URL {
|
||||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("crt")
|
||||
}
|
||||
|
||||
/// Combined generated and user specified arguments.
|
||||
@QEMUArgumentBuilder var allArguments: [QEMUArgument] {
|
||||
generatedArguments
|
||||
|
@ -129,48 +109,16 @@ import Virtualization // for getting network interfaces
|
|||
|
||||
@QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
|
||||
f("-spice")
|
||||
if let port = qemu.spiceServerPort {
|
||||
if qemu.isSpiceServerTlsEnabled {
|
||||
"tls-port=\(port)"
|
||||
"tls-channel=default"
|
||||
"x509-key-file="
|
||||
spiceTlsKeyUrl
|
||||
"x509-cert-file="
|
||||
spiceTlsCertUrl
|
||||
"x509-cacert-file="
|
||||
spiceTlsCertUrl
|
||||
} else {
|
||||
"port=\(port)"
|
||||
}
|
||||
} else {
|
||||
"unix=on"
|
||||
"addr=\(spiceSocketURL.lastPathComponent)"
|
||||
}
|
||||
if let _ = qemu.spiceServerPassword {
|
||||
"password-secret=secspice0"
|
||||
} else {
|
||||
"disable-ticketing=on"
|
||||
}
|
||||
if !isRemoteSpice {
|
||||
"image-compression=off"
|
||||
"playback-compression=off"
|
||||
"streaming-video=off"
|
||||
} else {
|
||||
"streaming-video=filter"
|
||||
}
|
||||
"gl=\(isGLSupported && !isRemoteSpice ? "on" : "off")"
|
||||
"unix=on"
|
||||
"addr=\(spiceSocketURL.lastPathComponent)"
|
||||
"disable-ticketing=on"
|
||||
"image-compression=off"
|
||||
"playback-compression=off"
|
||||
"streaming-video=off"
|
||||
"gl=\(isGLOn ? "on" : "off")"
|
||||
f()
|
||||
f("-chardev")
|
||||
if isRemoteSpice {
|
||||
"pipe"
|
||||
"path="
|
||||
monitorPipeURL
|
||||
} else {
|
||||
"spiceport"
|
||||
"name=org.qemu.monitor.qmp.0"
|
||||
}
|
||||
"id=org.qemu.monitor.qmp"
|
||||
f()
|
||||
f("spiceport,id=org.qemu.monitor.qmp,name=org.qemu.monitor.qmp.0")
|
||||
f("-mon")
|
||||
f("chardev=org.qemu.monitor.qmp,mode=control")
|
||||
if !isSparc { // disable -vga and other default devices
|
||||
|
@ -180,26 +128,6 @@ import Virtualization // for getting network interfaces
|
|||
f("-vga")
|
||||
f("none")
|
||||
}
|
||||
if let password = qemu.spiceServerPassword {
|
||||
// assume anyone who can read this is in our trust domain
|
||||
f("-object")
|
||||
f("secret,id=secspice0,data=\(password)")
|
||||
}
|
||||
}
|
||||
|
||||
private func filterDisplayIfRemote(_ display: any QEMUDisplayDevice) -> any QEMUDisplayDevice {
|
||||
if isRemoteSpice {
|
||||
let rawValue = display.rawValue
|
||||
if rawValue.hasSuffix("-gl") {
|
||||
return AnyQEMUConstant(rawValue: String(rawValue.dropLast(3)))!
|
||||
} else if rawValue.contains("-gl-") {
|
||||
return AnyQEMUConstant(rawValue: String(rawValue.replacingOccurrences(of: "-gl-", with: "-")))!
|
||||
} else {
|
||||
return display
|
||||
}
|
||||
} else {
|
||||
return display
|
||||
}
|
||||
}
|
||||
|
||||
@QEMUArgumentBuilder private var displayArguments: [QEMUArgument] {
|
||||
|
@ -215,7 +143,7 @@ import Virtualization // for getting network interfaces
|
|||
} else {
|
||||
for display in displays {
|
||||
f("-device")
|
||||
filterDisplayIfRemote(display.hardware)
|
||||
display.hardware
|
||||
if let vgaRamSize = displays[0].vgaRamMib {
|
||||
"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
|
||||
display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl")
|
||||
}
|
||||
|
@ -234,10 +162,6 @@ import Virtualization // for getting network interfaces
|
|||
system.architecture == .sparc || system.architecture == .sparc64
|
||||
}
|
||||
|
||||
private var isRemoteSpice: Bool {
|
||||
qemu.spiceServerPort != nil
|
||||
}
|
||||
|
||||
@QEMUArgumentBuilder private var serialArguments: [QEMUArgument] {
|
||||
for i in serials.indices {
|
||||
f("-chardev")
|
||||
|
@ -394,9 +318,9 @@ import Virtualization // for getting network interfaces
|
|||
}
|
||||
let tbSize = system.jitCacheSize > 0 ? system.jitCacheSize : system.memorySize / 4
|
||||
"tb-size=\(tbSize)"
|
||||
#if WITH_JIT
|
||||
#if !WITH_QEMU_TCI
|
||||
// use mirror mapping when we don't have JIT entitlements
|
||||
if !UTMCapabilities.current.contains(.hasJitEntitlements) {
|
||||
if !jb_has_jit_entitlement() {
|
||||
"split-wx=on"
|
||||
}
|
||||
#endif
|
||||
|
@ -509,10 +433,6 @@ import Virtualization // for getting network interfaces
|
|||
#if os(iOS) || os(visionOS)
|
||||
return false
|
||||
#else
|
||||
// only support SPICE audio if we are running remotely
|
||||
if isRemoteSpice {
|
||||
return false
|
||||
}
|
||||
// force CoreAudio backend for mac99 which only supports 44100 Hz
|
||||
// pcspk doesn't work with SPICE audio
|
||||
if sound.contains(where: { $0.hardware.rawValue == "screamer" || $0.hardware.rawValue == "pcspk" }) {
|
||||
|
@ -751,7 +671,7 @@ import Virtualization // for getting network interfaces
|
|||
f("usb-mouse,bus=usb-bus.0")
|
||||
f("-device")
|
||||
f("usb-kbd,bus=usb-bus.0")
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
let maxDevices = input.maximumUsbShare
|
||||
let buses = (maxDevices + 2) / 3
|
||||
if input.usbBusSupport == .usb3_0 {
|
||||
|
@ -939,16 +859,7 @@ import Virtualization // for getting network interfaces
|
|||
f("-device")
|
||||
f("virtserialport,chardev=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
|
||||
f("-chardev")
|
||||
if isRemoteSpice {
|
||||
"pipe"
|
||||
"path="
|
||||
guestAgentPipeURL
|
||||
} else {
|
||||
"spiceport"
|
||||
"name=org.qemu.guest_agent.0"
|
||||
}
|
||||
"id=org.qemu.guest_agent"
|
||||
f()
|
||||
f("spiceport,id=org.qemu.guest_agent,name=org.qemu.guest_agent.0")
|
||||
}
|
||||
if isSpiceAgentUsed {
|
||||
f("-device")
|
||||
|
|
|
@ -69,15 +69,6 @@ struct UTMQemuConfigurationQEMU: Codable {
|
|||
/// Set to true to request UEFI variable reset. Not saved.
|
||||
var isUefiVariableResetRequested: Bool = false
|
||||
|
||||
/// Set to open a port for remote SPICE session. Not saved.
|
||||
var spiceServerPort: UInt16?
|
||||
|
||||
/// If true, all SPICE channels will be over TLS. Not saved.
|
||||
var isSpiceServerTlsEnabled: Bool = false
|
||||
|
||||
/// Set to a password shared with the client. Not saved.
|
||||
var spiceServerPassword: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hasDebugLog = "DebugLog"
|
||||
case hasUefiBoot = "UEFIBoot"
|
||||
|
|
|
@ -34,7 +34,7 @@ class Main {
|
|||
static var jitAvailable = true
|
||||
|
||||
static func main() {
|
||||
#if (os(iOS) || os(visionOS)) && WITH_JIT
|
||||
#if (os(iOS) || os(visionOS)) && !WITH_QEMU_TCI
|
||||
// check if we have jailbreak
|
||||
if jb_spawn_ptrace_child(CommandLine.argc, CommandLine.unsafeArgv) {
|
||||
logger.info("JIT: ptrace() child spawn trick")
|
||||
|
|
|
@ -17,12 +17,12 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BigButtonStyle: ButtonStyle {
|
||||
let width: CGFloat?
|
||||
let height: CGFloat?
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
|
||||
fileprivate struct BigButtonView: View {
|
||||
let width: CGFloat?
|
||||
let height: CGFloat?
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let configuration: BigButtonStyle.Configuration
|
||||
@Environment(\.isEnabled) private var isEnabled: Bool
|
||||
|
||||
|
|
|
@ -20,11 +20,8 @@ import UniformTypeIdentifiers
|
|||
import IQKeyboardManagerSwift
|
||||
#endif
|
||||
|
||||
// on visionOS, there is no text to show more than UTM
|
||||
#if WITH_QEMU_TCI && !os(visionOS)
|
||||
#if WITH_QEMU_TCI
|
||||
let productName = "UTM SE"
|
||||
#elseif WITH_REMOTE && !os(visionOS)
|
||||
let productName = "UTM Remote"
|
||||
#else
|
||||
let productName = "UTM"
|
||||
#endif
|
||||
|
@ -36,7 +33,6 @@ struct ContentView: View {
|
|||
@State private var newPopupPresented = false
|
||||
@State private var openSheetPresented = false
|
||||
@Environment(\.openURL) var openURL
|
||||
@AppStorage("ServerAutostart") private var isServerAutostart: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VMNavigationListView()
|
||||
|
@ -71,11 +67,6 @@ struct ContentView: View {
|
|||
.onAppear {
|
||||
Task {
|
||||
await data.listRefresh()
|
||||
#if os(macOS)
|
||||
if isServerAutostart {
|
||||
await data.remoteServer.start()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Task {
|
||||
await releaseHelper.fetchReleaseNotes()
|
||||
|
@ -87,7 +78,7 @@ struct ContentView: View {
|
|||
#if !os(visionOS)
|
||||
IQKeyboardManager.shared.enable = true
|
||||
#endif
|
||||
#if WITH_JIT
|
||||
#if !WITH_QEMU_TCI
|
||||
if !Main.jitAvailable {
|
||||
data.busyWorkAsync {
|
||||
let jitStreamerAttach = UserDefaults.standard.bool(forKey: "JitStreamerAttach")
|
||||
|
@ -104,7 +95,7 @@ struct ContentView: View {
|
|||
#endif
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
@ -172,7 +163,7 @@ struct ContentView: View {
|
|||
case "pause":
|
||||
if let vm = findVM(), vm.state == .started {
|
||||
let shouldSaveOnPause: Bool
|
||||
if let vm = vm.wrapped as? (any UTMSpiceVirtualMachine) {
|
||||
if let vm = vm.wrapped as? UTMQemuVirtualMachine {
|
||||
shouldSaveOnPause = !vm.isRunningAsDisposible
|
||||
} else {
|
||||
shouldSaveOnPause = true
|
||||
|
|
|
@ -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"))
|
||||
}
|
|
@ -108,15 +108,6 @@ struct NumberTextField: View {
|
|||
self.promptKey = prompt
|
||||
}
|
||||
|
||||
init(_ titleKey: LocalizedStringKey, number: Binding<Int?>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
|
||||
let nsnumber = Binding<NSNumber?> {
|
||||
return number.wrappedValue as NSNumber?
|
||||
} set: { newValue in
|
||||
number.wrappedValue = newValue?.intValue
|
||||
}
|
||||
self.init(titleKey, number: nsnumber, prompt: prompt, onEditingChanged: onEditingChanged)
|
||||
}
|
||||
|
||||
init(_ titleKey: LocalizedStringKey, number: Binding<Int>, prompt: LocalizedStringKey = "0", onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
|
||||
let nsnumber = Binding<NSNumber?> {
|
||||
return number.wrappedValue as NSNumber
|
||||
|
|
|
@ -25,13 +25,7 @@ struct UTMUnavailableVMView: View {
|
|||
subtitle: vm.detailsSubtitleLabel,
|
||||
progress: nil,
|
||||
imageOverlaySystemName: "questionmark.circle.fill",
|
||||
popover: {
|
||||
#if WITH_REMOTE
|
||||
UnsupportedVMDetailsView(vm: vm)
|
||||
#else
|
||||
WrappedVMDetailsView(path: vm.pathUrl.path, onRemove: remove)
|
||||
#endif
|
||||
},
|
||||
popover: { WrappedVMDetailsView(path: vm.pathUrl.path, 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 {
|
||||
static var previews: some View {
|
||||
UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))
|
||||
|
|
|
@ -21,7 +21,6 @@ struct VMCommands: Commands {
|
|||
|
||||
@CommandsBuilder
|
||||
var body: some Commands {
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button(action: { NotificationCenter.default.post(name: NSNotification.NewVirtualMachine, object: nil) }, label: {
|
||||
Text("New…")
|
||||
|
@ -30,7 +29,6 @@ struct VMCommands: Commands {
|
|||
Text("Open…")
|
||||
}).keyboardShortcut(KeyEquivalent("o"))
|
||||
}
|
||||
#endif
|
||||
SidebarCommands()
|
||||
ToolbarCommands()
|
||||
CommandGroup(replacing: .windowList, addition: {
|
||||
|
|
|
@ -26,7 +26,7 @@ struct VMConfigInputView: View {
|
|||
VMConfigConstantPicker("USB Support", selection: $config.usbBusSupport)
|
||||
}
|
||||
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
if config.usbBusSupport != .disabled {
|
||||
Section(header: Text("USB Sharing")) {
|
||||
if !jb_has_usb_entitlement() {
|
||||
|
|
|
@ -101,7 +101,7 @@ struct VMConfigSystemView: View {
|
|||
}
|
||||
#endif
|
||||
let actualJitSizeMib = jitSizeMib == 0 ? memorySizeMib / 4 : jitSizeMib
|
||||
let jitMirrorMultiplier = UTMCapabilities.current.contains(.hasJitEntitlements) ? 1 : 2;
|
||||
let jitMirrorMultiplier = jb_has_jit_entitlement() ? 1 : 2;
|
||||
let estMemoryUsage = UInt64(memorySizeMib + jitMirrorMultiplier*actualJitSizeMib + baseUsageMib) * bytesInMib
|
||||
if Double(estMemoryUsage) > Double(totalDeviceMemory) * warningThreshold {
|
||||
warningMessage = WarningMessage.overallocatedRam(totalMib: totalDeviceMemory / bytesInMib, estimatedMib: estMemoryUsage / bytesInMib)
|
||||
|
@ -177,7 +177,7 @@ private struct HardwareOptions: View {
|
|||
}
|
||||
}
|
||||
.onChange(of: config.architecture) { newValue in
|
||||
isArchitectureSupported = ConcreteVirtualMachine.isSupported(systemArchitecture: newValue)
|
||||
isArchitectureSupported = UTMQemuVirtualMachine.isSupported(systemArchitecture: newValue)
|
||||
if newValue != architecture {
|
||||
architecture = newValue
|
||||
}
|
||||
|
|
|
@ -61,7 +61,6 @@ struct VMContextMenuModifier: ViewModifier {
|
|||
}.help("Reveal where the VM is stored.")
|
||||
Divider()
|
||||
#endif
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
Button {
|
||||
data.close(vm: vm) // close window
|
||||
data.edit(vm: vm)
|
||||
|
@ -69,7 +68,6 @@ struct VMContextMenuModifier: ViewModifier {
|
|||
Label("Edit", systemImage: "slider.horizontal.3")
|
||||
}.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
|
||||
.help("Modify settings for this VM.")
|
||||
#endif
|
||||
if vm.hasSuspendState || !vm.isStopped {
|
||||
Button {
|
||||
confirmAction = .confirmStopVM
|
||||
|
@ -101,7 +99,7 @@ struct VMContextMenuModifier: ViewModifier {
|
|||
}
|
||||
#endif
|
||||
|
||||
if let _ = vm.config as? UTMQemuConfiguration {
|
||||
if let _ = vm.wrapped as? UTMQemuVirtualMachine {
|
||||
Button {
|
||||
data.run(vm: vm, options: .bootDisposibleMode)
|
||||
} label: {
|
||||
|
@ -122,7 +120,6 @@ struct VMContextMenuModifier: ViewModifier {
|
|||
|
||||
Divider()
|
||||
}
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
Button {
|
||||
shareItem = .utmCopy(vm)
|
||||
showSharePopup.toggle()
|
||||
|
@ -167,7 +164,6 @@ struct VMContextMenuModifier: ViewModifier {
|
|||
}.disabled(!vm.isModifyAllowed)
|
||||
.help("Delete this VM and all its data.")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
|
||||
.modifier(VMConfirmActionModifier(vm: vm, confirmAction: $confirmAction) {
|
||||
|
@ -179,7 +175,7 @@ struct VMContextMenuModifier: ViewModifier {
|
|||
.onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
|
||||
if newValue == true {
|
||||
data.busyWorkAsync {
|
||||
try await data.mountSupportTools(for: vm.wrapped!)
|
||||
try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,9 +30,8 @@ struct VMDetailsView: View {
|
|||
private let regularScreenSizeClass: Bool = true
|
||||
#endif
|
||||
|
||||
@State private var size: Int64 = 0
|
||||
|
||||
private var sizeLabel: String {
|
||||
let size = data.computeSize(for: vm)
|
||||
return ByteCountFormatter.string(fromByteCount: size, countStyle: .binary)
|
||||
}
|
||||
|
||||
|
@ -71,8 +70,8 @@ struct VMDetailsView: View {
|
|||
.padding([.leading, .trailing, .bottom])
|
||||
}
|
||||
#else
|
||||
let qemuConfig = vm.config as! UTMQemuConfiguration
|
||||
VMRemovableDrivesView(vm: vm, config: qemuConfig)
|
||||
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
|
||||
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||
.padding([.leading, .trailing, .bottom])
|
||||
#endif
|
||||
} else {
|
||||
|
@ -90,8 +89,8 @@ struct VMDetailsView: View {
|
|||
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||
}
|
||||
#else
|
||||
let qemuConfig = vm.config as! UTMQemuConfiguration
|
||||
VMRemovableDrivesView(vm: vm, config: qemuConfig)
|
||||
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
|
||||
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||
#endif
|
||||
}.padding([.leading, .trailing, .bottom])
|
||||
}
|
||||
|
@ -110,16 +109,6 @@ struct VMDetailsView: View {
|
|||
}
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
size = await data.computeSize(for: vm)
|
||||
#if WITH_REMOTE
|
||||
if let vm = vm.wrapped as? UTMRemoteSpiceVirtualMachine {
|
||||
await vm.loadScreenshotFromServer()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -162,7 +151,7 @@ struct Screenshot: View {
|
|||
.blendMode(.hardLight)
|
||||
#if os(visionOS)
|
||||
.overlay {
|
||||
if vm.isStopped || vm.isTakeoverAllowed {
|
||||
if vm.isStopped {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 100, height: 100)
|
||||
|
@ -175,7 +164,7 @@ struct Screenshot: View {
|
|||
#endif
|
||||
if vm.isBusy {
|
||||
Spinner(size: .large)
|
||||
} else if vm.isStopped || vm.isTakeoverAllowed {
|
||||
} else if vm.isStopped {
|
||||
#if !os(visionOS)
|
||||
Button(action: { data.run(vm: vm) }, label: {
|
||||
Label("Run", systemImage: "play.circle.fill")
|
||||
|
|
|
@ -66,9 +66,7 @@ struct VMNavigationListView: View {
|
|||
}
|
||||
}
|
||||
}.onMove(perform: move)
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
.onDelete(perform: delete)
|
||||
#endif
|
||||
|
||||
if data.pendingVMs.count > 0 {
|
||||
Section(header: Text("Pending")) {
|
||||
|
@ -121,12 +119,10 @@ private struct VMListModifier: ViewModifier {
|
|||
newButton
|
||||
}
|
||||
#else
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
newButton
|
||||
}
|
||||
#endif
|
||||
#if !os(visionOS) && !WITH_REMOTE
|
||||
#if !os(visionOS)
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Settings") {
|
||||
settingsPresented.toggle()
|
||||
|
@ -144,9 +140,7 @@ private struct VMListModifier: ViewModifier {
|
|||
if data.showNewVMSheet {
|
||||
VMWizardView()
|
||||
} else if settingsPresented {
|
||||
#if !WITH_REMOTE
|
||||
UTMSettingsView()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: data.showNewVMSheet) { newValue in
|
||||
|
|
|
@ -17,90 +17,30 @@
|
|||
import SwiftUI
|
||||
|
||||
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
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
TileButton(Label(String.create, systemImage: "plus.circle")) {
|
||||
data.newVM()
|
||||
}
|
||||
TileButton(Label(String.browse, systemImage: "arrow.down.circle")) {
|
||||
openURL(URL(string: "https://mac.getutm.app/gallery/")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SecondRow: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
TileButton(Label(String.guide, systemImage: "book.circle")) {
|
||||
openURL(URL(string: "https://docs.getutm.app/basics/basics/")!)
|
||||
}
|
||||
TileButton(Label(String.support, systemImage: "questionmark.circle")) {
|
||||
openURL(URL(string: "https://docs.getutm.app/")!)
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Welcome to UTM").font(.title)
|
||||
}
|
||||
HStack {
|
||||
TileButton(Label(String.create, systemImage: "plus.circle")) {
|
||||
data.newVM()
|
||||
}
|
||||
TileButton(Label(String.browse, systemImage: "arrow.down.circle")) {
|
||||
openURL(URL(string: "https://mac.getutm.app/gallery/")!)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
TileButton(Label(String.guide, systemImage: "book.circle")) {
|
||||
openURL(URL(string: "https://docs.getutm.app/basics/basics/")!)
|
||||
}
|
||||
TileButton(Label(String.support, systemImage: "questionmark.circle")) {
|
||||
openURL(URL(string: "https://docs.getutm.app/")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -110,7 +50,6 @@ fileprivate extension String {
|
|||
static let browse = NSLocalizedString("Browse UTM Gallery", comment: "Welcome view")
|
||||
static let guide = NSLocalizedString("User Guide", comment: "Welcome view")
|
||||
static let support = NSLocalizedString("Support", comment: "Welcome view")
|
||||
static let server = NSLocalizedString("Server", comment: "Server view")
|
||||
}
|
||||
|
||||
private struct TileButton: View {
|
||||
|
|
|
@ -26,8 +26,8 @@ struct VMRemovableDrivesView: View {
|
|||
@State private var workaroundFileImporterBug: Bool = false
|
||||
@State private var currentDrive: UTMQemuConfigurationDrive?
|
||||
|
||||
private var qemuVM: (any UTMSpiceVirtualMachine)! {
|
||||
vm.wrapped as? any UTMSpiceVirtualMachine
|
||||
private var qemuVM: UTMQemuVirtualMachine! {
|
||||
vm.wrapped as? UTMQemuVirtualMachine
|
||||
}
|
||||
|
||||
var fileManager: FileManager {
|
||||
|
@ -78,7 +78,6 @@ struct VMRemovableDrivesView: View {
|
|||
}
|
||||
ForEach(config.drives.filter { $0.isExternal }) { drive in
|
||||
HStack {
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
// Drive menu
|
||||
Menu {
|
||||
// Browse button
|
||||
|
@ -119,9 +118,6 @@ struct VMRemovableDrivesView: View {
|
|||
} label: {
|
||||
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
|
||||
}.disabled(vm.hasSuspendState)
|
||||
#else
|
||||
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
|
||||
#endif
|
||||
Spacer()
|
||||
// Disk image path, or (empty)
|
||||
Text(pathFor(drive))
|
||||
|
|
|
@ -51,7 +51,6 @@ struct VMToolbarModifier: ViewModifier {
|
|||
UTMPreferenceButtonToolbarContent()
|
||||
#endif
|
||||
ToolbarItemGroup(placement: buttonPlacement) {
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
if vm.isShortcut {
|
||||
DestructiveButton {
|
||||
confirmAction = .confirmDeleteShortcut
|
||||
|
@ -113,7 +112,6 @@ struct VMToolbarModifier: ViewModifier {
|
|||
Spacer()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
if vm.hasSuspendState || !vm.isStopped {
|
||||
Button {
|
||||
confirmAction = .confirmStopVM
|
||||
|
@ -131,7 +129,6 @@ struct VMToolbarModifier: ViewModifier {
|
|||
}.help("Run selected VM")
|
||||
.padding(.leading, padding)
|
||||
}
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
#if !os(macOS)
|
||||
if bottom {
|
||||
Spacer()
|
||||
|
@ -146,7 +143,6 @@ struct VMToolbarModifier: ViewModifier {
|
|||
}.help("Edit selected VM")
|
||||
.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
|
||||
.padding(.leading, padding)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.modifier(VMShareItemModifier(isPresented: $showSharePopup, shareItem: shareItem))
|
||||
|
|
|
@ -26,12 +26,12 @@ struct VMWizardStartView: View {
|
|||
#if os(macOS)
|
||||
VZVirtualMachine.isSupported && !processIsTranslated()
|
||||
#else
|
||||
UTMCapabilities.current.contains(.hasHypervisorSupport)
|
||||
jb_has_hypervisor()
|
||||
#endif
|
||||
}
|
||||
|
||||
var isEmulationSupported: Bool {
|
||||
#if !WITH_JIT
|
||||
#if WITH_QEMU_TCI
|
||||
true
|
||||
#else
|
||||
Main.jitAvailable
|
||||
|
|
|
@ -21,19 +21,9 @@ import AppKit
|
|||
import UIKit
|
||||
import SwiftUI
|
||||
#endif
|
||||
#if canImport(AltKit) && WITH_JIT
|
||||
#if canImport(AltKit) && !WITH_QEMU_TCI
|
||||
import AltKit
|
||||
#endif
|
||||
#if WITH_SERVER
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
#if WITH_REMOTE
|
||||
import CocoaSpiceNoUsb
|
||||
typealias ConcreteVirtualMachine = UTMRemoteSpiceVirtualMachine
|
||||
#else
|
||||
typealias ConcreteVirtualMachine = UTMQemuVirtualMachine
|
||||
#endif
|
||||
|
||||
struct AlertMessage: Identifiable {
|
||||
var message: String
|
||||
|
@ -99,17 +89,6 @@ struct AlertMessage: Identifiable {
|
|||
UTMData.defaultStorageUrl
|
||||
}
|
||||
|
||||
#if WITH_SERVER
|
||||
/// Remote access server
|
||||
private(set) var remoteServer: UTMRemoteServer!
|
||||
|
||||
/// Listeners for remote access
|
||||
private var remoteChangeListeners: [VMData: Set<AnyCancellable>] = [:]
|
||||
|
||||
/// Listener for list changes
|
||||
private var listChangedListener: AnyCancellable?
|
||||
#endif
|
||||
|
||||
/// Queue to run `busyWork` tasks
|
||||
private var busyQueue: DispatchQueue
|
||||
|
||||
|
@ -121,10 +100,6 @@ struct AlertMessage: Identifiable {
|
|||
self.virtualMachines = []
|
||||
self.pendingVMs = []
|
||||
self.selectedVM = nil
|
||||
#if WITH_SERVER
|
||||
self.remoteServer = UTMRemoteServer(data: self)
|
||||
beginObservingChanges()
|
||||
#endif
|
||||
listLoadFromDefaults()
|
||||
}
|
||||
|
||||
|
@ -158,7 +133,7 @@ struct AlertMessage: Identifiable {
|
|||
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
|
||||
continue
|
||||
}
|
||||
guard ConcreteVirtualMachine.isVirtualMachine(url: file) else {
|
||||
guard UTMQemuVirtualMachine.isVirtualMachine(url: file) else {
|
||||
continue
|
||||
}
|
||||
await Task.yield()
|
||||
|
@ -193,7 +168,7 @@ struct AlertMessage: Identifiable {
|
|||
}
|
||||
|
||||
/// Load VM list (and order) from persistent storage
|
||||
fileprivate func listLoadFromDefaults() {
|
||||
private func listLoadFromDefaults() {
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.object(forKey: "VMList") == nil else {
|
||||
listLegacyLoadFromDefaults()
|
||||
|
@ -211,7 +186,7 @@ struct AlertMessage: Identifiable {
|
|||
guard let list = defaults.stringArray(forKey: "VMEntryList") else {
|
||||
return
|
||||
}
|
||||
let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in
|
||||
virtualMachines = list.uniqued().compactMap { uuidString in
|
||||
guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
|
||||
return nil
|
||||
}
|
||||
|
@ -223,7 +198,6 @@ struct AlertMessage: Identifiable {
|
|||
}
|
||||
return vm
|
||||
}
|
||||
listReplace(with: virtualMachines)
|
||||
}
|
||||
|
||||
/// Load VM list (and order) from persistent storage (legacy)
|
||||
|
@ -231,7 +205,7 @@ struct AlertMessage: Identifiable {
|
|||
let defaults = UserDefaults.standard
|
||||
// legacy path list
|
||||
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)
|
||||
if let vm = try? VMData(url: url) {
|
||||
return vm
|
||||
|
@ -239,11 +213,10 @@ struct AlertMessage: Identifiable {
|
|||
return nil
|
||||
}
|
||||
})
|
||||
listReplace(with: virtualMachines)
|
||||
}
|
||||
// bookmark list
|
||||
if let list = defaults.array(forKey: "VMList") {
|
||||
let virtualMachines = list.compactMap { item in
|
||||
virtualMachines = list.compactMap { item in
|
||||
let vm: VMData?
|
||||
if let bookmark = item as? Data {
|
||||
vm = VMData(bookmark: bookmark)
|
||||
|
@ -255,7 +228,6 @@ struct AlertMessage: Identifiable {
|
|||
try? vm?.load()
|
||||
return vm
|
||||
}
|
||||
listReplace(with: virtualMachines)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -266,15 +238,8 @@ struct AlertMessage: Identifiable {
|
|||
defaults.set(wrappedVMs, forKey: "VMEntryList")
|
||||
}
|
||||
|
||||
/// Replace current VM list with a new list
|
||||
/// - Parameter vms: List to replace with
|
||||
fileprivate func listReplace(with vms: [VMData]) {
|
||||
virtualMachines.forEach({ endObservingChanges(for: $0) })
|
||||
private func listReplace(with vms: [VMData]) {
|
||||
virtualMachines = vms
|
||||
vms.forEach({ beginObservingChanges(for: $0) })
|
||||
if let vm = selectedVM, !vms.contains(where: { $0 == vm }) {
|
||||
selectedVM = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Add VM to list
|
||||
|
@ -289,7 +254,6 @@ struct AlertMessage: Identifiable {
|
|||
} else {
|
||||
virtualMachines.append(vm)
|
||||
}
|
||||
beginObservingChanges(for: vm)
|
||||
}
|
||||
|
||||
/// Select VM in list
|
||||
|
@ -303,7 +267,6 @@ struct AlertMessage: Identifiable {
|
|||
/// - Returns: Index of item removed or nil if already removed
|
||||
@discardableResult public func listRemove(vm: VMData) -> Int? {
|
||||
let index = virtualMachines.firstIndex(of: vm)
|
||||
endObservingChanges(for: vm)
|
||||
if let index = index {
|
||||
virtualMachines.remove(at: index)
|
||||
}
|
||||
|
@ -353,7 +316,7 @@ struct AlertMessage: Identifiable {
|
|||
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
|
||||
for i in 1..<1000 {
|
||||
let name = nameForId(i)
|
||||
let file = ConcreteVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
|
||||
let file = UTMQemuVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
|
||||
if !fileManager.fileExists(atPath: file.path) {
|
||||
return name
|
||||
}
|
||||
|
@ -420,13 +383,6 @@ struct AlertMessage: Identifiable {
|
|||
func save(vm: VMData) async throws {
|
||||
do {
|
||||
try await vm.save()
|
||||
#if WITH_SERVER
|
||||
if let qemuConfig = vm.config as? UTMQemuConfiguration {
|
||||
await remoteServer.broadcast { remote in
|
||||
try await remote.qemuConfigurationHasChanged(id: vm.id, configuration: qemuConfig)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} catch {
|
||||
// refresh the VM object as it is now stale
|
||||
let origError = error
|
||||
|
@ -494,7 +450,7 @@ struct AlertMessage: Identifiable {
|
|||
/// - Returns: The new VM
|
||||
@discardableResult func clone(vm: VMData) async throws -> VMData {
|
||||
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)
|
||||
guard let newVM = try? VMData(url: newPath) else {
|
||||
|
@ -576,7 +532,7 @@ struct AlertMessage: Identifiable {
|
|||
/// Calculate total size of VM and data
|
||||
/// - Parameter vm: VM to calculate size
|
||||
/// - Returns: Size in bytes
|
||||
func computeSize(for vm: VMData) async -> Int64 {
|
||||
func computeSize(for vm: VMData) -> Int64 {
|
||||
let path = vm.pathUrl
|
||||
guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
|
||||
logger.error("failed to create enumerator for \(path)")
|
||||
|
@ -660,7 +616,7 @@ struct AlertMessage: Identifiable {
|
|||
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) {
|
||||
let status = copyfile(srcURL.path, dstURL.path, nil, copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE | COPYFILE_DATA_SPARSE))
|
||||
if status < 0 {
|
||||
|
@ -721,10 +677,7 @@ struct AlertMessage: Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
|
||||
guard let vm = vm as? any UTMSpiceVirtualMachine else {
|
||||
throw UTMDataError.unsupportedBackend
|
||||
}
|
||||
func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
|
||||
let task = UTMDownloadSupportToolsTask(for: vm)
|
||||
if await task.hasExistingSupportTools {
|
||||
vm.config.qemu.isGuestToolsInstallRequested = false
|
||||
|
@ -804,59 +757,6 @@ struct AlertMessage: Identifiable {
|
|||
vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
|
||||
}
|
||||
|
||||
// MARK: - Change listener
|
||||
|
||||
private func beginObservingChanges() {
|
||||
#if WITH_SERVER
|
||||
listChangedListener = $virtualMachines.sink { vms in
|
||||
Task {
|
||||
await self.remoteServer.broadcast { remote in
|
||||
try await remote.listHasChanged(ids: vms.map({ $0.id }))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func beginObservingChanges(for vm: VMData) {
|
||||
#if WITH_SERVER
|
||||
var observers = Set<AnyCancellable>()
|
||||
let registryEntry = vm.registryEntry
|
||||
observers.insert(vm.objectWillChange.sink { [self] _ in
|
||||
// reset observers when registry changes
|
||||
if vm.registryEntry != registryEntry {
|
||||
endObservingChanges(for: vm)
|
||||
beginObservingChanges(for: vm)
|
||||
}
|
||||
})
|
||||
observers.insert(vm.$state.sink { state in
|
||||
Task {
|
||||
let isTakeoverAllowed = self.vmWindows[vm] is VMRemoteSessionState && (state == .started || state == .paused)
|
||||
await self.remoteServer.broadcast { remote in
|
||||
try await remote.virtualMachine(id: vm.id, didTransitionToState: state, isTakeoverAllowed: isTakeoverAllowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
if let registryEntry = registryEntry {
|
||||
observers.insert(registryEntry.externalDrivePublisher.sink { drives in
|
||||
let mountedDrives = drives.mapValues({ $0.path })
|
||||
Task {
|
||||
await self.remoteServer.broadcast { remote in
|
||||
try await remote.mountedDrivesHasChanged(id: vm.id, mountedDrives: mountedDrives)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
remoteChangeListeners[vm] = observers
|
||||
#endif
|
||||
}
|
||||
|
||||
private func endObservingChanges(for vm: VMData) {
|
||||
#if WITH_SERVER
|
||||
remoteChangeListeners.removeValue(forKey: vm)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Other utility functions
|
||||
|
||||
/// In some regions, iOS will prompt the user for network access
|
||||
|
@ -890,20 +790,16 @@ struct AlertMessage: Identifiable {
|
|||
|
||||
/// Execute a task with spinning progress indicator (Swift concurrency version)
|
||||
/// - Parameter work: Function to execute
|
||||
@discardableResult
|
||||
func busyWorkAsync<T>(_ work: @escaping @Sendable () async throws -> T) -> Task<T, any Error> {
|
||||
func busyWorkAsync(_ work: @escaping @Sendable () async throws -> Void) {
|
||||
Task.detached(priority: .userInitiated) {
|
||||
await self.setBusyIndicator(true)
|
||||
do {
|
||||
let result = try await work()
|
||||
await self.setBusyIndicator(false)
|
||||
return result
|
||||
try await work()
|
||||
} catch {
|
||||
logger.error("\(error)")
|
||||
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
|
||||
/// - components: Data (see UTM Wiki for details)
|
||||
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 let queryItems = components.queryItems else { return }
|
||||
/// Parse targeted position
|
||||
|
@ -972,7 +868,7 @@ struct AlertMessage: Identifiable {
|
|||
|
||||
// MARK: - AltKit
|
||||
|
||||
#if canImport(AltKit) && WITH_JIT
|
||||
#if canImport(AltKit) && !WITH_QEMU_TCI
|
||||
/// Detect if we are installed from AltStore and can use AltJIT
|
||||
var isAltServerCompatible: Bool {
|
||||
guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
|
||||
|
@ -1072,8 +968,6 @@ struct AlertMessage: Identifiable {
|
|||
// MARK: - Errors
|
||||
enum UTMDataError: Error {
|
||||
case virtualMachineAlreadyExists
|
||||
case virtualMachineUnavailable
|
||||
case unsupportedBackend
|
||||
case cloneFailed
|
||||
case shortcutCreationFailed
|
||||
case importFailed
|
||||
|
@ -1083,8 +977,6 @@ enum UTMDataError: Error {
|
|||
case jitStreamerDecodeFailed
|
||||
case jitStreamerAttachFailed
|
||||
case jitStreamerUrlInvalid(String)
|
||||
case notImplemented
|
||||
case reconnectFailed
|
||||
}
|
||||
|
||||
extension UTMDataError: LocalizedError {
|
||||
|
@ -1092,10 +984,6 @@ extension UTMDataError: LocalizedError {
|
|||
switch self {
|
||||
case .virtualMachineAlreadyExists:
|
||||
return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
|
||||
case .virtualMachineUnavailable:
|
||||
return NSLocalizedString("This virtual machine is currently unavailable, make sure it is not open in another session.", comment: "UTMData")
|
||||
case .unsupportedBackend:
|
||||
return NSLocalizedString("Operation not supported by the backend.", comment: "UTMData")
|
||||
case .cloneFailed:
|
||||
return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
|
||||
case .shortcutCreationFailed:
|
||||
|
@ -1114,239 +1002,6 @@ extension UTMDataError: LocalizedError {
|
|||
return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
|
||||
case .jitStreamerUrlInvalid(let urlString):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
|
||||
case .notImplemented:
|
||||
return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
|
||||
case .reconnectFailed:
|
||||
return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Client
|
||||
|
||||
/// Declare host capabilities to any remote client
|
||||
struct UTMCapabilities: OptionSet, Codable {
|
||||
let rawValue: UInt
|
||||
|
||||
/// If set, no trick is needed to get JIT working as the process is entitled.
|
||||
static let hasJitEntitlements = Self(rawValue: 1 << 0)
|
||||
|
||||
/// If set, virtualization is supported by this host.
|
||||
static let hasHypervisorSupport = Self(rawValue: 1 << 1)
|
||||
|
||||
/// If set, host is aarch64
|
||||
static let isAarch64 = Self(rawValue: 1 << 2)
|
||||
|
||||
/// If set, host is x86_64
|
||||
static let isX86_64 = Self(rawValue: 1 << 3)
|
||||
|
||||
static fileprivate(set) var current: Self = {
|
||||
var current = Self()
|
||||
#if WITH_JIT
|
||||
if jb_has_jit_entitlement() {
|
||||
current.insert(.hasJitEntitlements)
|
||||
}
|
||||
if jb_has_hypervisor() {
|
||||
current.insert(.hasHypervisorSupport)
|
||||
}
|
||||
#endif
|
||||
#if arch(arm64)
|
||||
current.insert(.isAarch64)
|
||||
#endif
|
||||
#if arch(x86_64)
|
||||
current.insert(.isX86_64)
|
||||
#endif
|
||||
return current
|
||||
}()
|
||||
}
|
||||
|
||||
#if WITH_REMOTE
|
||||
private let kReconnectTimeoutSeconds: UInt64 = 5
|
||||
|
||||
@MainActor
|
||||
class UTMRemoteData: UTMData {
|
||||
/// Remote access client
|
||||
private(set) var remoteClient: UTMRemoteClient!
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.remoteClient = UTMRemoteClient(data: self)
|
||||
}
|
||||
|
||||
override func listLoadFromDefaults() {
|
||||
// do nothing since we do not load from VMList
|
||||
}
|
||||
|
||||
override func listRefresh() async {
|
||||
busyWorkAsync {
|
||||
try await self.listRefreshFromRemote()
|
||||
}
|
||||
}
|
||||
|
||||
func reconnect(to server: UTMRemoteClient.State.SavedServer) async throws {
|
||||
var reconnectTask: Task<UTMRemoteClient.Remote, any Error>?
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: kReconnectTimeoutSeconds * NSEC_PER_SEC)
|
||||
reconnectTask?.cancel()
|
||||
}
|
||||
reconnectTask = busyWorkAsync { [self] in
|
||||
do {
|
||||
try await remoteClient.connect(server)
|
||||
} catch is CancellationError {
|
||||
throw UTMDataError.reconnectFailed
|
||||
}
|
||||
timeoutTask.cancel()
|
||||
try await listRefreshFromRemote()
|
||||
return await remoteClient.server
|
||||
}
|
||||
// make all active sessions wait on the reconnect
|
||||
for session in VMSessionState.allActiveSessions.values {
|
||||
let vm = session.vm as! UTMRemoteSpiceVirtualMachine
|
||||
Task {
|
||||
do {
|
||||
try await vm.reconnectServer {
|
||||
try await reconnectTask!.value
|
||||
}
|
||||
} catch {
|
||||
session.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = try await reconnectTask!.value
|
||||
}
|
||||
|
||||
private func listRefreshFromRemote() async throws {
|
||||
if let capabilities = await self.remoteClient.server.capabilities {
|
||||
UTMCapabilities.current = capabilities
|
||||
}
|
||||
let ids = try await remoteClient.server.listVirtualMachines()
|
||||
let items = try await remoteClient.server.getVirtualMachineInformation(for: ids)
|
||||
let openSessionVms = VMSessionState.allActiveSessions.values.map({ $0.vm })
|
||||
let vms = items.map { item in
|
||||
let wrapped = openSessionVms.first(where: { $0.id == item.id }) as? UTMRemoteSpiceVirtualMachine
|
||||
return VMRemoteData(fromRemoteItem: item, existingWrapped: wrapped)
|
||||
}
|
||||
await loadVirtualMachines(vms)
|
||||
}
|
||||
|
||||
private func loadVirtualMachines(_ vms: [VMData]) async {
|
||||
listReplace(with: vms)
|
||||
for vm in vms {
|
||||
let remoteVM = vm as! VMRemoteData
|
||||
if remoteVM.isLoaded {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try await remoteVM.load(withRemoteServer: remoteClient.server)
|
||||
} catch {
|
||||
remoteVM.unavailableReason = error.localizedDescription
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
|
||||
func remoteListHasChanged(ids: [UUID]) async {
|
||||
var existing = virtualMachines.reduce(into: [:]) { partialResult, vm in
|
||||
partialResult[vm.id] = vm
|
||||
}
|
||||
let new = ids.compactMap { id in
|
||||
if existing[id] == nil {
|
||||
return id
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if !new.isEmpty, let newItems = try? await remoteClient.server.getVirtualMachineInformation(for: new) {
|
||||
newItems.map({ VMRemoteData(fromRemoteItem: $0) }).forEach { vm in
|
||||
existing[vm.id] = vm
|
||||
}
|
||||
}
|
||||
let vms = ids.compactMap({ existing[$0] })
|
||||
await loadVirtualMachines(vms)
|
||||
}
|
||||
|
||||
func remoteQemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async {
|
||||
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
|
||||
return
|
||||
}
|
||||
await vm.reloadConfiguration(withRemoteServer: remoteClient.server, config: configuration)
|
||||
}
|
||||
|
||||
func remoteMountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async {
|
||||
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
|
||||
return
|
||||
}
|
||||
vm.updateMountedDrives(mountedDrives)
|
||||
}
|
||||
|
||||
func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async {
|
||||
guard let vm = virtualMachines.first(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
let remoteVM = vm as! VMRemoteData
|
||||
let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine
|
||||
remoteVM.isTakeoverAllowed = isTakeoverAllowed
|
||||
await wrapped.updateRemoteState(state)
|
||||
}
|
||||
|
||||
func remoteVirtualMachineDidError(id: UUID, message: String) async {
|
||||
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == id }) {
|
||||
session.nonfatalError = message
|
||||
}
|
||||
}
|
||||
|
||||
override func listMove(fromOffsets: IndexSet, toOffset: Int) {
|
||||
let ids = fromOffsets.map({ virtualMachines[$0].id })
|
||||
Task {
|
||||
try await remoteClient.server.reorderVirtualMachines(fromIds: ids, toOffset: toOffset)
|
||||
}
|
||||
super.listMove(fromOffsets: fromOffsets, toOffset: toOffset)
|
||||
}
|
||||
|
||||
override func save(vm: VMData) async throws {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
override func discardChanges(for vm: VMData) throws {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
override func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override func delete(vm: VMData, alsoRegistry: Bool) async throws -> Int? {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override func clone(vm: VMData) async throws -> VMData {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
override func export(vm: VMData, to url: URL) async throws {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
override func move(vm: VMData, to url: URL) async throws {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
override func template(vm: VMData) async throws {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
override func computeSize(for vm: VMData) async -> Int64 {
|
||||
(try? await remoteClient.server.getPackageSize(for: vm.id)) ?? 0
|
||||
}
|
||||
|
||||
override func importUTM(from url: URL, asShortcut: Bool) async throws {
|
||||
throw UTMDataError.notImplemented
|
||||
}
|
||||
|
||||
override func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
|
||||
try await remoteClient.server.mountGuestToolsOnVirtualMachine(id: vm.id)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -18,7 +18,7 @@ import Foundation
|
|||
|
||||
/// Downloads support tools ISO
|
||||
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")!
|
||||
|
||||
|
@ -42,7 +42,7 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
|
|||
}
|
||||
}
|
||||
|
||||
init(for vm: any UTMSpiceVirtualMachine) {
|
||||
init(for vm: UTMQemuVirtualMachine) {
|
||||
self.vm = vm
|
||||
let name = NSLocalizedString("Windows Guest Support Tools", comment: "UTMDownloadSupportToolsTask")
|
||||
super.init(for: Self.supportToolsDownloadUrl, named: name)
|
||||
|
|
|
@ -99,10 +99,6 @@ class UTMReleaseHelper: ObservableObject {
|
|||
if platform == "iOS SE" {
|
||||
currentSection.body.append(description)
|
||||
}
|
||||
#elseif WITH_REMOTE
|
||||
if platform == "iOS Remote" {
|
||||
currentSection.body.append(description)
|
||||
}
|
||||
#endif
|
||||
#if os(visionOS)
|
||||
if platform.hasPrefix("visionOS") {
|
||||
|
|
|
@ -20,7 +20,7 @@ import SwiftUI
|
|||
/// Model wrapping a single UTMVirtualMachine for use in views
|
||||
@MainActor class VMData: ObservableObject {
|
||||
/// Underlying virtual machine
|
||||
fileprivate(set) var wrapped: (any UTMVirtualMachine)? {
|
||||
private(set) var wrapped: (any UTMVirtualMachine)? {
|
||||
willSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
@ -53,7 +53,7 @@ import SwiftUI
|
|||
}
|
||||
|
||||
/// 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
|
||||
private var uuidUnknown: Bool = false
|
||||
|
@ -67,21 +67,13 @@ import SwiftUI
|
|||
@Published var state: UTMVirtualMachineState = .stopped
|
||||
|
||||
/// Copy from wrapped VM
|
||||
@Published var screenshot: UTMVirtualMachineScreenshot?
|
||||
|
||||
/// If true, it is possible to hijack the session.
|
||||
@Published var isTakeoverAllowed: Bool = false
|
||||
@Published var screenshot: PlatformImage?
|
||||
|
||||
/// Allows changes in the config, registry, and VM to be reflected
|
||||
private var observers: [AnyCancellable] = []
|
||||
|
||||
/// True if the .utm is loaded outside of the default storage
|
||||
var isShortcut: Bool {
|
||||
isShortcut(pathUrl)
|
||||
}
|
||||
|
||||
/// No default init
|
||||
fileprivate init() {
|
||||
private init() {
|
||||
|
||||
}
|
||||
|
||||
|
@ -137,11 +129,9 @@ import SwiftUI
|
|||
/// - Parameter config: Configuration to create new VM
|
||||
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
|
||||
self.init()
|
||||
#if !WITH_REMOTE
|
||||
if let qemuConfig = config as? UTMQemuConfiguration {
|
||||
wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
if let appleConfig = config as? UTMAppleConfiguration {
|
||||
wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
|
||||
|
@ -170,11 +160,9 @@ import SwiftUI
|
|||
}
|
||||
var loaded: (any UTMVirtualMachine)?
|
||||
let config = try UTMQemuConfiguration.load(from: url)
|
||||
#if !WITH_REMOTE
|
||||
if let qemuConfig = config as? UTMQemuConfiguration {
|
||||
loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut(url))
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
if let appleConfig = config as? UTMAppleConfiguration {
|
||||
loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut(url))
|
||||
|
@ -207,7 +195,7 @@ import SwiftUI
|
|||
}
|
||||
|
||||
/// Listen to changes in the underlying object and propogate upwards
|
||||
fileprivate func subscribeToChildren() {
|
||||
private func subscribeToChildren() {
|
||||
var s: [AnyCancellable] = []
|
||||
if let wrapped = wrapped {
|
||||
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
|
||||
if let wrapped = wrapped {
|
||||
self?.state = wrapped.state
|
||||
self?.screenshot = wrapped.screenshot
|
||||
}
|
||||
self?.state = wrapped.state
|
||||
self?.screenshot = wrapped.screenshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -295,6 +281,11 @@ extension VMData: Hashable {
|
|||
|
||||
// MARK: - VM State
|
||||
extension VMData {
|
||||
/// True if the .utm is loaded outside of the default storage
|
||||
var isShortcut: Bool {
|
||||
isShortcut(pathUrl)
|
||||
}
|
||||
|
||||
func isShortcut(_ url: URL) -> Bool {
|
||||
let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
|
||||
let parentUrl = url.deletingLastPathComponent().standardizedFileURL
|
||||
|
@ -431,98 +422,6 @@ extension VMData {
|
|||
|
||||
/// If non-null, is the most recent screenshot image of the running VM
|
||||
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
|
||||
|
|
|
@ -129,11 +129,7 @@ NS_AVAILABLE_IOS(13.4)
|
|||
- (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region {
|
||||
// Hide cursor while hovering in VM view
|
||||
if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
|
||||
#if TARGET_OS_VISION
|
||||
return nil; // FIXME: hidden pointer seems to jump around due to following gaze
|
||||
#else
|
||||
return [UIPointerStyle hiddenPointerStyle];
|
||||
#endif
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
@ -157,13 +153,11 @@ NS_AVAILABLE_IOS(13.4)
|
|||
|
||||
|
||||
- (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction regionForRequest:(UIPointerRegionRequest *)request defaultRegion:(UIPointerRegion *)defaultRegion {
|
||||
#if !TARGET_OS_VISION
|
||||
if (@available(iOS 14.0, *)) {
|
||||
if (self.prefersPointerLocked) {
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Requesting region for the VM display?
|
||||
if (interaction.view == self.mtkView && self.hasTouchpadPointer) {
|
||||
// Then we need to find out if the pointer is in the actual display area or outside
|
||||
|
|
|
@ -181,15 +181,11 @@ const CGFloat kScrollResistance = 10.0f;
|
|||
}
|
||||
|
||||
- (VMMouseType)indirectMouseType {
|
||||
#if TARGET_OS_VISION
|
||||
return VMMouseTypeAbsolute;
|
||||
#else
|
||||
if (@available(iOS 14.0, *)) {
|
||||
return VMMouseTypeRelative;
|
||||
} else {
|
||||
return VMMouseTypeAbsolute; // legacy iOS 13.4 mouse handling requires absolute
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#pragma mark - Converting view points to VM display points
|
||||
|
@ -639,7 +635,7 @@ static CGRect CGRectClipToBounds(CGRect rect1, CGRect rect2) {
|
|||
VMMouseType type = [self touchTypeToMouseType:touch.type];
|
||||
#if TARGET_OS_VISION
|
||||
if ([self isTouchGazeGesture:touch]) {
|
||||
type = VMMouseTypeRelative;
|
||||
type = self.indirectMouseType;
|
||||
}
|
||||
#endif
|
||||
if ([self switchMouseType:type]) {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "VMDisplayViewController.h"
|
||||
#if !defined(WITH_USB)
|
||||
#if defined(WITH_QEMU_TCI)
|
||||
@import CocoaSpiceNoUsb;
|
||||
#else
|
||||
@import CocoaSpice;
|
||||
|
@ -42,8 +42,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@property (nonatomic, strong) NSMutableArray<UIKeyCommand *> *mutableKeyCommands;
|
||||
|
||||
@property (nonatomic) BOOL isDynamicResolutionSupported;
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
|
||||
- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE;
|
||||
- (instancetype)initWithDisplay:(CSDisplay *)display input:(nullable CSInput *)input NS_DESIGNATED_INITIALIZER;
|
||||
|
|
|
@ -29,15 +29,11 @@
|
|||
#import "UTM-Swift.h"
|
||||
@import CocoaSpiceRenderer;
|
||||
|
||||
static const NSInteger kResizeDebounceSecs = 1;
|
||||
static const NSInteger kResizeTimeoutSecs = 5;
|
||||
|
||||
@interface VMDisplayMetalViewController ()
|
||||
|
||||
@property (nonatomic, nullable) CSMetalRenderer *renderer;
|
||||
@property (nonatomic, nullable) id debounceResize;
|
||||
@property (nonatomic, nullable) id cancelResize;
|
||||
@property (nonatomic) BOOL ignoreNextResize;
|
||||
@property (nonatomic) CGFloat windowScaling;
|
||||
@property (nonatomic) CGPoint windowOrigin;
|
||||
|
||||
@end
|
||||
|
||||
|
@ -47,6 +43,9 @@ static const NSInteger kResizeTimeoutSecs = 5;
|
|||
if (self = [super initWithNibName:nil bundle:nil]) {
|
||||
self.vmDisplay = display;
|
||||
self.vmInput = input;
|
||||
self.windowScaling = 1.0;
|
||||
self.windowOrigin = CGPointZero;
|
||||
[self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:NSKeyValueObservingOptionNew context:nil];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
@ -121,25 +120,19 @@ static const NSInteger kResizeTimeoutSecs = 5;
|
|||
- (void)viewWillAppear:(BOOL)animated {
|
||||
[super viewWillAppear:animated];
|
||||
self.prefersHomeIndicatorAutoHidden = YES;
|
||||
#if !TARGET_OS_VISION
|
||||
[self startGCMouse];
|
||||
#endif
|
||||
[self.vmDisplay addRenderer:self.renderer];
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
#if !TARGET_OS_VISION
|
||||
[self stopGCMouse];
|
||||
#endif
|
||||
[self.vmDisplay removeRenderer:self.renderer];
|
||||
[self removeObserver:self forKeyPath:@"vmDisplay.displaySize"];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
self.delegate.displayViewSize = [self convertSizeToNative:self.view.bounds.size];
|
||||
[self addObserver:self forKeyPath:@"vmDisplay.displaySize" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial) context:nil];
|
||||
}
|
||||
|
||||
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
|
||||
|
@ -147,12 +140,10 @@ static const NSInteger kResizeTimeoutSecs = 5;
|
|||
[coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||
self.delegate.displayViewSize = [self convertSizeToNative:size];
|
||||
[self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
|
||||
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
|
||||
if (!CGSizeEqualToSize(size, self.vmDisplay.displaySize)) {
|
||||
[self requestResolutionChangeToSize:size];
|
||||
}
|
||||
}
|
||||
}];
|
||||
if (self.delegate.qemuDisplayIsDynamicResolution) {
|
||||
[self displayResize:size];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)enterSuspendedWithIsBusy:(BOOL)busy {
|
||||
|
@ -170,8 +161,8 @@ static const NSInteger kResizeTimeoutSecs = 5;
|
|||
[super enterLive];
|
||||
self.prefersPointerLocked = YES;
|
||||
self.view.window.isIndirectPointerTouchIgnored = YES;
|
||||
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
|
||||
[self requestResolutionChangeToSize:self.view.bounds.size];
|
||||
if (self.delegate.qemuDisplayIsDynamicResolution) {
|
||||
[self displayResize:self.view.bounds.size];
|
||||
}
|
||||
if (self.delegate.qemuHasClipboardSharing) {
|
||||
[[UTMPasteboard generalPasteboard] requestPollingModeForObject:self];
|
||||
|
@ -209,21 +200,11 @@ static const NSInteger kResizeTimeoutSecs = 5;
|
|||
return size;
|
||||
}
|
||||
|
||||
- (void)requestResolutionChangeToSize:(CGSize)size {
|
||||
self.debounceResize = [self debounce:kResizeDebounceSecs context:self.debounceResize action:^{
|
||||
UTMLog(@"DISPLAY: requesting resolution (%f, %f)", size.width, size.height);
|
||||
CGSize newSize = [self convertSizeToNative:size];
|
||||
CGRect bounds = CGRectMake(0, 0, newSize.width, newSize.height);
|
||||
self.debounceResize = nil;
|
||||
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
|
||||
self.cancelResize = [self debounce:kResizeTimeoutSecs context:self.cancelResize action:^{
|
||||
self.cancelResize = nil;
|
||||
UTMLog(@"DISPLAY: requesting resolution cancelled");
|
||||
[self resizeWindowToDisplaySize];
|
||||
}];
|
||||
#endif
|
||||
[self.vmDisplay requestResolution:bounds];
|
||||
}];
|
||||
- (void)displayResize:(CGSize)size {
|
||||
UTMLog(@"resizing to (%f, %f)", size.width, size.height);
|
||||
size = [self convertSizeToNative:size];
|
||||
CGRect bounds = CGRectMake(0, 0, size.width, size.height);
|
||||
[self.vmDisplay requestResolution:bounds];
|
||||
}
|
||||
|
||||
- (void)setVmDisplay:(CSDisplay *)display {
|
||||
|
@ -236,6 +217,8 @@ static const NSInteger kResizeTimeoutSecs = 5;
|
|||
|
||||
- (void)setDisplayScaling:(CGFloat)scaling origin:(CGPoint)origin {
|
||||
self.vmDisplay.viewportOrigin = origin;
|
||||
self.windowScaling = scaling;
|
||||
self.windowOrigin = origin;
|
||||
if (!self.delegate.qemuDisplayIsNativeResolution) {
|
||||
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 {
|
||||
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
|
||||
CGSize minSize = displaySize;
|
||||
if (self.delegate.qemuDisplayIsNativeResolution) {
|
||||
minSize.width = CGPixelToPoint(minSize.width);
|
||||
minSize.height = CGPixelToPoint(minSize.height);
|
||||
}
|
||||
CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
|
||||
UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:minSize];
|
||||
if (self.delegate.qemuDisplayIsDynamicResolution && self.isDynamicResolutionSupported) {
|
||||
geoPref.minimumSize = CGSizeMake(800, 600);
|
||||
geoPref.maximumSize = maxSize;
|
||||
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsFreeform;
|
||||
} else {
|
||||
geoPref.minimumSize = minSize;
|
||||
geoPref.maximumSize = maxSize;
|
||||
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
CGSize currentViewSize = self.view.bounds.size;
|
||||
UTMLog(@"DISPLAY: old view size = (%f, %f)", currentViewSize.width, currentViewSize.height);
|
||||
if (CGSizeEqualToSize(minSize, currentViewSize)) {
|
||||
// since `-viewWillTransitionToSize:withTransitionCoordinator:` is not called
|
||||
self.delegate.displayViewSize = [self convertSizeToNative:currentViewSize];
|
||||
[self.delegate display:self.vmDisplay didResizeTo:displaySize];
|
||||
}
|
||||
[self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
|
||||
});
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
CGSize minSize = self.vmDisplay.displaySize;
|
||||
if (self.delegate.qemuDisplayIsNativeResolution) {
|
||||
minSize.width = CGPixelToPoint(minSize.width);
|
||||
minSize.height = CGPixelToPoint(minSize.height);
|
||||
}
|
||||
CGSize displaySize = CGSizeMake(minSize.width * self.windowScaling, minSize.height * self.windowScaling);
|
||||
CGSize maxSize = CGSizeMake(UIProposedSceneSizeNoPreference, UIProposedSceneSizeNoPreference);
|
||||
UIWindowSceneGeometryPreferencesVision *geoPref = [[UIWindowSceneGeometryPreferencesVision alloc] initWithSize:displaySize];
|
||||
geoPref.minimumSize = minSize;
|
||||
geoPref.maximumSize = maxSize;
|
||||
geoPref.resizingRestrictions = UIWindowSceneResizingRestrictionsUniform;
|
||||
[self.view.window.windowScene requestGeometryUpdateWithPreferences:geoPref errorHandler:nil];
|
||||
});
|
||||
#else
|
||||
if (CGSizeEqualToSize(displaySize, CGSizeZero)) {
|
||||
return;
|
||||
}
|
||||
[self.delegate display:self.vmDisplay didResizeTo:displaySize];
|
||||
[self.delegate display:self.vmDisplay didResizeTo:self.vmDisplay.displaySize];
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
|
|
@ -55,7 +55,7 @@ public extension VMDisplayViewController {
|
|||
parent.setChildViewControllerForPointerLock(self)
|
||||
UIPress.pressResponderOverride = self
|
||||
}
|
||||
#if !os(visionOS) && !WITH_REMOTE
|
||||
#if !os(visionOS)
|
||||
if runInBackground {
|
||||
logger.info("Start location tracking to enable running in background")
|
||||
UTMLocationManager.sharedInstance().startUpdatingLocation()
|
||||
|
@ -75,6 +75,24 @@ public extension VMDisplayViewController {
|
|||
func enterLive() {
|
||||
UIApplication.shared.isIdleTimerDisabled = disableIdleTimer
|
||||
}
|
||||
|
||||
private func suspend() {
|
||||
// dummy function for selector
|
||||
}
|
||||
|
||||
func terminateApplication() {
|
||||
DispatchQueue.main.async { [self] in
|
||||
// animate to home screen
|
||||
let app = UIApplication.shared
|
||||
app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
|
||||
|
||||
// wait 2 seconds while app is going background
|
||||
Thread.sleep(forTimeInterval: 2)
|
||||
|
||||
// exit app when app is in background
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Toolbar hiding
|
||||
|
@ -116,15 +134,4 @@ public extension VMDisplayViewController {
|
|||
func integerForSetting(_ key: String) -> Int {
|
||||
return UserDefaults.standard.integer(forKey: key)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func debounce(_ delaySeconds: Int, context: Any? = nil, action: @escaping () -> Void) -> Any {
|
||||
if context != nil {
|
||||
let previous = context as! DispatchWorkItem
|
||||
previous.cancel()
|
||||
}
|
||||
let item = DispatchWorkItem(block: action)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(delaySeconds), execute: item)
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
|
|
@ -370,7 +370,7 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
|||
|
||||
- (void)insertUTF8Sequence:(const char *)ctext {
|
||||
unsigned long ctext_len = strlen(ctext);
|
||||
//UTMLog(@"ctext length=%lu\n", ctext_len);
|
||||
UTMLog(@"ctext length=%lu\n", ctext_len);
|
||||
unsigned char tc = ctext[0];
|
||||
|
||||
int keycode = 0;
|
||||
|
@ -393,7 +393,7 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
|||
|
||||
switch (ctext_len) {
|
||||
case 1:
|
||||
//UTMLog(@"char=%d\n", tc);
|
||||
UTMLog(@"char=%d\n", tc);
|
||||
index = indexForChar(_map, _map_len, tc);
|
||||
if (index != -1) {
|
||||
keycode = _map[index].key;
|
||||
|
@ -401,8 +401,8 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
|||
}
|
||||
break;
|
||||
case 2:
|
||||
//UTMLog(@"char=%d\n", tc);
|
||||
//UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
||||
UTMLog(@"char=%d\n", tc);
|
||||
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
||||
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], 0);
|
||||
if (index != -1) {
|
||||
keycode = _ext_map[index].key;
|
||||
|
@ -412,9 +412,9 @@ static int indexForExtChar(const ext_key_mapping_t *table, size_t table_len, cha
|
|||
}
|
||||
break;
|
||||
case 3:
|
||||
//UTMLog(@"char=%d\n", tc);
|
||||
//UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
||||
//UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
|
||||
UTMLog(@"char=%d\n", tc);
|
||||
UTMLog(@"ext1=%d\n", (unsigned char) ctext[1]);
|
||||
UTMLog(@"ext2=%d\n", (unsigned char) ctext[2]);
|
||||
index = indexForExtChar(_ext_map, _ext_map_len, tc, ctext[1], ctext[2]);
|
||||
if (index != -1) {
|
||||
keycode = _ext_map[index].key;
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
"LU6-kH-vN3.accessibilityLabel" = "主页";
|
||||
|
||||
/* 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"; */
|
||||
"n12-9R-99C.accessibilityLabel" = "Esc";
|
||||
|
|
|
@ -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>
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,12 +21,6 @@
|
|||
<string>RunInBackground</string>
|
||||
<key>DefaultValue</key>
|
||||
<false/>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
</array>
|
||||
<key>Platform</key>
|
||||
<string>iOS</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -37,8 +31,6 @@
|
|||
<string>AutosaveBackground</string>
|
||||
<key>DefaultValue</key>
|
||||
<true/>
|
||||
<key>Platform</key>
|
||||
<string>iOS</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -91,11 +83,6 @@
|
|||
<string>NoUsbPrompt</string>
|
||||
<key>DefaultValue</key>
|
||||
<false/>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
<string>iOS-SE</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -112,10 +99,6 @@
|
|||
<string>PSGroupSpecifier</string>
|
||||
<key>Title</key>
|
||||
<string>Graphics</string>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -138,10 +121,6 @@
|
|||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -176,10 +155,6 @@
|
|||
<integer>105</integer>
|
||||
<integer>120</integer>
|
||||
</array>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -2814,11 +2789,6 @@
|
|||
<string>PSGroupSpecifier</string>
|
||||
<key>Title</key>
|
||||
<string>JitStreamer</string>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
<string>iOS-SE</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -2829,11 +2799,6 @@
|
|||
<string>JitStreamerAttach</string>
|
||||
<key>DefaultValue</key>
|
||||
<false/>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
<string>iOS-SE</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
@ -2844,11 +2809,6 @@
|
|||
<string>JitStreamerAddress</string>
|
||||
<key>DefaultValue</key>
|
||||
<string>69.69.0.1</string>
|
||||
<key>ExcludeTargets</key>
|
||||
<array>
|
||||
<string>iOS-Remote</string>
|
||||
<string>iOS-SE</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Type</key>
|
||||
|
|
|
@ -45,9 +45,6 @@
|
|||
"Drag cursor" = "カーソルをドラッグ";
|
||||
"Touch mode (always show cursor)" = "タッチモード(常にカーソルを表示)";
|
||||
"Touch mode (try hiding cursor)" = "タッチモード(カーソルの非表示を試みる)";
|
||||
"Visibility" = "可視性";
|
||||
"Always show cursor" = "常にカーソルを表示";
|
||||
"Try hiding cursor" = "カーソルの非表示を試みる";
|
||||
"Apple Pencil Input" = "Apple Pencil入力";
|
||||
"Tablet mode (always show cursor)" = "タブレットモード(常にカーソルを表示)";
|
||||
"Tablet mode (try hiding cursor)" = "タブレットモード(カーソルの非表示を試みる)";
|
||||
|
|
|
@ -26,16 +26,16 @@
|
|||
"Cursor" = "指標";
|
||||
|
||||
/* (No Comment) */
|
||||
"D-DOWN" = "向下鍵";
|
||||
"D-DOWN" = "下方向鍵";
|
||||
|
||||
/* (No Comment) */
|
||||
"D-LEFT" = "向左鍵";
|
||||
"D-LEFT" = "左方向鍵";
|
||||
|
||||
/* (No Comment) */
|
||||
"D-RIGHT" = "向右鍵";
|
||||
"D-RIGHT" = "右方向鍵";
|
||||
|
||||
/* (No Comment) */
|
||||
"D-UP" = "向上鍵";
|
||||
"D-UP" = "上方向鍵";
|
||||
|
||||
/* (No Comment) */
|
||||
"Disabled" = "已禁用";
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"Auto save on background" = "在后台运行时自动保存";
|
||||
|
||||
/* (No Comment) */
|
||||
"Auto save on low memory" = "在内存不足时自动保存";
|
||||
"Auto save on low memory" = "在内存低时自动保存";
|
||||
|
||||
/* (No Comment) */
|
||||
"Background" = "后台";
|
||||
|
@ -17,7 +17,7 @@
|
|||
"Caps" = "大写锁定";
|
||||
|
||||
/* (No Comment) */
|
||||
"Click & Hold" = "点击并按住";
|
||||
"Click & Hold" = "单击并按住";
|
||||
|
||||
/* (No Comment) */
|
||||
"Continue running VM in the background" = "在后台继续运行虚拟机";
|
||||
|
@ -89,7 +89,7 @@
|
|||
"Mouse Wheel" = "鼠标滚轮";
|
||||
|
||||
/* (No Comment) */
|
||||
"Mouse Wheel (per swipe)" = "鼠标滚轮 (每次滚动)";
|
||||
"Mouse Wheel (per swipe)" = "鼠标滚轮";
|
||||
|
||||
/* (No Comment) */
|
||||
"Move Screen" = "移动显示屏";
|
||||
|
@ -101,7 +101,7 @@
|
|||
"none given" = "未指定";
|
||||
|
||||
/* (No Comment) */
|
||||
"Right" = "右";
|
||||
"Right" = "方向键右";
|
||||
|
||||
/* (No Comment) */
|
||||
"Right Click" = "右键单击";
|
||||
|
@ -110,28 +110,28 @@
|
|||
"Space" = "空格";
|
||||
|
||||
/* (No Comment) */
|
||||
"Tablet mode (always show cursor)" = "平板模式 (总是显示光标)";
|
||||
"Tablet mode (always show cursor)" = "平板模式 (显示光标)";
|
||||
|
||||
/* (No Comment) */
|
||||
"Tablet mode (try hiding cursor)" = "平板模式 (尝试隐藏光标)";
|
||||
"Tablet mode (try hiding cursor)" = "平板模式 (隐藏光标)";
|
||||
|
||||
/* (No Comment) */
|
||||
"Three Finger Pan" = "三指拖移";
|
||||
"Three Finger Pan" = "三指拖动";
|
||||
|
||||
/* (No Comment) */
|
||||
"Touch Input" = "触摸输入";
|
||||
|
||||
/* (No Comment) */
|
||||
"Touch mode (always show cursor)" = "触摸模式 (总是显示光标)";
|
||||
"Touch mode (always show cursor)" = "触摸模式 (显示光标)";
|
||||
|
||||
/* (No Comment) */
|
||||
"Touch mode (try hiding cursor)" = "触摸模式 (尝试隐藏光标)";
|
||||
"Touch mode (try hiding cursor)" = "触摸模式 (隐藏光标)";
|
||||
|
||||
/* (No Comment) */
|
||||
"Touchpad/Mouse Input" = "触控板/鼠标输入";
|
||||
|
||||
/* (No Comment) */
|
||||
"Two Finger Pan" = "双指拖移";
|
||||
"Two Finger Pan" = "双指拖动";
|
||||
|
||||
/* (No Comment) */
|
||||
"Two Finger Scroll" = "双指滚动";
|
||||
|
|
|
@ -19,23 +19,15 @@ import SwiftUI
|
|||
|
||||
extension UTMData {
|
||||
func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
|
||||
#if WITH_SOLO_VM
|
||||
guard VMSessionState.allActiveSessions.count == 0 else {
|
||||
logger.error("Session already started")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard let wrapped = vm.wrapped else {
|
||||
return
|
||||
}
|
||||
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == wrapped.id }) {
|
||||
session.showWindow()
|
||||
} else if vm.isStopped || vm.isTakeoverAllowed {
|
||||
let session = VMSessionState(for: wrapped as! (any UTMSpiceVirtualMachine))
|
||||
session.start(options: options)
|
||||
} else {
|
||||
showErrorAlert(message: NSLocalizedString("This virtual machine is already running. In order to run it from this device, you must stop it first.", comment: "UTMDataExtension"))
|
||||
}
|
||||
let session = VMSessionState(for: wrapped as! UTMQemuVirtualMachine)
|
||||
session.start()
|
||||
}
|
||||
|
||||
func stop(vm: VMData) {
|
||||
|
@ -45,7 +37,6 @@ extension UTMData {
|
|||
if wrapped.registryEntry.isSuspended {
|
||||
wrapped.requestVmDeleteState()
|
||||
}
|
||||
wrapped.requestVmStop()
|
||||
}
|
||||
|
||||
func close(vm: VMData) {
|
||||
|
|
|
@ -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())
|
||||
}
|
|
@ -19,20 +19,12 @@ import SwiftUI
|
|||
struct UTMSettingsView: View {
|
||||
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
|
||||
|
||||
private var hasContainer: Bool {
|
||||
#if WITH_JIT
|
||||
jb_has_container()
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
IASKAppSettings()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appSettingsShowPrivacyLink(hasContainer)
|
||||
.appSettingsShowPrivacyLink(jb_has_container())
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Close") {
|
||||
|
|
|
@ -20,11 +20,7 @@ import SwiftUI
|
|||
struct UTMSingleWindowView: View {
|
||||
let isInteractive: Bool
|
||||
|
||||
#if WITH_REMOTE
|
||||
@State private var data: UTMRemoteData = UTMRemoteData()
|
||||
#else
|
||||
@State private var data: UTMData = UTMData()
|
||||
#endif
|
||||
@State private var session: VMSessionState?
|
||||
@State private var identifier: VMSessionState.WindowID?
|
||||
|
||||
|
@ -40,11 +36,7 @@ struct UTMSingleWindowView: View {
|
|||
if let session = session {
|
||||
VMWindowView(id: identifier!, isInteractive: isInteractive).environmentObject(session)
|
||||
} else if isInteractive {
|
||||
#if WITH_REMOTE
|
||||
RemoteContentView(remoteClientState: data.remoteClient.state).environmentObject(data)
|
||||
#else
|
||||
ContentView().environmentObject(data)
|
||||
#endif
|
||||
} else {
|
||||
VStack {
|
||||
Text("Waiting for VM to connect to display...")
|
||||
|
|
|
@ -19,7 +19,7 @@ import SwiftUI
|
|||
|
||||
struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||
internal class Coordinator: VMDisplayViewControllerDelegate {
|
||||
let vm: any UTMSpiceVirtualMachine
|
||||
let vm: UTMQemuVirtualMachine
|
||||
let device: VMWindowState.Device
|
||||
@Binding var state: VMWindowState
|
||||
var vmStateCancellable: AnyCancellable?
|
||||
|
@ -37,19 +37,19 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
|||
}
|
||||
|
||||
@MainActor var qemuDisplayUpscaler: MTLSamplerMinMagFilter {
|
||||
vmConfig.displays[device.configIndex].upscalingFilter.metalSamplerMinMagFilter
|
||||
vmConfig.displays[state.device!.configIndex].upscalingFilter.metalSamplerMinMagFilter
|
||||
}
|
||||
|
||||
@MainActor var qemuDisplayDownscaler: MTLSamplerMinMagFilter {
|
||||
vmConfig.displays[device.configIndex].downscalingFilter.metalSamplerMinMagFilter
|
||||
vmConfig.displays[state.device!.configIndex].downscalingFilter.metalSamplerMinMagFilter
|
||||
}
|
||||
|
||||
@MainActor var qemuDisplayIsDynamicResolution: Bool {
|
||||
vmConfig.displays[device.configIndex].isDynamicResolution
|
||||
vmConfig.displays[state.device!.configIndex].isDynamicResolution
|
||||
}
|
||||
|
||||
@MainActor var qemuDisplayIsNativeResolution: Bool {
|
||||
vmConfig.displays[device.configIndex].isNativeResolution
|
||||
vmConfig.displays[state.device!.configIndex].isNativeResolution
|
||||
}
|
||||
|
||||
@MainActor var qemuHasClipboardSharing: Bool {
|
||||
|
@ -57,7 +57,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
|||
}
|
||||
|
||||
@MainActor var qemuConsoleResizeCommand: String? {
|
||||
vmConfig.serials[device.configIndex].terminal?.resizeCommand
|
||||
vmConfig.serials[state.device!.configIndex].terminal?.resizeCommand
|
||||
}
|
||||
|
||||
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.device = device
|
||||
self._state = state
|
||||
|
@ -131,7 +131,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
let vm: any UTMSpiceVirtualMachine
|
||||
let vm: UTMQemuVirtualMachine
|
||||
let device: VMWindowState.Device
|
||||
|
||||
@Binding var state: VMWindowState
|
||||
|
@ -168,12 +168,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
|||
if let vc = uiViewController as? VMDisplayMetalViewController {
|
||||
vc.vmInput = session.primaryInput
|
||||
}
|
||||
#if os(visionOS)
|
||||
let useSystemOsk = !(uiViewController is VMDisplayMetalViewController)
|
||||
#else
|
||||
let useSystemOsk = true
|
||||
#endif
|
||||
if useSystemOsk && state.isKeyboardShown != state.isKeyboardRequested {
|
||||
if state.isKeyboardShown != state.isKeyboardRequested {
|
||||
DispatchQueue.main.async {
|
||||
if state.isKeyboardRequested {
|
||||
uiViewController.showKeyboard()
|
||||
|
@ -195,7 +190,6 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
|||
}
|
||||
// some obscure SwiftUI error means we cannot refer to Coordinator's state binding
|
||||
vc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
|
||||
vc.isDynamicResolutionSupported = state.isDynamicResolutionSupported
|
||||
}
|
||||
case .serial(let serial, _):
|
||||
if let vc = uiViewController as? VMDisplayTerminalViewController {
|
||||
|
|
|
@ -37,7 +37,7 @@ import SwiftUI
|
|||
|
||||
let id: ID = ID()
|
||||
|
||||
let vm: any UTMSpiceVirtualMachine
|
||||
let vm: UTMQemuVirtualMachine
|
||||
|
||||
var qemuConfig: UTMQemuConfiguration {
|
||||
vm.config
|
||||
|
@ -45,13 +45,13 @@ import SwiftUI
|
|||
|
||||
@Published var vmState: UTMVirtualMachineState = .stopped
|
||||
|
||||
@Published var nonfatalError: String?
|
||||
|
||||
@Published var fatalError: String?
|
||||
|
||||
@Published var nonfatalError: String?
|
||||
|
||||
@Published var primaryInput: CSInput?
|
||||
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
private var primaryUsbManager: CSUSBManager?
|
||||
|
||||
private var usbManagerQueue = DispatchQueue(label: "USB Manager Queue", qos: .utility)
|
||||
|
@ -79,11 +79,9 @@ import SwiftUI
|
|||
|
||||
@Published var hasShownMemoryWarning: Bool = false
|
||||
|
||||
@Published var isDynamicResolutionSupported: Bool = false
|
||||
|
||||
private var hasAutosave: Bool = false
|
||||
|
||||
init(for vm: any UTMSpiceVirtualMachine) {
|
||||
init(for vm: UTMQemuVirtualMachine) {
|
||||
self.vm = vm
|
||||
super.init()
|
||||
vm.delegate = self
|
||||
|
@ -150,7 +148,7 @@ extension VMSessionState: UTMVirtualMachineDelegate {
|
|||
Task { @MainActor in
|
||||
vmState = state
|
||||
if state == .stopped {
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
clearDevices()
|
||||
#endif
|
||||
}
|
||||
|
@ -159,7 +157,7 @@ extension VMSessionState: UTMVirtualMachineDelegate {
|
|||
|
||||
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
||||
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?) {
|
||||
Task { @MainActor in
|
||||
primaryUsbManager?.delegate = nil
|
||||
|
@ -293,21 +291,9 @@ extension VMSessionState: UTMSpiceIODelegate {
|
|||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
nonisolated func spiceDynamicResolutionSupportDidChange(_ supported: Bool) {
|
||||
Task { @MainActor in
|
||||
isDynamicResolutionSupported = supported
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func spiceDidDisconnect() {
|
||||
Task { @MainActor in
|
||||
fatalError = NSLocalizedString("Connection to the server was lost.", comment: "VMSessionState")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
extension VMSessionState: CSUSBManagerDelegate {
|
||||
nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceError error: String, for device: CSUSBDevice) {
|
||||
Task { @MainActor in
|
||||
|
@ -433,16 +419,8 @@ extension VMSessionState {
|
|||
logger.warning("Error starting audio session: \(error.localizedDescription)")
|
||||
}
|
||||
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])
|
||||
vm.requestVmStart(options: options)
|
||||
}
|
||||
|
||||
@objc private func suspend() {
|
||||
|
@ -458,9 +436,7 @@ extension VMSessionState {
|
|||
}
|
||||
// tell other screens to shut down
|
||||
Self.allActiveSessions.removeValue(forKey: id)
|
||||
closeWindows()
|
||||
|
||||
#if WITH_SOLO_VM
|
||||
NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
|
||||
// animate to home screen
|
||||
let app = UIApplication.shared
|
||||
app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
|
||||
|
@ -470,17 +446,12 @@ extension VMSessionState {
|
|||
|
||||
// exit app when app is in background
|
||||
exit(0)
|
||||
#endif
|
||||
}
|
||||
|
||||
func closeWindows() {
|
||||
NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
|
||||
}
|
||||
|
||||
func powerDown(isKill: Bool = false) {
|
||||
func powerDown() {
|
||||
Task {
|
||||
try? await vm.deleteSnapshot(name: nil)
|
||||
try await vm.stop(usingMethod: isKill ? .kill : .force)
|
||||
try await vm.stop(usingMethod: .force)
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
|
@ -511,7 +482,6 @@ extension VMSessionState {
|
|||
}
|
||||
|
||||
func didEnterBackground() {
|
||||
#if !os(visionOS)
|
||||
logger.info("Entering background")
|
||||
let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
|
||||
if shouldAutosaveBackground && vmState == .started {
|
||||
|
@ -524,7 +494,7 @@ extension VMSessionState {
|
|||
}
|
||||
Task {
|
||||
do {
|
||||
try await vm.saveSnapshot(name: nil)
|
||||
try await vm.saveSnapshot()
|
||||
self.hasAutosave = true
|
||||
logger.info("Save snapshot complete")
|
||||
} catch {
|
||||
|
@ -534,17 +504,14 @@ extension VMSessionState {
|
|||
task = .invalid
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func didEnterForeground() {
|
||||
#if !os(visionOS)
|
||||
logger.info("Entering foreground!")
|
||||
if (hasAutosave && vmState == .started) {
|
||||
logger.info("Deleting snapshot")
|
||||
vm.requestVmDeleteState()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,6 @@ struct VMToolbarDriveMenuView: View {
|
|||
}
|
||||
ForEach(config.drives) { drive in
|
||||
if drive.isExternal {
|
||||
#if !WITH_REMOTE // FIXME: implement remote feature
|
||||
Menu {
|
||||
Button {
|
||||
selectedDrive = drive
|
||||
|
@ -69,12 +68,6 @@ struct VMToolbarDriveMenuView: View {
|
|||
} label: {
|
||||
MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
|
||||
}
|
||||
#else
|
||||
Button {
|
||||
} label: {
|
||||
MenuLabel(label(for: drive), systemImage: session.vm.externalImageURL(for: drive) == nil ? "opticaldiscdrive" : "opticaldiscdrive.fill")
|
||||
}.disabled(true)
|
||||
#endif
|
||||
} else if drive.imageType == .disk || drive.imageType == .cd {
|
||||
Button {
|
||||
} label: {
|
||||
|
|
|
@ -82,17 +82,13 @@ struct VMToolbarView: View {
|
|||
GeometryReader { geometry in
|
||||
Group {
|
||||
Button {
|
||||
if state.isRunning {
|
||||
if session.vm.state == .started {
|
||||
state.alert = .powerDown
|
||||
} else {
|
||||
state.alert = .terminateApp
|
||||
}
|
||||
} label: {
|
||||
if state.isRunning {
|
||||
Label("Power Off", systemImage: "power")
|
||||
} else {
|
||||
Label("Force Kill", systemImage: "xmark")
|
||||
}
|
||||
Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
|
||||
}.offset(offset(for: 8))
|
||||
Button {
|
||||
session.pauseResume()
|
||||
|
@ -114,7 +110,7 @@ struct VMToolbarView: View {
|
|||
} label: {
|
||||
Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
|
||||
}.offset(offset(for: 5))
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
if session.vm.hasUsbRedirection {
|
||||
VMToolbarUSBMenuView()
|
||||
.offset(offset(for: 4))
|
||||
|
|
|
@ -71,8 +71,6 @@ struct VMWindowState: Identifiable {
|
|||
var isRunning: Bool = false
|
||||
|
||||
var alert: Alert?
|
||||
|
||||
var isDynamicResolutionSupported: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - VM action alerts
|
||||
|
@ -84,7 +82,7 @@ extension VMWindowState {
|
|||
case .powerDown: return 0
|
||||
case .terminateApp: return 1
|
||||
case .restart: return 2
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
case .deviceConnected(_): return 3
|
||||
#endif
|
||||
case .nonfatalError(_): return 4
|
||||
|
@ -96,7 +94,7 @@ extension VMWindowState {
|
|||
case powerDown
|
||||
case terminateApp
|
||||
case restart
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
case deviceConnected(CSUSBDevice)
|
||||
#endif
|
||||
case nonfatalError(String)
|
||||
|
|
|
@ -16,9 +16,6 @@
|
|||
|
||||
import SwiftUI
|
||||
import SwiftUIVisualEffects
|
||||
#if os(visionOS)
|
||||
import VisionKeyboardKit
|
||||
#endif
|
||||
|
||||
struct VMWindowView: View {
|
||||
let id: VMSessionState.WindowID
|
||||
|
@ -27,9 +24,6 @@ struct VMWindowView: View {
|
|||
@State private var state: VMWindowState
|
||||
@EnvironmentObject private var session: VMSessionState
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
#if os(visionOS)
|
||||
@Environment(\.dismissWindow) private var dismissWindow
|
||||
#endif
|
||||
|
||||
private let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
|
||||
private let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
|
||||
|
@ -114,13 +108,13 @@ struct VMWindowView: View {
|
|||
}, secondaryButton: .cancel(Text("No")))
|
||||
case .terminateApp:
|
||||
return Alert(title: Text("Are you sure you want to exit UTM?"), primaryButton: .destructive(Text("Yes")) {
|
||||
session.powerDown(isKill: true)
|
||||
session.stop()
|
||||
}, secondaryButton: .cancel(Text("No")))
|
||||
case .restart:
|
||||
return Alert(title: Text("Are you sure you want to reset this VM? Any unsaved changes will be lost."), primaryButton: .destructive(Text("Yes")) {
|
||||
session.reset()
|
||||
}, secondaryButton: .cancel(Text("No")))
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
case .deviceConnected(let device):
|
||||
return Alert(title: Text("Would you like to connect '\(device.name ?? device.description)' to this virtual machine?"), primaryButton: .default(Text("Yes")) {
|
||||
session.mostRecentConnectedDevice = nil
|
||||
|
@ -133,8 +127,6 @@ struct VMWindowView: View {
|
|||
return Alert(title: Text(message), dismissButton: .cancel(Text("OK")) {
|
||||
if case .fatalError(_) = type {
|
||||
session.stop()
|
||||
} else if session.vmState == .stopped {
|
||||
session.stop()
|
||||
} else {
|
||||
session.nonfatalError = nil
|
||||
}
|
||||
|
@ -159,7 +151,7 @@ struct VMWindowView: View {
|
|||
state.saveWindow(to: session.vm.registryEntry, device: oldDevice)
|
||||
state.restoreWindow(from: session.vm.registryEntry, device: newDevice)
|
||||
}
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
.onChange(of: session.mostRecentConnectedDevice) { newValue in
|
||||
if session.activeWindow == state.id, let device = newValue {
|
||||
state.alert = .deviceConnected(device)
|
||||
|
@ -179,9 +171,6 @@ struct VMWindowView: View {
|
|||
.onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
|
||||
vmStateUpdated(from: oldValue, to: newValue)
|
||||
}
|
||||
.onChange(of: session.isDynamicResolutionSupported) { newValue in
|
||||
state.isDynamicResolutionSupported = newValue
|
||||
}
|
||||
.onReceive(keyboardDidShowNotification) { _ in
|
||||
state.isKeyboardShown = true
|
||||
state.isKeyboardRequested = true
|
||||
|
@ -213,30 +202,12 @@ struct VMWindowView: View {
|
|||
if !isInteractive {
|
||||
session.externalWindowBinding = $state
|
||||
}
|
||||
state.isDynamicResolutionSupported = session.isDynamicResolutionSupported
|
||||
// in case an alert appeared before we created the view
|
||||
if session.activeWindow == state.id {
|
||||
#if WITH_USB
|
||||
if let device = session.mostRecentConnectedDevice {
|
||||
state.alert = .deviceConnected(device)
|
||||
}
|
||||
#endif
|
||||
if let nonfatalError = session.nonfatalError {
|
||||
state.alert = .nonfatalError(nonfatalError)
|
||||
}
|
||||
if let fatalError = session.fatalError {
|
||||
state.alert = .fatalError(fatalError)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
session.removeWindow(state.id)
|
||||
if !isInteractive {
|
||||
session.externalWindowBinding = nil
|
||||
}
|
||||
#if os(visionOS)
|
||||
dismissWindow(keyboardFor: state.id)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -250,12 +221,9 @@ struct VMWindowView: View {
|
|||
state.isBusy = false
|
||||
state.isRunning = false
|
||||
}
|
||||
// do not close if we have a popup open
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
if session.nonfatalError == nil && session.fatalError == nil {
|
||||
if session.vmState == .stopped {
|
||||
session.stop()
|
||||
}
|
||||
if session.vmState == .stopped && session.fatalError == nil {
|
||||
session.stop()
|
||||
}
|
||||
}
|
||||
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
"" = "";
|
|
@ -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";
|
|
@ -1,9 +0,0 @@
|
|||
/* Bundle name */
|
||||
"CFBundleName" = "UTMリモート";
|
||||
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "UTMはローカルネットワークを使用してUTMリモートサーバを検索し、接続します。";
|
||||
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "仮想マシンがマイクから録音するには、アクセス許可が必要です。";
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
/* Bundle name */
|
||||
"CFBundleName" = "UTM";
|
||||
"CFBundleName" = "UTM SE";
|
||||
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "虛擬電腦可以訪問本地網絡。UTM 還會使用本地網絡與 AltServer 進行通信。";
|
||||
|
||||
/* Privacy - Location Always and When In Use Usage Description */
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。";
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不會離開設備。";
|
||||
|
||||
/* Privacy - Location Always Usage Description */
|
||||
"NSLocationAlwaysUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。";
|
||||
"NSLocationAlwaysUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不會離開設備。";
|
||||
|
||||
/* Privacy - Location When In Use Usage Description */
|
||||
"NSLocationWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永不離開設備。";
|
||||
"NSLocationWhenInUseUsageDescription" = "UTM 定期請求位置資料,以確保系統保持背景程序處於啟用狀態。位置資料永遠不會離開設備。";
|
||||
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "任何虛擬電腦都需要許可才能由咪高風進行錄製。";
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
/* Bundle name */
|
||||
"CFBundleName" = "UTM";
|
||||
"CFBundleName" = "UTM SE";
|
||||
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "虚拟机可能会访问本地网络。UTM 还使用本地网络与 AltServer 通信。";
|
||||
"NSLocalNetworkUsageDescription" = "虚拟机可以访问本地网络。UTM 还使用本地网络与 AltServer 通信。";
|
||||
|
||||
/* Privacy - Location Always and When In Use Usage Description */
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription" = "UTM 定期请求位置数据,以确保系统保持后台进程处于活动状态。位置数据永远不会离开设备。";
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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>
|
|
@ -48,10 +48,8 @@
|
|||
|
||||
// UTMAppleConfigurationVirtualization.swift
|
||||
"Disabled" = "無効";
|
||||
"Generic Mouse" = "汎用マウス";
|
||||
"Mac Trackpad (macOS 13+)" = "Macトラックパッド(macOS 13以降)";
|
||||
"Generic USB" = "汎用USB";
|
||||
"Mac Keyboard (macOS 14+)" = "Macキーボード(macOS 14以降)";
|
||||
"Mouse" = "マウス";
|
||||
"Trackpad" = "トラックパッド";
|
||||
|
||||
// UTMQemuConfiguration.swift
|
||||
"Failed to migrate configuration from a previous UTM version." = "以前のUTMバージョンからの構成の移行に失敗しました。";
|
||||
|
@ -94,17 +92,13 @@
|
|||
|
||||
/* Services */
|
||||
|
||||
// UTMPipeInterface.swift
|
||||
"Failed to create pipe for communications." = "通信用パイプの作成に失敗しました。";
|
||||
|
||||
// UTMProcess.m
|
||||
// UTMQemu.m
|
||||
"Internal error has occurred." = "内部エラーが発生しました。";
|
||||
|
||||
// UTMQemuImage.swift
|
||||
"An unknown QEMU error has occurred." = "不明なQEMUエラーが発生しました。";
|
||||
|
||||
// UTMSpiceIO.m
|
||||
"Failed to change current directory." = "作業ディレクトリの変更に失敗しました。";
|
||||
"Failed to start SPICE client." = "SPICEクライアントの開始に失敗しました。";
|
||||
"Internal error trying to connect to SPICE server." = "SPICEサーバへの接続試行中に内部エラーが発生しました。";
|
||||
|
||||
|
@ -128,46 +122,22 @@
|
|||
"Failed to access shared directory." = "共有ディレクトリのアクセスに失敗しました。";
|
||||
"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 generate TLS key for server." = "サーバ用のTLS鍵の生成に失敗しました。";
|
||||
|
||||
|
||||
/* Platform/iOS */
|
||||
|
||||
// UTMDataExtension.swift
|
||||
"This virtual machine is already running. In order to run it from this device, you must stop it first." = "この仮想マシンはすでに実行されています。このデバイスから実行するには、まず停止する必要があります。";
|
||||
|
||||
// UTMSingleWindowView.swift
|
||||
// UTMMainView.swift
|
||||
"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
|
||||
"Settings" = "設定";
|
||||
"Close" = "閉じる";
|
||||
|
||||
// VMConfigNetworkPortForwardView.swift
|
||||
"Port Forward" = "ポート転送";
|
||||
"%@ ➡️ %@" = "%1$@ ➡️ %2$@";
|
||||
"New" = "新規";
|
||||
"Delete" = "削除";
|
||||
"Save" = "保存";
|
||||
|
||||
// VMDrivesSettingsView.swift
|
||||
|
@ -175,6 +145,7 @@
|
|||
"Are you sure you want to permanently delete this disk image?" = "このディスクイメージを完全に削除してもよろしいですか?";
|
||||
"EFI Variables" = "EFI変数";
|
||||
"%@ Drive" = "%@ドライブ";
|
||||
"Cancel" = "キャンセル";
|
||||
"Done" = "完了";
|
||||
|
||||
// VMSettingsView.swift
|
||||
|
@ -195,7 +166,7 @@
|
|||
|
||||
// VMToolbarView.swift
|
||||
"Power Off" = "電源オフ";
|
||||
"Force Kill" = "強制終了";
|
||||
"Quit" = "終了";
|
||||
"Pause" = "一時停止";
|
||||
"Play" = "再生";
|
||||
"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インジケータが同期しなくなる可能性があることに注意してください。";
|
||||
"QEMU USB" = "QEMU 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
|
||||
"UTM" = "UTM";
|
||||
"UTM Server" = "UTMサーバ";
|
||||
|
||||
// UTMDataExtension.swift
|
||||
"This virtual machine cannot be run on this machine." = "この仮想マシンはこのマシンでは実行できません。";
|
||||
|
@ -366,7 +326,6 @@
|
|||
"Hide dock icon on next launch" = "次回起動時にDockアイコンを非表示にする";
|
||||
"Requires restarting UTM to take affect." = "変更を適用するには、UTMを再起動する必要があります。";
|
||||
"No virtual machines found." = "仮想マシンが見つかりません。";
|
||||
"Quit" = "終了";
|
||||
"Terminate UTM and stop all running VMs." = "UTMを終了し、すべての実行中の仮想マシンを停止します。";
|
||||
"Start" = "開始";
|
||||
"Stop" = "停止";
|
||||
|
@ -374,22 +333,6 @@
|
|||
"Reset" = "リセット";
|
||||
"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
|
||||
"Operating System" = "オペレーティングシステム";
|
||||
"Bootloader" = "ブートローダ";
|
||||
|
@ -421,6 +364,7 @@
|
|||
|
||||
// VMConfigAppleDriveDetailsView.swift
|
||||
"Removable Drive" = "リムーバブルドライブ";
|
||||
"Name" = "名前";
|
||||
"(New Drive)" = "(新規ドライブ)";
|
||||
"Read Only?" = "読み出しのみ";
|
||||
"Delete Drive" = "ドライブを削除";
|
||||
|
@ -453,7 +397,8 @@
|
|||
"Enable Balloon Device" = "バルーンデバイスを有効にする";
|
||||
"Enable Entropy Device" = "エントロピデバイスを有効にする";
|
||||
"Enable Sound" = "サウンドを有効にする";
|
||||
"Pointer" = "ポインタ";
|
||||
"Enable Keyboard" = "キーボードを有効にする";
|
||||
"Enable Pointer" = "ポインタを有効にする";
|
||||
"Use Trackpad" = "トラックパッドを使用";
|
||||
"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エミュレーション)";
|
||||
|
@ -467,11 +412,9 @@
|
|||
"Guest Port" = "ゲストポート";
|
||||
"Host Address" = "ホストアドレス";
|
||||
"Host Port" = "ホストポート";
|
||||
"Edit…" = "編集…";
|
||||
"New…" = "新規…";
|
||||
|
||||
// VMSessionState.swift
|
||||
"Connection to the server was lost." = "サーバへの接続が切断されました。";
|
||||
|
||||
// VMConfigQEMUArgumentsView.swift
|
||||
"Arguments" = "引数";
|
||||
"Export QEMU Command…" = "QEMUコマンドを書き出す…";
|
||||
|
@ -511,13 +454,6 @@
|
|||
"Select where to export QEMU command:" = "QEMUコマンドの書き出し先を選択してください:";
|
||||
|
||||
|
||||
/* Platform/visionOS */
|
||||
|
||||
// VMToolbarOrnamentModifier.swift
|
||||
"Hide Controls" = "コントロールを非表示";
|
||||
"Show Controls" = "コントロールを表示";
|
||||
|
||||
|
||||
/* Platform/Shared */
|
||||
|
||||
// DestructiveButton.swift
|
||||
|
@ -679,6 +615,7 @@
|
|||
"Emulated Serial Device" = "仮想シリアルデバイス";
|
||||
"TCP" = "TCP";
|
||||
"Server Address" = "サーバアドレス";
|
||||
"Port" = "ポート";
|
||||
"The target does not support hardware emulated serial connections." = "このターゲットはハードウェア仮想シリアル接続に対応していません。";
|
||||
|
||||
// VMConfigSharingView.swift
|
||||
|
@ -763,7 +700,6 @@
|
|||
"Browse UTM Gallery" = "UTMギャラリーをブラウズ";
|
||||
"User Guide" = "ユーザガイド";
|
||||
"Support" = "サポート";
|
||||
"Server" = "サーバ";
|
||||
|
||||
// VMRemovableDrivesView.swift
|
||||
"%@ %@" = "%1$@ %2$@";
|
||||
|
@ -784,8 +720,6 @@
|
|||
"Stop selected VM" = "選択した仮想マシンを停止します";
|
||||
"Run selected VM" = "選択した仮想マシンを実行します";
|
||||
"Edit selected VM" = "選択した仮想マシンを編集します";
|
||||
"Preferences" = "環境設定";
|
||||
"Show UTM preferences" = "UTM環境設定を表示します";
|
||||
|
||||
// VMWizardDrivesView.swift
|
||||
"Storage" = "ストレージ";
|
||||
|
@ -793,7 +727,7 @@
|
|||
|
||||
// VMWizardHardwareView.swift
|
||||
"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アクセラレーションを有効にする";
|
||||
|
||||
// VMWizardOSLinuxView.swift
|
||||
|
@ -906,7 +840,6 @@
|
|||
|
||||
// UTMData.swift
|
||||
"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." = "仮想マシンの複製に失敗しました。";
|
||||
"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と互換性のないプラットフォームで作成されています。";
|
||||
|
@ -918,8 +851,6 @@
|
|||
"Failed to decode JitStreamer response." = "JitStreamerの応答のデコードに失敗しました。";
|
||||
"Failed to attach to JitStreamer." = "JitStreamerへのアタッチに失敗しました。";
|
||||
"Invalid JitStreamer attach URL:\n%@" = "JitStreamerアタッチURLが無効です:\n%@";
|
||||
"This functionality is not yet implemented." = "この機能はまだ実装されていません。";
|
||||
"Failed to reconnect to the server." = "サーバへの再接続に失敗しました。";
|
||||
|
||||
// UTMDownloadVMTask.swift
|
||||
"There is no UTM file in the downloaded ZIP archive." = "ダウンロードされたZIPアーカイブ内にUTMファイルがありません。";
|
||||
|
@ -950,51 +881,8 @@
|
|||
"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 */
|
||||
|
||||
// UTMScriptingUSBDeviceImpl.swift
|
||||
"UTM is not ready to accept commands." = "UTMはコマンドを受け入れる準備ができていません。";
|
||||
"The device cannot be found." = "デバイスが見つかりません。";
|
||||
"The device is not currently connected." = "デバイスが現在接続されていません。";
|
||||
|
||||
// UTMScriptingVirtualMachineImpl.swift
|
||||
"Operation not available." = "操作は利用できません。";
|
||||
"Operation not supported by the backend." = "操作はバックエンドが対応していません。";
|
||||
|
@ -1010,6 +898,7 @@
|
|||
"This device is not supported by the target." = "このデバイスはターゲットが対応していません。";
|
||||
|
||||
// UTMScriptingCreateCommand.swift
|
||||
"UTM is not ready to accept commands." = "UTMはコマンドを受け入れる準備ができていません。";
|
||||
"A valid backend must be specified." = "有効なバックエンドを指定する必要があります。";
|
||||
"This backend is not supported on your machine." = "このバックエンドはお使いのマシンでは対応していません。";
|
||||
"A valid configuration must be specified." = "有効な構成を指定する必要があります。";
|
||||
|
|
|
@ -44,7 +44,7 @@ class VMDisplayAppleTerminalWindowController: VMDisplayAppleWindowController, VM
|
|||
private var isSizeChangeIgnored: Bool = true
|
||||
@Setting("OptionAsMetaKey") var isOptionAsMetaKey: Bool = false
|
||||
|
||||
convenience init(primaryForIndex index: Int, vm: UTMAppleVirtualMachine, onClose: (() -> Void)?) {
|
||||
convenience init(primaryForIndex index: Int, vm: UTMAppleVirtualMachine, onClose: ((Notification) -> Void)?) {
|
||||
self.init(vm: vm, onClose: onClose)
|
||||
self.index = index
|
||||
}
|
||||
|
|
|
@ -257,9 +257,9 @@ extension VMDisplayAppleWindowController {
|
|||
}
|
||||
|
||||
extension VMDisplayAppleWindowController: UTMScreenshotProvider {
|
||||
var screenshot: UTMVirtualMachineScreenshot? {
|
||||
var screenshot: PlatformImage? {
|
||||
if let image = mainView?.image() {
|
||||
return UTMVirtualMachineScreenshot(wrapping: image)
|
||||
return image
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -149,7 +149,7 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController {
|
|||
override func enterSuspended(isBusy busy: Bool) {
|
||||
if !busy {
|
||||
metalView.isHidden = true
|
||||
screenshotView.image = vm.screenshot?.image
|
||||
screenshotView.image = vm.screenshot
|
||||
screenshotView.isHidden = false
|
||||
}
|
||||
if vm.state == .stopped {
|
||||
|
|
|
@ -38,7 +38,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
|||
|
||||
var shouldAutoStartVM: Bool = true
|
||||
var vm: (any UTMVirtualMachine)!
|
||||
var onClose: (() -> Void)?
|
||||
var onClose: ((Notification) -> Void)?
|
||||
private(set) var secondaryWindows: [VMDisplayWindowController] = []
|
||||
private(set) weak var primaryWindow: VMDisplayWindowController?
|
||||
private var preventIdleSleepAssertion: IOPMAssertionID?
|
||||
|
@ -60,7 +60,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
|||
self
|
||||
}
|
||||
|
||||
convenience init(vm: any UTMVirtualMachine, onClose: (() -> Void)?) {
|
||||
convenience init(vm: any UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
|
||||
self.init(window: nil)
|
||||
self.vm = vm
|
||||
self.onClose = onClose
|
||||
|
@ -236,7 +236,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
|||
|
||||
func registerSecondaryWindow(_ secondaryWindow: VMDisplayWindowController, at index: Int? = nil) {
|
||||
secondaryWindows.insert(secondaryWindow, at: index ?? secondaryWindows.endIndex)
|
||||
secondaryWindow.onClose = { [weak self] in
|
||||
secondaryWindow.onClose = { [weak self] _ in
|
||||
self?.secondaryWindows.removeAll(where: { $0 == secondaryWindow })
|
||||
}
|
||||
secondaryWindow.primaryWindow = self
|
||||
|
@ -367,7 +367,7 @@ extension VMDisplayWindowController: NSWindowDelegate {
|
|||
IOPMAssertionRelease(preventIdleSleepAssertion)
|
||||
}
|
||||
isFinalizing = true
|
||||
onClose?()
|
||||
onClose?(notification)
|
||||
}
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
|
|
|
@ -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";
|
|
@ -1,11 +1,11 @@
|
|||
/* 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"; */
|
||||
"7EC-GE-fIl.paletteLabel" = "分享資料夾";
|
||||
"7EC-GE-fIl.paletteLabel" = "共享資料夾";
|
||||
|
||||
/* 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"; */
|
||||
"bKL-Th-FFw.label" = "磁碟";
|
||||
|
@ -32,13 +32,13 @@
|
|||
"C8Y-BQ-Y6m.paletteLabel" = "工具列項目";
|
||||
|
||||
/* 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"; */
|
||||
"FN7-zs-mWC.paletteLabel" = "擷取輸入";
|
||||
"FN7-zs-mWC.paletteLabel" = "捕獲輸入";
|
||||
|
||||
/* 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"; */
|
||||
"G7P-HJ-bcy.label" = "重新啟動";
|
||||
|
|
|
@ -37,10 +37,6 @@ struct SettingsView: View {
|
|||
.tabItem {
|
||||
Label("Input", systemImage: "keyboard")
|
||||
}
|
||||
ServerSettingsView().padding()
|
||||
.tabItem {
|
||||
Label("Server", systemImage: "server.rack")
|
||||
}
|
||||
}.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 {
|
||||
@objc dynamic var KeepRunningAfterLastWindowClosed: Bool { false }
|
||||
@objc dynamic var ShowMenuIcon: Bool { false }
|
||||
|
|
|
@ -61,9 +61,6 @@ struct UTMApp: App {
|
|||
SettingsView()
|
||||
}
|
||||
UTMMenuBarExtraScene(data: data)
|
||||
Window("UTM Server", id: "server") {
|
||||
UTMServerView().environmentObject(data.remoteServer.state)
|
||||
}
|
||||
}
|
||||
|
||||
// HACK: SwiftUI doesn't provide if-statement support in SceneBuilder
|
||||
|
|
|
@ -22,7 +22,7 @@ extension UTMData {
|
|||
func run(vm: VMData, options: UTMVirtualMachineStartOptions = [], startImmediately: Bool = true) {
|
||||
var window: Any? = vmWindows[vm]
|
||||
if window == nil {
|
||||
let close = {
|
||||
let close = { (notification: Notification) -> Void in
|
||||
self.vmWindows.removeValue(forKey: vm)
|
||||
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) {
|
||||
guard let wrapped = vm.wrapped else {
|
||||
return
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -18,18 +18,20 @@ import Foundation
|
|||
import IOKit.pwr_mgt
|
||||
|
||||
/// 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
|
||||
var onStop: (() -> Void)?
|
||||
var onStop: ((Notification) -> Void)?
|
||||
|
||||
@Published var vmState: UTMVirtualMachineState = .stopped
|
||||
|
||||
@Published var fatalError: String?
|
||||
|
||||
private var hasStarted: Bool = false
|
||||
private var preventIdleSleepAssertion: IOPMAssertionID?
|
||||
|
||||
@Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
|
||||
|
||||
init(for vm: any UTMVirtualMachine, onStop: (() -> Void)?) {
|
||||
init(for vm: any UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
|
||||
self.vm = vm
|
||||
self.onStop = onStop
|
||||
super.init()
|
||||
|
@ -40,7 +42,9 @@ import IOKit.pwr_mgt
|
|||
deinit {
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
|
||||
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
|
||||
Task { @MainActor in
|
||||
vmState = state
|
||||
|
@ -59,6 +63,7 @@ import IOKit.pwr_mgt
|
|||
|
||||
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
||||
Task { @MainActor in
|
||||
fatalError = message
|
||||
NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
|
||||
if !hasStarted {
|
||||
// if we got an error and haven't started, then cleanup
|
||||
|
@ -96,7 +101,6 @@ extension VMHeadlessSessionState {
|
|||
if let preventIdleSleepAssertion = preventIdleSleepAssertion {
|
||||
IOPMAssertionRelease(preventIdleSleepAssertion)
|
||||
}
|
||||
onStop?()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
|
@ -4,10 +4,6 @@
|
|||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(TeamIdentifierPrefix)$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).UTM</string>
|
||||
</array>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
|
@ -18,8 +14,6 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.sbpl</key>
|
||||
<array>
|
||||
<string>(allow network-outbound)</string>
|
||||
|
|
|
@ -16,8 +16,6 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
<key>com.apple.vm.device-access</key>
|
||||
|
|
|
@ -73,7 +73,6 @@
|
|||
"TCP Client Connection" = "Połączenie kilent TCP";
|
||||
"TCP Server Connection" = "Połączenie serwer TCP";
|
||||
"Automatic Serial Device (max 4)" = "Automatyczne urządzenie szeregowe (maks. 4)";
|
||||
"Automatic" = "Automatyczny";
|
||||
"Manual Serial Device (advanced)" = "Manualne urządzenie szeregowe (zaawansowane)";
|
||||
"GDB Debug Stub" = "GDB Debug Stub";
|
||||
"QEMU Monitor (HMP)" = "Monitor QEMU (HMP)";
|
||||
|
@ -179,7 +178,7 @@
|
|||
"Information" = "Informacje";
|
||||
"System" = "System";
|
||||
"QEMU" = "QEMU";
|
||||
"Input" = "Urządzenie peryferyjne";
|
||||
"Input" = "Urządzenie WE/WY";
|
||||
"Sharing" = "Współdzielenie";
|
||||
"Devices" = "Urządzenia";
|
||||
"Display" = "Monitor";
|
||||
|
@ -296,12 +295,10 @@
|
|||
"Sound Backend" = "Tryb dźwięku";
|
||||
"SPICE with GStreamer (Input & Output)" = "SPICE z GStreamerem (WE/WY)";
|
||||
"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";
|
||||
"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).";
|
||||
"QEMU Pointer" = "Mysz QEMU";
|
||||
"QEMU Pointer" = "Wskaźnik QEMU";
|
||||
"Hold Control (⌃) for right click" = "Przytrzymaj Control (⌃) aby wykonać prawe kliknięcie";
|
||||
"Invert scrolling" = "Odwróć przewijanie";
|
||||
"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)";
|
||||
"Host Address" = "Adres hosta";
|
||||
"Host Address (IPv6)" = "Adres hosta(IPv6)";
|
||||
"DHCP Start" = "Pierwszy adres zakresu DHCP";
|
||||
"DHCP End" = "Ostatni adres zakresu DHCP";
|
||||
"DHCP Start" = "Start DHCP";
|
||||
"DHCP Domain Name" = "Nazwa domeny DHCP";
|
||||
"DNS Server" = "Serwer DNS";
|
||||
"DNS Server (IPv6)" = "Serwer DNS(IPv6)";
|
||||
|
@ -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.";
|
||||
"QEMU Machine Properties" = "Właściwości maszyny QEMU";
|
||||
"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…";
|
||||
"(Delete)" = "(Usuń)";
|
||||
|
||||
|
|
|
@ -15,40 +15,23 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import VisionKeyboardKit
|
||||
|
||||
@MainActor
|
||||
struct UTMApp: App {
|
||||
#if WITH_REMOTE
|
||||
@State private var data: UTMRemoteData = UTMRemoteData()
|
||||
#else
|
||||
@State private var data: UTMData = UTMData()
|
||||
#endif
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.dismissWindow) private var dismissWindow
|
||||
|
||||
private let vmSessionCreatedNotification = NotificationCenter.default.publisher(for: .vmSessionCreated)
|
||||
private let vmSessionEndedNotification = NotificationCenter.default.publisher(for: .vmSessionEnded)
|
||||
|
||||
private var contentView: some View {
|
||||
#if WITH_REMOTE
|
||||
RemoteContentView(remoteClientState: data.remoteClient.state)
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(id: "home") {
|
||||
contentView
|
||||
ContentView()
|
||||
.environmentObject(data)
|
||||
.onReceive(vmSessionCreatedNotification) { output in
|
||||
let newSession = output.userInfo!["Session"] as! VMSessionState
|
||||
if let window = newSession.windows.first {
|
||||
openWindow(value: window)
|
||||
} else {
|
||||
openWindow(value: newSession.newWindow())
|
||||
}
|
||||
openWindow(value: newSession.newWindow())
|
||||
}
|
||||
.onReceive(vmSessionEndedNotification) { output in
|
||||
let endedSession = output.userInfo!["Session"] as! VMSessionState
|
||||
|
@ -63,17 +46,12 @@ struct UTMApp: App {
|
|||
WindowGroup(for: VMSessionState.GlobalWindowID.self) { $globalID in
|
||||
if let globalID = globalID, let session = VMSessionState.allActiveSessions[globalID.sessionID] {
|
||||
VMWindowView(id: globalID.windowID).environmentObject(session)
|
||||
.glassBackgroundEffect(in: .rect(cornerRadius: 15))
|
||||
#if WITH_SOLO_VM
|
||||
.onAppear {
|
||||
// currently we only support one session, so close the home window
|
||||
dismissWindow(id: "home")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.windowStyle(.plain)
|
||||
.windowResizability(.contentMinSize)
|
||||
KeyboardWindowGroup()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,35 +15,23 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import VisionKeyboardKit
|
||||
#if !WITH_USB
|
||||
import CocoaSpiceNoUsb
|
||||
#else
|
||||
import CocoaSpice
|
||||
#endif
|
||||
|
||||
struct VMToolbarOrnamentModifier: ViewModifier {
|
||||
@Binding var state: VMWindowState
|
||||
@EnvironmentObject private var session: VMSessionState
|
||||
@AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.dismissWindow) private var dismissWindow
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.ornament(visibility: isCollapsed ? .hidden : .visible, attachmentAnchor: .scene(.top)) {
|
||||
HStack {
|
||||
Button {
|
||||
if state.isRunning {
|
||||
if session.vm.state == .started {
|
||||
state.alert = .powerDown
|
||||
} else {
|
||||
state.alert = .terminateApp
|
||||
}
|
||||
} label: {
|
||||
if state.isRunning {
|
||||
Label("Power Off", systemImage: "power")
|
||||
} else {
|
||||
Label("Force Kill", systemImage: "xmark")
|
||||
}
|
||||
Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
|
||||
}
|
||||
.disabled(state.isBusy)
|
||||
Button {
|
||||
|
@ -68,7 +56,7 @@ struct VMToolbarOrnamentModifier: ViewModifier {
|
|||
}
|
||||
.disabled(state.isBusy)
|
||||
}
|
||||
#if WITH_USB
|
||||
#if !WITH_QEMU_TCI
|
||||
if session.vm.hasUsbRedirection {
|
||||
VMToolbarUSBMenuView()
|
||||
.disabled(state.isBusy)
|
||||
|
@ -79,39 +67,11 @@ struct VMToolbarOrnamentModifier: ViewModifier {
|
|||
VMToolbarDisplayMenuView(state: $state)
|
||||
.disabled(state.isBusy)
|
||||
Button {
|
||||
if case .display(_, _) = state.device {
|
||||
state.isKeyboardRequested = !state.isKeyboardShown
|
||||
} else {
|
||||
state.isKeyboardRequested = true
|
||||
}
|
||||
state.isKeyboardRequested = true
|
||||
} label: {
|
||||
Label("Keyboard", systemImage: "keyboard")
|
||||
}
|
||||
.disabled(state.isBusy)
|
||||
.onChange(of: state.isKeyboardRequested) { _, newValue in
|
||||
guard case .display(_, _) = state.device else {
|
||||
return
|
||||
}
|
||||
if newValue {
|
||||
openWindow(keyboardFor: state.id)
|
||||
} else {
|
||||
dismissWindow(keyboardFor: state.id)
|
||||
}
|
||||
}
|
||||
.onReceive(KeyboardEvent.publisher(for: state.id)) { event in
|
||||
switch event {
|
||||
case .keyboardDidAppear:
|
||||
state.isKeyboardShown = true
|
||||
state.isKeyboardRequested = true
|
||||
case .keyboardDidDisappear:
|
||||
state.isKeyboardShown = false
|
||||
state.isKeyboardRequested = false
|
||||
case .keyUp(let keyCode, let modifier):
|
||||
handleKeyEvent(keyCode, modifier: modifier, isKeyDown: false)
|
||||
case .keyDown(let keyCode, let modifier):
|
||||
handleKeyEvent(keyCode, modifier: modifier, isKeyDown: true)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
isCollapsed = true
|
||||
|
@ -130,30 +90,6 @@ struct VMToolbarOrnamentModifier: ViewModifier {
|
|||
.modifier(ToolbarOrnamentViewModifier())
|
||||
}
|
||||
}
|
||||
|
||||
private func handleKeyEvent(_ keyCode: KeyboardKeyCode, modifier: KeyboardModifier, isKeyDown: Bool) {
|
||||
guard let primaryInput = session.primaryInput else {
|
||||
logger.debug("ignoring key event because input channel is not ready")
|
||||
return
|
||||
}
|
||||
var scanCode = keyCode.ps2Set1ScanMake(modifier).reduce(Int32(0), { ($0 << 8) | Int32($1) })
|
||||
if ((scanCode & 0xFF00) == 0xE000) {
|
||||
scanCode = 0x100 | (scanCode & 0xFF);
|
||||
}
|
||||
primaryInput.send(isKeyDown ? .press : .release, code: scanCode)
|
||||
}
|
||||
}
|
||||
|
||||
// the following was suggested by Apple via Feedback to look close to .toolbar() with .bottomOrnament
|
||||
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
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,9 +0,0 @@
|
|||
/* Bundle display name */
|
||||
"CFBundleDisplayName" = "QEMUHelper";
|
||||
|
||||
/* Bundle name */
|
||||
"CFBundleName" = "QEMUHelper";
|
||||
|
||||
/* Copyright (human-readable) */
|
||||
"NSHumanReadableCopyright" = "Copyright © 2020 osy. Tutti i diritti riservati.";
|
||||
|
|
@ -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";
|
||||
|
78
README.ko.md
78
README.ko.md
|
@ -1,78 +0,0 @@
|
|||
# UTM
|
||||
[][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
|
|
@ -4,7 +4,7 @@
|
|||
> 發明一台可用於計算任何可計算序列的機器是可行的。
|
||||
-- <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">
|
||||
<img width="450px" alt=「在 iPhone 上執行 UTM" src="screen.png">
|
||||
|
@ -14,7 +14,7 @@ UTM 是一個功能完备的系統模擬工具與虛擬电脑主機,適用於
|
|||
|
||||
## 特性
|
||||
|
||||
* 使用 QEMU 進行全作業系統模擬(MMU、裝置等)
|
||||
* 使用 QEMU 進行全作業系統模擬(MMU、設備等)
|
||||
* 支援逾三十種體系結構 CPU,包括 x86_64、ARM64 和 RISC-V
|
||||
* 使用 SPICE 與 QXL 的 VGA 圖形模式
|
||||
* 文本終端機模式
|
||||
|
@ -23,14 +23,14 @@ UTM 是一個功能完备的系統模擬工具與虛擬电脑主機,適用於
|
|||
* 採用了最新最靚的 API,從零開始設計前端,支援 macOS 11+ 與 iOS 11+
|
||||
* 從你的裝置上直接製作、管理和執行虛擬機
|
||||
|
||||
## 於 macOS 的附加功能
|
||||
## macOS 的附加功能
|
||||
|
||||
* 使用 Hypervisor.framework 與 QEMU 實現硬件加速虛擬化
|
||||
* 在 macOS 12+ 上使用 Virtualization.framework 來啓動 macOS 客戶端
|
||||
|
||||
## 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)。
|
||||
|
||||
|
@ -55,7 +55,7 @@ UTM 同時支援 macOS:https://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/) 製作。
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 */
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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, ©Result)
|
||||
if result == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
try withSecurityThrow(result)
|
||||
return (copyResult as! SecIdentity)
|
||||
}
|
||||
|
||||
private func deleteIdentity(_ identity: SecIdentity) throws {
|
||||
let query = [
|
||||
kSecClass as String: kSecClassIdentity,
|
||||
kSecMatchItemList as String: [identity],
|
||||
] as CFDictionary
|
||||
try withSecurityThrow(SecItemDelete(query))
|
||||
}
|
||||
|
||||
private func withSecurityThrow(_ block: @autoclosure () -> OSStatus) throws {
|
||||
let err = block()
|
||||
if err != errSecSuccess && err != errSecDuplicateItem {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(err), userInfo: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UTMRemoteKeyManager {
|
||||
func load() async throws {
|
||||
guard !isLoaded else {
|
||||
return
|
||||
}
|
||||
let identity = try await Task.detached { [self] in
|
||||
if let identity = try loadIdentity() {
|
||||
return identity
|
||||
} else {
|
||||
let identity = try generateKey()
|
||||
try importIdentity(identity)
|
||||
return identity
|
||||
}
|
||||
}.value
|
||||
var certificate: SecCertificate?
|
||||
try withSecurityThrow(SecIdentityCopyCertificate(identity, &certificate))
|
||||
self.identity = identity
|
||||
self.fingerprint = certificate!.fingerprint()
|
||||
self.isLoaded = true
|
||||
}
|
||||
|
||||
func reset() async throws {
|
||||
try await Task.detached { [self] in
|
||||
if let identity = try loadIdentity() {
|
||||
try deleteIdentity(identity)
|
||||
}
|
||||
}.value
|
||||
if isLoaded {
|
||||
isLoaded = false
|
||||
try await load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SecCertificate {
|
||||
func fingerprint() -> [UInt8] {
|
||||
let data = SecCertificateCopyData(self)
|
||||
return SHA256.hash(data: data as Data).map({ $0 })
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == UInt8 {
|
||||
func hexString() -> String {
|
||||
self.map({ String(format: "%02X", $0) }).joined(separator: ":")
|
||||
}
|
||||
|
||||
init?(hexString: String) {
|
||||
let cleanString = hexString.replacingOccurrences(of: ":", with: "")
|
||||
guard cleanString.count % 2 == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var byteArray = [UInt8]()
|
||||
var index = cleanString.startIndex
|
||||
|
||||
while index < cleanString.endIndex {
|
||||
let nextIndex = cleanString.index(index, offsetBy: 2)
|
||||
if let byte = UInt8(cleanString[index..<nextIndex], radix: 16) {
|
||||
byteArray.append(byte)
|
||||
} else {
|
||||
return nil // Invalid hex character
|
||||
}
|
||||
index = nextIndex
|
||||
}
|
||||
self = byteArray
|
||||
}
|
||||
|
||||
static func ^(lhs: Self, rhs: Self) -> Self {
|
||||
let length = Swift.min(lhs.count, rhs.count)
|
||||
return (0..<length).map({ lhs[$0] ^ rhs[$0] })
|
||||
}
|
||||
}
|
||||
|
||||
enum UTMRemoteKeyManagerError: Error {
|
||||
case generateKeyFailure
|
||||
case parseKeyFailure
|
||||
case importKeyFailure
|
||||
}
|
||||
|
||||
extension UTMRemoteKeyManagerError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .generateKeyFailure:
|
||||
return NSLocalizedString("Failed to generate a key pair.", comment: "UTMRemoteKeyManager")
|
||||
case .parseKeyFailure:
|
||||
return NSLocalizedString("Failed to parse generated key pair.", comment: "UTMRemoteKeyManager")
|
||||
case .importKeyFailure:
|
||||
return NSLocalizedString("Failed to import generated key.", comment: "UTMRemoteKeyManager")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,21 +25,14 @@
|
|||
#include "UTMLegacyQemuConfiguration+Sharing.h"
|
||||
#include "UTMLegacyQemuConfiguration+System.h"
|
||||
#include "UTMLegacyQemuConfigurationPortForward.h"
|
||||
#include "UTMLogging.h"
|
||||
#if !defined(WITH_REMOTE)
|
||||
#include "UTMProcess.h"
|
||||
#include "UTMQemuSystem.h"
|
||||
#include "UTMJailbreak.h"
|
||||
#else
|
||||
#include "UTMQemuSystemBackends.h"
|
||||
#endif
|
||||
#include "UTMLogging.h"
|
||||
#include "UTMLegacyViewState.h"
|
||||
#include "UTMSpiceIO.h"
|
||||
#include "GenerateKey.h"
|
||||
#if TARGET_OS_IPHONE
|
||||
#if !defined(WITH_REMOTE)
|
||||
#include "UTMLocationManager.h"
|
||||
#endif
|
||||
#include "VMDisplayViewController.h"
|
||||
//#if !defined(TARGET_OS_VISION) || !TARGET_OS_VISION
|
||||
#include "VMDisplayMetalViewController.h"
|
||||
|
|
|
@ -40,10 +40,6 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
|||
var supportsRecoveryMode: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var supportsRemoteSession: Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
static let capabilities = Capabilities()
|
||||
|
@ -89,7 +85,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
|||
}
|
||||
}
|
||||
|
||||
private(set) var screenshot: UTMVirtualMachineScreenshot? {
|
||||
private(set) var screenshot: PlatformImage? {
|
||||
willSet {
|
||||
onStateChange?()
|
||||
}
|
||||
|
@ -479,10 +475,6 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
|||
return true
|
||||
}
|
||||
|
||||
func reloadScreenshotFromFile() {
|
||||
screenshot = loadScreenshot()
|
||||
}
|
||||
|
||||
@MainActor private func createAppleVM() throws {
|
||||
for i in config.serials.indices {
|
||||
let (fd, sfd, name) = try createPty()
|
||||
|
@ -729,7 +721,7 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
|
|||
}
|
||||
|
||||
protocol UTMScreenshotProvider: AnyObject {
|
||||
var screenshot: UTMVirtualMachineScreenshot? { get }
|
||||
var screenshot: PlatformImage? { get }
|
||||
}
|
||||
|
||||
enum UTMAppleVirtualMachineError: Error {
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import Network
|
||||
|
||||
extension Optional where Wrapped == String {
|
||||
var _bound: String? {
|
||||
|
@ -384,44 +383,4 @@ extension String {
|
|||
}
|
||||
return Int(numeric)
|
||||
}
|
||||
|
||||
static func random(length: Int) -> String {
|
||||
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return String((0..<length).map{ _ in letters.randomElement()! })
|
||||
}
|
||||
}
|
||||
|
||||
extension Encodable {
|
||||
func propertyList() throws -> Any {
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .xml
|
||||
let xml = try encoder.encode(self)
|
||||
return try PropertyListSerialization.propertyList(from: xml, format: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension Decodable {
|
||||
init(fromPropertyList propertyList: Any) throws {
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: propertyList, format: .xml, options: 0)
|
||||
let decoder = PropertyListDecoder()
|
||||
self = try decoder.decode(Self.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
extension NWEndpoint {
|
||||
var hostname: String? {
|
||||
if case .hostPort(let host, _) = self {
|
||||
switch host {
|
||||
case .name(let hostname, _):
|
||||
return hostname
|
||||
case .ipv4(let address):
|
||||
return "\(address)"
|
||||
case .ipv6(let address):
|
||||
return "\(address)"
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ typedef struct memorystatus_memlimit_properties {
|
|||
|
||||
int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, user_addr_t buffer, size_t buffersize);
|
||||
|
||||
#if !TARGET_OS_OSX && defined(WITH_JIT)
|
||||
#if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
|
||||
extern int csops(pid_t pid, unsigned int ops, void * useraddr, size_t usersize);
|
||||
extern boolean_t exc_server(mach_msg_header_t *, mach_msg_header_t *);
|
||||
extern int ptrace(int request, pid_t pid, caddr_t addr, int data);
|
||||
|
@ -100,7 +100,7 @@ static bool jb_has_debugger_attached(void) {
|
|||
#endif
|
||||
|
||||
bool jb_has_cs_disabled(void) {
|
||||
#if TARGET_OS_OSX || !defined(WITH_JIT)
|
||||
#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
|
||||
return false;
|
||||
#else
|
||||
int flags;
|
||||
|
@ -236,7 +236,7 @@ static bool is_device_A12_or_newer(void) {
|
|||
bool jb_has_jit_entitlement(void) {
|
||||
#if TARGET_OS_OSX
|
||||
return true;
|
||||
#elif !defined(WITH_JIT)
|
||||
#elif defined(WITH_QEMU_TCI)
|
||||
return false;
|
||||
#else
|
||||
NSDictionary *entitlements = cached_app_entitlements();
|
||||
|
@ -330,7 +330,7 @@ bool jb_has_cs_execseg_allow_unsigned(void) {
|
|||
}
|
||||
|
||||
bool jb_enable_ptrace_hack(void) {
|
||||
#if TARGET_OS_OSX || !defined(WITH_JIT)
|
||||
#if TARGET_OS_OSX || defined(WITH_QEMU_TCI)
|
||||
return false;
|
||||
#else
|
||||
bool debugged = jb_has_debugger_attached();
|
||||
|
@ -380,7 +380,7 @@ bool jb_increase_memlimit(void) {
|
|||
return ret1 == 0 && ret2 == 0;
|
||||
}
|
||||
|
||||
#if !TARGET_OS_OSX && defined(WITH_JIT)
|
||||
#if !TARGET_OS_OSX && !defined(WITH_QEMU_TCI)
|
||||
extern const char *environ[];
|
||||
|
||||
static char *childArgv[] = {NULL, "debugme", NULL};
|
||||
|
@ -397,7 +397,7 @@ bool jb_spawn_ptrace_child(int argc, char **argv) {
|
|||
return false;
|
||||
}
|
||||
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 true;
|
||||
|
|
|
@ -15,9 +15,7 @@
|
|||
//
|
||||
|
||||
#import "UTMLogging.h"
|
||||
#if !defined(WITH_REMOTE)
|
||||
@import QEMUKitInternal;
|
||||
#endif
|
||||
|
||||
static UTMLogging *gLoggingInstance;
|
||||
|
||||
|
@ -44,11 +42,7 @@ void UTMLog(NSString *format, ...) {
|
|||
}
|
||||
|
||||
- (void)writeLine:(NSString *)line {
|
||||
#if defined(WITH_REMOTE)
|
||||
NSLog(@"%@", line);
|
||||
#else
|
||||
[QEMULogging.sharedInstance writeLine:line];
|
||||
#endif
|
||||
}
|
||||
|
||||
@end
|
||||
|
|
|
@ -26,7 +26,7 @@ typealias SystemPasteboardType = NSPasteboard.PasteboardType
|
|||
#else
|
||||
#error("Neither UIKit nor AppKit found!")
|
||||
#endif
|
||||
#if !WITH_USB
|
||||
#if WITH_QEMU_TCI
|
||||
import CocoaSpiceNoUsb
|
||||
#else
|
||||
import CocoaSpice
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
//
|
||||
|
||||
import QEMUKitInternal
|
||||
#if !WITH_USB
|
||||
#if WITH_QEMU_TCI
|
||||
import CocoaSpiceNoUsb
|
||||
#else
|
||||
import CocoaSpice
|
||||
|
|
|
@ -15,9 +15,24 @@
|
|||
//
|
||||
|
||||
#import "UTMProcess.h"
|
||||
#import "UTMQemuSystemBackends.h"
|
||||
@import QEMUKitInternal;
|
||||
|
||||
/// Specify the backend renderer for this VM
|
||||
typedef NS_ENUM(NSInteger, UTMQEMURendererBackend) {
|
||||
kQEMURendererBackendDefault = 0,
|
||||
kQEMURendererBackendAngleGL = 1,
|
||||
kQEMURendererBackendAngleMetal = 2,
|
||||
kQEMURendererBackendMax = 3,
|
||||
};
|
||||
|
||||
/// Specify the sound backend for this VM
|
||||
typedef NS_ENUM(NSInteger, UTMQEMUSoundBackend) {
|
||||
kQEMUSoundBackendDefault = 0,
|
||||
kQEMUSoundBackendSPICE = 1,
|
||||
kQEMUSoundBackendCoreAudio = 2,
|
||||
kQEMUSoundBackendMax = 3,
|
||||
};
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface UTMQemuSystem : UTMProcess <QEMULauncher>
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue