12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463 |
- //
- // 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
- import SwiftUI
- #if os(macOS)
- import AppKit
- #else
- import UIKit
- #endif
- #if canImport(AltKit) && WITH_JIT
- import AltKit
- #endif
- #if WITH_SERVER
- import Combine
- #endif
- import SwiftCopyfile
- #if WITH_REMOTE
- import CocoaSpiceNoUsb
- typealias ConcreteVirtualMachine = UTMRemoteSpiceVirtualMachine
- #else
- typealias ConcreteVirtualMachine = UTMQemuVirtualMachine
- #endif
- enum AlertItem: Identifiable {
- case message(String)
- case localizedMessage(LocalizedStringKey)
- case downloadUrl(URL)
- var id: Int {
- switch self {
- case .downloadUrl(let url):
- return url.hashValue
- case .message(let message):
- return message.hashValue
- case .localizedMessage(let message):
- return message.localizedString.hashValue
- }
- }
- }
- @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 alertItem: AlertItem?
- /// View: show busy spinner
- @Published var busy: Bool
-
- /// View: show a percent progress in the busy spinner
- @Published var busyProgress: Float?
- /// 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) {
- alertItem = .message(message)
- }
- func showLocalizedErrorAlert(_ message: LocalizedStringKey) {
- alertItem = .localizedMessage(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)
- do {
- try await save(vm: vm)
- } catch {
- if isDirectoryEmpty(vm.pathUrl) {
- try? fileManager.removeItem(at: vm.pathUrl)
- }
- throw error
- }
- 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)
- let isRegenerateMACOnClone = UserDefaults.standard.bool(forKey: "IsRegenerateMACOnClone")
- 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)
- if isRegenerateMACOnClone {
- if let config = newVM.wrapped!.config as? UTMQemuConfiguration {
- for i in config.networks.indices {
- config.networks[i].macAddress = UTMQemuConfigurationNetwork.randomMacAddress()
- }
- }
- }
- 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 {
- return computeSize(recursiveFor: vm.pathUrl)
- }
- private func computeSize(recursiveFor url: URL) -> Int64 {
- guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
- logger.error("failed to create enumerator for \(url)")
- 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 }
- let isScopedAccess = url.startAccessingSecurityScopedResource()
- defer {
- if isScopedAccess {
- 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)
- // warn user if imported .utm has custom arguments
- if let qemuConfig = vm.wrapped?.config as? UTMQemuConfiguration, !qemuConfig.qemu.additionalArguments.isEmpty {
- showLocalizedErrorAlert("This virtual machine uses custom QEMU arguments which is potentially dangerous and can cause damage to your machine. You should only run this virtual machine if you trust it.")
- }
- }
-
- /// Handles UTM file URLs similar to importUTM, with few differences
- ///
- /// Always creates new VM (no shortcuts)
- /// Copies VM file with a unique name to default storage (to avoid duplicates)
- /// Returns VM data Object (to access UUID)
- /// - Parameter url: File URL to read from
- func importNewUTM(from url: URL) async throws -> VMData {
- guard url.isFileURL else {
- throw UTMDataError.importFailed
- }
- let isScopedAccess = url.startAccessingSecurityScopedResource()
- defer {
- if isScopedAccess {
- 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)
-
- // get unique filename, for every import we create a new VM
- let newUrl = UTMData.newImage(from: url, to: documentsURL)
- let fileName = newUrl.lastPathComponent
- // create destination name (default storage + file name)
- let dest = documentsURL.appendingPathComponent(fileName, isDirectory: true)
-
- // check if VM is valid
- guard let _ = try? VMData(url: url) else {
- throw UTMDataError.importFailed
- }
-
- // Copy file to documents
- let vm: VMData?
- 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
- }
- // Add vm to the list
- listAdd(vm: vm)
- listSelect(vm: vm)
-
- return vm
- }
- private func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws {
- let totalSize = computeSize(recursiveFor: srcURL)
- var lastUpdate = Date()
- var lastProgress: CopyManager.Progress?
- var copiedSize: Int64 = 0
- defer {
- busyProgress = nil
- }
- for try await progress in CopyManager.default.copyItemProgress(at: srcURL, to: dstURL, flags: [.all, .recursive, .clone, .dataSparse]) {
- if let _lastProgress = lastProgress, _lastProgress.srcPath != _lastProgress.srcPath {
- copiedSize += _lastProgress.bytesCopied
- lastProgress = progress
- } else {
- lastProgress = progress
- }
- if totalSize > 0 && lastUpdate.timeIntervalSinceNow < -1 {
- lastUpdate = Date()
- let completed = Float(copiedSize + progress.bytesCopied) / Float(totalSize)
- busyProgress = completed > 1.0 ? 1.0 : completed
- }
- }
- }
- private func isDirectoryEmpty(_ pathURL: URL) -> Bool {
- guard let enumerator = fileManager.enumerator(at: pathURL, includingPropertiesForKeys: [.isDirectoryKey]) else {
- return false
- }
- for case let itemURL as URL in enumerator {
- let isDirectory = (try? itemURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
- if !isDirectory {
- return false
- }
- }
- // if we get here, we only found empty directories
- return true
- }
- // 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 url: URL) {
- 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)
- }
- }
- private func mountWindowsSupportTools(for vm: any UTMSpiceVirtualMachine) async throws {
- let task = UTMDownloadSupportToolsTask(for: vm)
- if await task.hasExistingSupportTools {
- _ = try await task.mountTools()
- } else {
- listAdd(pendingVM: task.pendingVM)
- Task {
- do {
- _ = try await task.download()
- } catch {
- showErrorAlert(message: error.localizedDescription)
- }
- listRemove(pendingVM: task.pendingVM)
- }
- }
- }
- #if os(macOS)
- @available(macOS 15, *)
- private func mountMacSupportTools(for vm: UTMAppleVirtualMachine) async throws {
- let task = UTMDownloadMacSupportToolsTask(for: vm)
- if await task.hasExistingSupportTools {
- _ = try await task.mountTools()
- } else {
- listAdd(pendingVM: task.pendingVM)
- Task {
- do {
- _ = try await task.download()
- } catch {
- showErrorAlert(message: error.localizedDescription)
- }
- listRemove(pendingVM: task.pendingVM)
- }
- }
- }
- #endif
- func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
- if let vm = vm as? any UTMSpiceVirtualMachine {
- return try await mountWindowsSupportTools(for: vm)
- }
- #if os(macOS)
- if #available(macOS 15, *), let vm = vm as? UTMAppleVirtualMachine, vm.config.system.boot.operatingSystem == .macOS {
- return try await mountMacSupportTools(for: vm)
- }
- #endif
- throw UTMDataError.unsupportedBackend
- }
-
- /// 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")
- defer {
- busyProgress = nil
- }
- try await UTMQemuImage.convert(from: driveUrl, toQcow2: dstUrl, withCompression: isCompressed) { progress in
- Task { @MainActor in
- self.busyProgress = progress / 100
- }
- }
- busyProgress = nil
- 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))
- }
- @available(macOS 14, *)
- func appleDriveInfo(for driveUrl: URL) -> (format: String?, size: Int64?) {
- var format: String? = nil
- var size: Int64? = nil
- guard let info = try? UTMASIFImage.sharedInstance()?.retrieveInfo(driveUrl) else {
- return (format, size)
- }
- if let _format = info["Image Format"] as? String {
- format = _format
- }
- if let _sizeInfo = info["Size Info"] as? [String: Any] {
- if let _totalBytes = _sizeInfo["Total Bytes"] as? Int64 {
- size = _totalBytes
- }
- }
- return (format, size)
- }
- @available(macOS 14, *)
- func resizeAppleDrive(for driveUrl: URL, sizeInMib: Int) throws {
- let bytesinMib = 1048576
- let size = Int(sizeInMib * bytesinMib)
- try UTMASIFImage.sharedInstance()!.resize(with: driveUrl, size: size)
- }
- #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.alertItem = .message(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: - 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
|