UTM/Platform/UTMData.swift

1353 lines
49 KiB
Swift

//
// Copyright © 2020 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
#if os(macOS)
import AppKit
#else
import UIKit
import SwiftUI
#endif
#if canImport(AltKit) && WITH_JIT
import AltKit
#endif
#if WITH_SERVER
import Combine
#endif
#if WITH_REMOTE
import CocoaSpiceNoUsb
typealias ConcreteVirtualMachine = UTMRemoteSpiceVirtualMachine
#else
typealias ConcreteVirtualMachine = UTMQemuVirtualMachine
#endif
struct AlertMessage: Identifiable {
var message: String
public var id: String {
message
}
init(_ message: String) {
self.message = message
}
}
@MainActor class UTMData: ObservableObject {
/// Sandbox location for storing .utm bundles
nonisolated static var defaultStorageUrl: URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
/// View: show VM settings
@Published var showSettingsModal: Bool
/// View: show new VM wizard
@Published var showNewVMSheet: Bool
/// View: show an alert message
@Published var alertMessage: AlertMessage?
/// View: show busy spinner
@Published var busy: Bool
/// View: currently selected VM
@Published var selectedVM: VMData?
/// View: all VMs listed, we save a bookmark to each when array is modified
@Published private(set) var virtualMachines: [VMData] {
didSet {
listSaveToDefaults()
}
}
/// View: all pending VMs listed (ZIP and IPSW downloads)
@Published private(set) var pendingVMs: [UTMPendingVirtualMachine]
#if os(macOS)
/// View controller for every VM currently active
var vmWindows: [VMData: Any] = [:]
#else
/// View controller for currently active VM
var vmVC: Any?
/// View state for active VM primary display
@State var vmPrimaryWindowState: VMWindowState?
#endif
/// Shortcut for accessing FileManager.default
nonisolated private var fileManager: FileManager {
FileManager.default
}
/// Shortcut for accessing storage URL from instance
nonisolated private var documentsURL: URL {
UTMData.defaultStorageUrl
}
#if WITH_SERVER
/// Remote access server
private(set) var remoteServer: UTMRemoteServer!
/// Listeners for remote access
private var remoteChangeListeners: [VMData: Set<AnyCancellable>] = [:]
/// Listener for list changes
private var listChangedListener: AnyCancellable?
#endif
/// Queue to run `busyWork` tasks
private var busyQueue: DispatchQueue
init() {
self.busyQueue = DispatchQueue(label: "UTM Busy Queue", qos: .userInitiated)
self.showSettingsModal = false
self.showNewVMSheet = false
self.busy = false
self.virtualMachines = []
self.pendingVMs = []
self.selectedVM = nil
#if WITH_SERVER
self.remoteServer = UTMRemoteServer(data: self)
beginObservingChanges()
#endif
listLoadFromDefaults()
}
// MARK: - VM listing
/// Re-loads UTM bundles from default path
///
/// This removes stale entries (deleted/not accessible) and duplicate entries
func listRefresh() async {
// create Documents directory if it doesn't exist
if !fileManager.fileExists(atPath: Self.defaultStorageUrl.path) {
try? fileManager.createDirectory(at: Self.defaultStorageUrl, withIntermediateDirectories: false)
}
// wrap stale VMs
var list = virtualMachines
for i in list.indices.reversed() {
let vm = list[i]
if let registryEntry = vm.registryEntry, !fileManager.fileExists(atPath: registryEntry.package.path) {
list[i] = VMData(from: registryEntry)
}
}
// now look for and add new VMs in default storage
do {
let files = try fileManager.contentsOfDirectory(at: UTMData.defaultStorageUrl, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
let newFiles = files.filter { newFile in
!list.contains { existingVM in
existingVM.pathUrl.standardizedFileURL == newFile.standardizedFileURL
}
}
for file in newFiles {
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
continue
}
guard ConcreteVirtualMachine.isVirtualMachine(url: file) else {
continue
}
await Task.yield()
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
list[index] = vm
// update the registry with the new bookmark
try? await vm.wrapped!.updateRegistryFromConfig()
continue
} else {
// duplicate is not stale so we need a new UUID
uuidRegenerate(for: vm)
}
}
list.insert(vm, at: 0)
} else {
logger.error("Failed to create object for \(file)")
}
}
} catch {
logger.error("\(error.localizedDescription)")
}
// replace the VM list with our new one
if virtualMachines != list {
listReplace(with: list)
}
// prune the registry
let uuids = list.compactMap({ $0.registryEntry?.uuid.uuidString })
UTMRegistry.shared.prune(exceptFor: Set(uuids))
}
/// Load VM list (and order) from persistent storage
fileprivate func listLoadFromDefaults() {
let defaults = UserDefaults.standard
guard defaults.object(forKey: "VMList") == nil else {
listLegacyLoadFromDefaults()
// fix collisions
for vm in virtualMachines {
if uuidHasCollision(with: vm) {
uuidRegenerate(for: vm)
}
}
// delete legacy
defaults.removeObject(forKey: "VMList")
return
}
// registry entry list
guard let list = defaults.stringArray(forKey: "VMEntryList") else {
return
}
let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in
guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
return nil
}
let vm = VMData(from: entry)
do {
try vm.load()
} catch {
logger.error("Error loading '\(entry.uuid)': \(error)")
}
return vm
}
listReplace(with: virtualMachines)
}
/// Load VM list (and order) from persistent storage (legacy)
private func listLegacyLoadFromDefaults() {
let defaults = UserDefaults.standard
// legacy path list
if let files = defaults.array(forKey: "VMList") as? [String] {
let virtualMachines = files.uniqued().compactMap({ file in
let url = documentsURL.appendingPathComponent(file, isDirectory: true)
if let vm = try? VMData(url: url) {
return vm
} else {
return nil
}
})
listReplace(with: virtualMachines)
}
// bookmark list
if let list = defaults.array(forKey: "VMList") {
let virtualMachines = list.compactMap { item in
let vm: VMData?
if let bookmark = item as? Data {
vm = VMData(bookmark: bookmark)
} else if let dict = item as? [String: Any] {
vm = VMData(from: dict)
} else {
vm = nil
}
try? vm?.load()
return vm
}
listReplace(with: virtualMachines)
}
}
/// Save VM list (and order) to persistent storage
private func listSaveToDefaults() {
let defaults = UserDefaults.standard
let wrappedVMs = virtualMachines.map { $0.id.uuidString }
defaults.set(wrappedVMs, forKey: "VMEntryList")
}
/// Replace current VM list with a new list
/// - Parameter vms: List to replace with
fileprivate func listReplace(with vms: [VMData]) {
virtualMachines.forEach({ endObservingChanges(for: $0) })
virtualMachines = vms
vms.forEach({ beginObservingChanges(for: $0) })
if let vm = selectedVM, !vms.contains(where: { $0 == vm }) {
selectedVM = nil
}
}
/// Add VM to list
/// - Parameter vm: VM to add
/// - Parameter at: Optional index to add to, otherwise will be added to the end
private func listAdd(vm: VMData, at index: Int? = nil) {
if uuidHasCollision(with: vm) {
uuidRegenerate(for: vm)
}
if let index = index {
virtualMachines.insert(vm, at: index)
} else {
virtualMachines.append(vm)
}
beginObservingChanges(for: vm)
}
/// Select VM in list
/// - Parameter vm: VM to select
public func listSelect(vm: VMData) {
selectedVM = vm
}
/// Remove a VM from list
/// - Parameter vm: VM to remove
/// - Returns: Index of item removed or nil if already removed
@discardableResult public func listRemove(vm: VMData) -> Int? {
let index = virtualMachines.firstIndex(of: vm)
endObservingChanges(for: vm)
if let index = index {
virtualMachines.remove(at: index)
}
if vm == selectedVM {
selectedVM = nil
}
vm.isDeleted = true // alert views to update
return index
}
/// Add pending VM to list
/// - Parameter pendingVM: Pending VM to add
/// - Parameter at: Optional index to add to, otherwise will be added to the end
private func listAdd(pendingVM: UTMPendingVirtualMachine, at index: Int? = nil) {
if let index = index {
pendingVMs.insert(pendingVM, at: index)
} else {
pendingVMs.append(pendingVM)
}
}
/// Remove pending VM from list
/// - Parameter pendingVM: Pending VM to remove
/// - Returns: Index of item removed or nil if already removed
@discardableResult private func listRemove(pendingVM: UTMPendingVirtualMachine) -> Int? {
let index = pendingVMs.firstIndex(where: { $0.id == pendingVM.id })
if let index = index {
pendingVMs.remove(at: index)
}
return index
}
/// Move items in VM list
/// - Parameters:
/// - fromOffsets: Offsets from move from
/// - toOffset: Offsets to move to
func listMove(fromOffsets: IndexSet, toOffset: Int) {
virtualMachines.move(fromOffsets: fromOffsets, toOffset: toOffset)
}
// MARK: - New name
/// Generate a unique VM name
/// - Parameter base: Base name
/// - Returns: Unique name for a non-existing item in the default storage path
nonisolated func newDefaultVMName(base: String = NSLocalizedString("Virtual Machine", comment: "UTMData")) -> String {
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
for i in 1..<1000 {
let name = nameForId(i)
let file = ConcreteVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
if !fileManager.fileExists(atPath: file.path) {
return name
}
}
return ProcessInfo.processInfo.globallyUniqueString
}
/// Generate a filename for an imported file, avoiding duplicate names
/// - Parameters:
/// - sourceUrl: Source image where name will come from
/// - destUrl: Destination directory where duplicates will be checked
/// - withExtension: Optionally change the file extension
/// - Returns: Unique filename that is not used in the destUrl
nonisolated static func newImage(from sourceUrl: URL, to destUrl: URL, withExtension: String? = nil) -> URL {
let name = sourceUrl.deletingPathExtension().lastPathComponent
let ext = withExtension ?? sourceUrl.pathExtension
let strFromInt = { (i: Int) in i == 1 ? "" : "-\(i)" }
for i in 1..<1000 {
let attempt = "\(name)\(strFromInt(i))"
let attemptUrl = destUrl.appendingPathComponent(attempt).appendingPathExtension(ext)
if !FileManager.default.fileExists(atPath: attemptUrl.path) {
return attemptUrl
}
}
repeat {
let attempt = UUID().uuidString
let attemptUrl = destUrl.appendingPathComponent(attempt).appendingPathExtension(ext)
if !FileManager.default.fileExists(atPath: attemptUrl.path) {
return attemptUrl
}
} while true
}
// MARK: - Other view states
private func setBusyIndicator(_ busy: Bool) {
self.busy = busy
}
func showErrorAlert(message: String) {
alertMessage = AlertMessage(message)
}
func newVM() {
showSettingsModal = false
showNewVMSheet = true
}
func showSettingsForCurrentVM() {
#if os(iOS) || os(visionOS)
// SwiftUI bug: cannot show modal at the same time as changing selected VM or it breaks
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self.showSettingsModal = true
}
#else
showSettingsModal = true
#endif
}
// MARK: - VM operations
/// Save an existing VM to disk
/// - Parameter vm: VM to save
func save(vm: VMData) async throws {
do {
try await vm.save()
#if WITH_SERVER
if let qemuConfig = vm.config as? UTMQemuConfiguration {
await remoteServer.broadcast { remote in
try await remote.qemuConfigurationHasChanged(id: vm.id, configuration: qemuConfig)
}
}
#endif
} catch {
// refresh the VM object as it is now stale
let origError = error
do {
try discardChanges(for: vm)
} catch {
// if we can't discard changes, recreate the VM from scratch
let path = vm.pathUrl
guard let newVM = try? VMData(url: path) else {
logger.debug("Cannot create new object for \(path.path)")
throw origError
}
let index = listRemove(vm: vm)
listAdd(vm: newVM, at: index)
listSelect(vm: newVM)
}
throw origError
}
}
/// Discard changes to VM configuration
/// - Parameter vm: VM configuration to discard
func discardChanges(for vm: VMData) throws {
if let wrapped = vm.wrapped {
try wrapped.reload(from: nil)
if uuidHasCollision(with: vm) {
wrapped.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
}
}
}
/// Save a new VM to disk
/// - Parameters:
/// - config: New VM configuration
func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
throw UTMDataError.virtualMachineAlreadyExists
}
let vm = try VMData(creatingFromConfig: config, destinationUrl: Self.defaultStorageUrl)
try await save(vm: vm)
listAdd(vm: vm)
listSelect(vm: vm)
return vm
}
/// Delete a VM from disk
/// - Parameter vm: VM to delete
/// - Returns: Index of item removed in VM list or nil if not in list
@discardableResult func delete(vm: VMData, alsoRegistry: Bool = true) async throws -> Int? {
if vm.isLoaded {
try fileManager.removeItem(at: vm.pathUrl)
}
// close any open window
close(vm: vm)
if alsoRegistry, let registryEntry = vm.registryEntry {
UTMRegistry.shared.remove(entry: registryEntry)
}
return listRemove(vm: vm)
}
/// Save a copy of the VM and all data to default storage location
/// - Parameter vm: VM to clone
/// - Returns: The new VM
@discardableResult func clone(vm: VMData) async throws -> VMData {
let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
let newPath = ConcreteVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
try await copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
guard let newVM = try? VMData(url: newPath) else {
throw UTMDataError.cloneFailed
}
newVM.wrapped!.changeUuid(to: UUID(), name: newName, copyingEntry: nil)
try await newVM.save()
var index = virtualMachines.firstIndex(of: vm)
if index != nil {
index! += 1
}
listAdd(vm: newVM, at: index)
listSelect(vm: newVM)
return newVM
}
/// Save a copy of the VM and all data to arbitary location
/// - Parameters:
/// - vm: VM to copy
/// - url: Location to copy to (must be writable)
func export(vm: VMData, to url: URL) async throws {
let sourceUrl = vm.pathUrl
if fileManager.fileExists(atPath: url.path) {
try fileManager.removeItem(at: url)
}
try await copyItemWithCopyfile(at: sourceUrl, to: url)
}
/// Save a copy of the VM and all data to arbitary location and delete the original data
/// - Parameters:
/// - vm: VM to move
/// - url: Location to move to (must be writable)
func move(vm: VMData, to url: URL) async throws {
try await export(vm: vm, to: url)
guard let newVM = try? VMData(url: url) else {
throw UTMDataError.shortcutCreationFailed
}
try await newVM.wrapped!.updateRegistryFromConfig()
let oldSelected = selectedVM
let index = try await delete(vm: vm, alsoRegistry: false)
listAdd(vm: newVM, at: index)
if oldSelected == vm {
listSelect(vm: newVM)
}
}
/// Open settings modal
/// - Parameter vm: VM to edit settings
func edit(vm: VMData) {
listSelect(vm: vm)
showNewVMSheet = false
showSettingsForCurrentVM()
}
/// Copy configuration but not data from existing VM to a new VM
/// - Parameter vm: Existing VM to copy configuration from
func template(vm: VMData) async throws {
let copy = try UTMQemuConfiguration.load(from: vm.pathUrl)
if let copy = copy as? UTMQemuConfiguration {
copy.information.name = self.newDefaultVMName(base: copy.information.name)
copy.information.uuid = UUID()
copy.drives = []
_ = try await create(config: copy)
}
#if os(macOS)
if let copy = copy as? UTMAppleConfiguration {
copy.information.name = self.newDefaultVMName(base: copy.information.name)
copy.information.uuid = UUID()
copy.drives = []
_ = try await create(config: copy)
}
#endif
showSettingsForCurrentVM()
}
// MARK: - File I/O related
/// Calculate total size of VM and data
/// - Parameter vm: VM to calculate size
/// - Returns: Size in bytes
func computeSize(for vm: VMData) async -> Int64 {
let path = vm.pathUrl
guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
logger.error("failed to create enumerator for \(path)")
return 0
}
var total: Int64 = 0
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), let size = resourceValues.totalFileAllocatedSize else {
continue
}
total += Int64(size)
}
return total
}
/// Calculate size of a single file URL
/// - Parameter url: File URL
/// - Returns: Size in bytes
func computeSize(for url: URL) -> Int64 {
if let resourceValues = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), let size = resourceValues.totalFileAllocatedSize {
return Int64(size)
} else {
return 0
}
}
/// Handles UTM file URLs
///
/// If .utm is already in the list, select it
/// If .utm is in the Inbox directory, move it to the default storage
/// Otherwise we create a shortcut (default for macOS) or a copy (default for iOS)
/// - Parameter url: File URL to read from
/// - Parameter asShortcut: Create a shortcut rather than a copy
func importUTM(from url: URL, asShortcut: Bool = true) async throws {
guard url.isFileURL else { return }
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
logger.info("importing: \(url)")
// attempt to turn temp URL to presistent bookmark early otherwise,
// when stopAccessingSecurityScopedResource() is called, we lose access
let bookmark = try url.persistentBookmarkData()
let url = try URL(resolvingPersistentBookmarkData: bookmark)
let fileBasePath = url.deletingLastPathComponent()
let fileName = url.lastPathComponent
let dest = documentsURL.appendingPathComponent(fileName, isDirectory: true)
if let vm = virtualMachines.first(where: { vm -> Bool in
return vm.pathUrl.standardizedFileURL == url.standardizedFileURL
}) {
logger.info("found existing vm!")
if !vm.isLoaded {
logger.info("existing vm is wrapped")
try vm.load()
} else {
logger.info("existing vm is not wrapped")
listSelect(vm: vm)
}
return
}
// check if VM is valid
guard let _ = try? VMData(url: url) else {
throw UTMDataError.importFailed
}
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)
vm = try VMData(url: dest)
} else if asShortcut {
logger.info("loading as a shortcut")
vm = try VMData(url: url)
} else {
logger.info("copying to Documents")
try fileManager.copyItem(at: url, to: dest)
vm = try VMData(url: dest)
}
guard let vm = vm else {
throw UTMDataError.importParseFailed
}
listAdd(vm: vm)
listSelect(vm: vm)
}
private func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws {
try await Task.detached(priority: .userInitiated) {
let status = copyfile(srcURL.path, dstURL.path, nil, copyfile_flags_t(COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_CLONE | COPYFILE_DATA_SPARSE))
if status < 0 {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
}
}.value
}
// MARK: - Downloading VMs
#if os(macOS) && arch(arm64)
/// Create a new VM using configuration and downloaded IPSW
/// - Parameter config: Apple VM configuration
@available(macOS 12, *)
func downloadIPSW(using config: UTMAppleConfiguration) async {
let task = UTMDownloadIPSWTask(for: config)
guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
showErrorAlert(message: NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData"))
return
}
listAdd(pendingVM: task.pendingVM)
Task {
do {
if let wrapped = try await task.download() {
let vm = VMData(wrapping: wrapped)
try await self.save(vm: vm)
listAdd(vm: vm)
}
} catch {
showErrorAlert(message: error.localizedDescription)
}
listRemove(pendingVM: task.pendingVM)
}
}
#endif
/// Create a new VM by downloading a .zip and extracting it
/// - Parameter components: Download URL components
func downloadUTMZip(from components: URLComponents) async {
guard let urlParameter = components.queryItems?.first(where: { $0.name == "url" })?.value,
let url = URL(string: urlParameter) else {
showErrorAlert(message: NSLocalizedString("Failed to parse download URL.", comment: "UTMData"))
return
}
let task = UTMDownloadVMTask(for: url)
listAdd(pendingVM: task.pendingVM)
Task {
do {
if let wrapped = try await task.download() {
let vm = VMData(wrapping: wrapped)
try await self.save(vm: vm)
listAdd(vm: vm)
}
} catch {
showErrorAlert(message: error.localizedDescription)
}
listRemove(pendingVM: task.pendingVM)
}
}
func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
guard let vm = vm as? any UTMSpiceVirtualMachine else {
throw UTMDataError.unsupportedBackend
}
let task = UTMDownloadSupportToolsTask(for: vm)
if await task.hasExistingSupportTools {
vm.config.qemu.isGuestToolsInstallRequested = false
_ = try await task.mountTools()
} else {
listAdd(pendingVM: task.pendingVM)
Task {
do {
_ = try await task.download()
} catch {
showErrorAlert(message: error.localizedDescription)
}
vm.config.qemu.isGuestToolsInstallRequested = false
listRemove(pendingVM: task.pendingVM)
}
}
}
/// Cancel a download and discard any data
/// - Parameter pendingVM: Pending VM to cancel
func cancelDownload(for pendingVM: UTMPendingVirtualMachine) {
pendingVM.cancel()
}
// MARK: - Reclaim space
#if os(macOS)
/// Reclaim empty space in a file by (re)-converting it to QCOW2
///
/// This will overwrite driveUrl with the converted file on success!
/// - Parameter driveUrl: Original drive to convert
/// - Parameter isCompressed: Compress existing data
func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool = false) async throws {
let baseUrl = driveUrl.deletingLastPathComponent()
let dstUrl = Self.newImage(from: driveUrl, to: baseUrl, withExtension: "qcow2")
try await UTMQemuImage.convert(from: driveUrl, toQcow2: dstUrl, withCompression: isCompressed)
do {
try fileManager.replaceItem(at: driveUrl, withItemAt: dstUrl, backupItemName: nil, resultingItemURL: nil)
} catch {
// on failure delete the converted file
try? fileManager.removeItem(at: dstUrl)
throw error
}
}
func qcow2DriveSize(for driveUrl: URL) async -> Int64 {
return (try? await UTMQemuImage.size(image: driveUrl)) ?? 0
}
func resizeQcow2Drive(for driveUrl: URL, sizeInMib: Int) async throws {
let bytesinMib = 1048576
try await UTMQemuImage.resize(image: driveUrl, size: UInt64(sizeInMib * bytesinMib))
}
#endif
// MARK: - UUID migration
private func uuidHasCollision(with vm: VMData) -> Bool {
return uuidHasCollision(with: vm, in: virtualMachines)
}
private func uuidHasCollision(with vm: VMData, in list: [VMData]) -> Bool {
for otherVM in list {
if otherVM == vm {
return false
} else if let lhs = otherVM.registryEntry?.uuid, let rhs = vm.registryEntry?.uuid, lhs == rhs {
return true
}
}
return false
}
private func uuidRegenerate(for vm: VMData) {
guard let vm = vm.wrapped else {
return
}
vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
}
// MARK: - Change listener
private func beginObservingChanges() {
#if WITH_SERVER
listChangedListener = $virtualMachines.sink { vms in
Task {
await self.remoteServer.broadcast { remote in
try await remote.listHasChanged(ids: vms.map({ $0.id }))
}
}
}
#endif
}
private func beginObservingChanges(for vm: VMData) {
#if WITH_SERVER
var observers = Set<AnyCancellable>()
let registryEntry = vm.registryEntry
observers.insert(vm.objectWillChange.sink { [self] _ in
// reset observers when registry changes
if vm.registryEntry != registryEntry {
endObservingChanges(for: vm)
beginObservingChanges(for: vm)
}
})
observers.insert(vm.$state.sink { state in
Task {
let isTakeoverAllowed = self.vmWindows[vm] is VMRemoteSessionState && (state == .started || state == .paused)
await self.remoteServer.broadcast { remote in
try await remote.virtualMachine(id: vm.id, didTransitionToState: state, isTakeoverAllowed: isTakeoverAllowed)
}
}
})
if let registryEntry = registryEntry {
observers.insert(registryEntry.externalDrivePublisher.sink { drives in
let mountedDrives = drives.mapValues({ $0.path })
Task {
await self.remoteServer.broadcast { remote in
try await remote.mountedDrivesHasChanged(id: vm.id, mountedDrives: mountedDrives)
}
}
})
}
remoteChangeListeners[vm] = observers
#endif
}
private func endObservingChanges(for vm: VMData) {
#if WITH_SERVER
remoteChangeListeners.removeValue(forKey: vm)
#endif
}
// MARK: - Other utility functions
/// In some regions, iOS will prompt the user for network access
func triggeriOSNetworkAccessPrompt() {
let task = URLSession.shared.dataTask(with: URL(string: "http://captive.apple.com")!)
task.resume()
}
/// Execute a task with spinning progress indicator
/// - Parameter work: Function to execute
func busyWork(_ work: @escaping () throws -> Void) {
busyQueue.async {
DispatchQueue.main.async {
self.busy = true
}
defer {
DispatchQueue.main.async {
self.busy = false
}
}
do {
try work()
} catch {
logger.error("\(error)")
DispatchQueue.main.async {
self.alertMessage = AlertMessage(error.localizedDescription)
}
}
}
}
/// Execute a task with spinning progress indicator (Swift concurrency version)
/// - Parameter work: Function to execute
@discardableResult
func busyWorkAsync<T>(_ work: @escaping @Sendable () async throws -> T) -> Task<T, any Error> {
Task.detached(priority: .userInitiated) {
await self.setBusyIndicator(true)
do {
let result = try await work()
await self.setBusyIndicator(false)
return result
} catch {
logger.error("\(error)")
await self.showErrorAlert(message: error.localizedDescription)
await self.setBusyIndicator(false)
throw error
}
}
}
// MARK: - Automation Features
/// Send text as keyboard input to VM
/// - Parameters:
/// - vm: VM to send text to
/// - components: Data (see UTM Wiki for details)
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)
trySendTextSpice(vm: vm, text: text)
#else
trySendTextSpice(text)
#endif
}
/// Send mouse/tablet coordinates to VM
/// - Parameters:
/// - vm: VM to send mouse/tablet coordinates to
/// - components: Data (see UTM Wiki for details)
func automationSendMouse(to vm: VMData, urlComponents components: URLComponents) {
guard let qemuVm = vm.wrapped as? any UTMSpiceVirtualMachine else { return } // FIXME: implement for Apple VM
guard !qemuVm.config.displays.isEmpty else { return }
guard let queryItems = components.queryItems else { return }
/// Parse targeted position
var x: CGFloat? = nil
var y: CGFloat? = nil
let nf = NumberFormatter()
nf.allowsFloats = false
if let xStr = components.queryItems?.first(where: { item in
item.name == "x"
})?.value {
x = nf.number(from: xStr) as? CGFloat
}
if let yStr = components.queryItems?.first(where: { item in
item.name == "y"
})?.value {
y = nf.number(from: yStr) as? CGFloat
}
guard let xPos = x, let yPos = y else { return }
let point = CGPoint(x: xPos, y: yPos)
/// Parse which button should be clicked
var button: CSInputButton = .left
if let buttonStr = queryItems.first(where: { $0.name == "button"})?.value {
switch buttonStr {
case "middle":
button = .middle
break
case "right":
button = .right
break
default:
break
}
}
/// All parameters parsed, perform the click
#if os(macOS)
tryClickAtPoint(vm: vm, point: point, button: button)
#else
tryClickAtPoint(point: point, button: button)
#endif
}
// MARK: - AltKit
#if canImport(AltKit) && WITH_JIT
/// Detect if we are installed from AltStore and can use AltJIT
var isAltServerCompatible: Bool {
guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
return false
}
guard let _ = Bundle.main.infoDictionary?["ALTDeviceID"] else {
return false
}
return true
}
/// Find and run AltJIT to enable JIT
func startAltJIT() throws {
let event = DispatchSemaphore(value: 0)
var connectError: Error?
DispatchQueue.main.async {
ServerManager.shared.autoconnect { result in
switch result
{
case .failure(let error):
logger.error("Could not auto-connect to server. \(error.localizedDescription)")
connectError = error
event.signal()
case .success(let connection):
connection.enableUnsignedCodeExecution { result in
switch result
{
case .failure(let error):
logger.error("Could not enable JIT compilation. \(error.localizedDescription)")
connectError = error
case .success:
logger.debug("Successfully enabled JIT compilation!")
Main.jitAvailable = true
}
connection.disconnect()
event.signal()
}
}
}
ServerManager.shared.startDiscovering()
}
defer {
ServerManager.shared.stopDiscovering()
}
if event.wait(timeout: .now() + 10) == .timedOut {
throw UTMDataError.altServerNotFound
} else if let error = connectError {
throw UTMDataError.altJitError(error.localizedDescription)
}
}
#endif
// MARK - JitStreamer
#if os(iOS) || os(visionOS)
@available(iOS 15, *)
func jitStreamerAttach() async throws {
let urlString = String(
format: "http://%@/attach/%ld/",
UserDefaults.standard.string(forKey: "JitStreamerAddress") ?? "",
getpid()
)
if let url = URL(string: urlString) {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = "".data(using: .utf8)
var attachError: Error?
do {
let (data, _) = try await URLSession.shared.data(for: request)
let attachResponse = try JSONDecoder().decode(AttachResponse.self, from: data)
if !attachResponse.success {
attachError = String.localizedStringWithFormat(NSLocalizedString("Failed to attach to JitStreamer:\n%@", comment: "ContentView"), attachResponse.message)
} else {
Main.jitAvailable = true
}
} catch is DecodingError {
throw UTMDataError.jitStreamerDecodeFailed
} catch {
throw UTMDataError.jitStreamerAttachFailed
}
if let attachError = attachError {
throw attachError
}
} else {
throw UTMDataError.jitStreamerUrlInvalid(urlString)
}
}
private struct AttachResponse: Decodable {
var message: String
var success: Bool
}
#endif
}
// MARK: - Errors
enum UTMDataError: Error {
case virtualMachineAlreadyExists
case virtualMachineUnavailable
case unsupportedBackend
case cloneFailed
case shortcutCreationFailed
case importFailed
case importParseFailed
case altServerNotFound
case altJitError(String)
case jitStreamerDecodeFailed
case jitStreamerAttachFailed
case jitStreamerUrlInvalid(String)
case notImplemented
case reconnectFailed
}
extension UTMDataError: LocalizedError {
var errorDescription: String? {
switch self {
case .virtualMachineAlreadyExists:
return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
case .virtualMachineUnavailable:
return NSLocalizedString("This virtual machine is currently unavailable, make sure it is not open in another session.", comment: "UTMData")
case .unsupportedBackend:
return NSLocalizedString("Operation not supported by the backend.", comment: "UTMData")
case .cloneFailed:
return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
case .shortcutCreationFailed:
return NSLocalizedString("Unable to add a shortcut to the new location.", comment: "UTMData")
case .importFailed:
return NSLocalizedString("Cannot import this VM. Either the configuration is invalid, created in a newer version of UTM, or on a platform that is incompatible with this version of UTM.", comment: "UTMData")
case .importParseFailed:
return NSLocalizedString("Failed to parse imported VM.", comment: "UTMData")
case .altServerNotFound:
return NSLocalizedString("Cannot find AltServer for JIT enable. You cannot run VMs until JIT is enabled.", comment: "UTMData")
case .altJitError(let message):
return String.localizedStringWithFormat(NSLocalizedString("AltJIT error: %@", comment: "UTMData"), message)
case .jitStreamerDecodeFailed:
return NSLocalizedString("Failed to decode JitStreamer response.", comment: "UTMData")
case .jitStreamerAttachFailed:
return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
case .jitStreamerUrlInvalid(let urlString):
return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
case .notImplemented:
return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
case .reconnectFailed:
return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
}
}
}
// MARK: - Remote Client
/// Declare host capabilities to any remote client
struct UTMCapabilities: OptionSet, Codable {
let rawValue: UInt
/// If set, no trick is needed to get JIT working as the process is entitled.
static let hasJitEntitlements = Self(rawValue: 1 << 0)
/// If set, virtualization is supported by this host.
static let hasHypervisorSupport = Self(rawValue: 1 << 1)
/// If set, host is aarch64
static let isAarch64 = Self(rawValue: 1 << 2)
/// If set, host is x86_64
static let isX86_64 = Self(rawValue: 1 << 3)
static fileprivate(set) var current: Self = {
var current = Self()
#if WITH_JIT
if jb_has_jit_entitlement() {
current.insert(.hasJitEntitlements)
}
if jb_has_hypervisor() {
current.insert(.hasHypervisorSupport)
}
#endif
#if arch(arm64)
current.insert(.isAarch64)
#endif
#if arch(x86_64)
current.insert(.isX86_64)
#endif
return current
}()
}
#if WITH_REMOTE
private let kReconnectTimeoutSeconds: UInt64 = 5
@MainActor
class UTMRemoteData: UTMData {
/// Remote access client
private(set) var remoteClient: UTMRemoteClient!
override init() {
super.init()
self.remoteClient = UTMRemoteClient(data: self)
}
override func listLoadFromDefaults() {
// do nothing since we do not load from VMList
}
override func listRefresh() async {
busyWorkAsync {
try await self.listRefreshFromRemote()
}
}
func reconnect(to server: UTMRemoteClient.State.SavedServer) async throws {
var reconnectTask: Task<UTMRemoteClient.Remote, any Error>?
let timeoutTask = Task {
try await Task.sleep(nanoseconds: kReconnectTimeoutSeconds * NSEC_PER_SEC)
reconnectTask?.cancel()
}
reconnectTask = busyWorkAsync { [self] in
do {
try await remoteClient.connect(server)
} catch is CancellationError {
throw UTMDataError.reconnectFailed
}
timeoutTask.cancel()
try await listRefreshFromRemote()
return await remoteClient.server
}
// make all active sessions wait on the reconnect
for session in VMSessionState.allActiveSessions.values {
let vm = session.vm as! UTMRemoteSpiceVirtualMachine
Task {
do {
try await vm.reconnectServer {
try await reconnectTask!.value
}
} catch {
session.stop()
}
}
}
_ = try await reconnectTask!.value
}
private func listRefreshFromRemote() async throws {
if let capabilities = await self.remoteClient.server.capabilities {
UTMCapabilities.current = capabilities
}
let ids = try await remoteClient.server.listVirtualMachines()
let items = try await remoteClient.server.getVirtualMachineInformation(for: ids)
let openSessionVms = VMSessionState.allActiveSessions.values.map({ $0.vm })
let vms = items.map { item in
let wrapped = openSessionVms.first(where: { $0.id == item.id }) as? UTMRemoteSpiceVirtualMachine
return VMRemoteData(fromRemoteItem: item, existingWrapped: wrapped)
}
await loadVirtualMachines(vms)
}
private func loadVirtualMachines(_ vms: [VMData]) async {
listReplace(with: vms)
for vm in vms {
let remoteVM = vm as! VMRemoteData
if remoteVM.isLoaded {
continue
}
do {
try await remoteVM.load(withRemoteServer: remoteClient.server)
} catch {
remoteVM.unavailableReason = error.localizedDescription
}
await Task.yield()
}
}
func remoteListHasChanged(ids: [UUID]) async {
var existing = virtualMachines.reduce(into: [:]) { partialResult, vm in
partialResult[vm.id] = vm
}
let new = ids.compactMap { id in
if existing[id] == nil {
return id
} else {
return nil
}
}
if !new.isEmpty, let newItems = try? await remoteClient.server.getVirtualMachineInformation(for: new) {
newItems.map({ VMRemoteData(fromRemoteItem: $0) }).forEach { vm in
existing[vm.id] = vm
}
}
let vms = ids.compactMap({ existing[$0] })
await loadVirtualMachines(vms)
}
func remoteQemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async {
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
return
}
await vm.reloadConfiguration(withRemoteServer: remoteClient.server, config: configuration)
}
func remoteMountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async {
guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
return
}
vm.updateMountedDrives(mountedDrives)
}
func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async {
guard let vm = virtualMachines.first(where: { $0.id == id }) else {
return
}
let remoteVM = vm as! VMRemoteData
let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine
remoteVM.isTakeoverAllowed = isTakeoverAllowed
await wrapped.updateRemoteState(state)
}
func remoteVirtualMachineDidError(id: UUID, message: String) async {
if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == id }) {
session.nonfatalError = message
}
}
override func listMove(fromOffsets: IndexSet, toOffset: Int) {
let ids = fromOffsets.map({ virtualMachines[$0].id })
Task {
try await remoteClient.server.reorderVirtualMachines(fromIds: ids, toOffset: toOffset)
}
super.listMove(fromOffsets: fromOffsets, toOffset: toOffset)
}
override func save(vm: VMData) async throws {
throw UTMDataError.notImplemented
}
override func discardChanges(for vm: VMData) throws {
throw UTMDataError.notImplemented
}
override func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
throw UTMDataError.notImplemented
}
@discardableResult
override func delete(vm: VMData, alsoRegistry: Bool) async throws -> Int? {
throw UTMDataError.notImplemented
}
@discardableResult
override func clone(vm: VMData) async throws -> VMData {
throw UTMDataError.notImplemented
}
override func export(vm: VMData, to url: URL) async throws {
throw UTMDataError.notImplemented
}
override func move(vm: VMData, to url: URL) async throws {
throw UTMDataError.notImplemented
}
override func template(vm: VMData) async throws {
throw UTMDataError.notImplemented
}
override func computeSize(for vm: VMData) async -> Int64 {
(try? await remoteClient.server.getPackageSize(for: vm.id)) ?? 0
}
override func importUTM(from url: URL, asShortcut: Bool) async throws {
throw UTMDataError.notImplemented
}
override func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
try await remoteClient.server.mountGuestToolsOnVirtualMachine(id: vm.id)
}
}
#endif