Ver Fonte

vm(apple): support installing guest tools

osy há 9 meses atrás
pai
commit
fd1bafda39

+ 4 - 1
Configuration/UTMAppleConfiguration.swift

@@ -36,7 +36,10 @@ final class UTMAppleConfiguration: UTMConfiguration {
     @Published private var _networks: [UTMAppleConfigurationNetwork] = [.init()]
     
     @Published private var _serials: [UTMAppleConfigurationSerial] = []
-    
+
+    /// Set to true to request guest tools install. Not saved.
+    @Published var isGuestToolsInstallRequested: Bool = false
+
     var backend: UTMBackend {
         .apple
     }

+ 9 - 0
Platform/Shared/VMContextMenuModifier.swift

@@ -183,5 +183,14 @@ struct VMContextMenuModifier: ViewModifier {
                 }
             }
         }
+        #if os(macOS)
+        .onChange(of: (vm.config as? UTMAppleConfiguration)?.isGuestToolsInstallRequested) { newValue in
+            if newValue == true {
+                data.busyWorkAsync {
+                    try await data.mountSupportTools(for: vm.wrapped!)
+                }
+            }
+        }
+        #endif
     }
 }

+ 36 - 5
Platform/UTMData.swift

@@ -743,11 +743,8 @@ enum AlertItem: Identifiable {
             listRemove(pendingVM: task.pendingVM)
         }
     }
-    
-    func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
-        guard let vm = vm as? any UTMSpiceVirtualMachine else {
-            throw UTMDataError.unsupportedBackend
-        }
+
+    private func mountWindowsSupportTools(for vm: any UTMSpiceVirtualMachine) async throws {
         let task = UTMDownloadSupportToolsTask(for: vm)
         if await task.hasExistingSupportTools {
             vm.config.qemu.isGuestToolsInstallRequested = false
@@ -765,6 +762,40 @@ enum AlertItem: Identifiable {
             }
         }
     }
