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