Browse Source

vm(apple): implement detachable drives for macOS 15

osy 9 months ago
parent
commit
ae3e5ad9d6

+ 8 - 1
Configuration/UTMAppleConfiguration.swift

@@ -263,7 +263,11 @@ extension UTMAppleConfiguration {
                     return nil
                 }
                 if #available(macOS 13, *), drive.isExternal {
-                    return VZUSBMassStorageDeviceConfiguration(attachment: attachment)
+                    if #available(macOS 15, *) {
+                        return nil // we will handle removable drives in `UTMAppleVirtualMachine`
+                    } else {
+                        return VZUSBMassStorageDeviceConfiguration(attachment: attachment)
+                    }
                 } else if #available(macOS 14, *), drive.isNvme, system.boot.operatingSystem == .linux {
                     return VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
                 } else {
@@ -297,6 +301,9 @@ extension UTMAppleConfiguration {
         } else if system.boot.operatingSystem != .macOS && !displays.isEmpty {
             throw UTMAppleConfigurationError.featureNotSupported
         }
+        if #available(macOS 15, *) {
+            vzconfig.usbControllers = [VZXHCIControllerConfiguration()]
+        }
         return vzconfig
     }
 

+ 23 - 5
Platform/macOS/VMAppleRemovableDrivesView.swift

@@ -46,7 +46,15 @@ struct VMAppleRemovableDrivesView: View {
             return false
         }
     }
-    
+
+    private var hasLiveRemovableDrives: Bool {
+        if #available(macOS 15, *) {
+            return true
+        } else {
+            return false
+        }
+    }
+
     var body: some View {
         Group {
             ForEach($registryEntry.sharedDirectories) { $sharedDirectory in
@@ -95,7 +103,7 @@ struct VMAppleRemovableDrivesView: View {
                             }
                         } label: {
                             Label("External Drive", systemImage: "externaldrive")
-                        }.disabled(vm.hasSuspendState || vm.state != .stopped)
+                        }.disabled(vm.hasSuspendState || (vm.state != .stopped && !hasLiveRemovableDrives))
                     } else {
                         Label("\(diskImage.sizeString) Drive", systemImage: "internaldrive")
                     }
@@ -196,13 +204,23 @@ struct VMAppleRemovableDrivesView: View {
     private func selectRemovableImage(for diskImage: UTMAppleConfigurationDrive, result: Result<URL, Error>) {
         data.busyWorkAsync {
             let url = try result.get()
-            let file = try UTMRegistryEntry.File(url: url)
-            await registryEntry.setExternalDrive(file, forId: diskImage.id)
+            if #available(macOS 15, *) {
+                try await appleVM.changeMedium(diskImage, to: url)
+            } else {
+                let file = try UTMRegistryEntry.File(url: url)
+                await registryEntry.setExternalDrive(file, forId: diskImage.id)
+            }
         }
     }
     
     private func clearRemovableImage(_ diskImage: UTMAppleConfigurationDrive) {
-        registryEntry.removeExternalDrive(forId: diskImage.id)
+        data.busyWorkAsync {
+            if #available(macOS 15, *) {
+                try await appleVM.eject(diskImage)
+            } else {
+                await registryEntry.removeExternalDrive(forId: diskImage.id)
+            }
+        }
     }
 }
 

+ 2 - 1
Platform/macOS/VMConfigAppleVirtualizationView.swift

@@ -36,7 +36,8 @@ struct VMConfigAppleVirtualizationView: View {
                 Toggle("Enable Rosetta on Linux (x86_64 Emulation)", isOn: $config.hasRosetta.bound)
                     .help("If enabled, a virtiofs share tagged 'rosetta' will be available on the Linux guest for installing Rosetta for emulating x86_64 on ARM64.")
                 #endif
-                
+            }
+            if #available(macOS 13, *) {
                 Toggle("Enable Clipboard Sharing", isOn: $config.hasClipboardSharing)
                     .help("Requires SPICE guest agent tools to be installed.")
             }

+ 127 - 5
Services/UTMAppleVirtualMachine.swift

@@ -114,8 +114,10 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
     
     weak var screenshotDelegate: UTMScreenshotProvider?
     
-    private var activeResourceUrls: [URL] = []
-    
+    private var activeResourceUrls: [String: URL] = [:]
+
+    private var removableDrives: [String: Any] = [:]
+
     @MainActor required init(packageUrl: URL, configuration: UTMAppleConfiguration, isShortcut: Bool = false) throws {
         self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
         // load configuration
@@ -187,6 +189,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
             } else {
                 try await _start(options: options)
             }
