vm: rewrite UTMVirtualMachine in Swift
This commit is contained in:
parent
d498239b38
commit
37991d246f
|
@ -44,9 +44,6 @@ struct UTMAppleConfigurationBoot: Codable {
|
||||||
/// IPSW for installing macOS. Not saved.
|
/// IPSW for installing macOS. Not saved.
|
||||||
var macRecoveryIpswURL: URL?
|
var macRecoveryIpswURL: URL?
|
||||||
|
|
||||||
/// Next startup should be in recovery. Not saved.
|
|
||||||
var startUpFromMacOSRecovery: Bool = false
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case operatingSystem = "OperatingSystem"
|
case operatingSystem = "OperatingSystem"
|
||||||
case linuxKernelPath = "LinuxKernelPath"
|
case linuxKernelPath = "LinuxKernelPath"
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private let kUTMBundleConfigFilename = "config.plist"
|
||||||
|
|
||||||
protocol UTMConfiguration: Codable, ObservableObject {
|
protocol UTMConfiguration: Codable, ObservableObject {
|
||||||
associatedtype Drive: UTMConfigurationDrive
|
associatedtype Drive: UTMConfigurationDrive
|
||||||
static var oldestVersion: Int { get }
|
static var oldestVersion: Int { get }
|
||||||
|
@ -91,6 +93,12 @@ extension UTMConfiguration {
|
||||||
static var dataDirectoryName: String { "Data" }
|
static var dataDirectoryName: String { "Data" }
|
||||||
|
|
||||||
static func load(from packageURL: URL) throws -> any UTMConfiguration {
|
static func load(from packageURL: URL) throws -> any UTMConfiguration {
|
||||||
|
let scopedAccess = packageURL.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if scopedAccess {
|
||||||
|
packageURL.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
let dataURL = packageURL.appendingPathComponent(Self.dataDirectoryName)
|
let dataURL = packageURL.appendingPathComponent(Self.dataDirectoryName)
|
||||||
let configURL = packageURL.appendingPathComponent(kUTMBundleConfigFilename)
|
let configURL = packageURL.appendingPathComponent(kUTMBundleConfigFilename)
|
||||||
let configData = try Data(contentsOf: configURL)
|
let configData = try Data(contentsOf: configURL)
|
||||||
|
@ -112,7 +120,7 @@ extension UTMConfiguration {
|
||||||
#endif
|
#endif
|
||||||
// is it a legacy QEMU config?
|
// is it a legacy QEMU config?
|
||||||
let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
|
let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
|
||||||
let name = UTMVirtualMachine.virtualMachineName(packageURL)
|
let name = UTMQemuVirtualMachine.virtualMachineName(for: packageURL)
|
||||||
let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
|
let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
|
||||||
return UTMQemuConfiguration(migrating: legacy)
|
return UTMQemuConfiguration(migrating: legacy)
|
||||||
} else if stub.backend == .qemu {
|
} else if stub.backend == .qemu {
|
||||||
|
|
|
@ -60,6 +60,9 @@ struct UTMQemuConfigurationQEMU: Codable {
|
||||||
/// If true, changes to the VM will not be committed to disk. Not saved.
|
/// If true, changes to the VM will not be committed to disk. Not saved.
|
||||||
var isDisposable: Bool = false
|
var isDisposable: Bool = false
|
||||||
|
|
||||||
|
/// Set to true to request guest tools install. Not saved.
|
||||||
|
var isGuestToolsInstallRequested: Bool = false
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case hasDebugLog = "DebugLog"
|
case hasDebugLog = "DebugLog"
|
||||||
case hasUefiBoot = "UEFIBoot"
|
case hasUefiBoot = "UEFIBoot"
|
||||||
|
|
|
@ -19,41 +19,83 @@ import Virtualization
|
||||||
|
|
||||||
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
|
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
|
||||||
@available(macOS 11, *)
|
@available(macOS 11, *)
|
||||||
@objc class UTMAppleVirtualMachine: UTMVirtualMachine {
|
final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||||
private let quitTimeoutSeconds = DispatchTimeInterval.seconds(30)
|
struct Capabilities: UTMVirtualMachineCapabilities {
|
||||||
|
var supportsProcessKill: Bool {
|
||||||
var appleConfig: UTMAppleConfiguration {
|
false
|
||||||
config.appleConfig!
|
}
|
||||||
|
|
||||||
|
var supportsSnapshots: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsScreenshots: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsDisposibleMode: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsRecoveryMode: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override var detailsTitleLabel: String {
|
static let capabilities = Capabilities()
|
||||||
appleConfig.information.name
|
|
||||||
|
private(set) var pathUrl: URL {
|
||||||
|
didSet {
|
||||||
|
if isScopedAccess {
|
||||||
|
oldValue.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
isScopedAccess = pathUrl.startAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override var detailsSubtitleLabel: String {
|
private(set) var isShortcut: Bool = false
|
||||||
detailsSystemTargetLabel
|
|
||||||
|
let isRunningAsDisposible: Bool = false
|
||||||
|
|
||||||
|
weak var delegate: (any UTMVirtualMachineDelegate)?
|
||||||
|
|
||||||
|
var onConfigurationChange: (() -> Void)?
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
|
private(set) var config: UTMAppleConfiguration {
|
||||||
|
willSet {
|
||||||
|
onConfigurationChange?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override var detailsNotes: String? {
|
private(set) var registryEntry: UTMRegistryEntry {
|
||||||
appleConfig.information.notes
|
willSet {
|
||||||
|
onConfigurationChange?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override var detailsSystemTargetLabel: String {
|
private(set) var state: UTMVirtualMachineState = .stopped {
|
||||||
appleConfig.system.boot.operatingSystem.rawValue
|
willSet {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
didSet {
|
||||||
|
Task { @MainActor in
|
||||||
|
delegate?.virtualMachine(self, didTransitionToState: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override var detailsSystemArchitectureLabel: String {
|
private(set) var screenshot: PlatformImage? {
|
||||||
appleConfig.system.architecture
|
willSet {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override var detailsSystemMemoryLabel: String {
|
private var isScopedAccess: Bool = false
|
||||||
let bytesInMib = Int64(1048576)
|
|
||||||
return ByteCountFormatter.string(fromByteCount: Int64(appleConfig.system.memorySize) * bytesInMib, countStyle: .binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
override var hasSaveState: Bool {
|
private weak var screenshotTimer: Timer?
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
private let vmQueue = DispatchQueue(label: "VZVirtualMachineQueue", qos: .userInteractive)
|
private let vmQueue = DispatchQueue(label: "VZVirtualMachineQueue", qos: .userInteractive)
|
||||||
|
|
||||||
|
@ -70,20 +112,46 @@ import Virtualization
|
||||||
|
|
||||||
private var activeResourceUrls: [URL] = []
|
private var activeResourceUrls: [URL] = []
|
||||||
|
|
||||||
@MainActor override func reloadConfiguration() throws {
|
@MainActor required init(packageUrl: URL, configuration: UTMAppleConfiguration? = nil, isShortcut: Bool = false) throws {
|
||||||
let newConfig = try UTMAppleConfiguration.load(from: path) as! UTMAppleConfiguration
|
self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
|
||||||
let oldConfig = appleConfig
|
// load configuration
|
||||||
config = UTMConfigurationWrapper(wrapping: newConfig)
|
let config: UTMAppleConfiguration
|
||||||
|
if configuration == nil {
|
||||||
|
guard let appleConfig = try UTMAppleConfiguration.load(from: packageUrl) as? UTMAppleConfiguration else {
|
||||||
|
throw UTMConfigurationError.invalidBackend
|
||||||
|
}
|
||||||
|
config = appleConfig
|
||||||
|
} else {
|
||||||
|
config = configuration!
|
||||||
|
}
|
||||||
|
self.config = config
|
||||||
|
self.pathUrl = packageUrl
|
||||||
|
self.isShortcut = isShortcut
|
||||||
|
self.registryEntry = UTMRegistryEntry.empty
|
||||||
|
self.registryEntry = loadRegistry()
|
||||||
|
self.screenshot = loadScreenshot()
|
||||||
updateConfigFromRegistry()
|
updateConfigFromRegistry()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func accessShortcut() async throws {
|
deinit {
|
||||||
// not needed for Apple VMs
|
if isScopedAccess {
|
||||||
|
pathUrl.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmStart() async throws {
|
@MainActor func reload(from packageUrl: URL?) throws {
|
||||||
|
let packageUrl = packageUrl ?? pathUrl
|
||||||
|
guard let newConfig = try UTMAppleConfiguration.load(from: packageUrl) as? UTMAppleConfiguration else {
|
||||||
|
throw UTMConfigurationError.invalidBackend
|
||||||
|
}
|
||||||
|
config = newConfig
|
||||||
|
pathUrl = packageUrl
|
||||||
|
updateConfigFromRegistry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _start(options: UTMVirtualMachineStartOptions) async throws {
|
||||||
try await createAppleVM()
|
try await createAppleVM()
|
||||||
let boot = await appleConfig.system.boot
|
let boot = await config.system.boot
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in
|
||||||
vmQueue.async {
|
vmQueue.async {
|
||||||
guard let apple = self.apple else {
|
guard let apple = self.apple else {
|
||||||
|
@ -92,9 +160,9 @@ import Virtualization
|
||||||
}
|
}
|
||||||
#if os(macOS) && arch(arm64)
|
#if os(macOS) && arch(arm64)
|
||||||
if #available(macOS 13, *), boot.operatingSystem == .macOS {
|
if #available(macOS 13, *), boot.operatingSystem == .macOS {
|
||||||
let options = VZMacOSVirtualMachineStartOptions()
|
let vzoptions = VZMacOSVirtualMachineStartOptions()
|
||||||
options.startUpFromMacOSRecovery = boot.startUpFromMacOSRecovery
|
vzoptions.startUpFromMacOSRecovery = options.contains(.bootRecovery)
|
||||||
apple.start(options: options) { result in
|
apple.start(options: vzoptions) { result in
|
||||||
if let result = result {
|
if let result = result {
|
||||||
continuation.resume(with: .failure(result))
|
continuation.resume(with: .failure(result))
|
||||||
} else {
|
} else {
|
||||||
|
@ -111,17 +179,17 @@ import Virtualization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmStart() async throws {
|
func start(options: UTMVirtualMachineStartOptions = []) async throws {
|
||||||
guard state == .vmStopped else {
|
guard state == .stopped else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changeState(.vmStarting)
|
state = .starting
|
||||||
do {
|
do {
|
||||||
try await beginAccessingResources()
|
try await beginAccessingResources()
|
||||||
try await _vmStart()
|
try await _start(options: options)
|
||||||
if #available(macOS 12, *) {
|
if #available(macOS 12, *) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
sharedDirectoriesChanged = appleConfig.sharedDirectoriesPublisher.sink { [weak self] newShares in
|
sharedDirectoriesChanged = config.sharedDirectoriesPublisher.sink { [weak self] newShares in
|
||||||
guard let self = self else {
|
guard let self = self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -131,16 +199,19 @@ import Virtualization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changeState(.vmStarted)
|
state = .started
|
||||||
|
if screenshotTimer == nil {
|
||||||
|
screenshotTimer = startScreenshotTimer()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await stopAccesingResources()
|
await stopAccesingResources()
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmStop(force: Bool) async throws {
|
private func _stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
|
||||||
if force, #available(macOS 12, *) {
|
if method != .request, #available(macOS 12, *) {
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
vmQueue.async {
|
vmQueue.async {
|
||||||
guard let apple = self.apple else {
|
guard let apple = self.apple else {
|
||||||
|
@ -175,25 +246,25 @@ import Virtualization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmStop(force: Bool) async throws {
|
func stop(usingMethod method: UTMVirtualMachineStopMethod = .request) async throws {
|
||||||
if let installProgress = installProgress {
|
if let installProgress = installProgress {
|
||||||
installProgress.cancel()
|
installProgress.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard state == .vmStarted || state == .vmPaused else {
|
guard state == .started || state == .paused else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changeState(.vmStopping)
|
state = .stopping
|
||||||
do {
|
do {
|
||||||
try await _vmStop(force: force)
|
try await _stop(usingMethod: method)
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
} catch {
|
} catch {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmReset() async throws {
|
private func _restart() async throws {
|
||||||
guard #available(macOS 12, *) else {
|
guard #available(macOS 12, *) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -216,31 +287,31 @@ import Virtualization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmReset() async throws {
|
func restart() async throws {
|
||||||
guard state == .vmStarted || state == .vmPaused else {
|
guard state == .started || state == .paused else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changeState(.vmStopping)
|
state = .stopping
|
||||||
do {
|
do {
|
||||||
try await _vmReset()
|
try await _restart()
|
||||||
changeState(.vmStarted)
|
state = .started
|
||||||
} catch {
|
} catch {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmPause() async throws {
|
private func _pause() async throws {
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
vmQueue.async {
|
vmQueue.async {
|
||||||
guard let apple = self.apple else {
|
guard let apple = self.apple else {
|
||||||
continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable)
|
continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
DispatchQueue.main.sync {
|
Task { @MainActor in
|
||||||
self.updateScreenshot()
|
await self.takeScreenshot()
|
||||||
|
try? self.saveScreenshot()
|
||||||
}
|
}
|
||||||
self.saveScreenshot()
|
|
||||||
apple.pause { result in
|
apple.pause { result in
|
||||||
continuation.resume(with: result)
|
continuation.resume(with: result)
|
||||||
}
|
}
|
||||||
|
@ -248,26 +319,33 @@ import Virtualization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmPause(save: Bool) async throws {
|
func pause() async throws {
|
||||||
changeState(.vmPausing)
|
guard state == .started else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .pausing
|
||||||
do {
|
do {
|
||||||
try await _vmPause()
|
try await _pause()
|
||||||
changeState(.vmPaused)
|
state = .paused
|
||||||
} catch {
|
} catch {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmSaveState() async throws {
|
func saveSnapshot(name: String? = nil) async throws {
|
||||||
// FIXME: implement this
|
// FIXME: implement this
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmDeleteState() async throws {
|
func deleteSnapshot(name: String? = nil) async throws {
|
||||||
// FIXME: implement this
|
// FIXME: implement this
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmResume() async throws {
|
func restoreSnapshot(name: String? = nil) async throws {
|
||||||
|
// FIXME: implement this
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _resume() async throws {
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
vmQueue.async {
|
vmQueue.async {
|
||||||
guard let apple = self.apple else {
|
guard let apple = self.apple else {
|
||||||
|
@ -281,52 +359,37 @@ import Virtualization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmResume() async throws {
|
func resume() async throws {
|
||||||
guard state == .vmPaused else {
|
guard state == .paused else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changeState(.vmResuming)
|
state = .resuming
|
||||||
do {
|
do {
|
||||||
try await _vmResume()
|
try await _resume()
|
||||||
changeState(.vmStarted)
|
state = .started
|
||||||
} catch {
|
} catch {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmGuestPowerDown() async throws {
|
@discardableResult @MainActor
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
func takeScreenshot() async -> Bool {
|
||||||
vmQueue.async {
|
|
||||||
guard let apple = self.apple else {
|
|
||||||
continuation.resume() // already stopped
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try apple.requestStop()
|
|
||||||
continuation.resume()
|
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateScreenshot() {
|
|
||||||
screenshot = screenshotDelegate?.screenshot
|
screenshot = screenshotDelegate?.screenshot
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor private func createAppleVM() throws {
|
@MainActor private func createAppleVM() throws {
|
||||||
for i in appleConfig.serials.indices {
|
for i in config.serials.indices {
|
||||||
let (fd, sfd, name) = try createPty()
|
let (fd, sfd, name) = try createPty()
|
||||||
let terminalTtyHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
|
let terminalTtyHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
|
||||||
let slaveTtyHandle = FileHandle(fileDescriptor: sfd, closeOnDealloc: false)
|
let slaveTtyHandle = FileHandle(fileDescriptor: sfd, closeOnDealloc: false)
|
||||||
appleConfig.serials[i].fileHandleForReading = terminalTtyHandle
|
config.serials[i].fileHandleForReading = terminalTtyHandle
|
||||||
appleConfig.serials[i].fileHandleForWriting = terminalTtyHandle
|
config.serials[i].fileHandleForWriting = terminalTtyHandle
|
||||||
let serialPort = UTMSerialPort(portNamed: name, readFileHandle: slaveTtyHandle, writeFileHandle: slaveTtyHandle, terminalFileHandle: terminalTtyHandle)
|
let serialPort = UTMSerialPort(portNamed: name, readFileHandle: slaveTtyHandle, writeFileHandle: slaveTtyHandle, terminalFileHandle: terminalTtyHandle)
|
||||||
appleConfig.serials[i].interface = serialPort
|
config.serials[i].interface = serialPort
|
||||||
}
|
}
|
||||||
let vzConfig = try appleConfig.appleVZConfiguration()
|
let vzConfig = try config.appleVZConfiguration()
|
||||||
vmQueue.async { [self] in
|
vmQueue.async { [self] in
|
||||||
apple = VZVirtualMachine(configuration: vzConfig, queue: vmQueue)
|
apple = VZVirtualMachine(configuration: vzConfig, queue: vmQueue)
|
||||||
apple!.delegate = self
|
apple!.delegate = self
|
||||||
|
@ -349,10 +412,10 @@ import Virtualization
|
||||||
|
|
||||||
@available(macOS 12, *)
|
@available(macOS 12, *)
|
||||||
func installVM(with ipswUrl: URL) async throws {
|
func installVM(with ipswUrl: URL) async throws {
|
||||||
guard state == .vmStopped else {
|
guard state == .stopped else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changeState(.vmStarting)
|
state = .starting
|
||||||
do {
|
do {
|
||||||
_ = ipswUrl.startAccessingSecurityScopedResource()
|
_ = ipswUrl.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
|
@ -368,7 +431,9 @@ import Virtualization
|
||||||
}
|
}
|
||||||
let installer = VZMacOSInstaller(virtualMachine: apple, restoringFromImageAt: ipswUrl)
|
let installer = VZMacOSInstaller(virtualMachine: apple, restoringFromImageAt: ipswUrl)
|
||||||
self.progressObserver = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { progress, change in
|
self.progressObserver = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { progress, change in
|
||||||
self.delegate?.virtualMachine?(self, didUpdateInstallationProgress: progress.fractionCompleted)
|
Task { @MainActor in
|
||||||
|
self.delegate?.virtualMachine(self, didUpdateInstallationProgress: progress.fractionCompleted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.installProgress = installer.progress
|
self.installProgress = installer.progress
|
||||||
installer.install { result in
|
installer.install { result in
|
||||||
|
@ -376,30 +441,21 @@ import Virtualization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changeState(.vmStarted)
|
state = .started
|
||||||
progressObserver = nil
|
progressObserver = nil
|
||||||
installProgress = nil
|
installProgress = nil
|
||||||
delegate?.virtualMachine?(self, didCompleteInstallation: true)
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didCompleteInstallation: true)
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
throw UTMAppleVirtualMachineError.operatingSystemInstallNotSupported
|
throw UTMAppleVirtualMachineError.operatingSystemInstallNotSupported
|
||||||
#endif
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
delegate?.virtualMachine?(self, didCompleteInstallation: false)
|
await MainActor.run {
|
||||||
changeState(.vmStopped)
|
delegate?.virtualMachine(self, didCompleteInstallation: false)
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 12, *)
|
|
||||||
func requestInstallVM(with ipswUrl: URL) {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await installVM(with: ipswUrl)
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
state = .stopped
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -439,24 +495,24 @@ import Virtualization
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor private func beginAccessingResources() throws {
|
@MainActor private func beginAccessingResources() throws {
|
||||||
for i in appleConfig.drives.indices {
|
for i in config.drives.indices {
|
||||||
let drive = appleConfig.drives[i]
|
let drive = config.drives[i]
|
||||||
if let url = drive.imageURL, drive.isExternal {
|
if let url = drive.imageURL, drive.isExternal {
|
||||||
if url.startAccessingSecurityScopedResource() {
|
if url.startAccessingSecurityScopedResource() {
|
||||||
activeResourceUrls.append(url)
|
activeResourceUrls.append(url)
|
||||||
} else {
|
} else {
|
||||||
appleConfig.drives[i].imageURL = nil
|
config.drives[i].imageURL = nil
|
||||||
throw UTMAppleVirtualMachineError.cannotAccessResource(url)
|
throw UTMAppleVirtualMachineError.cannotAccessResource(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i in appleConfig.sharedDirectories.indices {
|
for i in config.sharedDirectories.indices {
|
||||||
let share = appleConfig.sharedDirectories[i]
|
let share = config.sharedDirectories[i]
|
||||||
if let url = share.directoryURL {
|
if let url = share.directoryURL {
|
||||||
if url.startAccessingSecurityScopedResource() {
|
if url.startAccessingSecurityScopedResource() {
|
||||||
activeResourceUrls.append(url)
|
activeResourceUrls.append(url)
|
||||||
} else {
|
} else {
|
||||||
appleConfig.sharedDirectories[i].directoryURL = nil
|
config.sharedDirectories[i].directoryURL = nil
|
||||||
throw UTMAppleVirtualMachineError.cannotAccessResource(url)
|
throw UTMAppleVirtualMachineError.cannotAccessResource(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -480,28 +536,88 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
|
||||||
sharedDirectoriesChanged = nil
|
sharedDirectoriesChanged = nil
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
stopAccesingResources()
|
stopAccesingResources()
|
||||||
for i in appleConfig.serials.indices {
|
for i in config.serials.indices {
|
||||||
if let serialPort = appleConfig.serials[i].interface {
|
if let serialPort = config.serials[i].interface {
|
||||||
serialPort.close()
|
serialPort.close()
|
||||||
appleConfig.serials[i].interface = nil
|
config.serials[i].interface = nil
|
||||||
appleConfig.serials[i].fileHandleForReading = nil
|
config.serials[i].fileHandleForReading = nil
|
||||||
appleConfig.serials[i].fileHandleForWriting = nil
|
config.serials[i].fileHandleForWriting = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
|
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
|
||||||
guestDidStop(virtualMachine)
|
guestDidStop(virtualMachine)
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
self.delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
self.delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fake methods to adhere to NSObjectProtocol
|
||||||
|
|
||||||
|
func isEqual(_ object: Any?) -> Bool {
|
||||||
|
self === object as? UTMAppleVirtualMachine
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash: Int {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
var superclass: AnyClass? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func `self`() -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform(_ aSelector: Selector!) -> Unmanaged<AnyObject>! {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform(_ aSelector: Selector!, with object: Any!) -> Unmanaged<AnyObject>! {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform(_ aSelector: Selector!, with object1: Any!, with object2: Any!) -> Unmanaged<AnyObject>! {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProxy() -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKind(of aClass: AnyClass) -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMember(of aClass: AnyClass) -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
func conforms(to aProtocol: Protocol) -> Bool {
|
||||||
|
aProtocol is VZVirtualMachineDelegate
|
||||||
|
}
|
||||||
|
|
||||||
|
func responds(to aSelector: Selector!) -> Bool {
|
||||||
|
if aSelector == #selector(VZVirtualMachineDelegate.guestDidStop(_:)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if aSelector == #selector(VZVirtualMachineDelegate.virtualMachine(_:didStopWithError:)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol UTMScreenshotProvider: AnyObject {
|
protocol UTMScreenshotProvider: AnyObject {
|
||||||
var screenshot: CSScreenshot? { get }
|
var screenshot: PlatformImage? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UTMAppleVirtualMachineError: Error {
|
enum UTMAppleVirtualMachineError: Error {
|
||||||
|
@ -525,11 +641,11 @@ extension UTMAppleVirtualMachineError: LocalizedError {
|
||||||
|
|
||||||
// MARK: - Registry access
|
// MARK: - Registry access
|
||||||
extension UTMAppleVirtualMachine {
|
extension UTMAppleVirtualMachine {
|
||||||
@MainActor override func updateRegistryFromConfig() async throws {
|
@MainActor func updateRegistryFromConfig() async throws {
|
||||||
// save a copy to not collide with updateConfigFromRegistry()
|
// save a copy to not collide with updateConfigFromRegistry()
|
||||||
let configShares = appleConfig.sharedDirectories
|
let configShares = config.sharedDirectories
|
||||||
let configDrives = appleConfig.drives
|
let configDrives = config.drives
|
||||||
try await super.updateRegistryFromConfig()
|
try await updateRegistryBasics()
|
||||||
registryEntry.sharedDirectories.removeAll(keepingCapacity: true)
|
registryEntry.sharedDirectories.removeAll(keepingCapacity: true)
|
||||||
for sharedDirectory in configShares {
|
for sharedDirectory in configShares {
|
||||||
if let url = sharedDirectory.directoryURL {
|
if let url = sharedDirectory.directoryURL {
|
||||||
|
@ -552,24 +668,51 @@ extension UTMAppleVirtualMachine {
|
||||||
configDrives.contains(where: { $0.id == element.key && $0.isExternal })
|
configDrives.contains(where: { $0.id == element.key && $0.isExternal })
|
||||||
})
|
})
|
||||||
// save IPSW reference
|
// save IPSW reference
|
||||||
if let url = appleConfig.system.boot.macRecoveryIpswURL {
|
if let url = config.system.boot.macRecoveryIpswURL {
|
||||||
_ = url.startAccessingSecurityScopedResource()
|
_ = url.startAccessingSecurityScopedResource()
|
||||||
registryEntry.macRecoveryIpsw = try UTMRegistryEntry.File(url: url, isReadOnly: true)
|
registryEntry.macRecoveryIpsw = try UTMRegistryEntry.File(url: url, isReadOnly: true)
|
||||||
url.stopAccessingSecurityScopedResource()
|
url.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override func updateConfigFromRegistry() {
|
@MainActor func updateConfigFromRegistry() {
|
||||||
super.updateConfigFromRegistry()
|
config.sharedDirectories = registryEntry.sharedDirectories.map({ UTMAppleConfigurationSharedDirectory(directoryURL: $0.url, isReadOnly: $0.isReadOnly )})
|
||||||
appleConfig.sharedDirectories = registryEntry.sharedDirectories.map({ UTMAppleConfigurationSharedDirectory(directoryURL: $0.url, isReadOnly: $0.isReadOnly )})
|
for i in config.drives.indices {
|
||||||
for i in appleConfig.drives.indices {
|
let id = config.drives[i].id
|
||||||
let id = appleConfig.drives[i].id
|
if config.drives[i].isExternal {
|
||||||
if appleConfig.drives[i].isExternal {
|
config.drives[i].imageURL = registryEntry.externalDrives[id]?.url
|
||||||
appleConfig.drives[i].imageURL = registryEntry.externalDrives[id]?.url
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let file = registryEntry.macRecoveryIpsw {
|
if let file = registryEntry.macRecoveryIpsw {
|
||||||
appleConfig.system.boot.macRecoveryIpswURL = file.url
|
config.system.boot.macRecoveryIpswURL = file.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
|
||||||
|
config.information.uuid = uuid
|
||||||
|
if let name = name {
|
||||||
|
config.information.name = name
|
||||||
|
}
|
||||||
|
registryEntry = UTMRegistry.shared.entry(for: self)
|
||||||
|
if let entry = entry {
|
||||||
|
registryEntry.update(copying: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Non-asynchronous version (to be removed)
|
||||||
|
|
||||||
|
extension UTMAppleVirtualMachine {
|
||||||
|
@available(macOS 12, *)
|
||||||
|
func requestInstallVM(with url: URL) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await installVM(with: url)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,11 +21,85 @@ private var SpiceIoServiceGuestAgentContext = 0
|
||||||
private let kSuspendSnapshotName = "suspend"
|
private let kSuspendSnapshotName = "suspend"
|
||||||
|
|
||||||
/// QEMU backend virtual machine
|
/// QEMU backend virtual machine
|
||||||
@objc class UTMQemuVirtualMachine: UTMVirtualMachine {
|
final class UTMQemuVirtualMachine: UTMVirtualMachine {
|
||||||
/// Set to true to request guest tools install.
|
struct Capabilities: UTMVirtualMachineCapabilities {
|
||||||
///
|
var supportsProcessKill: Bool {
|
||||||
/// This property is observable and must only be accessed on the main thread.
|
true
|
||||||
@Published var isGuestToolsInstallRequested: Bool = false
|
}
|
||||||
|
|
||||||
|
var supportsSnapshots: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsScreenshots: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsDisposibleMode: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsRecoveryMode: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let capabilities = Capabilities()
|
||||||
|
|
||||||
|
private(set) var pathUrl: URL {
|
||||||
|
didSet {
|
||||||
|
if isScopedAccess {
|
||||||
|
oldValue.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
isScopedAccess = pathUrl.startAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var isShortcut: Bool = false
|
||||||
|
|
||||||
|
private(set) var isRunningAsDisposible: Bool = false
|
||||||
|
|
||||||
|
weak var delegate: (any UTMVirtualMachineDelegate)?
|
||||||
|
|
||||||
|
var onConfigurationChange: (() -> Void)?
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
|
private(set) var config: UTMQemuConfiguration {
|
||||||
|
willSet {
|
||||||
|
onConfigurationChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var registryEntry: UTMRegistryEntry {
|
||||||
|
willSet {
|
||||||
|
onConfigurationChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var state: UTMVirtualMachineState = .stopped {
|
||||||
|
willSet {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
didSet {
|
||||||
|
Task { @MainActor in
|
||||||
|
delegate?.virtualMachine(self, didTransitionToState: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var screenshot: PlatformImage? {
|
||||||
|
willSet {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var logging: QEMULogging
|
||||||
|
|
||||||
|
private var isScopedAccess: Bool = false
|
||||||
|
|
||||||
|
private weak var screenshotTimer: Timer?
|
||||||
|
|
||||||
/// Handle SPICE IO related events
|
/// Handle SPICE IO related events
|
||||||
weak var ioServiceDelegate: UTMSpiceIODelegate? {
|
weak var ioServiceDelegate: UTMSpiceIODelegate? {
|
||||||
|
@ -67,11 +141,53 @@ private let kSuspendSnapshotName = "suspend"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var startTask: Task<Void, any Error>?
|
private var startTask: Task<Void, any Error>?
|
||||||
|
|
||||||
|
@MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration? = nil, isShortcut: Bool = false) throws {
|
||||||
|
self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
|
||||||
|
// load configuration
|
||||||
|
let config: UTMQemuConfiguration
|
||||||
|
if configuration == nil {
|
||||||
|
guard let qemuConfig = try UTMQemuConfiguration.load(from: packageUrl) as? UTMQemuConfiguration else {
|
||||||
|
throw UTMConfigurationError.invalidBackend
|
||||||
|
}
|
||||||
|
config = qemuConfig
|
||||||
|
} else {
|
||||||
|
config = configuration!
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
self.logging = QEMULogging()
|
||||||
|
#else
|
||||||
|
self.logging = QEMULogging.sharedInstance()
|
||||||
|
#endif
|
||||||
|
self.config = config
|
||||||
|
self.pathUrl = packageUrl
|
||||||
|
self.isShortcut = isShortcut
|
||||||
|
self.registryEntry = UTMRegistryEntry.empty
|
||||||
|
self.registryEntry = loadRegistry()
|
||||||
|
self.screenshot = loadScreenshot()
|
||||||
|
updateConfigFromRegistry()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if isScopedAccess {
|
||||||
|
pathUrl.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor func reload(from packageUrl: URL?) throws {
|
||||||
|
let packageUrl = packageUrl ?? pathUrl
|
||||||
|
guard let qemuConfig = try UTMQemuConfiguration.load(from: packageUrl) as? UTMQemuConfiguration else {
|
||||||
|
throw UTMConfigurationError.invalidBackend
|
||||||
|
}
|
||||||
|
config = qemuConfig
|
||||||
|
pathUrl = packageUrl
|
||||||
|
updateConfigFromRegistry()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shortcut access
|
// MARK: - Shortcut access
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
override func accessShortcut() async throws {
|
func accessShortcut() async throws {
|
||||||
guard isShortcut else {
|
guard isShortcut else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -81,7 +197,7 @@ extension UTMQemuVirtualMachine {
|
||||||
let existing = bookmark != nil
|
let existing = bookmark != nil
|
||||||
if !existing {
|
if !existing {
|
||||||
// create temporary bookmark
|
// create temporary bookmark
|
||||||
bookmark = try path.bookmarkData()
|
bookmark = try pathUrl.bookmarkData()
|
||||||
} else {
|
} else {
|
||||||
let bookmarkPath = await registryEntry.package.path
|
let bookmarkPath = await registryEntry.package.path
|
||||||
// in case old path is still accessed
|
// in case old path is still accessed
|
||||||
|
@ -109,34 +225,35 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor private func qemuEnsureEfiVarsAvailable() async throws {
|
@MainActor private func qemuEnsureEfiVarsAvailable() async throws {
|
||||||
guard let efiVarsURL = qemuConfig.qemu.efiVarsURL else {
|
guard let efiVarsURL = config.qemu.efiVarsURL else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard qemuConfig.isLegacy else {
|
guard config.isLegacy else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = try await qemuConfig.qemu.saveData(to: efiVarsURL.deletingLastPathComponent(), for: qemuConfig.system)
|
_ = try await config.qemu.saveData(to: efiVarsURL.deletingLastPathComponent(), for: config.system)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmStart() async throws {
|
private func _start(options: UTMVirtualMachineStartOptions) async throws {
|
||||||
// check if we can actually start this VM
|
// check if we can actually start this VM
|
||||||
guard isSupported else {
|
guard await isSupported else {
|
||||||
throw UTMQemuVirtualMachineError.emulationNotSupported
|
throw UTMQemuVirtualMachineError.emulationNotSupported
|
||||||
}
|
}
|
||||||
// start logging
|
// start logging
|
||||||
if await qemuConfig.qemu.hasDebugLog, let debugLogURL = await qemuConfig.qemu.debugLogURL {
|
if await config.qemu.hasDebugLog, let debugLogURL = await config.qemu.debugLogURL {
|
||||||
logging.log(toFile: debugLogURL)
|
logging.log(toFile: debugLogURL)
|
||||||
}
|
}
|
||||||
|
let isRunningAsDisposible = options.contains(.bootDisposibleMode)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
qemuConfig.qemu.isDisposable = isRunningAsSnapshot
|
config.qemu.isDisposable = isRunningAsDisposible
|
||||||
}
|
}
|
||||||
|
|
||||||
let allArguments = await qemuConfig.allArguments
|
let allArguments = await config.allArguments
|
||||||
let arguments = allArguments.map({ $0.string })
|
let arguments = allArguments.map({ $0.string })
|
||||||
let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
|
let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
|
||||||
let remoteBookmarks = await remoteBookmarks
|
let remoteBookmarks = await remoteBookmarks
|
||||||
|
|
||||||
let system = await UTMQemuSystem(arguments: arguments, architecture: qemuConfig.system.architecture.rawValue)
|
let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
|
||||||
system.resources = resources
|
system.resources = resources
|
||||||
system.remoteBookmarks = remoteBookmarks as NSDictionary
|
system.remoteBookmarks = remoteBookmarks as NSDictionary
|
||||||
system.rendererBackend = rendererBackend
|
system.rendererBackend = rendererBackend
|
||||||
|
@ -147,7 +264,18 @@ extension UTMQemuVirtualMachine {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
}
|
}
|
||||||
|
|
||||||
let ioService = UTMSpiceIO(configuration: config)
|
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)
|
||||||
|
}
|
||||||
|
let spiceSocketUrl = await config.spiceSocketURL
|
||||||
|
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
|
||||||
try ioService.start()
|
try ioService.start()
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
@ -163,7 +291,7 @@ extension UTMQemuVirtualMachine {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
// load saved state if requested
|
// load saved state if requested
|
||||||
if !isRunningAsSnapshot, await registryEntry.isSuspended {
|
if !isRunningAsDisposible, await registryEntry.isSuspended {
|
||||||
try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName)
|
try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName)
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
}
|
}
|
||||||
|
@ -178,62 +306,75 @@ extension UTMQemuVirtualMachine {
|
||||||
|
|
||||||
// delete saved state
|
// delete saved state
|
||||||
if await registryEntry.isSuspended {
|
if await registryEntry.isSuspended {
|
||||||
try? await _vmDeleteState()
|
try? await deleteSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
// save ioService and let it set the delegate
|
// save ioService and let it set the delegate
|
||||||
self.ioService = ioService
|
self.ioService = ioService
|
||||||
|
self.isRunningAsDisposible = isRunningAsDisposible
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmStart() async throws {
|
func start(options: UTMVirtualMachineStartOptions = []) async throws {
|
||||||
guard state == .vmStopped else {
|
guard state == .stopped else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
changeState(.vmStarting)
|
state = .starting
|
||||||
do {
|
do {
|
||||||
startTask = Task {
|
startTask = Task {
|
||||||
try await _vmStart()
|
try await _start(options: options)
|
||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
startTask = nil
|
startTask = nil
|
||||||
}
|
}
|
||||||
try await startTask!.value
|
try await startTask!.value
|
||||||
changeState(.vmStarted)
|
state = .started
|
||||||
|
if screenshotTimer == nil {
|
||||||
|
screenshotTimer = startScreenshotTimer()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// delete suspend state on error
|
// delete suspend state on error
|
||||||
await registryEntry.setIsSuspended(false)
|
await registryEntry.setIsSuspended(false)
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmStop(force: Bool) async throws {
|
func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
|
||||||
if force {
|
if method == .request {
|
||||||
|
guard let monitor = await monitor else {
|
||||||
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
|
}
|
||||||
|
try await monitor.qemuPowerDown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let kill = method == .kill
|
||||||
|
if kill {
|
||||||
// prevent deadlock force stopping during startup
|
// prevent deadlock force stopping during startup
|
||||||
ioService?.disconnect()
|
ioService?.disconnect()
|
||||||
}
|
}
|
||||||
guard state != .vmStopped else {
|
guard state != .stopped else {
|
||||||
return // nothing to do
|
return // nothing to do
|
||||||
}
|
}
|
||||||
guard force || state == .vmStarted else {
|
guard kill || state == .started || state == .paused else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
if !force {
|
if !kill {
|
||||||
changeState(.vmStopping)
|
state = .stopping
|
||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
}
|
}
|
||||||
if force {
|
if kill {
|
||||||
await qemuVM.kill()
|
await qemuVM.kill()
|
||||||
} else {
|
} else {
|
||||||
try await qemuVM.stop()
|
try await qemuVM.stop()
|
||||||
}
|
}
|
||||||
|
isRunningAsDisposible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmReset() async throws {
|
private func _restart() async throws {
|
||||||
if await registryEntry.isSuspended {
|
if await registryEntry.isSuspended {
|
||||||
try? await _vmDeleteState()
|
try? await deleteSnapshot()
|
||||||
}
|
}
|
||||||
guard let monitor = await qemuVM.monitor else {
|
guard let monitor = await qemuVM.monitor else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
|
@ -241,72 +382,77 @@ extension UTMQemuVirtualMachine {
|
||||||
try await monitor.qemuReset()
|
try await monitor.qemuReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmReset() async throws {
|
func restart() async throws {
|
||||||
guard state == .vmStarted || state == .vmPaused else {
|
guard state == .started || state == .paused else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
changeState(.vmStopping)
|
state = .stopping
|
||||||
do {
|
do {
|
||||||
try await _vmReset()
|
try await _restart()
|
||||||
changeState(.vmStarted)
|
state = .started
|
||||||
} catch {
|
} catch {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmPause() async throws {
|
private func _pause() async throws {
|
||||||
guard let monitor = await monitor else {
|
guard let monitor = await monitor else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
await updateScreenshot()
|
await takeScreenshot()
|
||||||
await saveScreenshot()
|
try saveScreenshot()
|
||||||
try await monitor.qemuStop()
|
try await monitor.qemuStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmPause(save: Bool) async throws {
|
func pause() async throws {
|
||||||
guard state == .vmStarted else {
|
guard state == .started else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
changeState(.vmPausing)
|
state = .pausing
|
||||||
do {
|
do {
|
||||||
try await _vmPause()
|
try await _pause()
|
||||||
if save {
|
state = .paused
|
||||||
try? await _vmSaveState()
|
|
||||||
}
|
|
||||||
changeState(.vmPaused)
|
|
||||||
} catch {
|
} catch {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmSaveState() async throws {
|
private func _saveSnapshot(name: String) async throws {
|
||||||
guard let monitor = await monitor else {
|
guard let monitor = await monitor else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let result = try await monitor.qemuSaveSnapshot(kSuspendSnapshotName)
|
let result = try await monitor.qemuSaveSnapshot(name)
|
||||||
if result.localizedCaseInsensitiveContains("Error") {
|
if result.localizedCaseInsensitiveContains("Error") {
|
||||||
throw UTMQemuVirtualMachineError.qemuError(result)
|
throw UTMQemuVirtualMachineError.qemuError(result)
|
||||||
}
|
}
|
||||||
await registryEntry.setIsSuspended(true)
|
await registryEntry.setIsSuspended(true)
|
||||||
await saveScreenshot()
|
try saveScreenshot()
|
||||||
} catch {
|
} catch {
|
||||||
throw UTMQemuVirtualMachineError.saveSnapshotFailed(error)
|
throw UTMQemuVirtualMachineError.saveSnapshotFailed(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmSaveState() async throws {
|
func saveSnapshot(name: String? = nil) async throws {
|
||||||
guard state == .vmPaused || state == .vmStarted else {
|
guard state == .paused || state == .started else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
try await _vmSaveState()
|
let prev = state
|
||||||
|
state = .saving
|
||||||
|
do {
|
||||||
|
try await _saveSnapshot(name: name ?? kSuspendSnapshotName)
|
||||||
|
state = prev
|
||||||
|
} catch {
|
||||||
|
state = .stopped
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmDeleteState() async throws {
|
private func _deleteSnapshot(name: String) async throws {
|
||||||
if let monitor = await monitor { // if QEMU is running
|
if let monitor = await monitor { // if QEMU is running
|
||||||
let result = try await monitor.qemuDeleteSnapshot(kSuspendSnapshotName)
|
let result = try await monitor.qemuDeleteSnapshot(name)
|
||||||
if result.localizedCaseInsensitiveContains("Error") {
|
if result.localizedCaseInsensitiveContains("Error") {
|
||||||
throw UTMQemuVirtualMachineError.qemuError(result)
|
throw UTMQemuVirtualMachineError.qemuError(result)
|
||||||
}
|
}
|
||||||
|
@ -314,39 +460,57 @@ extension UTMQemuVirtualMachine {
|
||||||
await registryEntry.setIsSuspended(false)
|
await registryEntry.setIsSuspended(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmDeleteState() async throws {
|
func deleteSnapshot(name: String? = nil) async throws {
|
||||||
try await _vmDeleteState()
|
try await _deleteSnapshot(name: name ?? kSuspendSnapshotName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _vmResume() async throws {
|
private func _resume() async throws {
|
||||||
guard let monitor = await monitor else {
|
guard let monitor = await monitor else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
try await monitor.qemuResume()
|
try await monitor.qemuResume()
|
||||||
if await registryEntry.isSuspended {
|
if await registryEntry.isSuspended {
|
||||||
try? await _vmDeleteState()
|
try? await deleteSnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmResume() async throws {
|
func resume() async throws {
|
||||||
guard state == .vmPaused else {
|
guard state == .paused else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
changeState(.vmResuming)
|
state = .resuming
|
||||||
do {
|
do {
|
||||||
try await _vmResume()
|
try await _resume()
|
||||||
changeState(.vmStarted)
|
state = .started
|
||||||
} catch {
|
} catch {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func vmGuestPowerDown() async throws {
|
private func _restoreSnapshot(name: String) async throws {
|
||||||
guard let monitor = await monitor else {
|
guard let monitor = await monitor else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
try await monitor.qemuPowerDown()
|
let result = try await monitor.qemuRestoreSnapshot(name)
|
||||||
|
if result.localizedCaseInsensitiveContains("Error") {
|
||||||
|
throw UTMQemuVirtualMachineError.qemuError(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSnapshot(name: String? = nil) async throws {
|
||||||
|
guard state == .paused || state == .started else {
|
||||||
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
|
}
|
||||||
|
let prev = state
|
||||||
|
state = .restoring
|
||||||
|
do {
|
||||||
|
try await _restoreSnapshot(name: name ?? kSuspendSnapshotName)
|
||||||
|
state = prev
|
||||||
|
} catch {
|
||||||
|
state = .stopped
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt to cancel the current operation
|
/// Attempt to cancel the current operation
|
||||||
|
@ -368,11 +532,13 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
|
func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
|
||||||
changeState(.vmStopped)
|
state = .stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
func qemuVM(_ qemuVM: QEMUVirtualMachine, didError error: Error) {
|
func qemuVM(_ qemuVM: QEMUVirtualMachine, didError error: Error) {
|
||||||
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
Task { @MainActor in
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func qemuVM(_ qemuVM: QEMUVirtualMachine, didCreatePttyDevice path: String, label: String) {
|
func qemuVM(_ qemuVM: QEMUVirtualMachine, didCreatePttyDevice path: String, label: String) {
|
||||||
|
@ -388,11 +554,11 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
|
||||||
}
|
}
|
||||||
let index = term
|
let index = term
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard index >= 0 && index < qemuConfig.serials.count else {
|
guard index >= 0 && index < config.serials.count else {
|
||||||
logger.error("Serial device '\(path)' out of bounds for index \(index)")
|
logger.error("Serial device '\(path)' out of bounds for index \(index)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
qemuConfig.serials[index].pttyDevice = URL(fileURLWithPath: path)
|
config.serials[index].pttyDevice = URL(fileURLWithPath: path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -413,52 +579,16 @@ extension UTMQemuVirtualMachine {
|
||||||
|
|
||||||
// MARK: - Screenshot
|
// MARK: - Screenshot
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
@MainActor
|
@MainActor @discardableResult
|
||||||
override func updateScreenshot() {
|
func takeScreenshot() async -> Bool {
|
||||||
ioService?.screenshot(completion: { screenshot in
|
let screenshot = await ioService?.screenshot()
|
||||||
Task { @MainActor in
|
self.screenshot = screenshot?.image
|
||||||
self.screenshot = screenshot
|
return true
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
override func saveScreenshot() {
|
|
||||||
super.saveScreenshot()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Display details
|
// MARK: - Architecture supported
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
internal var qemuConfig: UTMQemuConfiguration {
|
|
||||||
config.qemuConfig!
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor override var detailsTitleLabel: String {
|
|
||||||
qemuConfig.information.name
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor override var detailsSubtitleLabel: String {
|
|
||||||
detailsSystemTargetLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor override var detailsNotes: String? {
|
|
||||||
qemuConfig.information.notes
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor override var detailsSystemTargetLabel: String {
|
|
||||||
qemuConfig.system.target.prettyValue
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor override var detailsSystemArchitectureLabel: String {
|
|
||||||
qemuConfig.system.architecture.prettyValue
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor override var detailsSystemMemoryLabel: String {
|
|
||||||
let bytesInMib = Int64(1048576)
|
|
||||||
return ByteCountFormatter.string(fromByteCount: Int64(qemuConfig.system.memorySize) * bytesInMib, countStyle: .binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a QEMU target is supported
|
/// Check if a QEMU target is supported
|
||||||
/// - Parameter systemArchitecture: QEMU architecture
|
/// - Parameter systemArchitecture: QEMU architecture
|
||||||
/// - Returns: true if UTM is compiled with the supporting binaries
|
/// - Returns: true if UTM is compiled with the supporting binaries
|
||||||
|
@ -478,8 +608,8 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the current VM target is supported by the host
|
/// Check if the current VM target is supported by the host
|
||||||
@objc var isSupported: Bool {
|
@MainActor var isSupported: Bool {
|
||||||
return UTMQemuVirtualMachine.isSupported(systemArchitecture: qemuConfig._system.architecture)
|
return UTMQemuVirtualMachine.isSupported(systemArchitecture: config.system.architecture)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -526,7 +656,7 @@ extension UTMQemuVirtualMachine {
|
||||||
guard await system != nil else {
|
guard await system != nil else {
|
||||||
throw UTMQemuVirtualMachineError.invalidVmState
|
throw UTMQemuVirtualMachineError.invalidVmState
|
||||||
}
|
}
|
||||||
for drive in await qemuConfig.drives {
|
for drive in await config.drives {
|
||||||
if !drive.isExternal {
|
if !drive.isExternal {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -545,7 +675,7 @@ extension UTMQemuVirtualMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func restoreExternalDrivesAndShares(completion: @escaping (Error?) -> Void) {
|
func restoreExternalDrivesAndShares(completion: @escaping (Error?) -> Void) {
|
||||||
Task.detached {
|
Task.detached {
|
||||||
do {
|
do {
|
||||||
try await self.restoreExternalDrives()
|
try await self.restoreExternalDrives()
|
||||||
|
@ -581,13 +711,13 @@ extension UTMQemuVirtualMachine {
|
||||||
defer {
|
defer {
|
||||||
url.stopAccessingSecurityScopedResource()
|
url.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
let file = try await UTMRegistryEntry.File(url: url, isReadOnly: qemuConfig.sharing.isDirectoryShareReadOnly)
|
let file = try await UTMRegistryEntry.File(url: url, isReadOnly: config.sharing.isDirectoryShareReadOnly)
|
||||||
await registryEntry.setSingleSharedDirectory(file)
|
await registryEntry.setSingleSharedDirectory(file)
|
||||||
if await qemuConfig.sharing.directoryShareMode == .webdav {
|
if await config.sharing.directoryShareMode == .webdav {
|
||||||
if let ioService = ioService {
|
if let ioService = ioService {
|
||||||
ioService.changeSharedDirectory(url)
|
ioService.changeSharedDirectory(url)
|
||||||
}
|
}
|
||||||
} else if await qemuConfig.sharing.directoryShareMode == .virtfs {
|
} else if await config.sharing.directoryShareMode == .virtfs {
|
||||||
let tempBookmark = try url.bookmarkData()
|
let tempBookmark = try url.bookmarkData()
|
||||||
try await changeVirtfsSharedDirectory(with: tempBookmark, isSecurityScoped: false)
|
try await changeVirtfsSharedDirectory(with: tempBookmark, isSecurityScoped: false)
|
||||||
}
|
}
|
||||||
|
@ -606,7 +736,7 @@ extension UTMQemuVirtualMachine {
|
||||||
guard let share = await registryEntry.sharedDirectories.first else {
|
guard let share = await registryEntry.sharedDirectories.first else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if await qemuConfig.sharing.directoryShareMode == .virtfs {
|
if await config.sharing.directoryShareMode == .virtfs {
|
||||||
if let bookmark = share.remoteBookmark {
|
if let bookmark = share.remoteBookmark {
|
||||||
// a share bookmark was saved while QEMU was running
|
// a share bookmark was saved while QEMU was running
|
||||||
try await changeVirtfsSharedDirectory(with: bookmark, isSecurityScoped: true)
|
try await changeVirtfsSharedDirectory(with: bookmark, isSecurityScoped: true)
|
||||||
|
@ -615,7 +745,7 @@ extension UTMQemuVirtualMachine {
|
||||||
let url = try URL(resolvingPersistentBookmarkData: share.bookmark)
|
let url = try URL(resolvingPersistentBookmarkData: share.bookmark)
|
||||||
try await changeSharedDirectory(to: url)
|
try await changeSharedDirectory(to: url)
|
||||||
}
|
}
|
||||||
} else if await qemuConfig.sharing.directoryShareMode == .webdav {
|
} else if await config.sharing.directoryShareMode == .webdav {
|
||||||
if let ioService = ioService {
|
if let ioService = ioService {
|
||||||
ioService.changeSharedDirectory(share.url)
|
ioService.changeSharedDirectory(share.url)
|
||||||
}
|
}
|
||||||
|
@ -625,11 +755,11 @@ extension UTMQemuVirtualMachine {
|
||||||
|
|
||||||
// MARK: - Registry syncing
|
// MARK: - Registry syncing
|
||||||
extension UTMQemuVirtualMachine {
|
extension UTMQemuVirtualMachine {
|
||||||
@MainActor override func updateRegistryFromConfig() async throws {
|
@MainActor func updateRegistryFromConfig() async throws {
|
||||||
// save a copy to not collide with updateConfigFromRegistry()
|
// save a copy to not collide with updateConfigFromRegistry()
|
||||||
let configShare = qemuConfig.sharing.directoryShareUrl
|
let configShare = config.sharing.directoryShareUrl
|
||||||
let configDrives = qemuConfig.drives
|
let configDrives = config.drives
|
||||||
try await super.updateRegistryFromConfig()
|
try await updateRegistryBasics()
|
||||||
for drive in configDrives {
|
for drive in configDrives {
|
||||||
if drive.isExternal, let url = drive.imageURL {
|
if drive.isExternal, let url = drive.imageURL {
|
||||||
try await changeMedium(drive, to: url)
|
try await changeMedium(drive, to: url)
|
||||||
|
@ -644,18 +774,28 @@ extension UTMQemuVirtualMachine {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor override func updateConfigFromRegistry() {
|
@MainActor func updateConfigFromRegistry() {
|
||||||
super.updateConfigFromRegistry()
|
config.sharing.directoryShareUrl = sharedDirectoryURL
|
||||||
qemuConfig.sharing.directoryShareUrl = sharedDirectoryURL
|
for i in config.drives.indices {
|
||||||
for i in qemuConfig.drives.indices {
|
let id = config.drives[i].id
|
||||||
let id = qemuConfig.drives[i].id
|
if config.drives[i].isExternal {
|
||||||
if qemuConfig.drives[i].isExternal {
|
config.drives[i].imageURL = registryEntry.externalDrives[id]?.url
|
||||||
qemuConfig.drives[i].imageURL = registryEntry.externalDrives[id]?.url
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor @objc var remoteBookmarks: [URL: Data] {
|
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
|
||||||
|
config.information.uuid = uuid
|
||||||
|
if let name = name {
|
||||||
|
config.information.name = name
|
||||||
|
}
|
||||||
|
registryEntry = UTMRegistry.shared.entry(for: self)
|
||||||
|
if let entry = entry {
|
||||||
|
registryEntry.update(copying: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor var remoteBookmarks: [URL: Data] {
|
||||||
var dict = [URL: Data]()
|
var dict = [URL: Data]()
|
||||||
for file in registryEntry.externalDrives.values {
|
for file in registryEntry.externalDrives.values {
|
||||||
if let bookmark = file.remoteBookmark {
|
if let bookmark = file.remoteBookmark {
|
||||||
|
|
|
@ -73,8 +73,8 @@ class UTMRegistry: NSObject {
|
||||||
/// Gets an existing registry entry or create a new entry
|
/// Gets an existing registry entry or create a new entry
|
||||||
/// - Parameter vm: UTM virtual machine to locate in the registry
|
/// - Parameter vm: UTM virtual machine to locate in the registry
|
||||||
/// - Returns: Either an existing registry entry or a new entry
|
/// - Returns: Either an existing registry entry or a new entry
|
||||||
@objc func entry(for vm: UTMVirtualMachine) -> UTMRegistryEntry {
|
func entry(for vm: any UTMVirtualMachine) -> UTMRegistryEntry {
|
||||||
if let entry = entries[vm.config.uuid.uuidString] {
|
if let entry = entries[vm.id.uuidString] {
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
let newEntry = UTMRegistryEntry(newFrom: vm)
|
let newEntry = UTMRegistryEntry(newFrom: vm)
|
||||||
|
|
|
@ -17,6 +17,9 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
|
@objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
|
||||||
|
/// Empty registry entry used only as a workaround for object initialization
|
||||||
|
static let empty = UTMRegistryEntry(uuid: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, name: "", path: "")
|
||||||
|
|
||||||
@Published private var _name: String
|
@Published private var _name: String
|
||||||
|
|
||||||
@Published private var _package: File
|
@Published private var _package: File
|
||||||
|
@ -68,9 +71,9 @@ import Foundation
|
||||||
_hasMigratedConfig = false
|
_hasMigratedConfig = false
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(newFrom vm: UTMVirtualMachine) {
|
convenience init(newFrom vm: any UTMVirtualMachine) {
|
||||||
self.init(uuid: vm.config.uuid, name: vm.config.name, path: vm.path.path)
|
self.init(uuid: vm.id, name: vm.name, path: vm.pathUrl.path)
|
||||||
if let package = try? File(url: vm.path) {
|
if let package = try? File(url: vm.pathUrl) {
|
||||||
_package = package
|
_package = package
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -112,6 +115,15 @@ import Foundation
|
||||||
let dict = try PropertyListSerialization.propertyList(from: xml, format: nil)
|
let dict = try PropertyListSerialization.propertyList(from: xml, format: nil)
|
||||||
return dict as! [String: Any]
|
return dict as! [String: Any]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the UUID
|
||||||
|
///
|
||||||
|
/// Should only be called from `UTMRegistry`!
|
||||||
|
/// - Parameter uuid: UUID to change to
|
||||||
|
func _updateUuid(_ uuid: UUID) {
|
||||||
|
self.objectWillChange.send()
|
||||||
|
self.uuid = uuid
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol UTMRegistryEntryDecodable: Decodable {}
|
protocol UTMRegistryEntryDecodable: Decodable {}
|
||||||
|
|
|
@ -23,13 +23,18 @@
|
||||||
@import CocoaSpice;
|
@import CocoaSpice;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@class UTMConfigurationWrapper;
|
/// Options for initializing UTMSpiceIO
|
||||||
|
typedef NS_OPTIONS(NSUInteger, UTMSpiceIOOptions) {
|
||||||
|
UTMSpiceIOOptionsNone = 0,
|
||||||
|
UTMSpiceIOOptionsHasAudio = (1 << 0),
|
||||||
|
UTMSpiceIOOptionsHasClipboardSharing = (1 << 1),
|
||||||
|
UTMSpiceIOOptionsIsShareReadOnly = (1 << 2),
|
||||||
|
};
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
|
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
|
||||||
|
|
||||||
@property (nonatomic, readonly, nonnull) UTMConfigurationWrapper* configuration;
|
|
||||||
@property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
|
@property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
|
||||||
@property (nonatomic, readonly, nullable) CSInput *primaryInput;
|
@property (nonatomic, readonly, nullable) CSInput *primaryInput;
|
||||||
@property (nonatomic, readonly, nullable) CSPort *primarySerial;
|
@property (nonatomic, readonly, nullable) CSPort *primarySerial;
|
||||||
|
@ -42,7 +47,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
@property (nonatomic, readonly) BOOL isConnected;
|
@property (nonatomic, readonly) BOOL isConnected;
|
||||||
|
|
||||||
- (instancetype)init NS_UNAVAILABLE;
|
- (instancetype)init NS_UNAVAILABLE;
|
||||||
- (instancetype)initWithConfiguration:(UTMConfigurationWrapper *)configuration NS_DESIGNATED_INITIALIZER;
|
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
|
||||||
- (void)changeSharedDirectory:(NSURL *)url;
|
- (void)changeSharedDirectory:(NSURL *)url;
|
||||||
|
|
||||||
- (BOOL)startWithError:(NSError * _Nullable *)error;
|
- (BOOL)startWithError:(NSError * _Nullable *)error;
|
||||||
|
|
|
@ -18,11 +18,12 @@
|
||||||
#import "UTMSpiceIO.h"
|
#import "UTMSpiceIO.h"
|
||||||
#import "UTM-Swift.h"
|
#import "UTM-Swift.h"
|
||||||
|
|
||||||
extern NSString *const kUTMErrorDomain;
|
NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
||||||
|
|
||||||
@interface UTMSpiceIO ()
|
@interface UTMSpiceIO ()
|
||||||
|
|
||||||
@property (nonatomic, readwrite, nonnull) UTMConfigurationWrapper* configuration;
|
@property (nonatomic) NSURL *socketUrl;
|
||||||
|
@property (nonatomic) UTMSpiceIOOptions options;
|
||||||
@property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
|
@property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
|
||||||
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
|
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
|
||||||
@property (nonatomic, readwrite, nullable) CSInput *primaryInput;
|
@property (nonatomic, readwrite, nullable) CSInput *primaryInput;
|
||||||
|
@ -52,9 +53,10 @@ extern NSString *const kUTMErrorDomain;
|
||||||
return self.mutableSerials;
|
return self.mutableSerials;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (instancetype)initWithConfiguration:(UTMConfigurationWrapper *)configuration {
|
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options {
|
||||||
if (self = [super init]) {
|
if (self = [super init]) {
|
||||||
self.configuration = configuration;
|
self.socketUrl = socketUrl;
|
||||||
|
self.options = options;
|
||||||
self.mutableDisplays = [NSMutableArray array];
|
self.mutableDisplays = [NSMutableArray array];
|
||||||
self.mutableSerials = [NSMutableArray array];
|
self.mutableSerials = [NSMutableArray array];
|
||||||
}
|
}
|
||||||
|
@ -64,10 +66,10 @@ extern NSString *const kUTMErrorDomain;
|
||||||
|
|
||||||
- (void)initializeSpiceIfNeeded {
|
- (void)initializeSpiceIfNeeded {
|
||||||
if (!self.spiceConnection) {
|
if (!self.spiceConnection) {
|
||||||
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:self.configuration.qemuSpiceSocketURL];
|
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:self.socketUrl];
|
||||||
self.spiceConnection.delegate = self;
|
self.spiceConnection.delegate = self;
|
||||||
self.spiceConnection.audioEnabled = _configuration.qemuHasAudio;
|
self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
|
||||||
self.spiceConnection.session.shareClipboard = _configuration.qemuHasClipboardSharing;
|
self.spiceConnection.session.shareClipboard = (self.options & UTMSpiceIOOptionsHasClipboardSharing) == UTMSpiceIOOptionsHasClipboardSharing;
|
||||||
self.spiceConnection.session.pasteboardDelegate = [UTMPasteboard generalPasteboard];
|
self.spiceConnection.session.pasteboardDelegate = [UTMPasteboard generalPasteboard];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -235,7 +237,7 @@ extern NSString *const kUTMErrorDomain;
|
||||||
if (self.sharedDirectory) {
|
if (self.sharedDirectory) {
|
||||||
UTMLog(@"setting share directory to %@", self.sharedDirectory.path);
|
UTMLog(@"setting share directory to %@", self.sharedDirectory.path);
|
||||||
[self.sharedDirectory startAccessingSecurityScopedResource];
|
[self.sharedDirectory startAccessingSecurityScopedResource];
|
||||||
[self.spiceConnection.session setSharedDirectory:self.sharedDirectory.path readOnly:self.configuration.qemuIsDirectoryShareReadOnly];
|
[self.spiceConnection.session setSharedDirectory:self.sharedDirectory.path readOnly:(self.options & UTMSpiceIOOptionsIsShareReadOnly) == UTMSpiceIOOptionsIsShareReadOnly];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Copyright © 2020 osy. All rights reserved.
|
// Copyright © 2023 osy. All rights reserved.
|
||||||
//
|
//
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
// you may not use this file except in compliance with the License.
|
// you may not use this file except in compliance with the License.
|
||||||
|
@ -15,70 +15,346 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
extension UTMVirtualMachine: Identifiable {
|
private let kUTMBundleExtension = "utm"
|
||||||
public var id: UUID {
|
private let kScreenshotPeriodSeconds = 60.0
|
||||||
return registryEntry.uuid
|
private let kUTMBundleScreenshotFilename = "screenshot.png"
|
||||||
|
private let kUTMBundleViewFilename = "view.plist"
|
||||||
|
|
||||||
|
/// UTM virtual machine backend
|
||||||
|
protocol UTMVirtualMachine: AnyObject, Identifiable {
|
||||||
|
associatedtype Capabilities: UTMVirtualMachineCapabilities
|
||||||
|
associatedtype Configuration: UTMConfiguration
|
||||||
|
|
||||||
|
/// Path where the .utm is stored
|
||||||
|
var pathUrl: URL { get }
|
||||||
|
|
||||||
|
/// True if the .utm is loaded outside of the default storage
|
||||||
|
///
|
||||||
|
/// This indicates that we cannot access outside the container.
|
||||||
|
var isShortcut: Bool { get }
|
||||||
|
|
||||||
|
/// The VM is running in disposible mode
|
||||||
|
///
|
||||||
|
/// This indicates that changes should not be saved.
|
||||||
|
var isRunningAsDisposible: Bool { get }
|
||||||
|
|
||||||
|
/// Set by caller to handle VM events
|
||||||
|
var delegate: (any UTMVirtualMachineDelegate)? { get set }
|
||||||
|
|
||||||
|
/// Set by caller to handle changes in `config` or `registryEntry`
|
||||||
|
var onConfigurationChange: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// Set by caller to handle changes in `state` or `screenshot`
|
||||||
|
var onStateChange: (() -> Void)? { get set }
|
||||||
|
|
||||||
|
/// Configuration for this VM
|
||||||
|
var config: Configuration { get }
|
||||||
|
|
||||||
|
/// Additional configuration on a short lived, per-host basis
|
||||||
|
///
|
||||||
|
/// This includes display size, bookmarks to removable drives, etc.
|
||||||
|
var registryEntry: UTMRegistryEntry { get }
|
||||||
|
|
||||||
|
/// Current VM state
|
||||||
|
var state: UTMVirtualMachineState { get }
|
||||||
|
|
||||||
|
/// If non-null, is the most recent screenshot of the running VM
|
||||||
|
var screenshot: PlatformImage? { get }
|
||||||
|
|
||||||
|
static func isVirtualMachine(url: URL) -> Bool
|
||||||
|
|
||||||
|
/// Get name of UTM virtual machine from a file
|
||||||
|
/// - Parameter url: File URL
|
||||||
|
/// - Returns: The name of the VM
|
||||||
|
static func virtualMachineName(for url: URL) -> String
|
||||||
|
|
||||||
|
/// Get the path of a UTM virtual machine from a name and parent directory
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: VM name
|
||||||
|
/// - parentUrl: Base directory file URL
|
||||||
|
/// - Returns: URL of virtual machine
|
||||||
|
static func virtualMachinePath(for name: String, in parentUrl: URL) -> URL
|
||||||
|
|
||||||
|
/// Returns supported capabilities for this backend
|
||||||
|
static var capabilities: Capabilities { get }
|
||||||
|
|
||||||
|
/// Instantiate a new virtual machine
|
||||||
|
/// - Parameters:
|
||||||
|
/// - packageUrl: Package where the virtual machine resides
|
||||||
|
/// - configuration: New virtual machine configuration or nil to load an existing one
|
||||||
|
/// - isShortcut: Indicate that this package cannot be moved
|
||||||
|
init(packageUrl: URL, configuration: Configuration?, isShortcut: Bool) throws
|
||||||
|
|
||||||
|
/// Discard any changes to configuration by reloading from disk
|
||||||
|
/// - Parameter packageUrl: URL to reload from, if nil then use the existing package URL
|
||||||
|
func reload(from packageUrl: URL?) throws
|
||||||
|
|
||||||
|
/// Save .utm bundle to disk
|
||||||
|
///
|
||||||
|
/// This will create a configuration file and any auxiliary data files if needed.
|
||||||
|
func save() async throws
|
||||||
|
|
||||||
|
/// Called when we save the config
|
||||||
|
func updateRegistryFromConfig() async throws
|
||||||
|
|
||||||
|
/// Called whenever the registry entry changes
|
||||||
|
func updateConfigFromRegistry()
|
||||||
|
|
||||||
|
/// Called when we have a duplicate UUID
|
||||||
|
/// - Parameters:
|
||||||
|
/// - uuid: New UUID
|
||||||
|
/// - name: Optionally change name as well
|
||||||
|
/// - entry: Optionally copy data from an entry
|
||||||
|
func changeUuid(to uuid: UUID, name: String?, copyingEntry entry: UTMRegistryEntry?)
|
||||||
|
|
||||||
|
/// Starts the VM
|
||||||
|
/// - Parameter options: Options for startup
|
||||||
|
func start(options: UTMVirtualMachineStartOptions) async throws
|
||||||
|
|
||||||
|
/// Stops the VM
|
||||||
|
/// - Parameter method: How to handle the stop request
|
||||||
|
func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws
|
||||||
|
|
||||||
|
/// Restarts the VM
|
||||||
|
func restart() async throws
|
||||||
|
|
||||||
|
/// Pauses the VM
|
||||||
|
func pause() async throws
|
||||||
|
|
||||||
|
/// Resumes the VM
|
||||||
|
func resume() async throws
|
||||||
|
|
||||||
|
/// Saves the current VM state
|
||||||
|
/// - Parameter name: Optional snaphot name (default if nil)
|
||||||
|
func saveSnapshot(name: String?) async throws
|
||||||
|
|
||||||
|
/// Deletes the saved VM state
|
||||||
|
/// - Parameter name: Optional snaphot name (default if nil)
|
||||||
|
func deleteSnapshot(name: String?) async throws
|
||||||
|
|
||||||
|
/// Restore saved VM state
|
||||||
|
/// - Parameter name: Optional snaphot name (default if nil)
|
||||||
|
func restoreSnapshot(name: String?) async throws
|
||||||
|
|
||||||
|
/// Request a screenshot of the primary graphics device
|
||||||
|
/// - Returns: true if successful and the screenshot will be in `screenshot`
|
||||||
|
@discardableResult func takeScreenshot() async -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported capabilities for a UTM backend
|
||||||
|
protocol UTMVirtualMachineCapabilities {
|
||||||
|
/// The backend supports killing the VM process.
|
||||||
|
var supportsProcessKill: Bool { get }
|
||||||
|
|
||||||
|
/// The backend supports saving/restoring VM state.
|
||||||
|
var supportsSnapshots: Bool { get }
|
||||||
|
|
||||||
|
/// The backend supports taking screenshots.
|
||||||
|
var supportsScreenshots: Bool { get }
|
||||||
|
|
||||||
|
/// The backend supports running without persisting changes.
|
||||||
|
var supportsDisposibleMode: Bool { get }
|
||||||
|
|
||||||
|
/// The backend supports booting into recoveryOS.
|
||||||
|
var supportsRecoveryMode: Bool { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate for UTMVirtualMachine events
|
||||||
|
protocol UTMVirtualMachineDelegate: AnyObject {
|
||||||
|
/// Called when VM state changes
|
||||||
|
///
|
||||||
|
/// Will always be called from the main thread.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - vm: Virtual machine
|
||||||
|
/// - state: New state
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState)
|
||||||
|
|
||||||
|
/// Called when VM errors
|
||||||
|
///
|
||||||
|
/// Will always be called from the main thread.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - vm: Virtual machine
|
||||||
|
/// - message: Localized error message when supported, English message otherwise
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String)
|
||||||
|
|
||||||
|
/// Called when VM installation updates progress
|
||||||
|
/// - Parameters:
|
||||||
|
/// - vm: Virtual machine
|
||||||
|
/// - progress: Number between 0.0 and 1.0 indiciating installation progress
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double)
|
||||||
|
|
||||||
|
/// Called when VM successfully completes installation
|
||||||
|
/// - Parameters:
|
||||||
|
/// - vm: Virtual machine
|
||||||
|
/// - success: True if installation is successful
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual machine state
|
||||||
|
enum UTMVirtualMachineState {
|
||||||
|
case stopped
|
||||||
|
case starting
|
||||||
|
case started
|
||||||
|
case pausing
|
||||||
|
case paused
|
||||||
|
case resuming
|
||||||
|
case saving
|
||||||
|
case restoring
|
||||||
|
case stopping
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Additional options for VM start
|
||||||
|
struct UTMVirtualMachineStartOptions: OptionSet {
|
||||||
|
let rawValue: UInt
|
||||||
|
|
||||||
|
/// Boot without persisting any changes.
|
||||||
|
static let bootDisposibleMode = Self(rawValue: 1 << 0)
|
||||||
|
/// Boot into recoveryOS (when supported).
|
||||||
|
static let bootRecovery = Self(rawValue: 1 << 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Method to stop the VM
|
||||||
|
enum UTMVirtualMachineStopMethod {
|
||||||
|
/// Sends a request to the guest to shut down gracefully.
|
||||||
|
case request
|
||||||
|
/// Sends a hardware power down signal.
|
||||||
|
case force
|
||||||
|
/// Terminate the VM process.
|
||||||
|
case kill
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Class functions
|
||||||
|
|
||||||
|
extension UTMVirtualMachine {
|
||||||
|
private static var fileManager: FileManager {
|
||||||
|
FileManager.default
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isVirtualMachine(url: URL) -> Bool {
|
||||||
|
return url.pathExtension == kUTMBundleExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
static func virtualMachineName(for url: URL) -> String {
|
||||||
|
(fileManager.displayName(atPath: url.path) as NSString).deletingPathExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
static func virtualMachinePath(for name: String, in parentUrl: URL) -> URL {
|
||||||
|
let illegalFileNameCharacters = CharacterSet(charactersIn: ",/:\\?%*|\"<>")
|
||||||
|
let name = name.components(separatedBy: illegalFileNameCharacters).joined(separator: "-")
|
||||||
|
return parentUrl.appendingPathComponent(name).appendingPathExtension(kUTMBundleExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instantiate a new VM from a new configuration
|
||||||
|
/// - Parameters:
|
||||||
|
/// - configuration: New configuration
|
||||||
|
/// - destinationUrl: Directory to store VM
|
||||||
|
init(newForConfiguration configuration: Self.Configuration, destinationUrl: URL) throws {
|
||||||
|
let packageUrl = Self.virtualMachinePath(for: configuration.information.name, in: destinationUrl)
|
||||||
|
try self.init(packageUrl: packageUrl, configuration: configuration, isShortcut: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UTMVirtualMachine: ObservableObject {
|
// MARK: - Snapshots
|
||||||
|
|
||||||
|
extension UTMVirtualMachine {
|
||||||
|
func saveSnapshot(name: String?) async throws {
|
||||||
|
throw UTMVirtualMachineError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSnapshot(name: String?) async throws {
|
||||||
|
throw UTMVirtualMachineError.notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSnapshot(name: String?) async throws {
|
||||||
|
throw UTMVirtualMachineError.notImplemented
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc extension UTMVirtualMachine {
|
// MARK: - Screenshot
|
||||||
fileprivate static let gibInMib = 1024
|
|
||||||
func subscribeToChildren() {
|
extension UTMVirtualMachine {
|
||||||
var s: [AnyObject] = []
|
private var isScreenshotSaveEnabled: Bool {
|
||||||
if let config = config.qemuConfig {
|
!UserDefaults.standard.bool(forKey: "NoSaveScreenshot")
|
||||||
s.append(config.objectWillChange.sink { [weak self] in
|
}
|
||||||
self?.objectWillChange.send()
|
|
||||||
})
|
private var screenshotUrl: URL {
|
||||||
} else if let config = config.appleConfig {
|
pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename)
|
||||||
s.append(config.objectWillChange.sink { [weak self] in
|
}
|
||||||
self?.objectWillChange.send()
|
|
||||||
})
|
func startScreenshotTimer() -> Timer {
|
||||||
|
// delete existing screenshot if required
|
||||||
|
if !isScreenshotSaveEnabled && !isRunningAsDisposible {
|
||||||
|
try? deleteScreenshot()
|
||||||
}
|
}
|
||||||
s.append(registryEntry.objectWillChange.sink { [weak self] in
|
return Timer.scheduledTimer(withTimeInterval: kScreenshotPeriodSeconds, repeats: true) { [weak self] timer in
|
||||||
guard let self = self else {
|
guard let self = self else {
|
||||||
|
timer.invalidate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.objectWillChange.send()
|
if self.state == .started {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.updateConfigFromRegistry()
|
await self.takeScreenshot()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
anyCancellable = s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor func propertyWillChange() -> Void {
|
func loadScreenshot() -> PlatformImage? {
|
||||||
objectWillChange.send()
|
#if canImport(AppKit)
|
||||||
|
return NSImage(contentsOf: screenshotUrl)
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
return UIImage(contentsOfURL: screenshotUrl)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@nonobjc convenience init<Config: UTMConfiguration>(newConfig: Config, destinationURL: URL) {
|
func saveScreenshot() throws {
|
||||||
let packageURL = UTMVirtualMachine.virtualMachinePath(newConfig.information.name, inParentURL: destinationURL)
|
guard isScreenshotSaveEnabled && !isRunningAsDisposible else {
|
||||||
let configuration = UTMConfigurationWrapper(wrapping: newConfig)
|
return
|
||||||
self.init(configurationWrapper: configuration, packageURL: packageURL)
|
}
|
||||||
|
guard let screenshot = screenshot else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if canImport(AppKit)
|
||||||
|
guard let cgref = screenshot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let newrep = NSBitmapImageRep(cgImage: cgref)
|
||||||
|
newrep.size = screenshot.size
|
||||||
|
let pngdata = newrep.representation(using: .png, properties: [:])
|
||||||
|
try pngdata?.write(to: screenshotUrl)
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
try screenshot.pngData()?.write(to: screenshotUrl)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteScreenshot() throws {
|
||||||
|
try Self.fileManager.removeItem(at: screenshotUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor func takeScreenshot() async -> Bool {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc extension UTMVirtualMachine {
|
// MARK: - Save UTM
|
||||||
@MainActor func reloadConfiguration() throws {
|
|
||||||
try config.reload(from: path)
|
@MainActor extension UTMVirtualMachine {
|
||||||
updateConfigFromRegistry()
|
func save() async throws {
|
||||||
}
|
let existingPath = pathUrl
|
||||||
|
let newPath = Self.virtualMachinePath(for: config.information.name, in: existingPath.deletingLastPathComponent())
|
||||||
@MainActor func saveUTM() async throws {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let existingPath = path
|
|
||||||
let newPath = UTMVirtualMachine.virtualMachinePath(config.name, inParentURL: existingPath.deletingLastPathComponent())
|
|
||||||
try await config.save(to: existingPath)
|
try await config.save(to: existingPath)
|
||||||
let hasRenamed: Bool
|
let hasRenamed: Bool
|
||||||
if !isShortcut && existingPath.path != newPath.path {
|
if !isShortcut && existingPath.path != newPath.path {
|
||||||
try await Task.detached {
|
try await Task.detached {
|
||||||
try fileManager.moveItem(at: existingPath, to: newPath)
|
try Self.fileManager.moveItem(at: existingPath, to: newPath)
|
||||||
}.value
|
}.value
|
||||||
path = newPath
|
|
||||||
hasRenamed = true
|
hasRenamed = true
|
||||||
} else {
|
} else {
|
||||||
hasRenamed = false
|
hasRenamed = false
|
||||||
|
@ -86,85 +362,168 @@ extension UTMVirtualMachine: ObservableObject {
|
||||||
try await updateRegistryFromConfig()
|
try await updateRegistryFromConfig()
|
||||||
// reload the config if we renamed in order to point all the URLs to the right path
|
// reload the config if we renamed in order to point all the URLs to the right path
|
||||||
if hasRenamed {
|
if hasRenamed {
|
||||||
try config.reload(from: path)
|
try reload(from: newPath)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called when we save the config
|
|
||||||
@MainActor func updateRegistryFromConfig() async throws {
|
|
||||||
if registryEntry.uuid != config.uuid {
|
|
||||||
changeUuid(to: config.uuid)
|
|
||||||
}
|
|
||||||
registryEntry.name = config.name
|
|
||||||
let oldPath = registryEntry.package.path
|
|
||||||
let oldRemoteBookmark = registryEntry.package.remoteBookmark
|
|
||||||
registryEntry.package = try UTMRegistryEntry.File(url: path)
|
|
||||||
if registryEntry.package.path == oldPath {
|
|
||||||
registryEntry.package.remoteBookmark = oldRemoteBookmark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called whenever the registry entry changes
|
|
||||||
@MainActor func updateConfigFromRegistry() {
|
|
||||||
// implement in subclass
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called when we have a duplicate UUID
|
|
||||||
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyFromExisting existing: UTMRegistryEntry? = nil) {
|
|
||||||
if let qemuConfig = config.qemuConfig {
|
|
||||||
qemuConfig.information.uuid = uuid
|
|
||||||
if let name = name {
|
|
||||||
qemuConfig.information.name = name
|
|
||||||
}
|
|
||||||
} else if let appleConfig = config.appleConfig {
|
|
||||||
appleConfig.information.uuid = uuid
|
|
||||||
if let name = name {
|
|
||||||
appleConfig.information.name = name
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fatalError("Invalid configuration.")
|
|
||||||
}
|
|
||||||
registryEntry = UTMRegistry.shared.entry(for: self)
|
|
||||||
if let existing = existing {
|
|
||||||
registryEntry.update(copying: existing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bookmark handling
|
// MARK: - Registry functions
|
||||||
extension URL {
|
|
||||||
private static var defaultCreationOptions: BookmarkCreationOptions {
|
@MainActor extension UTMVirtualMachine {
|
||||||
#if os(iOS)
|
nonisolated func loadRegistry() -> UTMRegistryEntry {
|
||||||
return .minimalBookmark
|
let registryEntry = UTMRegistry.shared.entry(for: self)
|
||||||
#else
|
// migrate legacy view state
|
||||||
return .withSecurityScope
|
let viewStateUrl = pathUrl.appendingPathComponent(kUTMBundleViewFilename)
|
||||||
#endif
|
registryEntry.migrateUnsafe(viewStateURL: viewStateUrl)
|
||||||
|
return registryEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var defaultResolutionOptions: BookmarkResolutionOptions {
|
/// Default implementation
|
||||||
#if os(iOS)
|
func updateRegistryFromConfig() async throws {
|
||||||
return []
|
try await updateRegistryBasics()
|
||||||
#else
|
|
||||||
return .withSecurityScope
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func persistentBookmarkData(isReadyOnly: Bool = false) throws -> Data {
|
/// Called when we save the config
|
||||||
var options = Self.defaultCreationOptions
|
func updateRegistryBasics() async throws {
|
||||||
#if os(macOS)
|
if registryEntry.uuid != id {
|
||||||
if isReadyOnly {
|
changeUuid(to: id, name: nil, copyingEntry: registryEntry)
|
||||||
options.insert(.securityScopeAllowOnlyReadAccess)
|
}
|
||||||
|
registryEntry.name = name
|
||||||
|
let oldPath = registryEntry.package.path
|
||||||
|
let oldRemoteBookmark = registryEntry.package.remoteBookmark
|
||||||
|
registryEntry.package = try UTMRegistryEntry.File(url: pathUrl)
|
||||||
|
if registryEntry.package.path == oldPath {
|
||||||
|
registryEntry.package.remoteBookmark = oldRemoteBookmark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identity
|
||||||
|
|
||||||
|
extension UTMVirtualMachine {
|
||||||
|
var id: UUID {
|
||||||
|
config.information.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
config.information.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum UTMVirtualMachineError: Error {
|
||||||
|
case notImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UTMVirtualMachineError: LocalizedError {
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notImplemented:
|
||||||
|
return NSLocalizedString("Not implemented.", comment: "UTMVirtualMachine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Non-asynchronous version (to be removed)
|
||||||
|
|
||||||
|
extension UTMVirtualMachine {
|
||||||
|
func requestVmStart(options: UTMVirtualMachineStartOptions = []) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await start(options: options)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVmStop(force: Bool = false) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await stop(usingMethod: force ? .kill : .force)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVmReset() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await restart()
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVmPause(save: Bool = false) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await pause()
|
||||||
|
if save {
|
||||||
|
try? await saveSnapshot(name: nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVmSaveState() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await saveSnapshot(name: nil)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVmDeleteState() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await deleteSnapshot(name: nil)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVmResume() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await resume()
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestGuestPowerDown() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await stop(usingMethod: .request)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
return try self.bookmarkData(options: options,
|
|
||||||
includingResourceValuesForKeys: nil,
|
|
||||||
relativeTo: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(resolvingPersistentBookmarkData bookmark: Data) throws {
|
|
||||||
var stale: Bool = false
|
|
||||||
try self.init(resolvingBookmarkData: bookmark,
|
|
||||||
options: Self.defaultResolutionOptions,
|
|
||||||
bookmarkDataIsStale: &stale)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,44 +143,47 @@ struct ContentView: View {
|
||||||
if let action = components.host {
|
if let action = components.host {
|
||||||
switch action {
|
switch action {
|
||||||
case "start":
|
case "start":
|
||||||
if let vm = findVM(), vm.wrapped?.state == .vmStopped {
|
if let vm = findVM(), vm.state == .stopped {
|
||||||
data.run(vm: vm)
|
data.run(vm: vm)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "stop":
|
case "stop":
|
||||||
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
|
if let vm = findVM(), vm.state == .started {
|
||||||
vm.wrapped!.requestVmStop(force: true)
|
try await vm.wrapped!.stop(usingMethod: .force)
|
||||||
data.stop(vm: vm)
|
data.stop(vm: vm)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "restart":
|
case "restart":
|
||||||
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
|
if let vm = findVM(), vm.state == .started {
|
||||||
vm.wrapped!.requestVmReset()
|
try await vm.wrapped!.restart()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "pause":
|
case "pause":
|
||||||
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
|
if let vm = findVM(), vm.state == .started {
|
||||||
let shouldSaveOnPause: Bool
|
let shouldSaveOnPause: Bool
|
||||||
if let vm = vm.wrapped as? UTMQemuVirtualMachine {
|
if let vm = vm.wrapped as? UTMQemuVirtualMachine {
|
||||||
shouldSaveOnPause = !vm.isRunningAsSnapshot
|
shouldSaveOnPause = !vm.isRunningAsDisposible
|
||||||
} else {
|
} else {
|
||||||
shouldSaveOnPause = true
|
shouldSaveOnPause = true
|
||||||
}
|
}
|
||||||
vm.wrapped!.requestVmPause(save: shouldSaveOnPause)
|
try await vm.wrapped!.pause()
|
||||||
|
if shouldSaveOnPause {
|
||||||
|
try? await vm.wrapped!.saveSnapshot(name: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "resume":
|
case "resume":
|
||||||
if let vm = findVM(), vm.wrapped?.state == .vmPaused {
|
if let vm = findVM(), vm.state == .paused {
|
||||||
vm.wrapped!.requestVmResume()
|
try await vm.wrapped!.resume()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "sendText":
|
case "sendText":
|
||||||
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
|
if let vm = findVM(), vm.state == .started {
|
||||||
data.automationSendText(to: vm.wrapped!, urlComponents: components)
|
data.automationSendText(to: vm, urlComponents: components)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "click":
|
case "click":
|
||||||
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
|
if let vm = findVM(), vm.state == .started {
|
||||||
data.automationSendMouse(to: vm.wrapped!, urlComponents: components)
|
data.automationSendMouse(to: vm, urlComponents: components)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "downloadVM":
|
case "downloadVM":
|
||||||
|
|
|
@ -32,7 +32,7 @@ class UTMDownloadIPSWTask: UTMDownloadTask {
|
||||||
super.init(for: config.system.boot.macRecoveryIpswURL!, named: config.information.name)
|
super.init(for: config.system.boot.macRecoveryIpswURL!, named: config.information.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
|
override func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
|
||||||
if !fileManager.fileExists(atPath: cacheUrl.path) {
|
if !fileManager.fileExists(atPath: cacheUrl.path) {
|
||||||
try fileManager.createDirectory(at: cacheUrl, withIntermediateDirectories: false)
|
try fileManager.createDirectory(at: cacheUrl, withIntermediateDirectories: false)
|
||||||
}
|
}
|
||||||
|
@ -45,6 +45,6 @@ class UTMDownloadIPSWTask: UTMDownloadTask {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
config.system.boot.macRecoveryIpswURL = cacheIpsw
|
config.system.boot.macRecoveryIpswURL = cacheIpsw
|
||||||
}
|
}
|
||||||
return UTMVirtualMachine(newConfig: config, destinationURL: UTMData.defaultStorageUrl)
|
return try UTMAppleVirtualMachine(newForConfiguration: config, destinationUrl: UTMData.defaultStorageUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
|
||||||
super.init(for: Self.supportToolsDownloadUrl, named: name)
|
super.init(for: Self.supportToolsDownloadUrl, named: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
|
override func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
|
||||||
if !fileManager.fileExists(atPath: supportUrl.path) {
|
if !fileManager.fileExists(atPath: supportUrl.path) {
|
||||||
try fileManager.createDirectory(at: supportUrl, withIntermediateDirectories: true)
|
try fileManager.createDirectory(at: supportUrl, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
@ -52,13 +52,13 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
|
||||||
return try await mountTools()
|
return try await mountTools()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mountTools() async throws -> UTMVirtualMachine {
|
func mountTools() async throws -> any UTMVirtualMachine {
|
||||||
for file in await vm.registryEntry.externalDrives.values {
|
for file in await vm.registryEntry.externalDrives.values {
|
||||||
if file.path == supportToolsLocalUrl.path {
|
if file.path == supportToolsLocalUrl.path {
|
||||||
throw UTMDownloadSupportToolsTaskError.alreadyMounted
|
throw UTMDownloadSupportToolsTaskError.alreadyMounted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard let drive = await vm.qemuConfig.drives.last(where: { $0.isExternal && $0.imageURL == nil }) else {
|
guard let drive = await vm.config.drives.last(where: { $0.isExternal && $0.imageURL == nil }) else {
|
||||||
throw UTMDownloadSupportToolsTaskError.driveUnavailable
|
throw UTMDownloadSupportToolsTaskError.driveUnavailable
|
||||||
}
|
}
|
||||||
try await vm.changeMedium(drive, to: supportToolsLocalUrl)
|
try await vm.changeMedium(drive, to: supportToolsLocalUrl)
|
||||||
|
|
|
@ -21,8 +21,8 @@ import Logging
|
||||||
class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
|
class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
|
||||||
let url: URL
|
let url: URL
|
||||||
let name: String
|
let name: String
|
||||||
private var downloadTask: Task<UTMVirtualMachine?, Error>!
|
private var downloadTask: Task<(any UTMVirtualMachine)?, Error>!
|
||||||
private var taskContinuation: CheckedContinuation<UTMVirtualMachine?, Error>?
|
private var taskContinuation: CheckedContinuation<(any UTMVirtualMachine)?, Error>?
|
||||||
@MainActor private(set) lazy var pendingVM: UTMPendingVirtualMachine = createPendingVM()
|
@MainActor private(set) lazy var pendingVM: UTMPendingVirtualMachine = createPendingVM()
|
||||||
|
|
||||||
private let kMaxRetries = 5
|
private let kMaxRetries = 5
|
||||||
|
@ -40,7 +40,7 @@ class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate
|
||||||
/// Called by subclass when download is completed
|
/// Called by subclass when download is completed
|
||||||
/// - Parameter location: Downloaded file location
|
/// - Parameter location: Downloaded file location
|
||||||
/// - Returns: Processed UTM virtual machine
|
/// - Returns: Processed UTM virtual machine
|
||||||
func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
|
func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
|
||||||
throw "Not Implemented"
|
throw "Not Implemented"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,7 +147,7 @@ class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate
|
||||||
|
|
||||||
/// Starts the download
|
/// Starts the download
|
||||||
/// - Returns: Completed download or nil if canceled
|
/// - Returns: Completed download or nil if canceled
|
||||||
func download() async throws -> UTMVirtualMachine? {
|
func download() async throws -> (any UTMVirtualMachine)? {
|
||||||
/// begin the download
|
/// begin the download
|
||||||
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||||
downloadTask = Task.detached { [self] in
|
downloadTask = Task.detached { [self] in
|
||||||
|
|
|
@ -35,7 +35,7 @@ class UTMDownloadVMTask: UTMDownloadTask {
|
||||||
return nameWithoutZIP
|
return nameWithoutZIP
|
||||||
}
|
}
|
||||||
|
|
||||||
override func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
|
override func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
|
||||||
let tempDir = fileManager.temporaryDirectory
|
let tempDir = fileManager.temporaryDirectory
|
||||||
let originalFilename = url.lastPathComponent
|
let originalFilename = url.lastPathComponent
|
||||||
let downloadedZip = tempDir.appendingPathComponent(originalFilename)
|
let downloadedZip = tempDir.appendingPathComponent(originalFilename)
|
||||||
|
@ -51,11 +51,8 @@ class UTMDownloadVMTask: UTMDownloadTask {
|
||||||
/// remove the downloaded ZIP file
|
/// remove the downloaded ZIP file
|
||||||
try fileManager.removeItem(at: downloadedZip)
|
try fileManager.removeItem(at: downloadedZip)
|
||||||
/// load the downloaded VM into the UI
|
/// load the downloaded VM into the UI
|
||||||
if let vm = UTMVirtualMachine(url: utmURL) {
|
let vm = try await VMData(url: utmURL)
|
||||||
return vm
|
return await vm.wrapped!
|
||||||
} else {
|
|
||||||
throw CreateUTMFailed()
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
logger.error(Logger.Message(stringLiteral: error.localizedDescription))
|
logger.error(Logger.Message(stringLiteral: error.localizedDescription))
|
||||||
if let fileURL = fileURL {
|
if let fileURL = fileURL {
|
||||||
|
|
|
@ -73,6 +73,6 @@ fileprivate struct WrappedVMDetailsView: View {
|
||||||
|
|
||||||
struct UTMUnavailableVMView_Previews: PreviewProvider {
|
struct UTMUnavailableVMView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
UTMUnavailableVMView(vm: VMData(wrapping: UTMWrappedVirtualMachine(bookmark: Data(), name: "Wrapped VM", path: URL(fileURLWithPath: "/"))))
|
UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,6 +113,6 @@ struct Logo: View {
|
||||||
|
|
||||||
struct VMCardView_Previews: PreviewProvider {
|
struct VMCardView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VMCardView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: UTMQemuConfiguration(), destinationURL: URL(fileURLWithPath: "/"))))
|
VMCardView(vm: VMData(from: .empty))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,31 +64,29 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
#if os(macOS) && arch(arm64)
|
#if os(macOS) && arch(arm64)
|
||||||
if #available(macOS 13, *), let appleConfig = vm.config as? UTMAppleConfiguration, appleConfig.system.boot.operatingSystem == .macOS {
|
if #available(macOS 13, *), let appleConfig = vm.config as? UTMAppleConfiguration, appleConfig.system.boot.operatingSystem == .macOS {
|
||||||
Button {
|
Button {
|
||||||
appleConfig.system.boot.startUpFromMacOSRecovery = true
|
data.run(vm: vm, options: .bootRecovery)
|
||||||
data.run(vm: vm)
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Run Recovery", systemImage: "play.fill")
|
Label("Run Recovery", systemImage: "play.fill")
|
||||||
}.help("Boot into recovery mode.")
|
}.help("Boot into recovery mode.")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
|
if let _ = vm.wrapped as? UTMQemuVirtualMachine {
|
||||||
Button {
|
Button {
|
||||||
qemuVM.isRunningAsSnapshot = true
|
data.run(vm: vm, options: .bootDisposibleMode)
|
||||||
data.run(vm: vm)
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Run without saving changes", systemImage: "play")
|
Label("Run without saving changes", systemImage: "play")
|
||||||
}.help("Run the VM in the foreground, without saving data changes to disk.")
|
}.help("Run the VM in the foreground, without saving data changes to disk.")
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
|
if let qemuConfig = vm.config as? UTMQemuConfiguration {
|
||||||
Button {
|
Button {
|
||||||
qemuVM.isGuestToolsInstallRequested = true
|
qemuConfig.qemu.isGuestToolsInstallRequested = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Install Windows Guest Tools…", systemImage: "wrench.and.screwdriver")
|
Label("Install Windows Guest Tools…", systemImage: "wrench.and.screwdriver")
|
||||||
}.help("Download and mount the guest tools for Windows.")
|
}.help("Download and mount the guest tools for Windows.")
|
||||||
.disabled(qemuVM.isGuestToolsInstallRequested)
|
.disabled(qemuConfig.qemu.isGuestToolsInstallRequested)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@ -146,7 +144,7 @@ struct VMContextMenuModifier: ViewModifier {
|
||||||
showSharePopup.toggle()
|
showSharePopup.toggle()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onChange(of: (vm.wrapped as? UTMQemuVirtualMachine)?.isGuestToolsInstallRequested) { newValue in
|
.onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
|
||||||
if newValue == true {
|
if newValue == true {
|
||||||
data.busyWorkAsync {
|
data.busyWorkAsync {
|
||||||
try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)
|
try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)
|
||||||
|
|
|
@ -63,15 +63,15 @@ struct VMDetailsView: View {
|
||||||
}.padding([.leading, .trailing])
|
}.padding([.leading, .trailing])
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
|
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
|
||||||
VMAppleRemovableDrivesView(vm: appleVM, config: appleVM.appleConfig, registryEntry: appleVM.registryEntry)
|
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
|
||||||
.padding([.leading, .trailing, .bottom])
|
.padding([.leading, .trailing, .bottom])
|
||||||
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
|
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
|
||||||
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
|
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||||
.padding([.leading, .trailing, .bottom])
|
.padding([.leading, .trailing, .bottom])
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
let qemuVM = vm as! UTMQemuVirtualMachine
|
let qemuVM = vm as! UTMQemuVirtualMachine
|
||||||
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
|
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||||
.padding([.leading, .trailing, .bottom])
|
.padding([.leading, .trailing, .bottom])
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
|
@ -84,13 +84,13 @@ struct VMDetailsView: View {
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
|
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
|
||||||
VMAppleRemovableDrivesView(vm: appleVM, config: appleVM.appleConfig, registryEntry: appleVM.registryEntry)
|
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
|
||||||
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
|
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
|
||||||
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
|
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
|
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
|
||||||
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
|
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
|
||||||
#endif
|
#endif
|
||||||
}.padding([.leading, .trailing, .bottom])
|
}.padding([.leading, .trailing, .bottom])
|
||||||
}
|
}
|
||||||
|
@ -228,14 +228,14 @@ struct Details: View {
|
||||||
plainLabel("Serial (Client)", systemImage: "network")
|
plainLabel("Serial (Client)", systemImage: "network")
|
||||||
Spacer()
|
Spacer()
|
||||||
let address = "\(serial.tcpHostAddress ?? "example.com"):\(serial.tcpPort ?? 1234)"
|
let address = "\(serial.tcpHostAddress ?? "example.com"):\(serial.tcpPort ?? 1234)"
|
||||||
OptionalSelectableText(vm.wrapped?.state == .vmStarted ? address : nil)
|
OptionalSelectableText(vm.state == .started ? address : nil)
|
||||||
}
|
}
|
||||||
} else if serial.mode == .tcpServer {
|
} else if serial.mode == .tcpServer {
|
||||||
HStack {
|
HStack {
|
||||||
plainLabel("Serial (Server)", systemImage: "network")
|
plainLabel("Serial (Server)", systemImage: "network")
|
||||||
Spacer()
|
Spacer()
|
||||||
let address = "\(serial.tcpPort ?? 1234)"
|
let address = "\(serial.tcpPort ?? 1234)"
|
||||||
OptionalSelectableText(vm.wrapped?.state == .vmStarted ? address : nil)
|
OptionalSelectableText(vm.state == .started ? address : nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
@ -302,7 +302,7 @@ struct VMDetailsView_Previews: PreviewProvider {
|
||||||
@State static private var config = UTMQemuConfiguration()
|
@State static private var config = UTMQemuConfiguration()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VMDetailsView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: ""))))
|
VMDetailsView(vm: VMData(from: .empty))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
config.sharing.directoryShareMode = .webdav
|
config.sharing.directoryShareMode = .webdav
|
||||||
var drive = UTMQemuConfigurationDrive()
|
var drive = UTMQemuConfigurationDrive()
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VMRemovableDrivesView: View {
|
struct VMRemovableDrivesView: View {
|
||||||
@ObservedObject var vm: UTMQemuVirtualMachine
|
@ObservedObject var vm: VMData
|
||||||
@ObservedObject var config: UTMQemuConfiguration
|
@ObservedObject var config: UTMQemuConfiguration
|
||||||
@EnvironmentObject private var data: UTMData
|
@EnvironmentObject private var data: UTMData
|
||||||
@State private var shareDirectoryFileImportPresented: Bool = false
|
@State private var shareDirectoryFileImportPresented: Bool = false
|
||||||
|
@ -26,13 +26,17 @@ struct VMRemovableDrivesView: View {
|
||||||
@State private var workaroundFileImporterBug: Bool = false
|
@State private var workaroundFileImporterBug: Bool = false
|
||||||
@State private var currentDrive: UTMQemuConfigurationDrive?
|
@State private var currentDrive: UTMQemuConfigurationDrive?
|
||||||
|
|
||||||
|
private var qemuVM: UTMQemuVirtualMachine! {
|
||||||
|
vm.wrapped as? UTMQemuVirtualMachine
|
||||||
|
}
|
||||||
|
|
||||||
var fileManager: FileManager {
|
var fileManager: FileManager {
|
||||||
FileManager.default
|
FileManager.default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Is a shared directory set?
|
// Is a shared directory set?
|
||||||
private var hasSharedDir: Bool { vm.sharedDirectoryURL != nil }
|
private var hasSharedDir: Bool { qemuVM.sharedDirectoryURL != nil }
|
||||||
|
|
||||||
@ViewBuilder private var shareMenuActions: some View {
|
@ViewBuilder private var shareMenuActions: some View {
|
||||||
Button(action: { shareDirectoryFileImportPresented.toggle() }) {
|
Button(action: { shareDirectoryFileImportPresented.toggle() }) {
|
||||||
|
@ -55,7 +59,7 @@ struct VMRemovableDrivesView: View {
|
||||||
|
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
let mode = vm.config.qemuConfig!.sharing.directoryShareMode
|
let mode = config.sharing.directoryShareMode
|
||||||
if mode != .none {
|
if mode != .none {
|
||||||
HStack {
|
HStack {
|
||||||
title
|
title
|
||||||
|
@ -64,13 +68,13 @@ struct VMRemovableDrivesView: View {
|
||||||
Menu {
|
Menu {
|
||||||
shareMenuActions
|
shareMenuActions
|
||||||
} label: {
|
} label: {
|
||||||
SharedPath(path: vm.sharedDirectoryURL?.path)
|
SharedPath(path: qemuVM.sharedDirectoryURL?.path)
|
||||||
}.fixedSize()
|
}.fixedSize()
|
||||||
} else {
|
} else {
|
||||||
Button("Browse…", action: { shareDirectoryFileImportPresented.toggle() })
|
Button("Browse…", action: { shareDirectoryFileImportPresented.toggle() })
|
||||||
}
|
}
|
||||||
}.fileImporter(isPresented: $shareDirectoryFileImportPresented, allowedContentTypes: [.folder], onCompletion: selectShareDirectory)
|
}.fileImporter(isPresented: $shareDirectoryFileImportPresented, allowedContentTypes: [.folder], onCompletion: selectShareDirectory)
|
||||||
.disabled(mode == .virtfs && vm.state != .vmStopped)
|
.disabled(mode == .virtfs && vm.state != .stopped)
|
||||||
}
|
}
|
||||||
ForEach(config.drives.filter { $0.isExternal }) { drive in
|
ForEach(config.drives.filter { $0.isExternal }) { drive in
|
||||||
HStack {
|
HStack {
|
||||||
|
@ -106,14 +110,14 @@ struct VMRemovableDrivesView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Eject button
|
// Eject button
|
||||||
if vm.externalImageURL(for: drive) != nil {
|
if qemuVM.externalImageURL(for: drive) != nil {
|
||||||
Button(action: { clearRemovableImage(forDrive: drive) }, label: {
|
Button(action: { clearRemovableImage(forDrive: drive) }, label: {
|
||||||
Label("Clear", systemImage: "eject")
|
Label("Clear", systemImage: "eject")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
DriveLabel(drive: drive, isInserted: vm.externalImageURL(for: drive) != nil)
|
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
|
||||||
}.disabled(vm.hasSaveState)
|
}.disabled(vm.hasSuspendState)
|
||||||
Spacer()
|
Spacer()
|
||||||
// Disk image path, or (empty)
|
// Disk image path, or (empty)
|
||||||
Text(pathFor(drive))
|
Text(pathFor(drive))
|
||||||
|
@ -152,7 +156,7 @@ struct VMRemovableDrivesView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pathFor(_ drive: UTMQemuConfigurationDrive) -> String {
|
private func pathFor(_ drive: UTMQemuConfigurationDrive) -> String {
|
||||||
if let url = vm.externalImageURL(for: drive) {
|
if let url = qemuVM.externalImageURL(for: drive) {
|
||||||
return url.lastPathComponent
|
return url.lastPathComponent
|
||||||
} else {
|
} else {
|
||||||
return NSLocalizedString("(empty)", comment: "A removable drive that has no image file inserted.")
|
return NSLocalizedString("(empty)", comment: "A removable drive that has no image file inserted.")
|
||||||
|
@ -179,7 +183,7 @@ struct VMRemovableDrivesView: View {
|
||||||
data.busyWorkAsync {
|
data.busyWorkAsync {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let url):
|
case .success(let url):
|
||||||
try await vm.changeSharedDirectory(to: url)
|
try await qemuVM.changeSharedDirectory(to: url)
|
||||||
break
|
break
|
||||||
case .failure(let err):
|
case .failure(let err):
|
||||||
throw err
|
throw err
|
||||||
|
@ -189,7 +193,7 @@ struct VMRemovableDrivesView: View {
|
||||||
|
|
||||||
private func clearShareDirectory() {
|
private func clearShareDirectory() {
|
||||||
data.busyWorkAsync {
|
data.busyWorkAsync {
|
||||||
await vm.clearSharedDirectory()
|
await qemuVM.clearSharedDirectory()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -197,7 +201,7 @@ struct VMRemovableDrivesView: View {
|
||||||
data.busyWorkAsync {
|
data.busyWorkAsync {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let url):
|
case .success(let url):
|
||||||
try await vm.changeMedium(drive, to: url)
|
try await qemuVM.changeMedium(drive, to: url)
|
||||||
break
|
break
|
||||||
case .failure(let err):
|
case .failure(let err):
|
||||||
throw err
|
throw err
|
||||||
|
@ -207,7 +211,7 @@ struct VMRemovableDrivesView: View {
|
||||||
|
|
||||||
private func clearRemovableImage(forDrive drive: UTMQemuConfigurationDrive) {
|
private func clearRemovableImage(forDrive drive: UTMQemuConfigurationDrive) {
|
||||||
data.busyWorkAsync {
|
data.busyWorkAsync {
|
||||||
try await vm.eject(drive)
|
try await qemuVM.eject(drive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -216,7 +220,7 @@ struct VMRemovableDrivesView_Previews: PreviewProvider {
|
||||||
@State static private var config = UTMQemuConfiguration()
|
@State static private var config = UTMQemuConfiguration()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VMDetailsView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: ""))))
|
VMDetailsView(vm: VMData(from: .empty))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
config.sharing.directoryShareMode = .webdav
|
config.sharing.directoryShareMode = .webdav
|
||||||
var drive = UTMQemuConfigurationDrive()
|
var drive = UTMQemuConfigurationDrive()
|
||||||
|
|
|
@ -30,9 +30,6 @@
|
||||||
#include "UTMJailbreak.h"
|
#include "UTMJailbreak.h"
|
||||||
#include "UTMLogging.h"
|
#include "UTMLogging.h"
|
||||||
#include "UTMLegacyViewState.h"
|
#include "UTMLegacyViewState.h"
|
||||||
#include "UTMVirtualMachine.h"
|
|
||||||
#include "UTMVirtualMachine-Protected.h"
|
|
||||||
#include "UTMVirtualMachineDelegate.h"
|
|
||||||
#include "UTMSpiceIO.h"
|
#include "UTMSpiceIO.h"
|
||||||
#if TARGET_OS_IPHONE
|
#if TARGET_OS_IPHONE
|
||||||
#include "UTMLocationManager.h"
|
#include "UTMLocationManager.h"
|
||||||
|
|
|
@ -70,7 +70,7 @@ struct AlertMessage: Identifiable {
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// View controller for every VM currently active
|
/// View controller for every VM currently active
|
||||||
var vmWindows: [UTMVirtualMachine: Any] = [:]
|
var vmWindows: [VMData: Any] = [:]
|
||||||
#else
|
#else
|
||||||
/// View controller for currently active VM
|
/// View controller for currently active VM
|
||||||
var vmVC: Any?
|
var vmVC: Any?
|
||||||
|
@ -129,13 +129,11 @@ struct AlertMessage: Identifiable {
|
||||||
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
|
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guard UTMVirtualMachine.isVirtualMachine(url: file) else {
|
guard UTMQemuVirtualMachine.isVirtualMachine(url: file) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
let vm = UTMVirtualMachine(url: file)
|
if let vm = try? VMData(url: file) {
|
||||||
if let vm = vm {
|
|
||||||
let vm = VMData(wrapping: vm)
|
|
||||||
if uuidHasCollision(with: vm, in: list) {
|
if uuidHasCollision(with: vm, in: list) {
|
||||||
if let index = list.firstIndex(where: { !$0.isLoaded && $0.id == vm.id }) {
|
if let index = list.firstIndex(where: { !$0.isLoaded && $0.id == vm.id }) {
|
||||||
// we have a stale VM with the same UUID, so we replace that entry with this one
|
// we have a stale VM with the same UUID, so we replace that entry with this one
|
||||||
|
@ -189,7 +187,11 @@ struct AlertMessage: Identifiable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let vm = VMData(from: entry)
|
let vm = VMData(from: entry)
|
||||||
try? vm.load()
|
do {
|
||||||
|
try vm.load()
|
||||||
|
} catch {
|
||||||
|
logger.error("Error loading '\(entry.uuid)': \(error)")
|
||||||
|
}
|
||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -201,8 +203,8 @@ struct AlertMessage: Identifiable {
|
||||||
if let files = defaults.array(forKey: "VMList") as? [String] {
|
if let files = defaults.array(forKey: "VMList") as? [String] {
|
||||||
virtualMachines = files.uniqued().compactMap({ file in
|
virtualMachines = files.uniqued().compactMap({ file in
|
||||||
let url = documentsURL.appendingPathComponent(file, isDirectory: true)
|
let url = documentsURL.appendingPathComponent(file, isDirectory: true)
|
||||||
if let wrapped = UTMVirtualMachine(url: url) {
|
if let vm = try? VMData(url: url) {
|
||||||
return VMData(wrapping: wrapped)
|
return vm
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -310,7 +312,7 @@ struct AlertMessage: Identifiable {
|
||||||
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
|
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
|
||||||
for i in 1..<1000 {
|
for i in 1..<1000 {
|
||||||
let name = nameForId(i)
|
let name = nameForId(i)
|
||||||
let file = UTMVirtualMachine.virtualMachinePath(name, inParentURL: documentsURL)
|
let file = UTMQemuVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
|
||||||
if !fileManager.fileExists(atPath: file.path) {
|
if !fileManager.fileExists(atPath: file.path) {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
@ -385,11 +387,10 @@ struct AlertMessage: Identifiable {
|
||||||
} catch {
|
} catch {
|
||||||
// if we can't discard changes, recreate the VM from scratch
|
// if we can't discard changes, recreate the VM from scratch
|
||||||
let path = vm.pathUrl
|
let path = vm.pathUrl
|
||||||
guard let newWrapped = UTMVirtualMachine(url: path) else {
|
guard let newVM = try? VMData(url: path) else {
|
||||||
logger.debug("Cannot create new object for \(path.path)")
|
logger.debug("Cannot create new object for \(path.path)")
|
||||||
throw origError
|
throw origError
|
||||||
}
|
}
|
||||||
let newVM = VMData(wrapping: newWrapped)
|
|
||||||
let index = listRemove(vm: vm)
|
let index = listRemove(vm: vm)
|
||||||
listAdd(vm: newVM, at: index)
|
listAdd(vm: newVM, at: index)
|
||||||
listSelect(vm: newVM)
|
listSelect(vm: newVM)
|
||||||
|
@ -402,9 +403,9 @@ struct AlertMessage: Identifiable {
|
||||||
/// - Parameter vm: VM configuration to discard
|
/// - Parameter vm: VM configuration to discard
|
||||||
func discardChanges(for vm: VMData) throws {
|
func discardChanges(for vm: VMData) throws {
|
||||||
if let wrapped = vm.wrapped {
|
if let wrapped = vm.wrapped {
|
||||||
try wrapped.reloadConfiguration()
|
try wrapped.reload(from: nil)
|
||||||
if uuidHasCollision(with: vm) {
|
if uuidHasCollision(with: vm) {
|
||||||
wrapped.changeUuid(to: UUID())
|
wrapped.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -416,7 +417,7 @@ struct AlertMessage: Identifiable {
|
||||||
guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
|
guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
|
||||||
throw UTMDataError.virtualMachineAlreadyExists
|
throw UTMDataError.virtualMachineAlreadyExists
|
||||||
}
|
}
|
||||||
let vm = VMData(creatingFromConfig: config, destinationUrl: Self.defaultStorageUrl)
|
let vm = try VMData(creatingFromConfig: config, destinationUrl: Self.defaultStorageUrl)
|
||||||
try await save(vm: vm)
|
try await save(vm: vm)
|
||||||
listAdd(vm: vm)
|
listAdd(vm: vm)
|
||||||
listSelect(vm: vm)
|
listSelect(vm: vm)
|
||||||
|
@ -445,14 +446,13 @@ struct AlertMessage: Identifiable {
|
||||||
/// - Returns: The new VM
|
/// - Returns: The new VM
|
||||||
@discardableResult func clone(vm: VMData) async throws -> VMData {
|
@discardableResult func clone(vm: VMData) async throws -> VMData {
|
||||||
let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
|
let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
|
||||||
let newPath = UTMVirtualMachine.virtualMachinePath(newName, inParentURL: documentsURL)
|
let newPath = UTMQemuVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
|
||||||
|
|
||||||
try copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
|
try copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
|
||||||
guard let wrapped = UTMVirtualMachine(url: newPath) else {
|
guard let newVM = try? VMData(url: newPath) else {
|
||||||
throw UTMDataError.cloneFailed
|
throw UTMDataError.cloneFailed
|
||||||
}
|
}
|
||||||
wrapped.changeUuid(to: UUID(), name: newName)
|
newVM.wrapped!.changeUuid(to: UUID(), name: newName, copyingEntry: nil)
|
||||||
let newVM = VMData(wrapping: wrapped)
|
|
||||||
try await newVM.save()
|
try await newVM.save()
|
||||||
var index = virtualMachines.firstIndex(of: vm)
|
var index = virtualMachines.firstIndex(of: vm)
|
||||||
if index != nil {
|
if index != nil {
|
||||||
|
@ -481,13 +481,10 @@ struct AlertMessage: Identifiable {
|
||||||
/// - url: Location to move to (must be writable)
|
/// - url: Location to move to (must be writable)
|
||||||
func move(vm: VMData, to url: URL) async throws {
|
func move(vm: VMData, to url: URL) async throws {
|
||||||
try export(vm: vm, to: url)
|
try export(vm: vm, to: url)
|
||||||
guard let wrapped = UTMVirtualMachine(url: url) else {
|
guard let newVM = try? VMData(url: url) else {
|
||||||
throw UTMDataError.shortcutCreationFailed
|
throw UTMDataError.shortcutCreationFailed
|
||||||
}
|
}
|
||||||
wrapped.isShortcut = true
|
try await newVM.wrapped!.updateRegistryFromConfig()
|
||||||
try await wrapped.updateRegistryFromConfig()
|
|
||||||
try await wrapped.accessShortcut()
|
|
||||||
let newVM = VMData(wrapping: wrapped)
|
|
||||||
|
|
||||||
let oldSelected = selectedVM
|
let oldSelected = selectedVM
|
||||||
let index = try await delete(vm: vm, alsoRegistry: false)
|
let index = try await delete(vm: vm, alsoRegistry: false)
|
||||||
|
@ -592,28 +589,25 @@ struct AlertMessage: Identifiable {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// check if VM is valid
|
// check if VM is valid
|
||||||
guard let _ = UTMVirtualMachine(url: url) else {
|
guard let _ = try? VMData(url: url) else {
|
||||||
throw UTMDataError.importFailed
|
throw UTMDataError.importFailed
|
||||||
}
|
}
|
||||||
let wrapped: UTMVirtualMachine?
|
let vm: VMData?
|
||||||
if (fileBasePath.resolvingSymlinksInPath().path == documentsURL.appendingPathComponent("Inbox", isDirectory: true).path) {
|
if (fileBasePath.resolvingSymlinksInPath().path == documentsURL.appendingPathComponent("Inbox", isDirectory: true).path) {
|
||||||
logger.info("moving from Inbox")
|
logger.info("moving from Inbox")
|
||||||
try fileManager.moveItem(at: url, to: dest)
|
try fileManager.moveItem(at: url, to: dest)
|
||||||
wrapped = UTMVirtualMachine(url: dest)
|
vm = try VMData(url: dest)
|
||||||
} else if asShortcut {
|
} else if asShortcut {
|
||||||
logger.info("loading as a shortcut")
|
logger.info("loading as a shortcut")
|
||||||
wrapped = UTMVirtualMachine(url: url)
|
vm = try VMData(url: url)
|
||||||
wrapped?.isShortcut = true
|
|
||||||
try await wrapped?.accessShortcut()
|
|
||||||
} else {
|
} else {
|
||||||
logger.info("copying to Documents")
|
logger.info("copying to Documents")
|
||||||
try fileManager.copyItem(at: url, to: dest)
|
try fileManager.copyItem(at: url, to: dest)
|
||||||
wrapped = UTMVirtualMachine(url: dest)
|
vm = try VMData(url: dest)
|
||||||
}
|
}
|
||||||
guard let wrapped = wrapped else {
|
guard let vm = vm else {
|
||||||
throw UTMDataError.importParseFailed
|
throw UTMDataError.importParseFailed
|
||||||
}
|
}
|
||||||
let vm = VMData(wrapping: wrapped)
|
|
||||||
listAdd(vm: vm)
|
listAdd(vm: vm)
|
||||||
listSelect(vm: vm)
|
listSelect(vm: vm)
|
||||||
}
|
}
|
||||||
|
@ -678,7 +672,7 @@ struct AlertMessage: Identifiable {
|
||||||
func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
|
func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
|
||||||
let task = UTMDownloadSupportToolsTask(for: vm)
|
let task = UTMDownloadSupportToolsTask(for: vm)
|
||||||
if task.hasExistingSupportTools {
|
if task.hasExistingSupportTools {
|
||||||
vm.isGuestToolsInstallRequested = false
|
vm.config.qemu.isGuestToolsInstallRequested = false
|
||||||
_ = try await task.mountTools()
|
_ = try await task.mountTools()
|
||||||
} else {
|
} else {
|
||||||
listAdd(pendingVM: task.pendingVM)
|
listAdd(pendingVM: task.pendingVM)
|
||||||
|
@ -687,7 +681,7 @@ struct AlertMessage: Identifiable {
|
||||||
} catch {
|
} catch {
|
||||||
showErrorAlert(message: error.localizedDescription)
|
showErrorAlert(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
vm.isGuestToolsInstallRequested = false
|
vm.config.qemu.isGuestToolsInstallRequested = false
|
||||||
listRemove(pendingVM: task.pendingVM)
|
listRemove(pendingVM: task.pendingVM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -750,8 +744,7 @@ struct AlertMessage: Identifiable {
|
||||||
guard let vm = vm.wrapped else {
|
guard let vm = vm.wrapped else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let previous = vm.registryEntry
|
vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
|
||||||
vm.changeUuid(to: UUID(), copyFromExisting: previous)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Other utility functions
|
// MARK: - Other utility functions
|
||||||
|
@ -806,7 +799,7 @@ struct AlertMessage: Identifiable {
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - vm: VM to send text to
|
/// - vm: VM to send text to
|
||||||
/// - components: Data (see UTM Wiki for details)
|
/// - components: Data (see UTM Wiki for details)
|
||||||
func automationSendText(to vm: UTMVirtualMachine, urlComponents components: URLComponents) {
|
func automationSendText(to vm: VMData, urlComponents components: URLComponents) {
|
||||||
guard let queryItems = components.queryItems else { return }
|
guard let queryItems = components.queryItems else { return }
|
||||||
guard let text = queryItems.first(where: { $0.name == "text" })?.value else { return }
|
guard let text = queryItems.first(where: { $0.name == "text" })?.value else { return }
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
@ -820,9 +813,9 @@ struct AlertMessage: Identifiable {
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - vm: VM to send mouse/tablet coordinates to
|
/// - vm: VM to send mouse/tablet coordinates to
|
||||||
/// - components: Data (see UTM Wiki for details)
|
/// - components: Data (see UTM Wiki for details)
|
||||||
func automationSendMouse(to vm: UTMVirtualMachine, urlComponents components: URLComponents) {
|
func automationSendMouse(to vm: VMData, urlComponents components: URLComponents) {
|
||||||
guard let qemuVm = vm as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
|
guard let qemuVm = vm.wrapped as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
|
||||||
guard !qemuVm.qemuConfig.displays.isEmpty else { return }
|
guard !qemuVm.config.displays.isEmpty else { return }
|
||||||
guard let queryItems = components.queryItems else { return }
|
guard let queryItems = components.queryItems else { return }
|
||||||
/// Parse targeted position
|
/// Parse targeted position
|
||||||
var x: CGFloat? = nil
|
var x: CGFloat? = nil
|
||||||
|
@ -857,7 +850,7 @@ struct AlertMessage: Identifiable {
|
||||||
}
|
}
|
||||||
/// All parameters parsed, perform the click
|
/// All parameters parsed, perform the click
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
tryClickAtPoint(vm: qemuVm, point: point, button: button)
|
tryClickAtPoint(vm: vm, point: point, button: button)
|
||||||
#else
|
#else
|
||||||
tryClickAtPoint(point: point, button: button)
|
tryClickAtPoint(point: point, button: button)
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -264,6 +264,12 @@ extension UIImage {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
typealias PlatformImage = NSImage
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
typealias PlatformImage = UIImage
|
||||||
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
enum FakeKeyboardType : Int {
|
enum FakeKeyboardType : Int {
|
||||||
case asciiCapable
|
case asciiCapable
|
||||||
|
@ -321,3 +327,41 @@ struct Setting<T> {
|
||||||
self.keyName = keyName
|
self.keyName = keyName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Bookmark handling
|
||||||
|
extension URL {
|
||||||
|
private static var defaultCreationOptions: BookmarkCreationOptions {
|
||||||
|
#if os(iOS)
|
||||||
|
return .minimalBookmark
|
||||||
|
#else
|
||||||
|
return .withSecurityScope
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var defaultResolutionOptions: BookmarkResolutionOptions {
|
||||||
|
#if os(iOS)
|
||||||
|
return []
|
||||||
|
#else
|
||||||
|
return .withSecurityScope
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistentBookmarkData(isReadyOnly: Bool = false) throws -> Data {
|
||||||
|
var options = Self.defaultCreationOptions
|
||||||
|
#if os(macOS)
|
||||||
|
if isReadyOnly {
|
||||||
|
options.insert(.securityScopeAllowOnlyReadAccess)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return try self.bookmarkData(options: options,
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(resolvingPersistentBookmarkData bookmark: Data) throws {
|
||||||
|
var stale: Bool = false
|
||||||
|
try self.init(resolvingBookmarkData: bookmark,
|
||||||
|
options: Self.defaultResolutionOptions,
|
||||||
|
bookmarkDataIsStale: &stale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import SwiftUI
|
||||||
/// Model wrapping a single UTMVirtualMachine for use in views
|
/// Model wrapping a single UTMVirtualMachine for use in views
|
||||||
@MainActor class VMData: ObservableObject {
|
@MainActor class VMData: ObservableObject {
|
||||||
/// Underlying virtual machine
|
/// Underlying virtual machine
|
||||||
private(set) var wrapped: UTMVirtualMachine? {
|
private(set) var wrapped: (any UTMVirtualMachine)? {
|
||||||
willSet {
|
willSet {
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
@ -32,13 +32,13 @@ import SwiftUI
|
||||||
|
|
||||||
/// Virtual machine configuration
|
/// Virtual machine configuration
|
||||||
var config: (any UTMConfiguration)? {
|
var config: (any UTMConfiguration)? {
|
||||||
wrapped?.config.wrappedValue as? (any UTMConfiguration)
|
wrapped?.config
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current path of the VM
|
/// Current path of the VM
|
||||||
var pathUrl: URL {
|
var pathUrl: URL {
|
||||||
if let wrapped = wrapped {
|
if let wrapped = wrapped {
|
||||||
return wrapped.path
|
return wrapped.pathUrl
|
||||||
} else if let registryEntry = registryEntry {
|
} else if let registryEntry = registryEntry {
|
||||||
return registryEntry.package.url
|
return registryEntry.package.url
|
||||||
} else {
|
} else {
|
||||||
|
@ -63,6 +63,12 @@ import SwiftUI
|
||||||
/// This is a workaround for SwiftUI bugs not hiding deleted elements.
|
/// This is a workaround for SwiftUI bugs not hiding deleted elements.
|
||||||
@Published var isDeleted: Bool = false
|
@Published var isDeleted: Bool = false
|
||||||
|
|
||||||
|
/// Copy from wrapped VM
|
||||||
|
@Published var state: UTMVirtualMachineState = .stopped
|
||||||
|
|
||||||
|
/// Copy from wrapped VM
|
||||||
|
@Published var screenshot: PlatformImage?
|
||||||
|
|
||||||
/// Allows changes in the config, registry, and VM to be reflected
|
/// Allows changes in the config, registry, and VM to be reflected
|
||||||
private var observers: [AnyCancellable] = []
|
private var observers: [AnyCancellable] = []
|
||||||
|
|
||||||
|
@ -73,12 +79,19 @@ import SwiftUI
|
||||||
|
|
||||||
/// Create a VM from an existing object
|
/// Create a VM from an existing object
|
||||||
/// - Parameter vm: VM to wrap
|
/// - Parameter vm: VM to wrap
|
||||||
convenience init(wrapping vm: UTMVirtualMachine) {
|
convenience init(wrapping vm: any UTMVirtualMachine) {
|
||||||
self.init()
|
self.init()
|
||||||
self.wrapped = vm
|
self.wrapped = vm
|
||||||
subscribeToChildren()
|
subscribeToChildren()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempt to a new wrapped UTM VM from a file path
|
||||||
|
/// - Parameter url: File path
|
||||||
|
convenience init(url: URL) throws {
|
||||||
|
self.init()
|
||||||
|
try load(from: url)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new wrapped UTM VM from a registry entry
|
/// Create a new wrapped UTM VM from a registry entry
|
||||||
/// - Parameter registryEntry: Registry entry
|
/// - Parameter registryEntry: Registry entry
|
||||||
convenience init(from registryEntry: UTMRegistryEntry) {
|
convenience init(from registryEntry: UTMRegistryEntry) {
|
||||||
|
@ -114,42 +127,62 @@ import SwiftUI
|
||||||
|
|
||||||
/// Create a new VM from a configuration
|
/// Create a new VM from a configuration
|
||||||
/// - Parameter config: Configuration to create new VM
|
/// - Parameter config: Configuration to create new VM
|
||||||
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) {
|
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
|
||||||
self.init()
|
self.init()
|
||||||
if config is UTMQemuConfiguration {
|
if let qemuConfig = config as? UTMQemuConfiguration {
|
||||||
wrapped = UTMQemuVirtualMachine(newConfig: config, destinationURL: destinationUrl)
|
wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if config is UTMAppleConfiguration {
|
if let appleConfig = config as? UTMAppleConfiguration {
|
||||||
wrapped = UTMAppleVirtualMachine(newConfig: config, destinationURL: destinationUrl)
|
wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
subscribeToChildren()
|
subscribeToChildren()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the VM from file
|
/// Loads the VM
|
||||||
///
|
///
|
||||||
/// If the VM is already loaded, it will return true without doing anything.
|
/// If the VM is already loaded, it will return true without doing anything.
|
||||||
|
/// - Parameter url: URL to load from
|
||||||
/// - Returns: If load was successful
|
/// - Returns: If load was successful
|
||||||
func load() throws {
|
func load() throws {
|
||||||
|
try load(from: pathUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the VM from a path
|
||||||
|
///
|
||||||
|
/// If the VM is already loaded, it will return true without doing anything.
|
||||||
|
/// - Parameter url: URL to load from
|
||||||
|
/// - Returns: If load was successful
|
||||||
|
private func load(from url: URL) throws {
|
||||||
guard !isLoaded else {
|
guard !isLoaded else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let vm = UTMVirtualMachine(url: pathUrl) else {
|
var loaded: (any UTMVirtualMachine)?
|
||||||
|
let config = try UTMQemuConfiguration.load(from: url)
|
||||||
|
if let qemuConfig = config as? UTMQemuConfiguration {
|
||||||
|
loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut)
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
if let appleConfig = config as? UTMAppleConfiguration {
|
||||||
|
loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
guard let vm = loaded else {
|
||||||
throw VMDataError.virtualMachineNotLoaded
|
throw VMDataError.virtualMachineNotLoaded
|
||||||
}
|
}
|
||||||
vm.isShortcut = isShortcut
|
|
||||||
if let oldEntry = registryEntry, oldEntry.uuid != vm.registryEntry.uuid {
|
if let oldEntry = registryEntry, oldEntry.uuid != vm.registryEntry.uuid {
|
||||||
if uuidUnknown {
|
if uuidUnknown {
|
||||||
// legacy VMs don't have UUID stored so we made a fake UUID
|
// legacy VMs don't have UUID stored so we made a fake UUID
|
||||||
UTMRegistry.shared.remove(entry: oldEntry)
|
UTMRegistry.shared.remove(entry: oldEntry)
|
||||||
} else {
|
} else {
|
||||||
// persistent uuid does not match indicating a cloned or legacy VM with a duplicate UUID
|
// persistent uuid does not match indicating a cloned or legacy VM with a duplicate UUID
|
||||||
vm.changeUuid(to: oldEntry.uuid, copyFromExisting: oldEntry)
|
vm.changeUuid(to: oldEntry.uuid, name: nil, copyingEntry: oldEntry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wrapped = vm
|
wrapped = vm
|
||||||
uuidUnknown = false
|
uuidUnknown = false
|
||||||
|
subscribeToChildren()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the VM to file
|
/// Saves the VM to file
|
||||||
|
@ -157,39 +190,42 @@ import SwiftUI
|
||||||
guard let wrapped = wrapped else {
|
guard let wrapped = wrapped else {
|
||||||
throw VMDataError.virtualMachineNotLoaded
|
throw VMDataError.virtualMachineNotLoaded
|
||||||
}
|
}
|
||||||
try await wrapped.saveUTM()
|
try await wrapped.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Listen to changes in the underlying object and propogate upwards
|
/// Listen to changes in the underlying object and propogate upwards
|
||||||
private func subscribeToChildren() {
|
private func subscribeToChildren() {
|
||||||
var s: [AnyCancellable] = []
|
var s: [AnyCancellable] = []
|
||||||
if let config = config as? UTMQemuConfiguration {
|
if let wrapped = wrapped {
|
||||||
s.append(config.objectWillChange.sink { [weak self] in
|
wrapped.onConfigurationChange = { [weak self] in
|
||||||
|
self?.subscribeToChildren()
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapped.onStateChange = { [weak self] in
|
||||||
|
guard let self = self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
self.state = wrapped.state
|
||||||
|
self.screenshot = wrapped.screenshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let qemuConfig = wrapped?.config as? UTMQemuConfiguration {
|
||||||
|
s.append(qemuConfig.objectWillChange.sink { [weak self] _ in
|
||||||
self?.objectWillChange.send()
|
self?.objectWillChange.send()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if let config = config as? UTMAppleConfiguration {
|
if let appleConfig = wrapped?.config as? UTMAppleConfiguration {
|
||||||
s.append(config.objectWillChange.sink { [weak self] in
|
s.append(appleConfig.objectWillChange.sink { [weak self] _ in
|
||||||
self?.objectWillChange.send()
|
self?.objectWillChange.send()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
if let registryEntry = registryEntry {
|
if let registryEntry = registryEntry {
|
||||||
s.append(registryEntry.objectWillChange.sink { [weak self] in
|
s.append(registryEntry.objectWillChange.sink { [weak self] in
|
||||||
guard let self = self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.objectWillChange.send()
|
|
||||||
self.wrapped?.updateConfigFromRegistry()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// observe KVO publisher for state changes
|
|
||||||
if let wrapped = wrapped {
|
|
||||||
s.append(wrapped.publisher(for: \.state).sink { [weak self] _ in
|
|
||||||
self?.objectWillChange.send()
|
|
||||||
})
|
|
||||||
s.append(wrapped.publisher(for: \.screenshot).sink { [weak self] _ in
|
|
||||||
self?.objectWillChange.send()
|
self?.objectWillChange.send()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -223,7 +259,7 @@ extension VMData: Identifiable {
|
||||||
extension VMData: Equatable {
|
extension VMData: Equatable {
|
||||||
static func == (lhs: VMData, rhs: VMData) -> Bool {
|
static func == (lhs: VMData, rhs: VMData) -> Bool {
|
||||||
if lhs.isLoaded && rhs.isLoaded {
|
if lhs.isLoaded && rhs.isLoaded {
|
||||||
return lhs.wrapped == rhs.wrapped
|
return lhs.wrapped === rhs.wrapped
|
||||||
}
|
}
|
||||||
if let lhsEntry = lhs.registryEntryWrapped, let rhsEntry = rhs.registryEntryWrapped {
|
if let lhsEntry = lhs.registryEntryWrapped, let rhsEntry = rhs.registryEntryWrapped {
|
||||||
return lhsEntry == rhsEntry
|
return lhsEntry == rhsEntry
|
||||||
|
@ -234,7 +270,7 @@ extension VMData: Equatable {
|
||||||
|
|
||||||
extension VMData: Hashable {
|
extension VMData: Hashable {
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(wrapped)
|
hasher.combine(pathUrl)
|
||||||
hasher.combine(registryEntryWrapped)
|
hasher.combine(registryEntryWrapped)
|
||||||
hasher.combine(isDeleted)
|
hasher.combine(isDeleted)
|
||||||
}
|
}
|
||||||
|
@ -244,13 +280,9 @@ extension VMData: Hashable {
|
||||||
extension VMData {
|
extension VMData {
|
||||||
/// True if the .utm is loaded outside of the default storage
|
/// True if the .utm is loaded outside of the default storage
|
||||||
var isShortcut: Bool {
|
var isShortcut: Bool {
|
||||||
if let wrapped = wrapped {
|
let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
|
||||||
return wrapped.isShortcut
|
let parentUrl = pathUrl.deletingLastPathComponent().standardizedFileURL
|
||||||
} else {
|
return parentUrl != defaultStorageUrl
|
||||||
let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
|
|
||||||
let parentUrl = pathUrl.deletingLastPathComponent().standardizedFileURL
|
|
||||||
return parentUrl != defaultStorageUrl
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VM is loaded
|
/// VM is loaded
|
||||||
|
@ -260,28 +292,22 @@ extension VMData {
|
||||||
|
|
||||||
/// VM is stopped
|
/// VM is stopped
|
||||||
var isStopped: Bool {
|
var isStopped: Bool {
|
||||||
if let state = wrapped?.state {
|
state == .stopped || state == .paused
|
||||||
return state == .vmStopped || state == .vmPaused
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VM can be modified
|
/// VM can be modified
|
||||||
var isModifyAllowed: Bool {
|
var isModifyAllowed: Bool {
|
||||||
if let state = wrapped?.state {
|
state == .stopped
|
||||||
return state == .vmStopped
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display VM as "busy" for UI elements
|
/// Display VM as "busy" for UI elements
|
||||||
var isBusy: Bool {
|
var isBusy: Bool {
|
||||||
wrapped?.state == .vmPausing ||
|
state == .pausing ||
|
||||||
wrapped?.state == .vmResuming ||
|
state == .resuming ||
|
||||||
wrapped?.state == .vmStarting ||
|
state == .starting ||
|
||||||
wrapped?.state == .vmStopping
|
state == .stopping ||
|
||||||
|
state == .saving ||
|
||||||
|
state == .resuming
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VM has been suspended before
|
/// VM has been suspended before
|
||||||
|
@ -292,12 +318,6 @@ extension VMData {
|
||||||
|
|
||||||
// MARK: - Home UI elements
|
// MARK: - Home UI elements
|
||||||
extension VMData {
|
extension VMData {
|
||||||
#if os(macOS)
|
|
||||||
typealias PlatformImage = NSImage
|
|
||||||
#else
|
|
||||||
typealias PlatformImage = UIImage
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Unavailable string
|
/// Unavailable string
|
||||||
private var unavailable: String {
|
private var unavailable: String {
|
||||||
NSLocalizedString("Unavailable", comment: "VMData")
|
NSLocalizedString("Unavailable", comment: "VMData")
|
||||||
|
@ -367,35 +387,34 @@ extension VMData {
|
||||||
|
|
||||||
/// Display current VM state as a string for UI elements
|
/// Display current VM state as a string for UI elements
|
||||||
var stateLabel: String {
|
var stateLabel: String {
|
||||||
guard let state = wrapped?.state else {
|
|
||||||
return unavailable
|
|
||||||
}
|
|
||||||
switch state {
|
switch state {
|
||||||
case .vmStopped:
|
case .stopped:
|
||||||
if registryEntry?.hasSaveState == true {
|
if registryEntry?.hasSaveState == true {
|
||||||
return NSLocalizedString("Suspended", comment: "VMData");
|
return NSLocalizedString("Suspended", comment: "VMData");
|
||||||
} else {
|
} else {
|
||||||
return NSLocalizedString("Stopped", comment: "VMData");
|
return NSLocalizedString("Stopped", comment: "VMData");
|
||||||
}
|
}
|
||||||
case .vmStarting:
|
case .starting:
|
||||||
return NSLocalizedString("Starting", comment: "VMData")
|
return NSLocalizedString("Starting", comment: "VMData")
|
||||||
case .vmStarted:
|
case .started:
|
||||||
return NSLocalizedString("Started", comment: "VMData")
|
return NSLocalizedString("Started", comment: "VMData")
|
||||||
case .vmPausing:
|
case .pausing:
|
||||||
return NSLocalizedString("Pausing", comment: "VMData")
|
return NSLocalizedString("Pausing", comment: "VMData")
|
||||||
case .vmPaused:
|
case .paused:
|
||||||
return NSLocalizedString("Paused", comment: "VMData")
|
return NSLocalizedString("Paused", comment: "VMData")
|
||||||
case .vmResuming:
|
case .resuming:
|
||||||
return NSLocalizedString("Resuming", comment: "VMData")
|
return NSLocalizedString("Resuming", comment: "VMData")
|
||||||
case .vmStopping:
|
case .stopping:
|
||||||
return NSLocalizedString("Stopping", comment: "VMData")
|
return NSLocalizedString("Stopping", comment: "VMData")
|
||||||
@unknown default:
|
case .saving:
|
||||||
fatalError()
|
return NSLocalizedString("Saving", comment: "VMData")
|
||||||
|
case .restoring:
|
||||||
|
return NSLocalizedString("Restoring", comment: "VMData")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If non-null, is the most recent screenshot image of the running VM
|
/// If non-null, is the most recent screenshot image of the running VM
|
||||||
var screenshotImage: PlatformImage? {
|
var screenshotImage: PlatformImage? {
|
||||||
wrapped?.screenshot?.image
|
wrapped?.screenshot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
#import "VMDisplayMetalViewController+Keyboard.h"
|
#import "VMDisplayMetalViewController+Keyboard.h"
|
||||||
#import "UTMLogging.h"
|
#import "UTMLogging.h"
|
||||||
#import "UTMVirtualMachine.h"
|
|
||||||
#import "VMKeyboardView.h"
|
#import "VMKeyboardView.h"
|
||||||
#import "VMKeyboardButton.h"
|
#import "VMKeyboardButton.h"
|
||||||
#import "UTM-Swift.h"
|
#import "UTM-Swift.h"
|
||||||
|
|
|
@ -21,7 +21,6 @@
|
||||||
#import "VMDisplayMetalViewController+Pencil.h"
|
#import "VMDisplayMetalViewController+Pencil.h"
|
||||||
#import "VMDisplayMetalViewController+Gamepad.h"
|
#import "VMDisplayMetalViewController+Gamepad.h"
|
||||||
#import "VMKeyboardView.h"
|
#import "VMKeyboardView.h"
|
||||||
#import "UTMVirtualMachine.h"
|
|
||||||
#import "UTMLogging.h"
|
#import "UTMLogging.h"
|
||||||
#import "CSDisplay.h"
|
#import "CSDisplay.h"
|
||||||
#import "UTM-Swift.h"
|
#import "UTM-Swift.h"
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@objc protocol VMDisplayViewControllerDelegate {
|
@objc protocol VMDisplayViewControllerDelegate {
|
||||||
var vmState: UTMVMState { get }
|
|
||||||
var qemuInputLegacy: Bool { get }
|
var qemuInputLegacy: Bool { get }
|
||||||
var qemuDisplayUpscaler: MTLSamplerMinMagFilter { get }
|
var qemuDisplayUpscaler: MTLSamplerMinMagFilter { get }
|
||||||
var qemuDisplayDownscaler: MTLSamplerMinMagFilter { get }
|
var qemuDisplayDownscaler: MTLSamplerMinMagFilter { get }
|
||||||
|
|
|
@ -18,7 +18,7 @@ import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension UTMData {
|
extension UTMData {
|
||||||
func run(vm: VMData) {
|
func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
|
||||||
guard let wrapped = vm.wrapped else {
|
guard let wrapped = vm.wrapped else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ extension UTMData {
|
||||||
guard let wrapped = vm.wrapped else {
|
guard let wrapped = vm.wrapped else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if wrapped.hasSaveState {
|
if wrapped.registryEntry.hasSaveState {
|
||||||
wrapped.requestVmDeleteState()
|
wrapped.requestVmDeleteState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,12 +24,12 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
@Binding var state: VMWindowState
|
@Binding var state: VMWindowState
|
||||||
var vmStateCancellable: AnyCancellable?
|
var vmStateCancellable: AnyCancellable?
|
||||||
|
|
||||||
var vmState: UTMVMState {
|
var vmState: UTMVirtualMachineState {
|
||||||
vm.state
|
vm.state
|
||||||
}
|
}
|
||||||
|
|
||||||
var vmConfig: UTMQemuConfiguration! {
|
var vmConfig: UTMQemuConfiguration {
|
||||||
vm.config.qemuConfig
|
vm.config
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor var qemuInputLegacy: Bool {
|
@MainActor var qemuInputLegacy: Bool {
|
||||||
|
@ -111,7 +111,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayDidAppear() {
|
func displayDidAppear() {
|
||||||
if vm.state == .vmStopped {
|
if vm.state == .stopped {
|
||||||
vm.requestVmStart()
|
vm.requestVmStart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -147,20 +147,18 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
|
||||||
mvc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
|
mvc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
|
||||||
vc = mvc
|
vc = mvc
|
||||||
case .serial(let serial, let id):
|
case .serial(let serial, let id):
|
||||||
let style = vm.qemuConfig.serials[id].terminal
|
let style = vm.config.serials[id].terminal
|
||||||
vc = VMDisplayTerminalViewController(port: serial, style: style)
|
vc = VMDisplayTerminalViewController(port: serial, style: style)
|
||||||
vc.delegate = context.coordinator
|
vc.delegate = context.coordinator
|
||||||
}
|
}
|
||||||
context.coordinator.vmStateCancellable = session.$vmState.sink { vmState in
|
context.coordinator.vmStateCancellable = session.$vmState.sink { vmState in
|
||||||
switch vmState {
|
switch vmState {
|
||||||
case .vmStopped, .vmPaused:
|
case .stopped, .paused:
|
||||||
vc.enterSuspended(isBusy: false)
|
vc.enterSuspended(isBusy: false)
|
||||||
case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
|
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
|
||||||
vc.enterSuspended(isBusy: true)
|
vc.enterSuspended(isBusy: true)
|
||||||
case .vmStarted:
|
case .started:
|
||||||
vc.enterLive()
|
vc.enterLive()
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return vc
|
return vc
|
||||||
|
|
|
@ -24,11 +24,11 @@ import SwiftUI
|
||||||
|
|
||||||
let vm: UTMQemuVirtualMachine
|
let vm: UTMQemuVirtualMachine
|
||||||
|
|
||||||
var qemuConfig: UTMQemuConfiguration! {
|
var qemuConfig: UTMQemuConfiguration {
|
||||||
vm.config.qemuConfig
|
vm.config
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var vmState: UTMVMState = .vmStopped
|
@Published var vmState: UTMVirtualMachineState = .stopped
|
||||||
|
|
||||||
@Published var fatalError: String?
|
@Published var fatalError: String?
|
||||||
|
|
||||||
|
@ -123,10 +123,10 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMSessionState: UTMVirtualMachineDelegate {
|
extension VMSessionState: UTMVirtualMachineDelegate {
|
||||||
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
vmState = state
|
vmState = state
|
||||||
if state == .vmStopped {
|
if state == .stopped {
|
||||||
#if !WITH_QEMU_TCI
|
#if !WITH_QEMU_TCI
|
||||||
clearDevices()
|
clearDevices()
|
||||||
#endif
|
#endif
|
||||||
|
@ -134,11 +134,19 @@ extension VMSessionState: UTMVirtualMachineDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
fatalError = message
|
fatalError = message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMSessionState: UTMSpiceIODelegate {
|
extension VMSessionState: UTMSpiceIODelegate {
|
||||||
|
@ -271,7 +279,7 @@ extension VMSessionState: CSUSBManagerDelegate {
|
||||||
|
|
||||||
nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
|
nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
if vmState == .vmStarted {
|
if vmState == .started {
|
||||||
mostRecentConnectedDevice = device
|
mostRecentConnectedDevice = device
|
||||||
}
|
}
|
||||||
allUsbDevices.append(device)
|
allUsbDevices.append(device)
|
||||||
|
@ -369,7 +377,7 @@ extension VMSessionState: CSUSBManagerDelegate {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
extension VMSessionState {
|
extension VMSessionState {
|
||||||
func start() {
|
func start(options: UTMVirtualMachineStartOptions = []) {
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
let preferDeviceMicrophone = UserDefaults.standard.bool(forKey: "PreferDeviceMicrophone")
|
let preferDeviceMicrophone = UserDefaults.standard.bool(forKey: "PreferDeviceMicrophone")
|
||||||
|
@ -384,7 +392,7 @@ extension VMSessionState {
|
||||||
}
|
}
|
||||||
Self.currentSession = self
|
Self.currentSession = self
|
||||||
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
|
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
|
||||||
vm.requestVmStart()
|
vm.requestVmStart(options: options)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func suspend() {
|
@objc private func suspend() {
|
||||||
|
@ -413,19 +421,18 @@ extension VMSessionState {
|
||||||
}
|
}
|
||||||
|
|
||||||
func powerDown() {
|
func powerDown() {
|
||||||
vm.requestVmDeleteState()
|
Task {
|
||||||
vm.vmStop { _ in
|
try? await vm.deleteSnapshot(name: nil)
|
||||||
Task { @MainActor in
|
try await vm.stop(usingMethod: .force)
|
||||||
self.stop()
|
self.stop()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseResume() {
|
func pauseResume() {
|
||||||
let shouldSaveState = !vm.isRunningAsSnapshot
|
let shouldSaveState = !vm.isRunningAsDisposible
|
||||||
if vm.state == .vmStarted {
|
if vm.state == .started {
|
||||||
vm.requestVmPause(save: shouldSaveState)
|
vm.requestVmPause(save: shouldSaveState)
|
||||||
} else if vm.state == .vmPaused {
|
} else if vm.state == .paused {
|
||||||
vm.requestVmResume()
|
vm.requestVmResume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -439,8 +446,9 @@ extension VMSessionState {
|
||||||
|
|
||||||
if shouldAutosave {
|
if shouldAutosave {
|
||||||
logger.info("Saving VM state on low memory warning.")
|
logger.info("Saving VM state on low memory warning.")
|
||||||
vm.vmSaveState { _ in
|
Task {
|
||||||
// ignore error
|
// ignore error
|
||||||
|
try? await vm.saveSnapshot(name: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -448,7 +456,7 @@ extension VMSessionState {
|
||||||
func didEnterBackground() {
|
func didEnterBackground() {
|
||||||
logger.info("Entering background")
|
logger.info("Entering background")
|
||||||
let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
|
let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
|
||||||
if shouldAutosaveBackground && vmState == .vmStarted {
|
if shouldAutosaveBackground && vmState == .started {
|
||||||
logger.info("Saving snapshot")
|
logger.info("Saving snapshot")
|
||||||
var task: UIBackgroundTaskIdentifier = .invalid
|
var task: UIBackgroundTaskIdentifier = .invalid
|
||||||
task = UIApplication.shared.beginBackgroundTask {
|
task = UIApplication.shared.beginBackgroundTask {
|
||||||
|
@ -456,12 +464,13 @@ extension VMSessionState {
|
||||||
UIApplication.shared.endBackgroundTask(task)
|
UIApplication.shared.endBackgroundTask(task)
|
||||||
task = .invalid
|
task = .invalid
|
||||||
}
|
}
|
||||||
vm.vmSaveState { error in
|
Task {
|
||||||
if let error = error {
|
do {
|
||||||
logger.error("error saving snapshot: \(error)")
|
try await vm.saveSnapshot()
|
||||||
} else {
|
|
||||||
self.hasAutosave = true
|
self.hasAutosave = true
|
||||||
logger.info("Save snapshot complete")
|
logger.info("Save snapshot complete")
|
||||||
|
} catch {
|
||||||
|
logger.error("error saving snapshot: \(error)")
|
||||||
}
|
}
|
||||||
UIApplication.shared.endBackgroundTask(task)
|
UIApplication.shared.endBackgroundTask(task)
|
||||||
task = .invalid
|
task = .invalid
|
||||||
|
@ -471,7 +480,7 @@ extension VMSessionState {
|
||||||
|
|
||||||
func didEnterForeground() {
|
func didEnterForeground() {
|
||||||
logger.info("Entering foreground!")
|
logger.info("Entering foreground!")
|
||||||
if (hasAutosave && vmState == .vmStarted) {
|
if (hasAutosave && vmState == .started) {
|
||||||
logger.info("Deleting snapshot")
|
logger.info("Deleting snapshot")
|
||||||
vm.requestVmDeleteState()
|
vm.requestVmDeleteState()
|
||||||
}
|
}
|
||||||
|
|
|
@ -242,6 +242,6 @@ struct VMSettingsView_Previews: PreviewProvider {
|
||||||
@State static private var config = UTMQemuConfiguration()
|
@State static private var config = UTMQemuConfiguration()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VMSettingsView(vm: VMData(wrapping: UTMVirtualMachine()), config: config)
|
VMSettingsView(vm: VMData(from: .empty), config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,7 +82,7 @@ struct VMToolbarView: View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
Group {
|
Group {
|
||||||
Button {
|
Button {
|
||||||
if session.vm.state == .vmStarted {
|
if session.vm.state == .started {
|
||||||
state.alert = .powerDown
|
state.alert = .powerDown
|
||||||
} else {
|
} else {
|
||||||
state.alert = .terminateApp
|
state.alert = .terminateApp
|
||||||
|
|
|
@ -57,7 +57,7 @@ struct VMWindowView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
if state.isBusy {
|
if state.isBusy {
|
||||||
Spinner(size: .large)
|
Spinner(size: .large)
|
||||||
} else if session.vmState == .vmPaused {
|
} else if session.vmState == .paused {
|
||||||
Button {
|
Button {
|
||||||
session.vm.requestVmResume()
|
session.vm.requestVmResume()
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -200,33 +200,31 @@ struct VMWindowView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func vmStateUpdated(from oldState: UTMVMState?, to vmState: UTMVMState) {
|
private func vmStateUpdated(from oldState: UTMVirtualMachineState?, to vmState: UTMVirtualMachineState) {
|
||||||
if oldState == .vmStarted {
|
if oldState == .started {
|
||||||
saveWindow()
|
saveWindow()
|
||||||
}
|
}
|
||||||
switch vmState {
|
switch vmState {
|
||||||
case .vmStopped, .vmPaused:
|
case .stopped, .paused:
|
||||||
withOptionalAnimation {
|
withOptionalAnimation {
|
||||||
state.isBusy = false
|
state.isBusy = false
|
||||||
state.isRunning = false
|
state.isRunning = false
|
||||||
}
|
}
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||||
if session.vmState == .vmStopped && session.fatalError == nil {
|
if session.vmState == .stopped && session.fatalError == nil {
|
||||||
session.stop()
|
session.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
|
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
|
||||||
withOptionalAnimation {
|
withOptionalAnimation {
|
||||||
state.isBusy = true
|
state.isBusy = true
|
||||||
state.isRunning = false
|
state.isRunning = false
|
||||||
}
|
}
|
||||||
case .vmStarted:
|
case .started:
|
||||||
withOptionalAnimation {
|
withOptionalAnimation {
|
||||||
state.isBusy = false
|
state.isBusy = false
|
||||||
state.isRunning = true
|
state.isRunning = true
|
||||||
}
|
}
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -95,11 +95,10 @@ fileprivate struct WizardWrapper: View {
|
||||||
let config = try await wizardState.generateConfig()
|
let config = try await wizardState.generateConfig()
|
||||||
if let qemuConfig = config.qemuConfig {
|
if let qemuConfig = config.qemuConfig {
|
||||||
let vm = try await data.create(config: qemuConfig)
|
let vm = try await data.create(config: qemuConfig)
|
||||||
let wrapped = await vm.wrapped as! UTMQemuVirtualMachine
|
|
||||||
if #available(iOS 15, *) {
|
if #available(iOS 15, *) {
|
||||||
// This is broken on iOS 14
|
// This is broken on iOS 14
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
wrapped.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
|
qemuConfig.qemu.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
guard let vmList = data?.vmWindows.keys else {
|
guard let vmList = data?.vmWindows.keys else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return vmList.contains(where: { $0.state == .vmStarted || ($0.state == .vmPaused && !$0.hasSaveState) })
|
return vmList.contains(where: { $0.wrapped?.state == .started || ($0.wrapped?.state == .paused && !$0.hasSuspendState) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -90,7 +90,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return .terminateLater
|
return .terminateLater
|
||||||
} else if vmList.allSatisfy({ $0.state == .vmStopped || $0.state == .vmPaused }) { // All VMs are stopped or suspended
|
} else if vmList.allSatisfy({ !$0.isLoaded || $0.wrapped?.state == .stopped || $0.wrapped?.state == .paused }) { // All VMs are stopped or suspended
|
||||||
return .terminateNow
|
return .terminateNow
|
||||||
} else { // There could be some VMs in other states (starting, pausing, etc.)
|
} else { // There could be some VMs in other states (starting, pausing, etc.)
|
||||||
return .terminateCancel
|
return .terminateCancel
|
||||||
|
|
|
@ -26,7 +26,7 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
var appleConfig: UTMAppleConfiguration! {
|
var appleConfig: UTMAppleConfiguration! {
|
||||||
vm?.config.appleConfig
|
appleVM?.config
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultTitle: String {
|
var defaultTitle: String {
|
||||||
|
@ -85,24 +85,21 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
|
||||||
startPauseToolbarItem.isEnabled = true
|
startPauseToolbarItem.isEnabled = true
|
||||||
resizeConsoleToolbarItem.isEnabled = false
|
resizeConsoleToolbarItem.isEnabled = false
|
||||||
if #available(macOS 12, *) {
|
if #available(macOS 12, *) {
|
||||||
isPowerForce = false
|
|
||||||
sharedFolderToolbarItem.isEnabled = appleConfig.system.boot.operatingSystem == .linux
|
sharedFolderToolbarItem.isEnabled = appleConfig.system.boot.operatingSystem == .linux
|
||||||
} else {
|
} else {
|
||||||
// stop() not available on macOS 11 for some reason
|
// stop() not available on macOS 11 for some reason
|
||||||
restartToolbarItem.isEnabled = false
|
restartToolbarItem.isEnabled = false
|
||||||
sharedFolderToolbarItem.isEnabled = false
|
sharedFolderToolbarItem.isEnabled = false
|
||||||
isPowerForce = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func enterSuspended(isBusy busy: Bool) {
|
override func enterSuspended(isBusy busy: Bool) {
|
||||||
isPowerForce = true
|
|
||||||
super.enterSuspended(isBusy: busy)
|
super.enterSuspended(isBusy: busy)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
|
override func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
|
||||||
super.virtualMachine(vm, didTransitionTo: state)
|
super.virtualMachine(vm, didTransitionToState: state)
|
||||||
if state == .vmStopped && isInstallSuccessful {
|
if state == .stopped && isInstallSuccessful {
|
||||||
isInstallSuccessful = false
|
isInstallSuccessful = false
|
||||||
vm.requestVmStart()
|
vm.requestVmStart()
|
||||||
}
|
}
|
||||||
|
@ -112,15 +109,6 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
|
||||||
// implement in subclass
|
// implement in subclass
|
||||||
}
|
}
|
||||||
|
|
||||||
override func stopButtonPressed(_ sender: Any) {
|
|
||||||
if isPowerForce {
|
|
||||||
super.stopButtonPressed(sender)
|
|
||||||
} else {
|
|
||||||
appleVM.requestVmStop(force: false)
|
|
||||||
isPowerForce = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func resizeConsoleButtonPressed(_ sender: Any) {
|
override func resizeConsoleButtonPressed(_ sender: Any) {
|
||||||
// implement in subclass
|
// implement in subclass
|
||||||
}
|
}
|
||||||
|
@ -144,6 +132,29 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
|
||||||
openShareMenu(sender)
|
openShareMenu(sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Installation progress
|
||||||
|
|
||||||
|
override func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.window!.subtitle = ""
|
||||||
|
if success {
|
||||||
|
// delete IPSW setting
|
||||||
|
self.enterSuspended(isBusy: true)
|
||||||
|
self.appleConfig.system.boot.macRecoveryIpswURL = nil
|
||||||
|
self.appleVM.registryEntry.macRecoveryIpsw = nil
|
||||||
|
self.isInstallSuccessful = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
|
||||||
|
Task { @MainActor in
|
||||||
|
let installationFormat = NSLocalizedString("Installation: %@", comment: "VMDisplayAppleWindowController")
|
||||||
|
let percentString = NumberFormatter.localizedString(from: progress as NSNumber, number: .percent)
|
||||||
|
self.window!.subtitle = String.localizedStringWithFormat(installationFormat, percentString)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 12, *)
|
@available(macOS 12, *)
|
||||||
|
@ -246,33 +257,10 @@ extension VMDisplayAppleWindowController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMDisplayAppleWindowController {
|
|
||||||
func virtualMachine(_ vm: UTMVirtualMachine, didCompleteInstallation success: Bool) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.window!.subtitle = ""
|
|
||||||
if success {
|
|
||||||
// delete IPSW setting
|
|
||||||
self.enterSuspended(isBusy: true)
|
|
||||||
self.appleConfig.system.boot.macRecoveryIpswURL = nil
|
|
||||||
self.appleVM.registryEntry.macRecoveryIpsw = nil
|
|
||||||
self.isInstallSuccessful = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func virtualMachine(_ vm: UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let installationFormat = NSLocalizedString("Installation: %@", comment: "VMDisplayAppleWindowController")
|
|
||||||
let percentString = NumberFormatter.localizedString(from: progress as NSNumber, number: .percent)
|
|
||||||
self.window!.subtitle = String.localizedStringWithFormat(installationFormat, percentString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VMDisplayAppleWindowController: UTMScreenshotProvider {
|
extension VMDisplayAppleWindowController: UTMScreenshotProvider {
|
||||||
var screenshot: CSScreenshot? {
|
var screenshot: PlatformImage? {
|
||||||
if let image = mainView?.image() {
|
if let image = mainView?.image() {
|
||||||
return CSScreenshot(image: image)
|
return image
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
var vmQemuConfig: UTMQemuConfiguration! {
|
var vmQemuConfig: UTMQemuConfiguration! {
|
||||||
vm?.config.qemuConfig
|
qemuVM?.config
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultTitle: String {
|
var defaultTitle: String {
|
||||||
|
@ -34,7 +34,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultSubtitle: String {
|
var defaultSubtitle: String {
|
||||||
if qemuVM.isRunningAsSnapshot {
|
if qemuVM.isRunningAsDisposible {
|
||||||
return NSLocalizedString("Disposable Mode", comment: "VMDisplayQemuDisplayController")
|
return NSLocalizedString("Disposable Mode", comment: "VMDisplayQemuDisplayController")
|
||||||
} else {
|
} else {
|
||||||
return ""
|
return ""
|
||||||
|
@ -68,7 +68,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
override var shouldSaveOnPause: Bool {
|
override var shouldSaveOnPause: Bool {
|
||||||
!qemuVM.isRunningAsSnapshot
|
!qemuVM.isRunningAsDisposible
|
||||||
}
|
}
|
||||||
|
|
||||||
override func enterLive() {
|
override func enterLive() {
|
||||||
|
@ -92,7 +92,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
override func enterSuspended(isBusy busy: Bool) {
|
override func enterSuspended(isBusy busy: Bool) {
|
||||||
if vm.state == .vmStopped {
|
if vm.state == .stopped {
|
||||||
connectedUsbDevices.removeAll()
|
connectedUsbDevices.removeAll()
|
||||||
allUsbDevices.removeAll()
|
allUsbDevices.removeAll()
|
||||||
if isSecondary {
|
if isSecondary {
|
||||||
|
@ -130,7 +130,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
|
||||||
} else {
|
} else {
|
||||||
let item = NSMenuItem()
|
let item = NSMenuItem()
|
||||||
item.title = NSLocalizedString("Install Windows Guest Tools…", comment: "VMDisplayWindowController")
|
item.title = NSLocalizedString("Install Windows Guest Tools…", comment: "VMDisplayWindowController")
|
||||||
item.isEnabled = !qemuVM.isGuestToolsInstallRequested
|
item.isEnabled = !vmQemuConfig.qemu.isGuestToolsInstallRequested
|
||||||
item.target = self
|
item.target = self
|
||||||
item.action = #selector(installWindowsGuestTools)
|
item.action = #selector(installWindowsGuestTools)
|
||||||
menu.addItem(item)
|
menu.addItem(item)
|
||||||
|
@ -227,7 +227,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor private func installWindowsGuestTools(sender: AnyObject) {
|
@MainActor private func installWindowsGuestTools(sender: AnyObject) {
|
||||||
qemuVM.isGuestToolsInstallRequested = true
|
vmQemuConfig.qemu.isGuestToolsInstallRequested = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -353,8 +353,8 @@ extension VMDisplayQemuWindowController: CSUSBManagerDelegate {
|
||||||
func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
|
func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
|
||||||
logger.debug("USB device attached: \(device)")
|
logger.debug("USB device attached: \(device)")
|
||||||
if !isNoUsbPrompt {
|
if !isNoUsbPrompt {
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
if self.window!.isKeyWindow && self.vm.state == .vmStarted {
|
if self.window!.isKeyWindow && self.vm.state == .started {
|
||||||
self.showConnectPrompt(for: device)
|
self.showConnectPrompt(for: device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -663,3 +663,12 @@ extension VMDisplayQemuWindowController {
|
||||||
return secondary
|
return secondary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Computer wakeup
|
||||||
|
extension VMDisplayQemuWindowController {
|
||||||
|
@objc override func didWake(_ notification: NSNotification) {
|
||||||
|
Task {
|
||||||
|
try? await qemuVM.guestAgent?.guestSetTime(NSDate.now.timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -142,10 +142,10 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController {
|
||||||
override func enterSuspended(isBusy busy: Bool) {
|
override func enterSuspended(isBusy busy: Bool) {
|
||||||
if !busy {
|
if !busy {
|
||||||
metalView.isHidden = true
|
metalView.isHidden = true
|
||||||
screenshotView.image = vm.screenshot?.image
|
screenshotView.image = vm.screenshot
|
||||||
screenshotView.isHidden = false
|
screenshotView.isHidden = false
|
||||||
}
|
}
|
||||||
if vm.state == .vmStopped {
|
if vm.state == .stopped {
|
||||||
vmDisplay = nil
|
vmDisplay = nil
|
||||||
vmInput = nil
|
vmInput = nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,7 +57,7 @@ class VMDisplayQemuTerminalWindowController: VMDisplayQemuWindowController, VMDi
|
||||||
}
|
}
|
||||||
|
|
||||||
override func enterSuspended(isBusy busy: Bool) {
|
override func enterSuspended(isBusy busy: Bool) {
|
||||||
if vm.state == .vmStopped {
|
if vm.state == .stopped {
|
||||||
vmSerialPort = nil
|
vmSerialPort = nil
|
||||||
}
|
}
|
||||||
super.enterSuspended(isBusy: busy)
|
super.enterSuspended(isBusy: busy)
|
||||||
|
|
|
@ -21,7 +21,7 @@ import SwiftUI
|
||||||
private let kVMDefaultResizeCmd = "stty cols $COLS rows $ROWS\\n"
|
private let kVMDefaultResizeCmd = "stty cols $COLS rows $ROWS\\n"
|
||||||
|
|
||||||
protocol VMDisplayTerminal {
|
protocol VMDisplayTerminal {
|
||||||
var vm: UTMVirtualMachine! { get }
|
var vm: (any UTMVirtualMachine)! { get }
|
||||||
var isOptionAsMetaKey: Bool { get }
|
var isOptionAsMetaKey: Bool { get }
|
||||||
@MainActor func setupTerminal(_ terminalView: TerminalView, using config: UTMConfigurationTerminal, id: Int, for window: NSWindow)
|
@MainActor func setupTerminal(_ terminalView: TerminalView, using config: UTMConfigurationTerminal, id: Int, for window: NSWindow)
|
||||||
func resizeCommand(for terminal: TerminalView, using config: UTMConfigurationTerminal) -> String
|
func resizeCommand(for terminal: TerminalView, using config: UTMConfigurationTerminal) -> String
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import IOKit.pwr_mgt
|
import IOKit.pwr_mgt
|
||||||
|
|
||||||
class VMDisplayWindowController: NSWindowController {
|
class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
|
||||||
|
|
||||||
@IBOutlet weak var displayView: NSView!
|
@IBOutlet weak var displayView: NSView!
|
||||||
@IBOutlet weak var screenshotView: NSImageView!
|
@IBOutlet weak var screenshotView: NSImageView!
|
||||||
|
@ -36,10 +36,9 @@ class VMDisplayWindowController: NSWindowController {
|
||||||
@IBOutlet weak var resizeConsoleToolbarItem: NSToolbarItem!
|
@IBOutlet weak var resizeConsoleToolbarItem: NSToolbarItem!
|
||||||
@IBOutlet weak var windowsToolbarItem: NSToolbarItem!
|
@IBOutlet weak var windowsToolbarItem: NSToolbarItem!
|
||||||
|
|
||||||
var isPowerForce: Bool = false
|
|
||||||
var shouldAutoStartVM: Bool = true
|
var shouldAutoStartVM: Bool = true
|
||||||
var shouldSaveOnPause: Bool { true }
|
var shouldSaveOnPause: Bool { true }
|
||||||
var vm: UTMVirtualMachine!
|
var vm: (any UTMVirtualMachine)!
|
||||||
var onClose: ((Notification) -> Void)?
|
var onClose: ((Notification) -> Void)?
|
||||||
private(set) var secondaryWindows: [VMDisplayWindowController] = []
|
private(set) var secondaryWindows: [VMDisplayWindowController] = []
|
||||||
private(set) weak var primaryWindow: VMDisplayWindowController?
|
private(set) weak var primaryWindow: VMDisplayWindowController?
|
||||||
|
@ -60,7 +59,7 @@ class VMDisplayWindowController: NSWindowController {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(vm: UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
|
convenience init(vm: any UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
|
||||||
self.init(window: nil)
|
self.init(window: nil)
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
self.onClose = onClose
|
self.onClose = onClose
|
||||||
|
@ -71,21 +70,25 @@ class VMDisplayWindowController: NSWindowController {
|
||||||
NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
|
NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func stopButtonPressed(_ sender: Any) {
|
private func stop(isKill: Bool = false) {
|
||||||
showConfirmAlert(NSLocalizedString("This may corrupt the VM and any unsaved changes will be lost. To quit safely, shut down from the guest.", comment: "VMDisplayWindowController")) {
|
showConfirmAlert(NSLocalizedString("This may corrupt the VM and any unsaved changes will be lost. To quit safely, shut down from the guest.", comment: "VMDisplayWindowController")) {
|
||||||
self.enterSuspended(isBusy: true) // early indicator
|
self.enterSuspended(isBusy: true) // early indicator
|
||||||
self.vm.requestVmDeleteState()
|
self.vm.requestVmDeleteState()
|
||||||
self.vm.requestVmStop(force: self.isPowerForce)
|
self.vm.requestVmStop(force: isKill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@IBAction func stopButtonPressed(_ sender: Any) {
|
||||||
|
stop(isKill: false)
|
||||||
|
}
|
||||||
|
|
||||||
@IBAction func startPauseButtonPressed(_ sender: Any) {
|
@IBAction func startPauseButtonPressed(_ sender: Any) {
|
||||||
enterSuspended(isBusy: true) // early indicator
|
enterSuspended(isBusy: true) // early indicator
|
||||||
if vm.state == .vmStarted {
|
if vm.state == .started {
|
||||||
vm.requestVmPause(save: shouldSaveOnPause)
|
vm.requestVmPause(save: shouldSaveOnPause)
|
||||||
} else if vm.state == .vmPaused {
|
} else if vm.state == .paused {
|
||||||
vm.requestVmResume()
|
vm.requestVmResume()
|
||||||
} else if vm.state == .vmStopped {
|
} else if vm.state == .stopped {
|
||||||
vm.requestVmStart()
|
vm.requestVmStart()
|
||||||
} else {
|
} else {
|
||||||
logger.error("Invalid state \(vm.state)")
|
logger.error("Invalid state \(vm.state)")
|
||||||
|
@ -122,7 +125,7 @@ class VMDisplayWindowController: NSWindowController {
|
||||||
window!.recalculateKeyViewLoop()
|
window!.recalculateKeyViewLoop()
|
||||||
setupStopButtonMenu()
|
setupStopButtonMenu()
|
||||||
|
|
||||||
if vm.state == .vmStopped {
|
if vm.state == .stopped {
|
||||||
enterSuspended(isBusy: false)
|
enterSuspended(isBusy: false)
|
||||||
} else {
|
} else {
|
||||||
enterLive()
|
enterLive()
|
||||||
|
@ -131,14 +134,14 @@ class VMDisplayWindowController: NSWindowController {
|
||||||
super.windowDidLoad()
|
super.windowDidLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func requestAutoStart() {
|
public func requestAutoStart(options: UTMVirtualMachineStartOptions = []) {
|
||||||
guard shouldAutoStartVM else {
|
guard shouldAutoStartVM else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
if (self.vm.state == .vmStopped) {
|
if (self.vm.state == .stopped) {
|
||||||
self.vm.requestVmStart()
|
self.vm.requestVmStart(options: options)
|
||||||
} else if (self.vm.state == .vmPaused) {
|
} else if (self.vm.state == .paused) {
|
||||||
self.vm.requestVmResume()
|
self.vm.requestVmResume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -171,7 +174,7 @@ class VMDisplayWindowController: NSWindowController {
|
||||||
func enterSuspended(isBusy busy: Bool) {
|
func enterSuspended(isBusy busy: Bool) {
|
||||||
overlayView.isHidden = false
|
overlayView.isHidden = false
|
||||||
let playDescription = NSLocalizedString("Play", comment: "VMDisplayWindowController")
|
let playDescription = NSLocalizedString("Play", comment: "VMDisplayWindowController")
|
||||||
let stopped = vm.state == .vmStopped
|
let stopped = vm.state == .stopped
|
||||||
startPauseToolbarItem.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: playDescription)
|
startPauseToolbarItem.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: playDescription)
|
||||||
startPauseToolbarItem.label = playDescription
|
startPauseToolbarItem.label = playDescription
|
||||||
if busy {
|
if busy {
|
||||||
|
@ -233,7 +236,39 @@ class VMDisplayWindowController: NSWindowController {
|
||||||
secondaryWindow.primaryWindow = self
|
secondaryWindow.primaryWindow = self
|
||||||
secondaryWindow.showWindow(self)
|
secondaryWindow.showWindow(self)
|
||||||
self.showWindow(self) // show primary window on top
|
self.showWindow(self) // show primary window on top
|
||||||
secondaryWindow.virtualMachine(vm, didTransitionTo: vm.state) // show correct starting state
|
secondaryWindow.virtualMachine(vm, didTransitionToState: vm.state) // show correct starting state
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Virtual machine delegate
|
||||||
|
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
|
||||||
|
switch state {
|
||||||
|
case .stopped, .paused:
|
||||||
|
enterSuspended(isBusy: false)
|
||||||
|
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
|
||||||
|
enterSuspended(isBusy: true)
|
||||||
|
case .started:
|
||||||
|
enterLive()
|
||||||
|
}
|
||||||
|
for subwindow in secondaryWindows {
|
||||||
|
subwindow.virtualMachine(vm, didTransitionToState: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
||||||
|
showErrorAlert(message) { _ in
|
||||||
|
if vm.state != .started && vm.state != .paused {
|
||||||
|
self.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -246,7 +281,7 @@ extension VMDisplayWindowController: NSWindowDelegate {
|
||||||
guard !isSecondary else {
|
guard !isSecondary else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
guard !(vm.state == .vmStopped || (vm.state == .vmPaused && vm.hasSaveState)) else {
|
guard !(vm.state == .stopped || (vm.state == .paused && vm.registryEntry.hasSaveState)) else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
guard !isNoQuitConfirmation else {
|
guard !isNoQuitConfirmation else {
|
||||||
|
@ -324,12 +359,14 @@ extension VMDisplayWindowController {
|
||||||
item2.target = self
|
item2.target = self
|
||||||
item2.action = #selector(forceShutDown)
|
item2.action = #selector(forceShutDown)
|
||||||
menu.addItem(item2)
|
menu.addItem(item2)
|
||||||
let item3 = NSMenuItem()
|
if type(of: vm).capabilities.supportsProcessKill {
|
||||||
item3.title = NSLocalizedString("Force kill", comment: "VMDisplayWindowController")
|
let item3 = NSMenuItem()
|
||||||
item3.toolTip = NSLocalizedString("Force kill the VM process with high risk of data corruption.", comment: "VMDisplayWindowController")
|
item3.title = NSLocalizedString("Force kill", comment: "VMDisplayWindowController")
|
||||||
item3.target = self
|
item3.toolTip = NSLocalizedString("Force kill the VM process with high risk of data corruption.", comment: "VMDisplayWindowController")
|
||||||
item3.action = #selector(forceKill)
|
item3.target = self
|
||||||
menu.addItem(item3)
|
item3.action = #selector(forceKill)
|
||||||
|
menu.addItem(item3)
|
||||||
|
}
|
||||||
stopToolbarItem.menu = menu
|
stopToolbarItem.menu = menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -338,55 +375,17 @@ extension VMDisplayWindowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor @objc private func forceShutDown(sender: AnyObject) {
|
@MainActor @objc private func forceShutDown(sender: AnyObject) {
|
||||||
let prev = isPowerForce
|
stop()
|
||||||
isPowerForce = false
|
|
||||||
stopButtonPressed(sender)
|
|
||||||
isPowerForce = prev
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor @objc private func forceKill(sender: AnyObject) {
|
@MainActor @objc private func forceKill(sender: AnyObject) {
|
||||||
let prev = isPowerForce
|
stop(isKill: true)
|
||||||
isPowerForce = true
|
|
||||||
stopButtonPressed(sender)
|
|
||||||
isPowerForce = prev
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VM Delegate
|
|
||||||
|
|
||||||
extension VMDisplayWindowController: UTMVirtualMachineDelegate {
|
|
||||||
func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
|
|
||||||
switch state {
|
|
||||||
case .vmStopped, .vmPaused:
|
|
||||||
enterSuspended(isBusy: false)
|
|
||||||
case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
|
|
||||||
enterSuspended(isBusy: true)
|
|
||||||
case .vmStarted:
|
|
||||||
enterLive()
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for subwindow in secondaryWindows {
|
|
||||||
subwindow.virtualMachine(vm, didTransitionTo: state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
|
|
||||||
showErrorAlert(message) { _ in
|
|
||||||
if vm.state != .vmStarted && vm.state != .vmPaused {
|
|
||||||
self.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computer wakeup
|
// MARK: - Computer wakeup
|
||||||
extension VMDisplayWindowController {
|
extension VMDisplayWindowController {
|
||||||
@objc private func didWake(_ notification: NSNotification) {
|
@objc func didWake(_ notification: NSNotification) {
|
||||||
if let qemuVM = vm as? UTMQemuVirtualMachine {
|
// do something in subclass
|
||||||
Task {
|
|
||||||
try? await qemuVM.guestAgent?.guestSetTime(NSDate.now.timeIntervalSince1970)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,33 +19,30 @@ import Carbon.HIToolbox
|
||||||
|
|
||||||
@available(macOS 11, *)
|
@available(macOS 11, *)
|
||||||
extension UTMData {
|
extension UTMData {
|
||||||
func run(vm: VMData, startImmediately: Bool = true) {
|
func run(vm: VMData, options: UTMVirtualMachineStartOptions = [], startImmediately: Bool = true) {
|
||||||
guard let vm = vm.wrapped else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var window: Any? = vmWindows[vm]
|
var window: Any? = vmWindows[vm]
|
||||||
if window == nil {
|
if window == nil {
|
||||||
let close = { (notification: Notification) -> Void in
|
let close = { (notification: Notification) -> Void in
|
||||||
self.vmWindows.removeValue(forKey: vm)
|
self.vmWindows.removeValue(forKey: vm)
|
||||||
window = nil
|
window = nil
|
||||||
}
|
}
|
||||||
if let avm = vm as? UTMAppleVirtualMachine {
|
if let avm = vm.wrapped as? UTMAppleVirtualMachine {
|
||||||
if avm.appleConfig.system.architecture == UTMAppleConfigurationSystem.currentArchitecture {
|
if avm.config.system.architecture == UTMAppleConfigurationSystem.currentArchitecture {
|
||||||
let primarySerialIndex = avm.appleConfig.serials.firstIndex { $0.mode == .builtin }
|
let primarySerialIndex = avm.config.serials.firstIndex { $0.mode == .builtin }
|
||||||
if let primarySerialIndex = primarySerialIndex {
|
if let primarySerialIndex = primarySerialIndex {
|
||||||
window = VMDisplayAppleTerminalWindowController(primaryForIndex: primarySerialIndex, vm: avm, onClose: close)
|
window = VMDisplayAppleTerminalWindowController(primaryForIndex: primarySerialIndex, vm: avm, onClose: close)
|
||||||
}
|
}
|
||||||
if #available(macOS 12, *), !avm.appleConfig.displays.isEmpty {
|
if #available(macOS 12, *), !avm.config.displays.isEmpty {
|
||||||
window = VMDisplayAppleDisplayWindowController(vm: vm, onClose: close)
|
window = VMDisplayAppleDisplayWindowController(vm: avm, onClose: close)
|
||||||
} else if avm.appleConfig.displays.isEmpty && window == nil {
|
} else if avm.config.displays.isEmpty && window == nil {
|
||||||
window = VMHeadlessSessionState(for: avm, onStop: close)
|
window = VMHeadlessSessionState(for: avm, onStop: close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let qvm = vm as? UTMQemuVirtualMachine {
|
if let qvm = vm.wrapped as? UTMQemuVirtualMachine {
|
||||||
if qvm.config.qemuHasDisplay {
|
if !qvm.config.displays.isEmpty {
|
||||||
window = VMDisplayQemuMetalWindowController(vm: qvm, onClose: close)
|
window = VMDisplayQemuMetalWindowController(vm: qvm, onClose: close)
|
||||||
} else if qvm.config.qemuHasTerminal {
|
} else if !qvm.config.serials.filter({ $0.mode == .builtin }).isEmpty {
|
||||||
window = VMDisplayQemuTerminalWindowController(vm: qvm, onClose: close)
|
window = VMDisplayQemuTerminalWindowController(vm: qvm, onClose: close)
|
||||||
} else {
|
} else {
|
||||||
window = VMHeadlessSessionState(for: qvm, onStop: close)
|
window = VMHeadlessSessionState(for: qvm, onStop: close)
|
||||||
|
@ -59,19 +56,19 @@ extension UTMData {
|
||||||
}
|
}
|
||||||
if let unwrappedWindow = window as? VMDisplayWindowController {
|
if let unwrappedWindow = window as? VMDisplayWindowController {
|
||||||
vmWindows[vm] = unwrappedWindow
|
vmWindows[vm] = unwrappedWindow
|
||||||
vm.delegate = unwrappedWindow
|
vm.wrapped!.delegate = unwrappedWindow
|
||||||
unwrappedWindow.showWindow(nil)
|
unwrappedWindow.showWindow(nil)
|
||||||
unwrappedWindow.window!.makeMain()
|
unwrappedWindow.window!.makeMain()
|
||||||
if startImmediately {
|
if startImmediately {
|
||||||
unwrappedWindow.requestAutoStart()
|
unwrappedWindow.requestAutoStart(options: options)
|
||||||
}
|
}
|
||||||
} else if let unwrappedWindow = window as? VMHeadlessSessionState {
|
} else if let unwrappedWindow = window as? VMHeadlessSessionState {
|
||||||
vmWindows[vm] = unwrappedWindow
|
vmWindows[vm] = unwrappedWindow
|
||||||
if startImmediately {
|
if startImmediately {
|
||||||
if vm.state == .vmPaused {
|
if vm.wrapped!.state == .paused {
|
||||||
vm.requestVmResume()
|
vm.wrapped!.requestVmResume()
|
||||||
} else {
|
} else {
|
||||||
vm.requestVmStart()
|
vm.wrapped!.requestVmStart(options: options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -83,26 +80,26 @@ extension UTMData {
|
||||||
guard let wrapped = vm.wrapped else {
|
guard let wrapped = vm.wrapped else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if wrapped.hasSaveState {
|
Task {
|
||||||
wrapped.requestVmDeleteState()
|
if wrapped.registryEntry.isSuspended {
|
||||||
|
try? await wrapped.deleteSnapshot(name: nil)
|
||||||
|
}
|
||||||
|
try? await wrapped.stop(usingMethod: .force)
|
||||||
|
await MainActor.run {
|
||||||
|
self.close(vm: vm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wrapped.vmStop(force: false, completion: { _ in
|
|
||||||
self.close(vm: vm)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func close(vm: VMData) {
|
func close(vm: VMData) {
|
||||||
guard let wrapped = vm.wrapped else {
|
if let window = vmWindows.removeValue(forKey: vm) as? VMDisplayWindowController {
|
||||||
return
|
|
||||||
}
|
|
||||||
if let window = vmWindows.removeValue(forKey: wrapped) as? VMDisplayWindowController {
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
window.close()
|
window.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func trySendTextSpice(vm: UTMVirtualMachine, text: String) {
|
func trySendTextSpice(vm: VMData, text: String) {
|
||||||
guard text.count > 0 else { return }
|
guard text.count > 0 else { return }
|
||||||
if let vc = vmWindows[vm] as? VMDisplayQemuMetalWindowController {
|
if let vc = vmWindows[vm] as? VMDisplayQemuMetalWindowController {
|
||||||
KeyCodeMap.createKeyMapIfNeeded()
|
KeyCodeMap.createKeyMapIfNeeded()
|
||||||
|
@ -197,7 +194,7 @@ extension UTMData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryClickAtPoint(vm: UTMQemuVirtualMachine, point: CGPoint, button: CSInputButton) {
|
func tryClickAtPoint(vm: VMData, point: CGPoint, button: CSInputButton) {
|
||||||
if let vc = vmWindows[vm] as? VMDisplayQemuMetalWindowController {
|
if let vc = vmWindows[vm] as? VMDisplayQemuMetalWindowController {
|
||||||
vc.mouseMove(absolutePoint: point, button: [])
|
vc.mouseMove(absolutePoint: point, button: [])
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
|
||||||
|
|
|
@ -66,7 +66,7 @@ private struct VMMenuItem: View {
|
||||||
data.stop(vm: vm)
|
data.stop(vm: vm)
|
||||||
}
|
}
|
||||||
Button("Suspend") {
|
Button("Suspend") {
|
||||||
let isSnapshot = (vm.wrapped as? UTMQemuVirtualMachine)?.isRunningAsSnapshot ?? false
|
let isSnapshot = (vm.wrapped as? UTMQemuVirtualMachine)?.isRunningAsDisposible ?? false
|
||||||
vm.wrapped!.requestVmPause(save: !isSnapshot)
|
vm.wrapped!.requestVmPause(save: !isSnapshot)
|
||||||
}
|
}
|
||||||
Button("Reset") {
|
Button("Reset") {
|
||||||
|
|
|
@ -22,7 +22,7 @@ struct VMAppleRemovableDrivesView: View {
|
||||||
case diskImage
|
case diskImage
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObservedObject var vm: UTMAppleVirtualMachine
|
@ObservedObject var vm: VMData
|
||||||
@ObservedObject var config: UTMAppleConfiguration
|
@ObservedObject var config: UTMAppleConfiguration
|
||||||
@ObservedObject var registryEntry: UTMRegistryEntry
|
@ObservedObject var registryEntry: UTMRegistryEntry
|
||||||
@EnvironmentObject private var data: UTMData
|
@EnvironmentObject private var data: UTMData
|
||||||
|
@ -33,6 +33,10 @@ struct VMAppleRemovableDrivesView: View {
|
||||||
/// Explanation see "SwiftUI FileImporter modal bug" in `showFileImporter`
|
/// Explanation see "SwiftUI FileImporter modal bug" in `showFileImporter`
|
||||||
@State private var workaroundFileImporterBug: Bool = false
|
@State private var workaroundFileImporterBug: Bool = false
|
||||||
|
|
||||||
|
private var appleVM: UTMAppleVirtualMachine! {
|
||||||
|
vm.wrapped as? UTMAppleVirtualMachine
|
||||||
|
}
|
||||||
|
|
||||||
private var hasSharingFeatures: Bool {
|
private var hasSharingFeatures: Bool {
|
||||||
if #available(macOS 13, *) {
|
if #available(macOS 13, *) {
|
||||||
return true
|
return true
|
||||||
|
@ -91,7 +95,7 @@ struct VMAppleRemovableDrivesView: View {
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("External Drive", systemImage: "externaldrive")
|
Label("External Drive", systemImage: "externaldrive")
|
||||||
}.disabled(vm.hasSaveState || vm.state != .vmStopped)
|
}.disabled(vm.hasSuspendState || vm.state != .stopped)
|
||||||
} else {
|
} else {
|
||||||
Label("\(diskImage.sizeString) Drive", systemImage: "internaldrive")
|
Label("\(diskImage.sizeString) Drive", systemImage: "internaldrive")
|
||||||
}
|
}
|
||||||
|
@ -186,7 +190,7 @@ struct VMAppleRemovableDrivesView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteShareDirectory(_ sharedDirectory: UTMRegistryEntry.File) {
|
private func deleteShareDirectory(_ sharedDirectory: UTMRegistryEntry.File) {
|
||||||
vm.registryEntry.sharedDirectories.removeAll { existing in
|
appleVM.registryEntry.sharedDirectories.removeAll { existing in
|
||||||
existing.url == sharedDirectory.url
|
existing.url == sharedDirectory.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -205,10 +209,10 @@ struct VMAppleRemovableDrivesView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VMAppleRemovableDrivesView_Previews: PreviewProvider {
|
struct VMAppleRemovableDrivesView_Previews: PreviewProvider {
|
||||||
@StateObject static var vm = UTMAppleVirtualMachine()
|
@StateObject static var vm = VMData(from: .empty)
|
||||||
@StateObject static var config = UTMAppleConfiguration()
|
@StateObject static var config = UTMAppleConfiguration()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VMAppleRemovableDrivesView(vm: vm, config: config, registryEntry: vm.registryEntry)
|
VMAppleRemovableDrivesView(vm: vm, config: config, registryEntry: vm.registryEntry!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,10 +19,10 @@ import IOKit.pwr_mgt
|
||||||
|
|
||||||
/// Represents the UI state for a single headless VM session.
|
/// Represents the UI state for a single headless VM session.
|
||||||
@MainActor class VMHeadlessSessionState: NSObject, ObservableObject {
|
@MainActor class VMHeadlessSessionState: NSObject, ObservableObject {
|
||||||
let vm: UTMVirtualMachine
|
let vm: any UTMVirtualMachine
|
||||||
var onStop: ((Notification) -> Void)?
|
var onStop: ((Notification) -> Void)?
|
||||||
|
|
||||||
@Published var vmState: UTMVMState = .vmStopped
|
@Published var vmState: UTMVirtualMachineState = .stopped
|
||||||
|
|
||||||
@Published var fatalError: String?
|
@Published var fatalError: String?
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ import IOKit.pwr_mgt
|
||||||
|
|
||||||
@Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
|
@Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
|
||||||
|
|
||||||
init(for vm: UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
|
init(for vm: any UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
self.onStop = onStop
|
self.onStop = onStop
|
||||||
super.init()
|
super.init()
|
||||||
|
@ -45,14 +45,14 @@ import IOKit.pwr_mgt
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
|
extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
|
||||||
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
vmState = state
|
vmState = state
|
||||||
if state == .vmStarted {
|
if state == .started {
|
||||||
hasStarted = true
|
hasStarted = true
|
||||||
didStart()
|
didStart()
|
||||||
}
|
}
|
||||||
if state == .vmStopped {
|
if state == .stopped {
|
||||||
if hasStarted {
|
if hasStarted {
|
||||||
didStop() // graceful exit
|
didStop() // graceful exit
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,7 @@ extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
fatalError = message
|
fatalError = message
|
||||||
NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
|
NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
|
||||||
|
@ -71,6 +71,14 @@ extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMHeadlessSessionState {
|
extension VMHeadlessSessionState {
|
||||||
|
|
|
@ -98,9 +98,8 @@ struct VMWizardView: View {
|
||||||
#endif
|
#endif
|
||||||
if let qemuConfig = config.qemuConfig {
|
if let qemuConfig = config.qemuConfig {
|
||||||
let vm = try await data.create(config: qemuConfig)
|
let vm = try await data.create(config: qemuConfig)
|
||||||
let wrapped = await vm.wrapped as! UTMQemuVirtualMachine
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
wrapped.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
|
qemuConfig.qemu.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
|
||||||
}
|
}
|
||||||
} else if let appleConfig = config.appleConfig {
|
} else if let appleConfig = config.appleConfig {
|
||||||
_ = try await data.create(config: appleConfig)
|
_ = try await data.create(config: appleConfig)
|
||||||
|
|
|
@ -18,7 +18,7 @@ import Foundation
|
||||||
|
|
||||||
@objc extension UTMScriptingVirtualMachineImpl {
|
@objc extension UTMScriptingVirtualMachineImpl {
|
||||||
@objc var configuration: [AnyHashable : Any] {
|
@objc var configuration: [AnyHashable : Any] {
|
||||||
let wrapper = UTMScriptingConfigImpl(vm.config.wrappedValue as! any UTMConfiguration, data: data)
|
let wrapper = UTMScriptingConfigImpl(vm.config, data: data)
|
||||||
return wrapper.serializeConfiguration()
|
return wrapper.serializeConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,10 +28,10 @@ import Foundation
|
||||||
guard let newConfiguration = newConfiguration else {
|
guard let newConfiguration = newConfiguration else {
|
||||||
throw ScriptingError.invalidParameter
|
throw ScriptingError.invalidParameter
|
||||||
}
|
}
|
||||||
guard vm.state == .vmStopped else {
|
guard vm.state == .stopped else {
|
||||||
throw ScriptingError.notStopped
|
throw ScriptingError.notStopped
|
||||||
}
|
}
|
||||||
let wrapper = UTMScriptingConfigImpl(vm.config.wrappedValue as! any UTMConfiguration)
|
let wrapper = UTMScriptingConfigImpl(vm.config)
|
||||||
try wrapper.updateConfiguration(from: newConfiguration)
|
try wrapper.updateConfiguration(from: newConfiguration)
|
||||||
try await data.save(vm: box)
|
try await data.save(vm: box)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,7 @@ import QEMUKitInternal
|
||||||
class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
@nonobjc var box: VMData
|
@nonobjc var box: VMData
|
||||||
@nonobjc var data: UTMData
|
@nonobjc var data: UTMData
|
||||||
@nonobjc var vm: UTMVirtualMachine! {
|
@nonobjc var vm: (any UTMVirtualMachine)! {
|
||||||
box.wrapped
|
box.wrapped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,23 +31,23 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc var name: String {
|
@objc var name: String {
|
||||||
vm.detailsTitleLabel
|
box.detailsTitleLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc var notes: String {
|
@objc var notes: String {
|
||||||
vm.detailsNotes ?? ""
|
box.detailsNotes ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc var machine: String {
|
@objc var machine: String {
|
||||||
vm.detailsSystemTargetLabel
|
box.detailsSystemTargetLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc var architecture: String {
|
@objc var architecture: String {
|
||||||
vm.detailsSystemArchitectureLabel
|
box.detailsSystemArchitectureLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc var memory: String {
|
@objc var memory: String {
|
||||||
vm.detailsSystemMemoryLabel
|
box.detailsSystemMemoryLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc var backend: UTMScriptingBackend {
|
@objc var backend: UTMScriptingBackend {
|
||||||
|
@ -62,21 +62,22 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
|
|
||||||
@objc var status: UTMScriptingStatus {
|
@objc var status: UTMScriptingStatus {
|
||||||
switch vm.state {
|
switch vm.state {
|
||||||
case .vmStopped: return .stopped
|
case .stopped: return .stopped
|
||||||
case .vmStarting: return .starting
|
case .starting: return .starting
|
||||||
case .vmStarted: return .started
|
case .started: return .started
|
||||||
case .vmPausing: return .pausing
|
case .pausing: return .pausing
|
||||||
case .vmPaused: return .paused
|
case .paused: return .paused
|
||||||
case .vmResuming: return .resuming
|
case .resuming: return .resuming
|
||||||
case .vmStopping: return .stopping
|
case .stopping: return .stopping
|
||||||
@unknown default: return .stopped
|
case .saving: return .pausing // FIXME: new entries
|
||||||
|
case .restoring: return .resuming // FIXME: new entries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc var serialPorts: [UTMScriptingSerialPortImpl] {
|
@objc var serialPorts: [UTMScriptingSerialPortImpl] {
|
||||||
if let config = vm.config.qemuConfig {
|
if let config = vm.config as? UTMQemuConfiguration {
|
||||||
return config.serials.indices.map({ UTMScriptingSerialPortImpl(qemuSerial: config.serials[$0], parent: self, index: $0) })
|
return config.serials.indices.map({ UTMScriptingSerialPortImpl(qemuSerial: config.serials[$0], parent: self, index: $0) })
|
||||||
} else if let config = vm.config.appleConfig {
|
} else if let config = vm.config as? UTMAppleConfiguration {
|
||||||
return config.serials.indices.map({ UTMScriptingSerialPortImpl(appleSerial: config.serials[$0], parent: self, index: $0) })
|
return config.serials.indices.map({ UTMScriptingSerialPortImpl(appleSerial: config.serials[$0], parent: self, index: $0) })
|
||||||
} else {
|
} else {
|
||||||
return []
|
return []
|
||||||
|
@ -106,16 +107,15 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? true
|
let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? true
|
||||||
withScriptCommand(command) { [self] in
|
withScriptCommand(command) { [self] in
|
||||||
if !shouldSaveState {
|
if !shouldSaveState {
|
||||||
guard let vm = vm as? UTMQemuVirtualMachine else {
|
guard type(of: vm).capabilities.supportsDisposibleMode else {
|
||||||
throw ScriptingError.operationNotSupported
|
throw ScriptingError.operationNotSupported
|
||||||
}
|
}
|
||||||
vm.isRunningAsSnapshot = true
|
|
||||||
}
|
}
|
||||||
data.run(vm: box, startImmediately: false)
|
data.run(vm: box, startImmediately: false)
|
||||||
if vm.state == .vmStopped {
|
if vm.state == .stopped {
|
||||||
try await vm.vmStart()
|
try await vm.start(options: shouldSaveState ? [] : .bootDisposibleMode)
|
||||||
} else if vm.state == .vmPaused {
|
} else if vm.state == .paused {
|
||||||
try await vm.vmResume()
|
try await vm.resume()
|
||||||
} else {
|
} else {
|
||||||
throw ScriptingError.operationNotAvailable
|
throw ScriptingError.operationNotAvailable
|
||||||
}
|
}
|
||||||
|
@ -125,10 +125,13 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
@objc func suspend(_ command: NSScriptCommand) {
|
@objc func suspend(_ command: NSScriptCommand) {
|
||||||
let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? false
|
let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? false
|
||||||
withScriptCommand(command) { [self] in
|
withScriptCommand(command) { [self] in
|
||||||
guard vm.state == .vmStarted else {
|
guard vm.state == .started else {
|
||||||
throw ScriptingError.notRunning
|
throw ScriptingError.notRunning
|
||||||
}
|
}
|
||||||
try await vm.vmPause(save: shouldSaveState)
|
try await vm.pause()
|
||||||
|
if shouldSaveState {
|
||||||
|
try await vm.saveSnapshot(name: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,23 +143,23 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
stopMethod = .force
|
stopMethod = .force
|
||||||
}
|
}
|
||||||
withScriptCommand(command) { [self] in
|
withScriptCommand(command) { [self] in
|
||||||
guard vm.state == .vmStarted || stopMethod == .kill else {
|
guard vm.state == .started || stopMethod == .kill else {
|
||||||
throw ScriptingError.notRunning
|
throw ScriptingError.notRunning
|
||||||
}
|
}
|
||||||
switch stopMethod {
|
switch stopMethod {
|
||||||
case .force:
|
case .force:
|
||||||
try await vm.vmStop(force: false)
|
try await vm.stop(usingMethod: .force)
|
||||||
case .kill:
|
case .kill:
|
||||||
try await vm.vmStop(force: true)
|
try await vm.stop(usingMethod: .kill)
|
||||||
case .request:
|
case .request:
|
||||||
vm.requestGuestPowerDown()
|
try await vm.stop(usingMethod: .request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func delete(_ command: NSDeleteCommand) {
|
@objc func delete(_ command: NSDeleteCommand) {
|
||||||
withScriptCommand(command) { [self] in
|
withScriptCommand(command) { [self] in
|
||||||
guard vm.state == .vmStopped else {
|
guard vm.state == .stopped else {
|
||||||
throw ScriptingError.notStopped
|
throw ScriptingError.notStopped
|
||||||
}
|
}
|
||||||
try await data.delete(vm: box, alsoRegistry: true)
|
try await data.delete(vm: box, alsoRegistry: true)
|
||||||
|
@ -166,7 +169,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
@objc func clone(_ command: NSCloneCommand) {
|
@objc func clone(_ command: NSCloneCommand) {
|
||||||
let properties = command.evaluatedArguments?["WithProperties"] as? [AnyHashable : Any]
|
let properties = command.evaluatedArguments?["WithProperties"] as? [AnyHashable : Any]
|
||||||
withScriptCommand(command) { [self] in
|
withScriptCommand(command) { [self] in
|
||||||
guard vm.state == .vmStopped else {
|
guard vm.state == .stopped else {
|
||||||
throw ScriptingError.notStopped
|
throw ScriptingError.notStopped
|
||||||
}
|
}
|
||||||
let newVM = try await data.clone(vm: box)
|
let newVM = try await data.clone(vm: box)
|
||||||
|
@ -182,7 +185,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||||
// MARK: - Guest agent suite
|
// MARK: - Guest agent suite
|
||||||
@objc extension UTMScriptingVirtualMachineImpl {
|
@objc extension UTMScriptingVirtualMachineImpl {
|
||||||
@nonobjc private func withGuestAgent<Result>(_ block: (QEMUGuestAgent) async throws -> Result) async throws -> Result {
|
@nonobjc private func withGuestAgent<Result>(_ block: (QEMUGuestAgent) async throws -> Result) async throws -> Result {
|
||||||
guard vm.state == .vmStarted else {
|
guard vm.state == .started else {
|
||||||
throw ScriptingError.notRunning
|
throw ScriptingError.notRunning
|
||||||
}
|
}
|
||||||
guard let vm = vm as? UTMQemuVirtualMachine else {
|
guard let vm = vm as? UTMQemuVirtualMachine else {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 52;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
@ -197,9 +197,6 @@
|
||||||
848F71EC277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
|
848F71EC277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
|
||||||
848F71ED277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
|
848F71ED277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
|
||||||
848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
|
848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
|
||||||
84909A8927CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8827CABA54005605F1 /* UTMWrappedVirtualMachine.swift */; };
|
|
||||||
84909A8A27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8827CABA54005605F1 /* UTMWrappedVirtualMachine.swift */; };
|
|
||||||
84909A8B27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8827CABA54005605F1 /* UTMWrappedVirtualMachine.swift */; };
|
|
||||||
84909A8D27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
|
84909A8D27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
|
||||||
84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
|
84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
|
||||||
84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
|
84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
|
||||||
|
@ -329,7 +326,6 @@
|
||||||
CE0B6CFB24AD568400FE012D /* UTMLegacyQemuConfiguration+Networking.m in Sources */ = {isa = PBXBuildFile; fileRef = CEA02A982436C7A30087E45F /* UTMLegacyQemuConfiguration+Networking.m */; };
|
CE0B6CFB24AD568400FE012D /* UTMLegacyQemuConfiguration+Networking.m in Sources */ = {isa = PBXBuildFile; fileRef = CEA02A982436C7A30087E45F /* UTMLegacyQemuConfiguration+Networking.m */; };
|
||||||
CE0B6CFC24AD568400FE012D /* UTMLegacyQemuConfigurationPortForward.m in Sources */ = {isa = PBXBuildFile; fileRef = CE54252D2436E48D00E520F7 /* UTMLegacyQemuConfigurationPortForward.m */; };
|
CE0B6CFC24AD568400FE012D /* UTMLegacyQemuConfigurationPortForward.m in Sources */ = {isa = PBXBuildFile; fileRef = CE54252D2436E48D00E520F7 /* UTMLegacyQemuConfigurationPortForward.m */; };
|
||||||
CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
|
CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
|
||||||
CE0B6D0024AD56AE00FE012D /* UTMVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5F165B2261395000F3D56B /* UTMVirtualMachine.m */; };
|
|
||||||
CE0B6D0224AD56AE00FE012D /* UTMQemu.m in Sources */ = {isa = PBXBuildFile; fileRef = CE9D197B226542FE00355E14 /* UTMQemu.m */; };
|
CE0B6D0224AD56AE00FE012D /* UTMQemu.m in Sources */ = {isa = PBXBuildFile; fileRef = CE9D197B226542FE00355E14 /* UTMQemu.m */; };
|
||||||
CE0B6D0424AD56AE00FE012D /* UTMSpiceIO.m in Sources */ = {isa = PBXBuildFile; fileRef = E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */; };
|
CE0B6D0424AD56AE00FE012D /* UTMSpiceIO.m in Sources */ = {isa = PBXBuildFile; fileRef = E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */; };
|
||||||
CE0B6EBB24AD677200FE012D /* libgstgio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CE9D19622265425A00355E14 /* libgstgio.a */; };
|
CE0B6EBB24AD677200FE012D /* libgstgio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CE9D19622265425A00355E14 /* libgstgio.a */; };
|
||||||
|
@ -440,7 +436,6 @@
|
||||||
CE2D92AA24AD46670059923A /* UTMSpiceIO.m in Sources */ = {isa = PBXBuildFile; fileRef = E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */; };
|
CE2D92AA24AD46670059923A /* UTMSpiceIO.m in Sources */ = {isa = PBXBuildFile; fileRef = E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */; };
|
||||||
CE2D92BC24AD46670059923A /* UTMLegacyQemuConfiguration+Drives.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5425302437C09C00E520F7 /* UTMLegacyQemuConfiguration+Drives.m */; };
|
CE2D92BC24AD46670059923A /* UTMLegacyQemuConfiguration+Drives.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5425302437C09C00E520F7 /* UTMLegacyQemuConfiguration+Drives.m */; };
|
||||||
CE2D92CB24AD46670059923A /* VMDisplayMetalViewController+Gamepad.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC8F2437488E007E6CBC /* VMDisplayMetalViewController+Gamepad.m */; };
|
CE2D92CB24AD46670059923A /* VMDisplayMetalViewController+Gamepad.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC8F2437488E007E6CBC /* VMDisplayMetalViewController+Gamepad.m */; };
|
||||||
CE2D92D224AD46670059923A /* UTMVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5F165B2261395000F3D56B /* UTMVirtualMachine.m */; };
|
|
||||||
CE2D92D724AD46670059923A /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
|
CE2D92D724AD46670059923A /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
|
||||||
CE2D92DA24AD46670059923A /* VMCursor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD692411C661002D6A5F /* VMCursor.m */; };
|
CE2D92DA24AD46670059923A /* VMCursor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD692411C661002D6A5F /* VMCursor.m */; };
|
||||||
CE2D92E624AD46670059923A /* UTMLegacyQemuConfiguration+Networking.m in Sources */ = {isa = PBXBuildFile; fileRef = CEA02A982436C7A30087E45F /* UTMLegacyQemuConfiguration+Networking.m */; };
|
CE2D92E624AD46670059923A /* UTMLegacyQemuConfiguration+Networking.m in Sources */ = {isa = PBXBuildFile; fileRef = CEA02A982436C7A30087E45F /* UTMLegacyQemuConfiguration+Networking.m */; };
|
||||||
|
@ -675,7 +670,6 @@
|
||||||
CEA45EA3263519B5002FA97D /* VMConfigSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954724AD4F980059923A /* VMConfigSharingView.swift */; };
|
CEA45EA3263519B5002FA97D /* VMConfigSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954724AD4F980059923A /* VMConfigSharingView.swift */; };
|
||||||
CEA45EA5263519B5002FA97D /* VMConfigInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954824AD4F980059923A /* VMConfigInputView.swift */; };
|
CEA45EA5263519B5002FA97D /* VMConfigInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954824AD4F980059923A /* VMConfigInputView.swift */; };
|
||||||
CEA45EA8263519B5002FA97D /* VMDisplayMetalViewController+Gamepad.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC8F2437488E007E6CBC /* VMDisplayMetalViewController+Gamepad.m */; };
|
CEA45EA8263519B5002FA97D /* VMDisplayMetalViewController+Gamepad.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC8F2437488E007E6CBC /* VMDisplayMetalViewController+Gamepad.m */; };
|
||||||
CEA45EAE263519B5002FA97D /* UTMVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5F165B2261395000F3D56B /* UTMVirtualMachine.m */; };
|
|
||||||
CEA45EB3263519B5002FA97D /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
|
CEA45EB3263519B5002FA97D /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
|
||||||
CEA45EB7263519B5002FA97D /* VMToolbarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D953824AD4F980059923A /* VMToolbarModifier.swift */; };
|
CEA45EB7263519B5002FA97D /* VMToolbarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D953824AD4F980059923A /* VMToolbarModifier.swift */; };
|
||||||
CEA45EB9263519B5002FA97D /* VMCursor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD692411C661002D6A5F /* VMCursor.m */; };
|
CEA45EB9263519B5002FA97D /* VMCursor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD692411C661002D6A5F /* VMCursor.m */; };
|
||||||
|
@ -2888,7 +2882,6 @@
|
||||||
842B9F8D28CC58B700031EE7 /* UTMPatches.swift in Sources */,
|
842B9F8D28CC58B700031EE7 /* UTMPatches.swift in Sources */,
|
||||||
CE19392626DCB094005CEC17 /* RAMSlider.swift in Sources */,
|
CE19392626DCB094005CEC17 /* RAMSlider.swift in Sources */,
|
||||||
CE611BEB29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */,
|
CE611BEB29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */,
|
||||||
84909A8927CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */,
|
|
||||||
CEE7E936287CFDB100282049 /* UTMLegacyQemuConfiguration+Constants.m in Sources */,
|
CEE7E936287CFDB100282049 /* UTMLegacyQemuConfiguration+Constants.m in Sources */,
|
||||||
4B224B9D279D4D8100B63CFF /* InListButtonStyle.swift in Sources */,
|
4B224B9D279D4D8100B63CFF /* InListButtonStyle.swift in Sources */,
|
||||||
2C33B3A92566C9B100A954A6 /* VMContextMenuModifier.swift in Sources */,
|
2C33B3A92566C9B100A954A6 /* VMContextMenuModifier.swift in Sources */,
|
||||||
|
@ -2905,7 +2898,6 @@
|
||||||
84C60FB72681A41B00B58C00 /* VMToolbarView.swift in Sources */,
|
84C60FB72681A41B00B58C00 /* VMToolbarView.swift in Sources */,
|
||||||
CE2D92CB24AD46670059923A /* VMDisplayMetalViewController+Gamepad.m in Sources */,
|
CE2D92CB24AD46670059923A /* VMDisplayMetalViewController+Gamepad.m in Sources */,
|
||||||
CEF0307426A2B40B00667B63 /* VMWizardHardwareView.swift in Sources */,
|
CEF0307426A2B40B00667B63 /* VMWizardHardwareView.swift in Sources */,
|
||||||
CE2D92D224AD46670059923A /* UTMVirtualMachine.m in Sources */,
|
|
||||||
841E997528AA1191003C6CB6 /* UTMRegistry.swift in Sources */,
|
841E997528AA1191003C6CB6 /* UTMRegistry.swift in Sources */,
|
||||||
8401868F288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift in Sources */,
|
8401868F288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift in Sources */,
|
||||||
CE2D92D724AD46670059923A /* UTMLogging.m in Sources */,
|
CE2D92D724AD46670059923A /* UTMLogging.m in Sources */,
|
||||||
|
@ -2981,7 +2973,6 @@
|
||||||
files = (
|
files = (
|
||||||
CEB63A7724F4654400CAF323 /* Main.swift in Sources */,
|
CEB63A7724F4654400CAF323 /* Main.swift in Sources */,
|
||||||
84E3A91B2946D2590024A740 /* UTMMenuBarExtraScene.swift in Sources */,
|
84E3A91B2946D2590024A740 /* UTMMenuBarExtraScene.swift in Sources */,
|
||||||
CE0B6D0024AD56AE00FE012D /* UTMVirtualMachine.m in Sources */,
|
|
||||||
CEB63A7B24F469E300CAF323 /* UTMJailbreak.m in Sources */,
|
CEB63A7B24F469E300CAF323 /* UTMJailbreak.m in Sources */,
|
||||||
83A004BB26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */,
|
83A004BB26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */,
|
||||||
848A98B0286A0F74006F0550 /* UTMAppleConfiguration.swift in Sources */,
|
848A98B0286A0F74006F0550 /* UTMAppleConfiguration.swift in Sources */,
|
||||||
|
@ -3057,7 +3048,6 @@
|
||||||
8401FDA8269D4A4100265F0D /* VMConfigAppleSharingView.swift in Sources */,
|
8401FDA8269D4A4100265F0D /* VMConfigAppleSharingView.swift in Sources */,
|
||||||
CE0B6CFC24AD568400FE012D /* UTMLegacyQemuConfigurationPortForward.m in Sources */,
|
CE0B6CFC24AD568400FE012D /* UTMLegacyQemuConfigurationPortForward.m in Sources */,
|
||||||
CEF0306C26A2AFDF00667B63 /* VMWizardStartView.swift in Sources */,
|
CEF0306C26A2AFDF00667B63 /* VMWizardStartView.swift in Sources */,
|
||||||
84909A8B27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */,
|
|
||||||
843BF83628450C0B0029D60D /* UTMQemuConfigurationSound.swift in Sources */,
|
843BF83628450C0B0029D60D /* UTMQemuConfigurationSound.swift in Sources */,
|
||||||
848A98C6286F332D006F0550 /* UTMConfiguration.swift in Sources */,
|
848A98C6286F332D006F0550 /* UTMConfiguration.swift in Sources */,
|
||||||
CE8813D624CD265700532628 /* VMShareFileModifier.swift in Sources */,
|
CE8813D624CD265700532628 /* VMShareFileModifier.swift in Sources */,
|
||||||
|
@ -3244,7 +3234,6 @@
|
||||||
CEA45EA5263519B5002FA97D /* VMConfigInputView.swift in Sources */,
|
CEA45EA5263519B5002FA97D /* VMConfigInputView.swift in Sources */,
|
||||||
841E58CF28937FED00137A20 /* UTMMainView.swift in Sources */,
|
841E58CF28937FED00137A20 /* UTMMainView.swift in Sources */,
|
||||||
CEA45EA8263519B5002FA97D /* VMDisplayMetalViewController+Gamepad.m in Sources */,
|
CEA45EA8263519B5002FA97D /* VMDisplayMetalViewController+Gamepad.m in Sources */,
|
||||||
CEA45EAE263519B5002FA97D /* UTMVirtualMachine.m in Sources */,
|
|
||||||
848D99C12866D9CE0055C215 /* QEMUArgumentBuilder.swift in Sources */,
|
848D99C12866D9CE0055C215 /* QEMUArgumentBuilder.swift in Sources */,
|
||||||
CEA45EB3263519B5002FA97D /* UTMLogging.m in Sources */,
|
CEA45EB3263519B5002FA97D /* UTMLogging.m in Sources */,
|
||||||
848D99B928630A780055C215 /* VMConfigSerialView.swift in Sources */,
|
848D99B928630A780055C215 /* VMConfigSerialView.swift in Sources */,
|
||||||
|
@ -3273,7 +3262,6 @@
|
||||||
CE19392726DCB094005CEC17 /* RAMSlider.swift in Sources */,
|
CE19392726DCB094005CEC17 /* RAMSlider.swift in Sources */,
|
||||||
84E6F6FE289319AE00080EEF /* VMToolbarDisplayMenuView.swift in Sources */,
|
84E6F6FE289319AE00080EEF /* VMToolbarDisplayMenuView.swift in Sources */,
|
||||||
841E58CC28937EE200137A20 /* UTMExternalSceneDelegate.swift in Sources */,
|
841E58CC28937EE200137A20 /* UTMExternalSceneDelegate.swift in Sources */,
|
||||||
84909A8A27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */,
|
|
||||||
CEF0307226A2B04400667B63 /* VMWizardView.swift in Sources */,
|
CEF0307226A2B04400667B63 /* VMWizardView.swift in Sources */,
|
||||||
83034C0826AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
|
83034C0826AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
|
||||||
CEA45ED8263519B5002FA97D /* VMKeyboardButton.m in Sources */,
|
CEA45ED8263519B5002FA97D /* VMKeyboardButton.m in Sources */,
|
||||||
|
|
Loading…
Reference in New Issue