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") /// Applies the stored appearance override to `NSApp` (03-board-ui.md ▸ Toolbar). /// /// **Here rather than in `AppModel.init`**, and the distinction matters: a unit-test host *is* /// this app, so `KanbanApp.init()` and therefore `AppModel.init` run on every test launch /// (`AppStateHome.isUnitTestHost`). `AppearanceStore.init` only reads a default; this is the one /// call that hands its answer to `NSApp` — the global side effect `KanbanApp.init` must not /// carry (`AppearanceStore.applyCurrent`). Launch is the honest home for it, and this is the /// app's. func applicationDidFinishLaunching(_ notification: Notification) { appModel?.appearance.applyCurrent() } /// **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) } } /// Finder's `printFiles` Apple Event — File ▸ Print… on a board selected in Finder, a drag onto a /// printer queue, or a print-and-open service. Left unimplemented, this delivers to AppKit's stock /// nil-target print handling and its stock failure: "This application does not support printing. /// Please contact the application's developer." — the same wording the *reported* bug turned out to /// have a different cause for (the sandbox's `com.apple.security.print` entitlement, /// `Kanban.entitlements`, `PrintCommand`'s doc comment tells that story) — but an unhandled Apple /// Event is its own route to it regardless, which is what this method retires /// (`PrintCoordinator.printFiles`'s doc comment tells the whole story; this method is only the /// receiving end of the Apple Event). /// /// Every path is resolved and printed independently — `PrintCoordinator.printFiles` does the actual /// work, headless (no store, no window, `BoardLoader` on the path directly), so this method is a /// straight handoff of the Apple Event's three arguments and nothing more. `appModel` should always /// be set by the time an Apple Event can reach here (it exists from the first run-loop turn onward — /// this type's own note), but a `nil` is answered with `.printingFailure` rather than force-unwrapped, /// the same defensive posture `application(_:open:)` takes one method up. func application( _ application: NSApplication, printFiles filenames: [String], withSettings printSettings: [NSPrintInfo.AttributeKey: Any], showPrintPanels: Bool ) -> NSApplication.PrintReply { guard let appModel else { return .printingFailure } return PrintCoordinator.printFiles( filenames, settings: printSettings, showPrintPanels: showPrintPanels, profiles: appModel.printProfiles ) } /// 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 } }