vm(apple): support installing guest tools

This commit is contained in:
osy
2024-11-19 22:29:42 -08:00
parent 1deed70e06
commit fd1bafda39
7 changed files with 233 additions and 68 deletions
+4 -1
View File
@@ -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
}
@@ -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
View File
@@ -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
@@ -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
}
}
@@ -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
}
}
}
}
+82 -45
View File
@@ -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<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()
}
}
}
}
}
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<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
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<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[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 {
+4
View File
@@ -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 */,