+
+    #if os(macOS)
+    @available(macOS 15, *)
+    private func mountMacSupportTools(for vm: UTMAppleVirtualMachine) async throws {
+        let task = UTMDownloadMacSupportToolsTask(for: vm)
+        if await task.hasExistingSupportTools {
+            vm.config.isGuestToolsInstallRequested = false
+            _ = try await task.mountTools()
+        } else {
+            listAdd(pendingVM: task.pendingVM)
+            Task {
+                do {
+                    _ = try await task.download()
+                } catch {
+                    showErrorAlert(message: error.localizedDescription)
+                }
+                vm.config.isGuestToolsInstallRequested = false
+                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

+ 68 - 0
Platform/UTMDownloadMacSupportToolsTask.swift

@@ -0,0 +1,68 @@
+//
+// Copyright © 2022 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
+
+/// Downloads support tools for macOS
+@available(macOS 15, *)
+class UTMDownloadMacSupportToolsTask: UTMDownloadTask {
+    private let vm: UTMAppleVirtualMachine
+
+    private static let supportToolsDownloadUrl = URL(string: "https://getutm.app/downloads/utm-guest-tools-macos-latest.img")!
+
+    private var toolsUrl: URL {
+        fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("GuestSupportTools")
+    }
+    
+    private var supportToolsLocalUrl: URL {
+        toolsUrl.appendingPathComponent(Self.supportToolsDownloadUrl.lastPathComponent)
+    }
+
+    @Setting("LastDownloadedMacGuestTools")
+    private var lastDownloadMacGuestTools: Int = 0
+
+    var hasExistingSupportTools: Bool {
+        get async {
+            guard fileManager.fileExists(atPath: supportToolsLocalUrl.path) else {
+                return false
+            }
+            return await lastModifiedTimestamp <= lastDownloadMacGuestTools
+        }
+    }
+    
+    init(for vm: UTMAppleVirtualMachine) {
+        self.vm = vm
+        let name = NSLocalizedString("macOS Guest Support Tools", comment: "UTMDownloadMacSupportToolsTask")
+        super.init(for: Self.supportToolsDownloadUrl, named: name)
+    }
+    
+    override func processCompletedDownload(at location: URL, response: URLResponse?) async throws -> any UTMVirtualMachine {
+        if !fileManager.fileExists(atPath: toolsUrl.path) {
+            try fileManager.createDirectory(at: toolsUrl, withIntermediateDirectories: true)
+        }
+        if fileManager.fileExists(atPath: supportToolsLocalUrl.path) {
+            try fileManager.removeItem(at: supportToolsLocalUrl)
+        }
+        try fileManager.moveItem(at: location, to: supportToolsLocalUrl)
+        lastDownloadMacGuestTools = lastModifiedTimestamp(for: response) ?? 0
+        return try await mountTools()
+    }
+    
+    func mountTools() async throws -> any UTMVirtualMachine {
+        try await vm.attachGuestTools(supportToolsLocalUrl)
+        return vm
+    }
+}

+ 30 - 17
Platform/macOS/Display/VMDisplayAppleWindowController.swift

@@ -92,6 +92,9 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
             restartToolbarItem.isEnabled = false
             sharedFolderToolbarItem.isEnabled = false
         }
+        if #available(macOS 15, *) {
+            drivesToolbarItem.isEnabled = true
+        }
     }
     
     override func enterSuspended(isBusy busy: Bool) {
@@ -285,7 +288,8 @@ extension VMDisplayAppleWindowController {
         if #available(macOS 15, *), appleConfig.system.boot.operatingSystem == .macOS {
             let item = NSMenuItem()
             item.title = NSLocalizedString("Install Guest Tools…", comment: "VMDisplayAppleWindowController")
-            item.isEnabled = true
+            item.isEnabled = !appleConfig.isGuestToolsInstallRequested
+            item.state = appleVM.hasGuestToolsAttached ? .on : .off
             item.target = self
             item.action = #selector(installGuestTools)
             menu.addItem(item)
@@ -323,16 +327,10 @@ extension VMDisplayAppleWindowController {
         menu.update()
     }
 
-    @available(macOS 15, *)
-    func ejectDrive(sender: AnyObject) {
-        guard let menu = sender as? NSMenuItem else {
-            logger.error("wrong sender for ejectDrive")
-            return
-        }
-        let drive = appleConfig.drives[menu.tag]
+    @nonobjc private func withErrorAlert(_ callback: @escaping () async throws -> Void) {
         Task.detached(priority: .background) { [self] in
             do {
-                try await appleVM.eject(drive)
+                try await callback()
             } catch {
                 Task { @MainActor in
                     showErrorAlert(error.localizedDescription)
@@ -341,6 +339,18 @@ extension VMDisplayAppleWindowController {
         }
     }
 
+    @available(macOS 15, *)
+    func ejectDrive(sender: AnyObject) {
+        guard let menu = sender as? NSMenuItem else {
+            logger.error("wrong sender for ejectDrive")
+            return
+        }
+        let drive = appleConfig.drives[menu.tag]
+        withErrorAlert {
+            try await self.appleVM.eject(drive)
+        }
+    }
+
     @available(macOS 15, *)
     func openDriveImage(forDriveIndex index: Int) {
         let drive = appleConfig.drives[index]
@@ -355,14 +365,8 @@ extension VMDisplayAppleWindowController {
                 logger.debug("no file selected")
                 return
             }
-            Task.detached(priority: .background) { [self] in
-                do {
-                    try await appleVM.changeMedium(drive, to: url)
-                } catch {
-                    Task { @MainActor in
-                        showErrorAlert(error.localizedDescription)
-                    }
-                }
+            self.withErrorAlert {
+                try await self.appleVM.changeMedium(drive, to: url)
             }
         }
     }
@@ -384,6 +388,15 @@ extension VMDisplayAppleWindowController {
 
     @available(macOS 15, *)
     @MainActor private func installGuestTools(sender: AnyObject) {
+        if appleVM.hasGuestToolsAttached {
+            withErrorAlert {
+                try await self.appleVM.detachGuestTools()
+            }
+        } else {
+            showConfirmAlert(NSLocalizedString("An USB device containing the installer will be mounted in the virtual machine. Only macOS Sequoia (15.0) and newer guests are supported.", comment: "VMDisplayAppleDisplayController")) {
+                self.appleConfig.isGuestToolsInstallRequested = true
+            }
+        }
     }
 }
 

+ 81 - 44
Services/UTMAppleVirtualMachine.swift

@@ -740,32 +740,60 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
 
 @available(macOS 15, *)
 extension UTMAppleVirtualMachine {
+    private func detachDrive(id: String) async throws {
+        if let oldUrl = activeResourceUrls.removeValue(forKey: id) {
+            oldUrl.stopAccessingSecurityScopedResource()
+        }
+        if let device = removableDrives.removeValue(forKey: 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()
+                        }
+                    }
+                }
+            }
+        }
+    }
+
     /// 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()
-                            }
-                        }
+            try await detachDrive(id: drive.id)
+        }
+        await registryEntry.removeExternalDrive(forId: drive.id)
+    }
+
+    private func attachDrive(_ drive: VZDiskImageStorageDeviceAttachment, imageURL: URL, id: String) async throws {
+        if imageURL.startAccessingSecurityScopedResource() {
+            activeResourceUrls[id] = imageURL
+        }
+        let configuration = VZUSBMassStorageDeviceConfiguration(attachment: drive)
+        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()
                     }
                 }
             }
         }
