vm: rewrite UTMVirtualMachine in Swift

This commit is contained in:
osy 2023-06-28 19:45:05 -05:00
parent d498239b38
commit 37991d246f
49 changed files with 1578 additions and 858 deletions

View File

@ -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"

View File

@ -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 {

View File

@ -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"

View File

@ -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)
}
}
} }
} }
} }

View File

@ -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 {

View File

@ -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)

View File

@ -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 {}

View File

@ -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;

View File

@ -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];
} }
} }

View File

@ -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)
} }
} }

View File

@ -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":

View File

@ -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)
} }
} }

View File

@ -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)

View File

@ -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

View File

@ -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 {

View File

@ -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))
} }
} }

View File

@ -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))
} }
} }

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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"

View File

@ -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

View File

@ -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)
}
}

View File

@ -20,7 +20,7 @@ import SwiftUI
/// Model wrapping a single UTMVirtualMachine for use in views /// Model wrapping a single UTMVirtualMachine for use in views
@MainActor class VMData: ObservableObject { @MainActor class VMData: ObservableObject {
/// Underlying virtual machine /// Underlying virtual machine
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
} }
} }

View File

@ -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"

View File

@ -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"

View File

@ -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 }

View File

@ -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()
} }
} }

View File

@ -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

View File

@ -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()
} }

View File

@ -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)
} }
} }

View File

@ -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

View File

@ -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
} }
} }

View File

@ -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 {

View File

@ -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

View File

@ -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
} }

View File

@ -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)
}
}
}

View File

@ -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
} }

View File

@ -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)

View File

@ -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

View File

@ -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)
}
}
} }
} }

View File

@ -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) {

View File

@ -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") {

View File

@ -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!)
} }
} }

View File

@ -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 {

View File

@ -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)

View File

@ -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)
} }

View File

@ -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 {

View File

@ -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 */,