mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
vm(apple): support installing guest tools
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
Reference in New Issue
Block a user