-        await registryEntry.removeExternalDrive(forId: drive.id)
+        removableDrives[id] = device
     }
 
     /// Change mount image of a removable drive
@@ -773,33 +801,18 @@ extension UTMAppleVirtualMachine {
     ///   - 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()
-                        }
-                    }
-                }
+        var newDrive = drive
+        newDrive.imageURL = url
+        let scopedAccess = url.startAccessingSecurityScopedResource()
+        defer {
+            if scopedAccess {
+                url.stopAccessingSecurityScopedResource()
             }
-            removableDrives[drive.id] = device
+        }
+        let attachment = try newDrive.vzDiskImage()!
+        if state == .started {
+            try await detachDrive(id: drive.id)
+            try await attachDrive(attachment, imageURL: url, id: drive.id)
         }
         let file = try UTMRegistryEntry.File(url: url)
         await registryEntry.setExternalDrive(file, forId: drive.id)
@@ -852,6 +865,30 @@ extension UTMAppleVirtualMachine {
         }
         self.removableDrives = removableDrives
     }
+
+    private var guestToolsId: String {
+        "guest-tools"
+    }
+
+    var hasGuestToolsAttached: Bool {
+        removableDrives.keys.contains(guestToolsId)
+    }
+
+    func attachGuestTools(_ imageURL: URL) async throws {
+        try await detachDrive(id: guestToolsId)
+        let scopedAccess = imageURL.startAccessingSecurityScopedResource()
+        defer {
+            if scopedAccess {
+                imageURL.stopAccessingSecurityScopedResource()
+            }
+        }
+        let attachment = try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: true)
+        try await attachDrive(attachment, imageURL: imageURL, id: guestToolsId)
+    }
+
+    func detachGuestTools() async throws {
+        try await detachDrive(id: guestToolsId)
+    }
 }
 
 protocol UTMScreenshotProvider: AnyObject {

+ 4 - 0
UTM.xcodeproj/project.pbxproj

@@ -922,6 +922,7 @@
 		CEE8B4C32B71E2BA0035AE86 /* UTMLoggingSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE020BAA24AEE00000B44AB6 /* UTMLoggingSwift.swift */; };
 		CEEC811B24E48EC700ACB0B3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEC811A24E48EC600ACB0B3 /* SettingsView.swift */; };
 		CEECE13C25E47D9500A2AAB8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEECE13B25E47D9500A2AAB8 /* AppDelegate.swift */; };
+		CEEF26A72CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEF26A62CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift */; };
 		CEF01DB22B6724A300725A0F /* UTMSpiceVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF01DB12B6724A300725A0F /* UTMSpiceVirtualMachine.swift */; };
 		CEF01DB32B6724A300725A0F /* UTMSpiceVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF01DB12B6724A300725A0F /* UTMSpiceVirtualMachine.swift */; };
 		CEF01DB42B6724A300725A0F /* UTMSpiceVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF01DB12B6724A300725A0F /* UTMSpiceVirtualMachine.swift */; };
@@ -2052,6 +2053,7 @@
 		CEEB66452284B942002737B2 /* VMKeyboardButton.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VMKeyboardButton.m; sourceTree = "<group>"; };
 		CEEC811A24E48EC600ACB0B3 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
 		CEECE13B25E47D9500A2AAB8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
+		CEEF26A62CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadMacSupportToolsTask.swift; sourceTree = "<group>"; };
 		CEF01DB12B6724A300725A0F /* UTMSpiceVirtualMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSpiceVirtualMachine.swift; sourceTree = "<group>"; };
 		CEF01DB62B674BF000725A0F /* UTMPipeInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMPipeInterface.swift; sourceTree = "<group>"; };
 		CEF0300526A25A6900667B63 /* VMWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWizardView.swift; sourceTree = "<group>"; };
@@ -2645,6 +2647,7 @@
 				84B36D2427B704C200C22685 /* UTMDownloadVMTask.swift */,
 				844EC0FA2773EE49003C104A /* UTMDownloadIPSWTask.swift */,
 				843232B628C4816100CFBC97 /* UTMDownloadSupportToolsTask.swift */,
+				CEEF26A62CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift */,
 				835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */,
 				CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */,
 				847BF9A92A49C783000BD9AA /* VMData.swift */,
@@ -3838,6 +3841,7 @@
 				CEEC811B24E48EC700ACB0B3 /* SettingsView.swift in Sources */,
 				8443EFF42845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */,
 				CEFE96772B69A7CC000F00C9 /* VMRemoteSessionState.swift in Sources */,
+				CEEF26A72CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift in Sources */,
 				CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
 				CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */,
 				CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */,