diff --git a/Configuration/UTMQemuConfiguration+Arguments.swift b/Configuration/UTMQemuConfiguration+Arguments.swift index c2ebd0afb..5500ecc67 100644 --- a/Configuration/UTMQemuConfiguration+Arguments.swift +++ b/Configuration/UTMQemuConfiguration+Arguments.swift @@ -510,7 +510,7 @@ import Virtualization // for getting network interfaces } private var resourceURL: URL { - Bundle.main.url(forResource: "qemu", withExtension: nil)! + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("qemu", isDirectory: true) } private var soundBackend: UTMQEMUSoundBackend { diff --git a/Services/UTMQemuVirtualMachine.swift b/Services/UTMQemuVirtualMachine.swift index 2994fea08..28bee3c10 100644 --- a/Services/UTMQemuVirtualMachine.swift +++ b/Services/UTMQemuVirtualMachine.swift @@ -155,6 +155,8 @@ final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine { private var changeCursorRequestInProgress: Bool = false + private static var resourceCacheOperationQueue = DispatchQueue(label: "Resource Cache Operation") + #if WITH_SERVER @Setting("ServerPort") private var serverPort: Int = 0 private var spicePort: SwiftPortmap.Port? @@ -275,6 +277,10 @@ extension UTMQemuVirtualMachine { guard await isSupported else { throw UTMQemuVirtualMachineError.emulationNotSupported } + + // create QEMU resource cache if needed + try await ensureQemuResourceCacheUpToDate() + let hasDebugLog = await config.qemu.hasDebugLog // start logging if hasDebugLog, let debugLogURL = await config.qemu.debugLogURL { @@ -885,6 +891,87 @@ extension UTMQemuVirtualMachine { } } +// MARK: - Caching QEMU resources +extension UTMQemuVirtualMachine { + private func _ensureQemuResourceCacheUpToDate() throws { + let fm = FileManager.default + let qemuResourceUrl = Bundle.main.url(forResource: "qemu", withExtension: nil)! + let cacheUrl = try fm.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let qemuCacheUrl = cacheUrl.appendingPathComponent("qemu", isDirectory: true) + + guard fm.fileExists(atPath: qemuCacheUrl.path) else { + try fm.copyItem(at: qemuResourceUrl, to: qemuCacheUrl) + return + } + + logger.info("Updating QEMU resource cache...") + // first visit all the subdirectories and create them if needed + let subdirectoryEnumerator = fm.enumerator(at: qemuResourceUrl, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .producesRelativePathURLs, .includesDirectoriesPostOrder])! + for case let directoryURL as URL in subdirectoryEnumerator { + guard subdirectoryEnumerator.isEnumeratingDirectoryPostOrder else { + continue + } + let relativePath = directoryURL.relativePath + let destUrl = qemuCacheUrl.appendingPathComponent(relativePath) + var isDirectory: ObjCBool = false + if fm.fileExists(atPath: destUrl.path, isDirectory: &isDirectory) { + // old file is now a directory + if !isDirectory.boolValue { + logger.info("Removing file \(destUrl.path)") + try fm.removeItem(at: destUrl) + } else { + continue + } + } + logger.info("Creating directory \(destUrl.path)") + try fm.createDirectory(at: destUrl, withIntermediateDirectories: true) + } + // next check all the files + let fileEnumerator = fm.enumerator(at: qemuResourceUrl, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey], options: [.skipsHiddenFiles, .producesRelativePathURLs])! + for case let sourceUrl as URL in fileEnumerator { + let relativePath = sourceUrl.relativePath + let sourceResourceValues = try sourceUrl.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey]) + guard !sourceResourceValues.isDirectory! else { + continue + } + let destUrl = qemuCacheUrl.appendingPathComponent(relativePath) + if fm.fileExists(atPath: destUrl.path) { + // first do a quick comparsion with resource keys + let destResourceValues = try destUrl.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey]) + // old directory is now a file + if destResourceValues.isDirectory! { + logger.info("Removing directory \(destUrl.path)") + try fm.removeItem(at: destUrl) + } else if destResourceValues.contentModificationDate == sourceResourceValues.contentModificationDate && destResourceValues.fileSize == sourceResourceValues.fileSize { + // assume the file is the same + continue + } else { + logger.info("Removing file \(destUrl.path)") + try fm.removeItem(at: destUrl) + } + } + // if we are here, the file has changed + logger.info("Copying file \(sourceUrl.path) to \(destUrl.path)") + try fm.copyItem(at: sourceUrl, to: destUrl) + } + } + + func ensureQemuResourceCacheUpToDate() async throws { + try await withCheckedThrowingContinuation { continuation in + Self.resourceCacheOperationQueue.async { [weak self] in + do { + try self?._ensureQemuResourceCacheUpToDate() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } +} + +// MARK: - Errors + enum UTMQemuVirtualMachineError: Error { case failedToAccessShortcut case emulationNotSupported