| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import SwiftUI |
| 4 | |
| 5 | struct DocsCloseGuard: NSViewRepresentable { |
| 6 | let hasUnsaved: @MainActor () -> Bool |
| 7 | let save: @MainActor () async -> Bool |
| 8 | let discard: @MainActor () async -> Void |
| 9 | func makeNSView(context: Context) -> DocsCloseGuardView { |
| 10 | let view = DocsCloseGuardView() |
| 11 | view.hasUnsaved = hasUnsaved; view.save = save; view.discard = discard |
| 12 | return view |
| 13 | } |
| 14 | func updateNSView(_ view: DocsCloseGuardView, context: Context) { |
| 15 | view.hasUnsaved = hasUnsaved; view.save = save; view.discard = discard |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | @MainActor final class DocsCloseGuardView: NSView, NSWindowDelegate { |
| 20 | var hasUnsaved: @MainActor () -> Bool = { false } |
| 21 | var save: @MainActor () async -> Bool = { true } |
| 22 | var discard: @MainActor () async -> Void = {} |
| 23 | var presenting = false |
| 24 | nonisolated(unsafe) weak var previousDelegate: (any NSWindowDelegate)? |
| 25 | override func viewDidMoveToWindow() { |
| 26 | super.viewDidMoveToWindow() |
| 27 | guard let window, window.delegate !== self else { return } |
| 28 | DocsCloseRegistry.views.removeAll { $0.value == nil } |
| 29 | if !DocsCloseRegistry.views.contains(where: { $0.value === self }) { |
| 30 | DocsCloseRegistry.views.append(WeakDocsCloseGuard(value: self)) |
| 31 | } |
| 32 | previousDelegate = window.delegate |
| 33 | window.delegate = self |
| 34 | } |
| 35 | override func responds(to selector: Selector!) -> Bool { |
| 36 | super.responds(to: selector) || previousDelegate?.responds(to: selector) == true |
| 37 | } |
| 38 | override func forwardingTarget(for selector: Selector!) -> Any? { previousDelegate } |
| 39 | func windowShouldClose(_ sender: NSWindow) -> Bool { |
| 40 | guard hasUnsaved() else { return previousDelegate?.windowShouldClose?(sender) ?? true } |
| 41 | guard !presenting else { return false } |
| 42 | presenting = true |
| 43 | let alert = Self.alert() |
| 44 | alert.beginSheetModal(for: sender) { [weak self] response in |
| 45 | guard let self else { return } |
| 46 | Task { @MainActor in |
| 47 | defer { self.presenting = false } |
| 48 | if response == .alertFirstButtonReturn, await self.save() { sender.performClose(nil) } |
| 49 | if response == .alertThirdButtonReturn { await self.discard(); sender.performClose(nil) } |
| 50 | } |
| 51 | } |
| 52 | return false |
| 53 | } |
| 54 | static func alert() -> NSAlert { |
| 55 | let alert = NSAlert() |
| 56 | alert.messageText = String(localized: "native.close.pending.title", bundle: DocsResources.bundle) |
| 57 | alert.informativeText = String(localized: "native.close.pending.detail", bundle: DocsResources.bundle) |
| 58 | alert.addButton(withTitle: String(localized: "native.close.pending.save", bundle: DocsResources.bundle)) |
| 59 | alert.addButton(withTitle: String(localized: "native.close.pending.cancel", bundle: DocsResources.bundle)) |
| 60 | alert.addButton(withTitle: String(localized: "native.discard.action", bundle: DocsResources.bundle)) |
| 61 | return alert |
| 62 | } |
| 63 | } |
| 64 | @MainActor private struct WeakDocsCloseGuard { weak var value: DocsCloseGuardView? } |
| 65 | @MainActor private enum DocsCloseRegistry { static var views: [WeakDocsCloseGuard] = [] } |
| 66 | |
| 67 | @MainActor public final class DocsApplicationDelegate: NSObject, NSApplicationDelegate { |
| 68 | public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { |
| 69 | let views = DocsCloseRegistry.views.compactMap(\.value).filter { $0.window != nil } |
| 70 | if views.contains(where: \.presenting) { return .terminateCancel } |
| 71 | let pending = views.filter { $0.hasUnsaved() } |
| 72 | guard !pending.isEmpty else { return .terminateNow } |
| 73 | let response = DocsCloseGuardView.alert().runModal() |
| 74 | guard response == .alertFirstButtonReturn || response == .alertThirdButtonReturn else { return .terminateCancel } |
| 75 | Task { @MainActor in |
| 76 | var saved = true |
| 77 | for view in pending { |
| 78 | if response == .alertThirdButtonReturn { await view.discard() } |
| 79 | else if !(await view.save()) { saved = false } |
| 80 | } |
| 81 | sender.reply(toApplicationShouldTerminate: saved && !views.contains { $0.hasUnsaved() }) |
| 82 | } |
| 83 | return .terminateLater |
| 84 | } |
| 85 | } |
| 86 | #endif |
| 87 | |