The 2026-08-08 one-version ruling (12-editions.md ▸ PIVOT 2026-08-08) carried out: Kanban/Tier/ deleted wholesale (Tier, ProEntitlement, ProProducts, ProStorefront, the never-rendered ProSettingsSection) with TierTests and Configuration.storekit, whose project.yml resource entry and scheme storeKitConfiguration go with it. AppModel loses the entitlement, the currentTier seam, BoardSession.tier, and the purchase flow's reopenOpenBoards (its only caller was the storefront); AppDelegate's launch keeps only the appearance application. The three tests pinning the recorded tier and the reopen are deleted with their subject. The network-client entitlement stays — the sync capability to come needs it regardless — and the HistoryProviding seam stands untouched. 2,686 unit tests green (2,707 minus the 21 that tested what left). Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
96 lines
5.4 KiB
Swift
96 lines
5.4 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")
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|