From fd1bafda394165ede21e69688c5b5bc764e8a26c Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Tue, 19 Nov 2024 22:29:42 -0800 Subject: [PATCH] vm(apple): support installing guest tools --- Configuration/UTMAppleConfiguration.swift | 5 +- Platform/Shared/VMContextMenuModifier.swift | 9 ++ Platform/UTMData.swift | 41 +++++- Platform/UTMDownloadMacSupportToolsTask.swift | 68 ++++++++++ .../VMDisplayAppleWindowController.swift | 47 ++++--- Services/UTMAppleVirtualMachine.swift | 125 ++++++++++++------ UTM.xcodeproj/project.pbxproj | 4 + 7 files changed, 232 insertions(+), 67 deletions(-) create mode 100644 Platform/UTMDownloadMacSupportToolsTask.swift diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index 2ff4508b3..6b6fea738 100644 --- a/Configuration/UTMAppleConfiguration.swift +++ b/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 } diff --git a/Platform/Shared/VMContextMenuModifier.swift b/Platform/Shared/VMContextMenuModifier.swift index 066f5dfd1..27b761d81 100644 --- a/Platform/Shared/VMContextMenuModifier.swift +++ b/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 } } diff --git a/Platform/UTMData.swift b/Platform/UTMData.swift index 2e76f51c9..32407b131 100644 --- a/Platform/UTMData.swift +++ b/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 diff --git a/Platform/UTMDownloadMacSupportToolsTask.swift b/Platform/UTMDownloadMacSupportToolsTask.swift new file mode 100644 index 000000000..5090f9584 --- /dev/null +++ b/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 + } +} diff --git a/Platform/macOS/Display/VMDisplayAppleWindowController.swift b/Platform/macOS/Display/VMDisplayAppleWindowController.swift index 3bb417d06..584053dac 100644 --- a/Platform/macOS/Display/VMDisplayAppleWindowController.swift +++ b/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 + } + } } } diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index 48eeb27bc..df3153c54 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/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) 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) 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) 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) 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 { diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 75b685157..08f691d5d 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/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 = ""; }; CEEC811A24E48EC600ACB0B3 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; CEECE13B25E47D9500A2AAB8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + CEEF26A62CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadMacSupportToolsTask.swift; sourceTree = ""; }; CEF01DB12B6724A300725A0F /* UTMSpiceVirtualMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSpiceVirtualMachine.swift; sourceTree = ""; }; CEF01DB62B674BF000725A0F /* UTMPipeInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMPipeInterface.swift; sourceTree = ""; }; CEF0300526A25A6900667B63 /* VMWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWizardView.swift; sourceTree = ""; }; @@ -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 */,