Files
UTM/Platform/UTMData.swift
T
osy 3359543d2c ios: run qemu-img in a helper extension on iOS 26
QEMU tools cannot run in the app process on iOS: a process may load QEMU
only once and qemu-img calls exit(). So disk snapshots of a stopped VM,
reclaiming space, compression, resizing and conversion on import were
macOS-only.

iOS 26 brings ExtensionKit, so qemu-img now runs in an ExtensionKit
extension (iOSHelper, and iOSHelper-SE for UTM SE since an extension's
bundle identifier must be prefixed by its host's). The helper speaks the
same QEMUHelperProtocol as the macOS XPC helper but loads the tool into
its own process, one tool per process. UTMProcess accepts an injected
connection so the remote path is shared with macOS, including file
access through bookmarks, whose implicit security scope grants the
resolving process access. UTMHelperProcess launches the helper and,
because the system hands a process that is still exiting to a launch
that comes too soon, tears it down with a handshake on the interruption
callback and runs helpers one at a time.

UTMQemuImage now reads the tool's output until it ends instead of
racing the exit message, resumes its continuations off the XPC reply
queue (a call made from that queue blocks in NSXPC), and stops the
process explicitly since the connection's exported object kept it from
ever being deallocated on macOS. The last stderr line becomes the error
text when the tool gives none.

Assisted-by: Claude:claude-fable-5-1
2026-09-23 23:10:06 -05:00

1472 lines
53 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
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, .fileResourceIdentifierKey], options: .skipsHiddenFiles)
let newFiles = files.filter { newFile in
!list.contains { existingVM in
isSameFile(existingVM.pathUrl, as: newFile)
}
}
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
}
}
/// Compare two files and sees if they are the same
/// - Parameters:
/// - url1: first file
/// - url2: second file
/// - Returns: If they are the same file
func isSameFile(_ url1: URL, as url2: URL) -> Bool {
if let id1 = try? url1.resourceValues(forKeys: [.fileResourceIdentifierKey]).fileResourceIdentifier,
let id2 = try? url2.resourceValues(forKeys: [.fileResourceIdentifierKey]).fileResourceIdentifier {
return id1.isEqual(id2)
}
return url1.standardizedFileURL == url2.standardizedFileURL
}
/// 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 isSameFile(vm.pathUrl, as: url)
}) {
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
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 !WITH_REMOTE
/// 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 {
// converting leaves the snapshots in the image behind, and a suspended state is one of them
guard try await UTMQemuImage.snapshots(image: driveUrl).isEmpty else {
throw UTMDataError.driveHasSnapshots
}
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))
}
#endif
#if os(macOS)
@available(macOS 14, *)
func appleDriveInfo(for driveUrl: URL) -> (format: String?, size: Int64?) {
guard let info = try? UTMAppleDiskImage.info(for: driveUrl) else {
return (nil, nil)
}
return (info.format.localizedDescription, info.size)
}
@available(macOS 14, *)
func resizeAppleDrive(for driveUrl: URL, sizeInMib: Int) throws {
try UTMAppleDiskImage.resize(driveUrl, toSizeMib: sizeInMib)
}
#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)
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
case driveHasSnapshots
}
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")
case .driveHasSnapshots:
return NSLocalizedString("Space cannot be reclaimed from a drive while it holds snapshots or a suspended state.", 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