import AppKit import os /// The three window-lifecycle answers SwiftUI has no modifier for (02-architecture.md § Launch and /// window lifecycle, § Windows). /// /// It holds the `AppModel` rather than reaching for a singleton: `KanbanApp` creates the model and /// hands it over in its own `init`, so there is exactly one and no global to accidentally build a /// second registry behind. @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { /// Set by `KanbanApp.init()`. Optional only because the adaptor constructs this object before the /// model exists; it is non-`nil` from the first run-loop turn onward. var appModel: AppModel? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate") /// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar /// alive)" — a document-shaped app whose windows are boards has no business quitting because the /// user tidied one away, and welcome is one Dock click or one menu item back. func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } /// A Dock click with nothing on screen shows welcome — the other half of the rule above. /// /// `false` means "handled, do nothing further"; `true` lets AppKit run its default (unminiaturize, /// open an untitled document), which is right when windows do exist and wrong when they do not — /// this app has no untitled document to make. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { guard !hasVisibleWindows, let appModel else { return true } appModel.showWelcome() return false } /// Quit runs the close flush for **every** open board before the app goes away. /// /// The same `CloseFlushCoordinator` sequence as a user close, once per board, in the same fixed /// order — card windows and their sessions, then pending debounced work, then the registry stamp, /// then teardown (02 § Windows: "closing a board window (**and app quit**) first closes the /// board's card windows …"). The one difference is the cause: quit does not clear the open-now /// flags, which is what makes the next launch reopen exactly this set. /// /// `.terminateLater` plus a deferred reply is the only way to await anything here — the delegate /// method is synchronous and the flush is not. With no boards open there is nothing to flush and /// the app exits immediately rather than taking a run-loop turn to discover that. func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard let appModel, appModel.hasOpenBoards else { return .terminateNow } Task { @MainActor in await appModel.flushAllBoardsForQuit() Self.logger.debug("quit flush complete") sender.reply(toApplicationShouldTerminate: true) } return .terminateLater } }