From c63afe8c110ed47a6103b077625e3696d2136bec Mon Sep 17 00:00:00 2001 From: vectorseven Date: Tue, 18 Aug 2026 11:57:49 -0500 Subject: [PATCH] macOS: add graceful power-down quit policy Allow users to request guest shutdown when quitting UTM while preserving Save State as the default. Wait for all selected virtual machines to stop, resume paused non-suspended guests, and cancel termination with a visible error when shutdown fails or times out. Related to #5912. Assisted-by: Codex:gpt-5.6-sol Assisted-by: ChatGPT:GPT-5.6-Sol --- Platform/macOS/AppDelegate.swift | 147 +++++++++++++++++++++++++++++- Platform/macOS/SettingsView.swift | 8 ++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/Platform/macOS/AppDelegate.swift b/Platform/macOS/AppDelegate.swift index 140d56a7dc..a7aead0cfa 100644 --- a/Platform/macOS/AppDelegate.swift +++ b/Platform/macOS/AppDelegate.swift @@ -14,6 +14,13 @@ // limitations under the License. // +import Combine + +enum UTMQuitPolicy: Int { + case saveState = 0 + case requestPowerDown = 1 +} + @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private enum TerminateError: Error { case wrapped(originalError: any Error, window: NSWindow?) @@ -24,6 +31,14 @@ @Setting("KeepRunningAfterLastWindowClosed") private var isKeepRunningAfterLastWindowClosed: Bool = false @Setting("HideDockIcon") private var isDockIconHidden: Bool = false @Setting("NoQuitConfirmation") private var isNoQuitConfirmation: Bool = false + @Setting("QuitRunningVirtualMachinesPolicy") private var quitPolicy: Int = UTMQuitPolicy.saveState.rawValue + + private var powerDownStateObserver: AnyCancellable? + private var powerDownRequestTask: Task? + private var powerDownTimeoutTask: Task? + private var powerDownAttemptID: UUID? + private var isPowerDownRequestComplete = false + private var arePowerDownCandidatesStopped = false private var runningVirtualMachines: [VMData] { guard let vmList = data?.vmWindows.keys else { @@ -72,7 +87,11 @@ let vmList = data.vmWindows.keys let runningList = runningVirtualMachines if !runningList.isEmpty { // There is at least 1 running VM - handleTerminateAfterSaving(candidates: runningList, sender: sender) + if UTMQuitPolicy(rawValue: quitPolicy) == .requestPowerDown { + handleTerminateAfterPowerDown(candidates: runningList) + } else { + handleTerminateAfterSaving(candidates: runningList, sender: sender) + } return .terminateLater } else if vmList.allSatisfy({ !$0.isLoaded || $0.wrapped?.state == .stopped }) { // All VMs are stopped or suspended return .terminateNow @@ -80,6 +99,132 @@ return .terminateCancel } } + + private func handleTerminateAfterPowerDown(candidates: some Sequence) { + let candidates = Array(candidates) + cancelPowerDownAttempt() + let attemptID = UUID() + powerDownAttemptID = attemptID + isPowerDownRequestComplete = false + arePowerDownCandidatesStopped = false + + let stoppedPublishers = candidates.map { vm in + vm.$state + .filter { $0 == .stopped } + .prefix(1) + .map { _ in () } + .eraseToAnyPublisher() + } + powerDownStateObserver = Publishers.MergeMany(stoppedPublishers) + .collect(candidates.count) + .sink { [weak self] _ in + guard let self = self, self.powerDownAttemptID == attemptID else { + return + } + self.arePowerDownCandidatesStopped = true + self.finishPowerDownTerminationIfReady(attemptID: attemptID) + } + + powerDownTimeoutTask = Task { + do { + try await Task.sleep(nanoseconds: 45 * NSEC_PER_SEC) + let message = NSLocalizedString("Timed out after 45 seconds waiting for running VMs to stop.", comment: "AppDelegate") + failPowerDownTermination(attemptID: attemptID, message: message) + } catch { + // Timeout was cancelled because termination already completed. + } + } + + powerDownRequestTask = Task { + let errors = await withTaskGroup(of: String?.self, returning: [String].self) { group in + for vm in candidates { + group.addTask { + guard let wrapped = await vm.wrapped else { + return NSLocalizedString("A running VM became unavailable before requesting power down.", comment: "AppDelegate") + } + do { + if await wrapped.state == .stopped { + return nil + } + if await wrapped.state == .paused { + do { + try await wrapped.resume() + } catch { + if await wrapped.state == .stopped { + return nil + } + throw error + } + if await wrapped.state == .stopped { + return nil + } + } + if await wrapped.state == .stopped { + return nil + } + do { + try await wrapped.stop(usingMethod: .request) + } catch { + if await wrapped.state == .stopped { + return nil + } + throw error + } + return nil + } catch { + let format = NSLocalizedString("Failed to request power down for %@: %@", comment: "AppDelegate") + return String(format: format, await vm.detailsTitleLabel, error.localizedDescription) + } + } + } + var errors = [String]() + for await error in group { + if let error = error { + errors.append(error) + } + } + return errors + } + guard powerDownAttemptID == attemptID else { + return + } + if errors.isEmpty { + isPowerDownRequestComplete = true + finishPowerDownTerminationIfReady(attemptID: attemptID) + } else { + failPowerDownTermination(attemptID: attemptID, message: errors.joined(separator: "\n")) + } + } + } + + private func finishPowerDownTerminationIfReady(attemptID: UUID) { + guard powerDownAttemptID == attemptID && isPowerDownRequestComplete && arePowerDownCandidatesStopped else { + return + } + cancelPowerDownAttempt() + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } + + private func failPowerDownTermination(attemptID: UUID, message: String) { + guard powerDownAttemptID == attemptID else { + return + } + cancelPowerDownAttempt() + let format = NSLocalizedString("Graceful quit cancelled: %@", comment: "AppDelegate") + let visibleMessage = String(format: format, message) + logger.error("\(visibleMessage)") + data?.showErrorAlert(message: visibleMessage) + NSApplication.shared.reply(toApplicationShouldTerminate: false) + } + + private func cancelPowerDownAttempt() { + powerDownAttemptID = nil + powerDownRequestTask?.cancel() + powerDownRequestTask = nil + powerDownTimeoutTask?.cancel() + powerDownTimeoutTask = nil + powerDownStateObserver = nil + } private func handleTerminateAfterSaving(candidates: some Sequence, sender: NSApplication) { Task { diff --git a/Platform/macOS/SettingsView.swift b/Platform/macOS/SettingsView.swift index 1c20ae6b0c..70fe8e6a7a 100644 --- a/Platform/macOS/SettingsView.swift +++ b/Platform/macOS/SettingsView.swift @@ -161,6 +161,7 @@ struct ApplicationSettingsView: View { @AppStorage("PreventIdleSleep") var isPreventIdleSleep = false @AppStorage("NoQuitConfirmation") var isNoQuitConfirmation = false @AppStorage("NoUsbPrompt") var isNoUsbPrompt = false + @AppStorage("QuitRunningVirtualMachinesPolicy") var quitPolicy = UTMQuitPolicy.saveState.rawValue @State private var isConfirmResetAutoConnect = false @@ -185,6 +186,12 @@ struct ApplicationSettingsView: View { Toggle(isOn: $isPreventIdleSleep, label: { Text("Prevent system from sleeping when any VM is running") }) + Picker("Running VMs on quit", selection: $quitPolicy) { + Text("Save State (Default)").tag(UTMQuitPolicy.saveState.rawValue) + Text("Request Power Down").tag(UTMQuitPolicy.requestPowerDown.rawValue) + } + .fixedSize(horizontal: true, vertical: false) + .help("Choose whether UTM saves the state of running virtual machines or requests that their guest operating systems shut down when UTM quits.") Toggle(isOn: $isNoQuitConfirmation, label: { Text("Do not show confirmation when closing a running VM") }).help("Closing a VM without properly shutting it down could result in data loss.") @@ -552,6 +559,7 @@ extension UserDefaults { @objc dynamic var HideDockIcon: Bool { false } @objc dynamic var PreventIdleSleep: Bool { false } @objc dynamic var NoQuitConfirmation: Bool { false } + @objc dynamic var QuitRunningVirtualMachinesPolicy: Int { UTMQuitPolicy.saveState.rawValue } @objc dynamic var NoCursorCaptureAlert: Bool { false } @objc dynamic var FullScreenAutoCapture: Bool { false } @objc dynamic var OptionAsMetaKey: Bool { false }