Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 146 additions & 1 deletion Platform/macOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Expand All @@ -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<Void, Never>?
private var powerDownTimeoutTask: Task<Void, Never>?
private var powerDownAttemptID: UUID?
private var isPowerDownRequestComplete = false
private var arePowerDownCandidatesStopped = false

private var runningVirtualMachines: [VMData] {
guard let vmList = data?.vmWindows.keys else {
Expand Down Expand Up @@ -72,14 +87,144 @@
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
} else { // There could be some VMs in other states (starting, pausing, etc.)
return .terminateCancel
}
}

private func handleTerminateAfterPowerDown(candidates: some Sequence<VMData>) {
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<VMData>, sender: NSApplication) {
Task {
Expand Down
8 changes: 8 additions & 0 deletions Platform/macOS/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.")
Expand Down Expand Up @@ -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 }
Expand Down