diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index 2ff4508b..6b6fea73 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 066f5dfd..27b761d8 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 2e76f51c..32407b13 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 00000000..5090f958 --- /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 3bb417d0..584053da 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,6 +327,18 @@ extension VMDisplayAppleWindowController { menu.update() } + @nonobjc private func withErrorAlert(_ callback: @escaping () async throws -> Void) { + Task.detached(priority: .background) { [self] in + do { + try await callback() + } catch { + Task { @MainActor in + showErrorAlert(error.localizedDescription) + } + } + } + } + @available(macOS 15, *) func ejectDrive(sender: AnyObject) { guard let menu = sender as? NSMenuItem else { @@ -330,14 +346,8 @@ extension VMDisplayAppleWindowController { return } let drive = appleConfig.drives[menu.tag] - Task.detached(priority: .background) { [self] in - do { - try await appleVM.eject(drive) - } catch { - Task { @MainActor in - showErrorAlert(error.localizedDescription) - } - } + withErrorAlert { + try await self.appleVM.eject(drive) } } @@ -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 48eeb27b..df3153c5 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -740,57 +740,18 @@ 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) 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() - } - } - } - } - } + private func detachDrive(id: String) async throws { + if let oldUrl = activeResourceUrls.removeValue(forKey: id) { + oldUrl.stopAccessingSecurityScopedResource() } - 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) + 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.attach(device: device) { error in + usbController.detach(device: device) { error in if let error = error { continuation.resume(throwing: error) } else { @@ -799,7 +760,59 @@ extension UTMAppleVirtualMachine { } } } - removableDrives[drive.id] = device + } + } + + /// Eject a removable drive + /// - Parameter drive: Removable drive + func eject(_ drive: UTMAppleConfigurationDrive) async throws { + if state == .started { + 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() + } + } + } + } + removableDrives[id] = device + } + + /// Change mount image of a removable drive + /// - Parameters: + /// - drive: Removable drive + /// - url: New mount image + func changeMedium(_ drive: UTMAppleConfigurationDrive, to url: URL) async throws { + var newDrive = drive + newDrive.imageURL = url + let scopedAccess = url.startAccessingSecurityScopedResource() + defer { + if scopedAccess { + url.stopAccessingSecurityScopedResource() + } + } + 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 75b68515..08f691d5 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 */,