+            if #available(macOS 15, *) {
+                try await attachExternalDrives()
+            }
             if #available(macOS 12, *) {
                 Task { @MainActor in
                     let tag = config.shareDirectoryTag
@@ -615,7 +620,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
             let drive = config.drives[i]
             if let url = drive.imageURL, drive.isExternal {
                 if url.startAccessingSecurityScopedResource() {
-                    activeResourceUrls.append(url)
+                    activeResourceUrls[drive.id] = url
                 } else {
                     config.drives[i].imageURL = nil
                     throw UTMAppleVirtualMachineError.cannotAccessResource(url)
@@ -626,7 +631,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
             let share = config.sharedDirectories[i]
             if let url = share.directoryURL {
                 if url.startAccessingSecurityScopedResource() {
-                    activeResourceUrls.append(url)
+                    activeResourceUrls[share.id.uuidString] = url
                 } else {
                     config.sharedDirectories[i].directoryURL = nil
                     throw UTMAppleVirtualMachineError.cannotAccessResource(url)
@@ -636,7 +641,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
     }
     
     @MainActor private func stopAccesingResources() {
-        for url in activeResourceUrls {
+        for url in activeResourceUrls.values {
             url.stopAccessingSecurityScopedResource()
         }
         activeResourceUrls.removeAll()
@@ -650,6 +655,7 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
             apple = nil
             snapshotUnsupportedError = nil
         }
+        removableDrives.removeAll()
         sharedDirectoriesChanged = nil
         Task { @MainActor in
             stopAccesingResources()
@@ -732,6 +738,122 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
     }
 }
 
+@available(macOS 15, *)
+extension UTMAppleVirtualMachine {
+    /// Eject a removable drive
+    /// - Parameter drive: Removable drive
+    func eject(_ drive: UTMAppleConfigurationDrive) async throws {
+        if state == .started {
+            if let oldUrl = activeResourceUrls.removeValue(forKey: drive.id) {
+                oldUrl.stopAccessingSecurityScopedResource()
+            }
+            if let device = removableDrives.removeValue(forKey: drive.id) as? any VZUSBDevice {
+                try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
+                    vmQueue.async {
+                        guard let apple = self.apple, let usbController = apple.usbControllers.first else {
+                            continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable)
+                            return
+                        }
+                        usbController.detach(device: device) { error in
+                            if let error = error {
+                                continuation.resume(throwing: error)
+                            } else {
+                                continuation.resume()
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        await registryEntry.removeExternalDrive(forId: drive.id)
+    }
+
+    /// Change mount image of a removable drive
+    /// - Parameters:
+    ///   - drive: Removable drive
+    ///   - url: New mount image
+    func changeMedium(_ drive: UTMAppleConfigurationDrive, to url: URL) async throws {
+        try await eject(drive)
+        if state == .started {
+            guard url.startAccessingSecurityScopedResource() else {
+                throw UTMAppleVirtualMachineError.cannotAccessResource(url)
+            }
+            activeResourceUrls[drive.id] = url
+            var newDrive = drive
+            newDrive.imageURL = url
+            let attachment = try newDrive.vzDiskImage()!
+            let configuration = VZUSBMassStorageDeviceConfiguration(attachment: attachment)
+            let device = VZUSBMassStorageDevice(configuration: configuration)
+            try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
+                vmQueue.async {
+                    guard let apple = self.apple, let usbController = apple.usbControllers.first else {
+                        continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable)
+                        return
+                    }
+                    usbController.attach(device: device) { error in
+                        if let error = error {
+                            continuation.resume(throwing: error)
+                        } else {
+                            continuation.resume()
+                        }
+                    }
+                }
+            }
+            removableDrives[drive.id] = device
+        }
+        let file = try UTMRegistryEntry.File(url: url)
+        await registryEntry.setExternalDrive(file, forId: drive.id)
+    }
+
+    private func _attachExternalDrives(_ drives: [any VZUSBDevice]) -> (any Error)? {
+        let group = DispatchGroup()
+        var lastError: (any Error)?
+        group.enter()
+        vmQueue.async {
+            defer {
+                group.leave()
+            }
+            guard let apple = self.apple, let usbController = apple.usbControllers.first else {
+                lastError = UTMAppleVirtualMachineError.operationNotAvailable
+                return
+            }
+            for device in drives {
+                group.enter()
+                usbController.attach(device: device) { error in
+                    if let error = error {
+                        lastError = error
+                    }
+                    group.leave()
+                }
+            }
+        }
+        group.wait()
+        return lastError
+    }
+
+    private func attachExternalDrives() async throws {
+        let removableDrives = try await config.drives.reduce(into: [String: any VZUSBDevice]()) { devices, drive in
+            guard drive.isExternal else {
+                return
+            }
+            guard let attachment = try drive.vzDiskImage() else {
+                return
+            }
+            let configuration = VZUSBMassStorageDeviceConfiguration(attachment: attachment)
+            devices[drive.id] = VZUSBMassStorageDevice(configuration: configuration)
+        }
+        let drives = Array(removableDrives.values)
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
+            if let error = self._attachExternalDrives(drives) {
+                continuation.resume(throwing: error)
+            } else {
+                continuation.resume()
+            }
+        }
+        self.removableDrives = removableDrives
+    }
+}
+
 protocol UTMScreenshotProvider: AnyObject {
     var screenshot: UTMVirtualMachineScreenshot? { get }
 }