Skip to content

Commit

Permalink
vm(apple): support installing guest tools
Browse files Browse the repository at this point in the history
  • Loading branch information
osy committed Nov 20, 2024
1 parent 1deed70 commit fd1bafd
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 67 deletions.
5 changes: 4 additions & 1 deletion Configuration/UTMAppleConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions Platform/Shared/VMContextMenuModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
41 changes: 36 additions & 5 deletions Platform/UTMData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
68 changes: 68 additions & 0 deletions Platform/UTMDownloadMacSupportToolsTask.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
47 changes: 30 additions & 17 deletions Platform/macOS/Display/VMDisplayAppleWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand All @@ -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)
}
}
}
Expand All @@ -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
}
}
}
}

Expand Down
125 changes: 81 additions & 44 deletions Services/UTMAppleVirtualMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -740,66 +740,79 @@ 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
/// - 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()
}
}
}
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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit fd1bafd

Please sign in to comment.