mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-11 21:59:56 +00:00
snapshots: manage Apple VM snapshots
Apple virtualization reported snapshots as unsupported. It has no notion of them, but the pieces are there: the disks can be layered and the saved state and firmware storage are ordinary files. Add a backend that freezes a layer per drive and keeps copies of the saved state, the EFI variables and the macOS auxiliary storage beside them. The layers under a disk that is attached cannot be changed, so a running VM may only delete; everything else waits until it stops. A layer sits beside the image it belongs to and is named after it, and the saved state and firmware of a snapshot are copies named the same way. A bundle only keeps the files its configuration names, so saving the configuration would take all of them with it. Work out what the snapshots still need instead — the layer being written to, the layers of the snapshots in the bundle, and whatever lies underneath those — and keep exactly that, so anything left over is still cleaned up. When that cannot be worked out, because the manifest or a layer cannot be read, everything named after the file is kept instead. Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5
This commit is contained in:
@@ -384,6 +384,9 @@ private extension String {
|
||||
existingDataURLs += try await _drives[i].saveData(to: dataURL)
|
||||
}
|
||||
|
||||
// what the snapshots hold sits beside the files above and is named after them
|
||||
existingDataURLs += UTMAppleSnapshotFiles.preservedDataURLs(for: existingDataURLs, in: dataURL.deletingLastPathComponent())
|
||||
|
||||
return existingDataURLs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,33 @@ extension UTMAppleDiskImage {
|
||||
try removeUnusedLayers(of: imageURL)
|
||||
}
|
||||
|
||||
/// Layers of an image that are still needed, given the snapshots that are still wanted.
|
||||
///
|
||||
/// A layer is needed when it is the one being written to, when it belongs to one of `names`,
|
||||
/// or when it lies under a layer that is needed. Anything else is left over and may go.
|
||||
/// - Parameters:
|
||||
/// - imageURL: Location of the image
|
||||
/// - names: Names of the snapshots to keep
|
||||
static func preservedLayerURLs(of imageURL: URL, for names: Set<String>) throws -> [URL] {
|
||||
let fileURLs = layerFileURLs(of: imageURL)
|
||||
let layers = try layers(of: imageURL)
|
||||
// a layer that could not be read, such as the one a running VM writes to, may be needed
|
||||
// by any of the others and is kept along with all of them
|
||||
guard layers.count == fileURLs.count else {
|
||||
return fileURLs
|
||||
}
|
||||
var preserved = Set<URL>()
|
||||
for top in layers where top.isCurrent || names.contains(top.name) {
|
||||
guard let chain = try? chain(endingAt: top, in: layers) else {
|
||||
return fileURLs
|
||||
}
|
||||
for layer in chain {
|
||||
preserved.insert(layer.url)
|
||||
}
|
||||
}
|
||||
return Array(preserved)
|
||||
}
|
||||
|
||||
/// Delete the layers of deleted snapshots that no other layer needs anymore
|
||||
private static func removeUnusedLayers(of imageURL: URL) throws {
|
||||
var layers = try layers(of: imageURL)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//
|
||||
// Copyright © 2026 Turing Software, LLC. 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
|
||||
|
||||
extension UTMAppleVirtualMachine {
|
||||
/// Images that take part in a snapshot
|
||||
@MainActor var snapshotImageURLs: [URL] {
|
||||
config.drives.compactMap { drive in
|
||||
drive.isReadOnly || drive.isExternal ? nil : drive.imageURL
|
||||
}
|
||||
}
|
||||
|
||||
/// Small files the guest writes to that are not disks, such as the variables of the firmware
|
||||
@MainActor var snapshotAuxiliaryURLs: [URL] {
|
||||
[config.system.boot.efiVariableStorageURL, config.system.macPlatform?.auxiliaryStorageURL].compactMap { url in
|
||||
url.flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which files a bundle has to keep for the snapshots of an Apple VM.
|
||||
///
|
||||
/// A bundle only keeps the files its configuration names, and what a snapshot holds is named
|
||||
/// after the file it belongs to rather than being named by the configuration.
|
||||
enum UTMAppleSnapshotFiles {
|
||||
/// Files beside `baseURLs` that the snapshots recorded in the bundle still need.
|
||||
///
|
||||
/// Everything else beside them is left over — from a snapshot that was deleted, or from a
|
||||
/// tool that is no longer around — and is left out so that it is cleaned up with the rest.
|
||||
/// - Parameters:
|
||||
/// - baseURLs: Files the configuration keeps
|
||||
/// - bundleURL: Bundle of the VM
|
||||
static func preservedDataURLs(for baseURLs: [URL], in bundleURL: URL) -> [URL] {
|
||||
// without the manifest nothing can be ruled out, and snapshots it does not list yet are
|
||||
// adopted from these files the next time the snapshots are read
|
||||
guard let manifest = (try? UTMSnapshotManifest.load(from: bundleURL)) ?? nil else {
|
||||
return baseURLs.flatMap { namedAfter($0) }
|
||||
}
|
||||
var identifiers = Set(manifest.snapshots.map { $0.backendIdentifier })
|
||||
if let suspendIdentifier = manifest.suspendIdentifier {
|
||||
identifiers.insert(suspendIdentifier)
|
||||
}
|
||||
let fileManager = FileManager.default
|
||||
var preserved = [URL]()
|
||||
for baseURL in baseURLs {
|
||||
// the saved state and the firmware of a snapshot are copies named after the original
|
||||
for identifier in identifiers {
|
||||
let url = baseURL.appendingPathExtension(identifier)
|
||||
if fileManager.fileExists(atPath: url.path) {
|
||||
preserved.append(url)
|
||||
}
|
||||
}
|
||||
if #available(macOS 27, *), let layerURLs = try? UTMAppleDiskImage.preservedLayerURLs(of: baseURL, for: identifiers) {
|
||||
preserved += layerURLs
|
||||
} else {
|
||||
// the layers cannot be read, so keep all of them rather than lose one
|
||||
preserved += namedAfter(baseURL).filter { $0.pathExtension == "asif" }
|
||||
}
|
||||
}
|
||||
return preserved
|
||||
}
|
||||
|
||||
/// Files beside `baseURL` whose names start with its own, which is how snapshots name theirs
|
||||
private static func namedAfter(_ baseURL: URL) -> [URL] {
|
||||
let prefix = baseURL.lastPathComponent + "."
|
||||
let siblings = (try? FileManager.default.contentsOfDirectory(at: baseURL.deletingLastPathComponent(), includingPropertiesForKeys: nil)) ?? []
|
||||
return siblings.filter { $0.lastPathComponent.hasPrefix(prefix) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshots of a VM using Apple Virtualization.
|
||||
///
|
||||
/// A snapshot spans every drive. The disks are kept as a layer of each image (see
|
||||
/// `UTMAppleDiskImage`), while the running state and the files in `auxiliaryURLs` are small
|
||||
/// enough to be kept as copies named after the snapshot. Virtualization cannot change the layers of a
|
||||
/// disk that is attached, so the current state can only be saved or replaced while the VM is
|
||||
/// not running. Everything else works either way.
|
||||
@available(macOS 27, *)
|
||||
struct UTMAppleSnapshotBackend: UTMSnapshotBackend {
|
||||
let imageURLs: [URL]
|
||||
|
||||
let isInventoryExact = true
|
||||
|
||||
/// Files that are not disks but still part of the state of the guest
|
||||
let auxiliaryURLs: [URL]
|
||||
|
||||
/// File the VM is suspended to
|
||||
let savedStateURL: URL?
|
||||
|
||||
let isRunning: Bool
|
||||
|
||||
/// Kept from starting while its files change
|
||||
var vm: UTMAppleVirtualMachine? = nil
|
||||
|
||||
/// There is only one file to suspend to which is never a snapshot by itself
|
||||
let defaultSuspendIdentifier = "suspend"
|
||||
|
||||
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool {
|
||||
// the layers below a disk that is attached have to stay the same
|
||||
!isRunning || operation == .delete
|
||||
}
|
||||
|
||||
private var fileManager: FileManager {
|
||||
FileManager.default
|
||||
}
|
||||
|
||||
private func snapshotStateURL(for identifier: String) -> URL? {
|
||||
savedStateURL?.appendingPathExtension(identifier)
|
||||
}
|
||||
|
||||
/// Copy beside `url` for `replaceItemAt`, which consumes what it puts in place
|
||||
private func copyToTemporary(_ url: URL) throws -> URL {
|
||||
let temporaryURL = url.appendingPathExtension(UUID().uuidString)
|
||||
try fileManager.copyItem(at: url, to: temporaryURL)
|
||||
return temporaryURL
|
||||
}
|
||||
|
||||
private func size(of url: URL) -> Int64 {
|
||||
let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
|
||||
return Int64(values?.totalFileAllocatedSize ?? 0)
|
||||
}
|
||||
|
||||
func entries() async throws -> [UTMSnapshotBackendEntry] {
|
||||
var entries = [String: UTMSnapshotBackendEntry]()
|
||||
var imageCounts = [String: Int]()
|
||||
for imageURL in imageURLs {
|
||||
let layers = try UTMAppleDiskImage.layers(of: imageURL)
|
||||
for layer in layers where !layer.isCurrent && !layer.isUnlinked {
|
||||
// skip the layers of deleted snapshots to find the one this was taken from
|
||||
var parent = layers.first { $0.uuid == layer.parentUUID }
|
||||
while let unlinked = parent, unlinked.isUnlinked {
|
||||
parent = layers.first { $0.uuid == unlinked.parentUUID }
|
||||
}
|
||||
let date = (try? layer.url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date()
|
||||
let previous = entries[layer.name]
|
||||
entries[layer.name] = UTMSnapshotBackendEntry(identifier: layer.name,
|
||||
date: max(date, previous?.date ?? .distantPast),
|
||||
size: size(of: layer.url) + (previous?.size ?? 0),
|
||||
hasState: false,
|
||||
parentIdentifier: previous?.parentIdentifier ?? parent?.name)
|
||||
imageCounts[layer.name, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return entries.values.map { entry in
|
||||
var entry = entry
|
||||
entry.isComplete = imageCounts[entry.identifier] == imageURLs.count
|
||||
guard let stateURL = snapshotStateURL(for: entry.identifier), fileManager.fileExists(atPath: stateURL.path) else {
|
||||
return entry
|
||||
}
|
||||
return UTMSnapshotBackendEntry(identifier: entry.identifier,
|
||||
date: entry.date,
|
||||
size: entry.size + size(of: stateURL),
|
||||
hasState: true,
|
||||
isComplete: entry.isComplete,
|
||||
parentIdentifier: entry.parentIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry {
|
||||
let identifier = UUID().uuidString
|
||||
try await changingFiles(as: .saving) {
|
||||
do {
|
||||
for imageURL in imageURLs {
|
||||
try UTMAppleDiskImage.createSnapshot(of: imageURL, name: identifier)
|
||||
}
|
||||
for auxiliaryURL in auxiliaryURLs {
|
||||
try fileManager.copyItem(at: auxiliaryURL, to: auxiliaryURL.appendingPathExtension(identifier))
|
||||
}
|
||||
if suspendIdentifier != nil, let savedStateURL = savedStateURL, let stateURL = snapshotStateURL(for: identifier) {
|
||||
// the VM still resumes from its own copy
|
||||
try fileManager.copyItem(at: savedStateURL, to: stateURL)
|
||||
}
|
||||
} catch {
|
||||
try? await delete(identifier: identifier)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
guard let entry = try await entries().first(where: { $0.identifier == identifier }) else {
|
||||
throw UTMSnapshotError.notFound(identifier)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool {
|
||||
guard entry.isComplete else {
|
||||
throw UTMSnapshotError.incomplete(entry.identifier)
|
||||
}
|
||||
try await changingFiles(as: .restoring) {
|
||||
// every drive is restored or none is
|
||||
for imageURL in imageURLs {
|
||||
try UTMAppleDiskImage.validateSnapshot(of: imageURL, name: entry.identifier)
|
||||
}
|
||||
for imageURL in imageURLs {
|
||||
try UTMAppleDiskImage.restoreSnapshot(of: imageURL, name: entry.identifier)
|
||||
// what the VM was suspended to stops matching the drives once one is restored
|
||||
if let savedStateURL = savedStateURL, fileManager.fileExists(atPath: savedStateURL.path) {
|
||||
try? fileManager.removeItem(at: savedStateURL)
|
||||
}
|
||||
}
|
||||
for auxiliaryURL in auxiliaryURLs {
|
||||
let snapshotURL = auxiliaryURL.appendingPathExtension(entry.identifier)
|
||||
if fileManager.fileExists(atPath: snapshotURL.path) {
|
||||
// the original stays until the copy is in place so a failure cannot lose it
|
||||
_ = try fileManager.replaceItemAt(auxiliaryURL, withItemAt: try copyToTemporary(snapshotURL))
|
||||
}
|
||||
}
|
||||
if let savedStateURL = savedStateURL, entry.hasState, let stateURL = snapshotStateURL(for: entry.identifier) {
|
||||
try fileManager.copyItem(at: stateURL, to: savedStateURL)
|
||||
}
|
||||
}
|
||||
return savedStateURL != nil && entry.hasState
|
||||
}
|
||||
|
||||
private func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
|
||||
if let vm = vm {
|
||||
try await vm.changingFiles(as: state, body)
|
||||
} else {
|
||||
try await body()
|
||||
}
|
||||
}
|
||||
|
||||
func delete(identifier: String) async throws {
|
||||
var isFound = false
|
||||
for imageURL in imageURLs {
|
||||
do {
|
||||
try UTMAppleDiskImage.deleteSnapshot(of: imageURL, name: identifier)
|
||||
isFound = true
|
||||
} catch UTMAppleDiskImageError.snapshotNotFound(_) {
|
||||
// a drive added after the snapshot was created
|
||||
}
|
||||
}
|
||||
for auxiliaryURL in auxiliaryURLs {
|
||||
try? fileManager.removeItem(at: auxiliaryURL.appendingPathExtension(identifier))
|
||||
}
|
||||
if let stateURL = snapshotStateURL(for: identifier) {
|
||||
try? fileManager.removeItem(at: stateURL)
|
||||
}
|
||||
guard isFound else {
|
||||
throw UTMSnapshotError.notFound(identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
state = .stopped
|
||||
if !isDisposible {
|
||||
try? await deleteSnapshot()
|
||||
// the layers of a snapshot below the disks may be gone
|
||||
try? await UTMSnapshotService.identifyOrphanedSnapshots(on: self)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -553,9 +555,48 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
}
|
||||
await registryEntry.setIsSuspended(false)
|
||||
await registryEntry.setConnectedUsbDevices([])
|
||||
try FileManager.default.removeItem(at: vmSavedStateURL)
|
||||
// a state that is gone already is still let go of below
|
||||
if FileManager.default.fileExists(atPath: vmSavedStateURL.path) {
|
||||
try FileManager.default.removeItem(at: vmSavedStateURL)
|
||||
}
|
||||
if #available(macOS 27, *) {
|
||||
try? await releaseSuspendSnapshot(savedStateURL: vmSavedStateURL)
|
||||
}
|
||||
try? updateLastModified()
|
||||
}
|
||||
|
||||
/// Keep the VM from starting while the files of its snapshots change, which it would otherwise
|
||||
/// attach halfway through
|
||||
@available(macOS 27, *)
|
||||
func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
|
||||
guard self.state == .stopped else {
|
||||
throw UTMAppleVirtualMachineError.operationNotAvailable
|
||||
}
|
||||
self.state = state
|
||||
defer {
|
||||
self.state = .stopped
|
||||
}
|
||||
try await body()
|
||||
}
|
||||
|
||||
/// The saved state may have been a copy of a snapshot that was only kept to resume from
|
||||
@available(macOS 27, *)
|
||||
private func releaseSuspendSnapshot(savedStateURL: URL) async throws {
|
||||
let staleIdentifiers = try UTMSnapshotManifest.releaseSuspendState(in: pathUrl, default: nil)
|
||||
guard !staleIdentifiers.isEmpty else {
|
||||
return
|
||||
}
|
||||
let backend = UTMAppleSnapshotBackend(imageURLs: await snapshotImageURLs, auxiliaryURLs: await snapshotAuxiliaryURLs, savedStateURL: savedStateURL, isRunning: state != .stopped)
|
||||
// one that cannot go now stays listed and goes the next time
|
||||
for identifier in staleIdentifiers {
|
||||
do {
|
||||
try await backend.delete(identifier: identifier)
|
||||
} catch UTMSnapshotError.notFound(_) {
|
||||
// already gone
|
||||
}
|
||||
try UTMSnapshotManifest.forgetState(identifier, in: pathUrl)
|
||||
}
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
@available(macOS 14, *)
|
||||
|
||||
@@ -43,6 +43,8 @@ enum UTMSnapshotService {
|
||||
#if os(macOS)
|
||||
if vm is UTMQemuVirtualMachine {
|
||||
return true
|
||||
} else if #available(macOS 27, *), vm is UTMAppleVirtualMachine {
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
// qemu-img is needed for a VM that is not running and is only part of the macOS app
|
||||
@@ -379,6 +381,18 @@ enum UTMSnapshotService {
|
||||
default: throw UTMSnapshotError.invalidVmState
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
if #available(macOS 27, *), let apple = vm as? UTMAppleVirtualMachine {
|
||||
guard [.started, .paused, .stopped].contains(vm.state) else {
|
||||
throw UTMSnapshotError.invalidVmState
|
||||
}
|
||||
return UTMAppleSnapshotBackend(imageURLs: apple.snapshotImageURLs,
|
||||
auxiliaryURLs: apple.snapshotAuxiliaryURLs,
|
||||
savedStateURL: apple.config.system.boot.vmSavedStateURL,
|
||||
isRunning: vm.state != .stopped,
|
||||
vm: apple)
|
||||
}
|
||||
#endif
|
||||
throw UTMSnapshotError.notSupported
|
||||
}
|
||||
|
||||
|
||||
@@ -687,6 +687,7 @@
|
||||
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */; };
|
||||
CE7A1C062EFA0A01001671E9 /* AccessoryAccess.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */; };
|
||||
1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */; };
|
||||
CE1D15AD2F6B100100D15C01 /* DiskImageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||
CE6804852E4E5D84001671E9 /* UTMScriptingInputImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */; };
|
||||
CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; };
|
||||
@@ -2060,6 +2061,7 @@
|
||||
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = "<group>"; };
|
||||
CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AccessoryAccess.framework; path = System/Library/Frameworks/AccessoryAccess.framework; sourceTree = SDKROOT; };
|
||||
CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleDiskImage.swift; sourceTree = "<group>"; };
|
||||
0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleSnapshotBackend.swift; sourceTree = "<group>"; };
|
||||
CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DiskImageKit.framework; path = System/Library/Frameworks/DiskImageKit.framework; sourceTree = SDKROOT; };
|
||||
CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingInputImpl.swift; sourceTree = "<group>"; };
|
||||
CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = "<group>"; };
|
||||
@@ -3023,6 +3025,7 @@
|
||||
CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */,
|
||||
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */,
|
||||
CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */,
|
||||
0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */,
|
||||
CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */,
|
||||
CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */,
|
||||
4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */,
|
||||
@@ -4207,6 +4210,7 @@
|
||||
CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */,
|
||||
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */,
|
||||
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */,
|
||||
1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */,
|
||||
CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
|
||||
83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
|
||||
84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user