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 } /// Double-clicking a `.kanban` folder in Finder, or `open -a` — "opening a board from Finder is a /// standard document open" (02-architecture.md § Launch and window lifecycle). Every URL is /// forwarded to `AppModel.openBoard(at:)`, the exact entry point welcome and File ▸ Open… already /// call (`AppModel.presentOpenPanel()`), so a Finder open gets the identical registry record, /// board window, and recents stamp, and a board that is already open focuses its window rather /// than opening a second one — `openBoard` starts its own security-scoped access on the URL, the /// same as the open panel's, so nothing needs stashing here first. /// /// **Not pre-validated.** Info.plist's `CFBundleDocumentTypes` declares the UTI, so macOS should /// never route anything but a `.kanban` folder here — but if it did, or the folder has since gone /// missing, `openBoard`'s fail-fast load surfaces the failure row-level on welcome, uniform with /// every other open failure. A second vocabulary for "not a board" here would just be a worse copy /// of the one that already exists. /// /// **Can fire before any scene has appeared** — a cold launch (the app was not already running) /// delivers this ahead of the first window's `onAppear`, which is where `windowOpener` is normally /// captured (`CaptureOpenWindow`). `openBoard` buffers a URL that arrives that early and replays /// it once the action exists, so this method does not have to reason about launch ordering itself. func application(_ application: NSApplication, open urls: [URL]) { guard let appModel else { return } for url in urls { appModel.openBoard(at: url) } } /// 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 } }