Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution, ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro — deliberately no .lapsed case; unsubscribed and lapsed are one state) and the pure decision Tier.resolve(from:now:) over SubscriptionFacts (expiration + willAutoRenew), unit-tested through all five named states: free, active, lapsed, offline-grace, never-online. The facts are a persisted cache (standard defaults), not a live view: StoreKit ages an expired subscription out of currentEntitlements locally, so an offline device and a real lapse are indistinguishable from that property alone — the cache holds the last answer, empty entitlements read as silence, and holds end only on a definitive answer (revocation, or the subscription-group status read Settings performs). That is 12's offline-grace trade, resolved toward the paying user. ProEntitlement is the local adapter (currentEntitlements + Transaction.updates, started from launch, never from a test host); ProStorefront holds everything networked (product load, purchase, AppStore.sync) and only the Settings section ever constructs one — the split is the enforcement of "never network on the open path". beginSession reads the tier once at composition; BoardSession.tier is a let with no path back in, so a lapse never rebinds an open session. makeHistoryProvider now takes the tier; both tiers bind the native stack until pro-m1 builds the git provider — the seam's consumer is named, not invented early. Settings gains the Pro section (subscribe with localized price, manage, restore; a quiet unreachable line, no indefinite spinner) — the third of the exactly-three Pro mentions; the About line gains its "…in Settings" pointer now that there is a Settings to point at. A successful purchase or restore offers once to reopen open boards (close + reopen through the ordinary paths). Configuration.storekit wired into the scheme's run action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side steps and the rule that the product must not be configured before then. 1901 tests in 319 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
99 lines
5.6 KiB
Swift
99 lines
5.6 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")
|
|
|
|
/// Starts the Pro entitlement's transaction listener (12-editions.md ▸ The entitlement).
|
|
///
|
|
/// **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`). Building the entitlement there costs one `UserDefaults` read;
|
|
/// acquiring a StoreKit listener there would give every test run a live `Transaction.updates`
|
|
/// subscription for no reason. Launch is the honest home for a listener, and this is the app's.
|
|
///
|
|
/// It starts nothing the board-open path waits on: the listener writes cached facts that a
|
|
/// *later* composition may read, and never reaches into a session that is already open
|
|
/// (`ProEntitlement`).
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
appModel?.entitlement.start()
|
|
}
|
|
|
|
/// **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
|
|
}
|
|
}
|