Files
lanework/Kanban/App/AppDelegate.swift
T
rzen cc4cc99c71 Wire Finder open as a standard document open
application(_:open:) forwards every Finder-delivered URL to
AppModel.openBoard(at:) — the exact path welcome and File > Open use, so
a Finder open gets the same registry record-before-load, recents stamp,
already-open-focuses-its-window dedup, and row-level failure surfacing
on welcome (DESIGN/02 > Launch). A cold Finder launch can arrive before
any scene has captured the window opener; openBoard now buffers such
URLs and captureWindowActions replays them once opening is possible.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 07:09:44 -04:00

84 lines
4.7 KiB
Swift

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
}
}