Lanework Pro is a subscription — the tier seam, StoreKit 2, and Settings

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
This commit is contained in:
2026-07-30 18:14:58 -04:00
parent 2c6b8fe63a
commit 3b19883593
15 changed files with 1459 additions and 15 deletions
+8 -3
View File
@@ -15,8 +15,13 @@ import IndieAbout
/// one-app collapse (12, re-ruled 2026-07-30) makes Pro a subscription, so the line is a pointer
/// to something a subscriber has already taken up rather than to another download true either
/// way, and shown to everyone. It stays deliberately flat: what Pro adds, in one sentence, with
/// no call to action. It gains its "in Settings" when the Settings Pro section it would point at
/// actually exists (the StoreKit phase); until then a direction would be a dead end.
/// no call to action.
///
/// **The direction arrived with the section.** The line was written without its "in Settings"
/// while there was no Settings Pro section to point at a signpost to a dead end is worse than no
/// signpost. `ProSettingsSection` exists now, so the sentence ends where the subscription actually
/// lives. Still one sentence, still flat, still no verb aimed at the reader: it names the place, it
/// does not ask them to go there.
enum AboutBox {
/// The About window's content: version/build/date from the stamped Info.plist
@@ -26,7 +31,7 @@ enum AboutBox {
/// own single-line paragraphs.
static var configuration: AppInfoConfiguration {
AppInfoConfiguration(
copyrightText: "© 2026 rzen\nLanework Pro adds git-backed board history and sync.",
copyrightText: "© 2026 rzen\nLanework Pro adds git-backed board history and sync — see Settings.",
documents: [.license(extension: "md")],
changelogDocument: .changelog()
)
+15
View File
@@ -16,6 +16,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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.
+134 -2
View File
@@ -66,6 +66,16 @@ public enum AppPreferences {
/// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
/// the key is declared here with its neighbours for `WindowID`'s reason.
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
/// The cached subscription facts behind the tier decision (12-editions.md The entitlement)
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
///
/// A scalar default rather than a file in `AppStateHome` because it is two fields, which is the
/// line that type's own note draws. **Not a secret and not a receipt**: the signed transaction
/// store is StoreKit's and stays StoreKit's; this is a *cache of the last answer* whose worst
/// case if edited by hand is one wrong tier until the next refresh corrects it, which is the
/// same self-correction a fresh install already relies on.
public static let subscriptionFactsKey = "subscriptionFacts"
}
// MARK: - Launch failures
@@ -193,6 +203,29 @@ public final class AppModel {
/// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard: ClipboardStore
// MARK: The entitlement
/// **Lanework Pro's entitlement** (12-editions.md The entitlement) the cached, local answer
/// to "is this a subscriber?", owned here for the registries' reason: it is app-scoped, and a
/// test holds its own over its own defaults rather than colliding with the app's.
///
/// Nothing on the board-open path awaits anything through this object. See `ProEntitlement` for
/// why that is a property of its shape rather than a rule somebody has to remember.
public let entitlement: ProEntitlement
/// **The tier a board session composes under**, as an injectable seam.
///
/// Defaulted to the real entitlement's local read and separated from it for `makeHistoryProvider`'s
/// reason exactly: a test binds a tier without needing a StoreKit transaction, an App Store
/// account, or a second `AppModel` initializer. `@MainActor` on the closure type because the
/// entitlement it reads is main-actor state, and `@ObservationIgnored` because nothing renders
/// from it the tier reaches the UI, where it reaches it at all, through `entitlement`.
///
/// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller.
@ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free }
// MARK: The provider seam
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a
@@ -209,8 +242,21 @@ public final class AppModel {
/// store's snapshots. A property rather than an initializer argument so a test
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
///
/// ### The `Tier` argument, and the one consumer it is still short
///
/// **`pro-m1` is the consumer that will switch on it.** The default closure ignores the tier
/// today and binds the native stack for both not because the seam is decorative, but because
/// the git provider it would bind does not exist yet (12 Tier matrix: git init/adoption,
/// git-backed undo and the history surfaces are all Pro-tier work, designed in 06-history-undo.md
/// and unbuilt). The argument is here now so that arriving milestone is one closure body rather
/// than a change to the composition root, and so that the tier a board actually composed under is
/// a recorded fact from today (`BoardSession.tier`) instead of something pro-m1 has to introduce
/// alongside its provider.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore) -> any HistoryProviding = { _ in NativeHistoryProvider() }
public var makeHistoryProvider: (BoardStore, Tier) -> any HistoryProviding = { _, _ in
NativeHistoryProvider()
}
// MARK: Sessions
@@ -232,6 +278,22 @@ public final class AppModel {
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
public let history: any HistoryProviding
/// **The tier this board composed under** (12-editions.md The entitlement).
///
/// A `let`, on a value type, set once by `beginSession` which is the entire mechanism
/// behind "a lapse never interrupts an open session: an open board finishes with the provider
/// it composed; the next open composes the native stack over inert `.git`". There is no
/// setter, no observation, and nothing anywhere that re-evaluates a live session's tier: a
/// subscription ending mid-session is a fact about the *next* open and about nothing that is
/// already on screen.
///
/// It is recorded rather than merely used-and-discarded because the provider it selects is
/// not the only thing that will ever ask. pro-m1's surfaces the card window's History
/// section, View History (12 Tier matrix) are per-board questions asked long after
/// composition, and they must get the answer this board actually opened with rather than
/// whatever the entitlement happens to say when the sidebar renders.
public let tier: Tier
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
@@ -439,6 +501,14 @@ public final class AppModel {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
styleRecents = StyleRecents(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Reads the cached facts and nothing else no StoreKit API is touched until
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
let entitlement = ProEntitlement(defaults: preferences)
self.entitlement = entitlement
// Bound after the stored properties are in place, so the closure captures the object rather
// than a half-built `self`. This is the app's default wiring; a test that wants a tier
// assigns over it.
currentTier = { entitlement.tier }
// Read once here rather than lazily, so File Open Recent is populated from the app's first
// menu pass a launch that restores boards never shows welcome, and a submenu that filled
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
@@ -569,10 +639,18 @@ public final class AppModel {
/// drop 02 forbids it forbids a failure that was never surfaced disappearing, not one the user
/// has since fixed.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
// **The entitlement read** (12-editions.md The entitlement): "Pro state is read from
// StoreKit's signed on-device transaction store at board-session composition the open path
// gains no network dependency." Synchronous, over facts already in memory, on the same line
// as the provider it selects which is the shape that makes "the open path never waits on
// the App Store" checkable by reading four lines rather than by auditing a call graph. It is
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
// re-derives it.
let tier = currentTier()
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior".
let history = makeHistoryProvider(store)
let history = makeHistoryProvider(store, tier)
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
// store is that boundary every app-mediated mutation goes out through one of its write
// methods so it is the store that computes each inverse and registers it. What it cannot
@@ -584,6 +662,7 @@ public final class AppModel {
store: store,
recordID: recordID,
history: history,
tier: tier,
// The lock's enablement half (13-native-undo.md Rules): Undo and Redo disable with the
// other mutating commands while the board refuses writes, and the stack survives to
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
@@ -753,6 +832,59 @@ public final class AppModel {
return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
}
/// **The purchase flow's reopen offer, carried out** (12-editions.md The entitlement:
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate").
///
/// ### Close and open, through the ordinary paths
///
/// There is no reopen-in-place mechanism here and deliberately so: the provider binding is a
/// composition-time fact, so "apply the new tier to this board" *means* end its session and
/// compose a new one. Doing that through `closeBoard` and `openBoard` the same two calls W and
/// welcome make is what keeps every guarantee those paths carry: the close flush runs in its
/// fixed order (card windows, pending work, registry stamp, teardown), the reopen resolves and
/// re-scopes the board's URL exactly as a fresh open does, and the registry records both.
///
/// ### Why the window is dismissed rather than reused
///
/// A board window's identity is its root path (`BoardWindowRef`), so reopening the same board
/// hands `openWindow(value:)` a ref it already has a window for which *focuses* that window
/// instead of building a new one, and the window it would focus is one whose host has already run
/// its one-shot load. Dismissing first is what makes the reopen an open. The dismissals are all
/// issued before any reopen, then given a run-loop turn to land: SwiftUI processes a window's
/// teardown asynchronously, and asking for a value's window in the same turn it was dismissed is
/// the one way this sequence can produce a focused corpse.
///
/// ### Declining costs nothing, today least of all
///
/// Both tiers bind the native history stack until pro-m1 (`makeHistoryProvider`), so a user who
/// says Not Now loses exactly nothing that exists yet. The offer is built now because the
/// *mechanism* is what the design specifies and because a milestone that shipped the subscription
/// without it would leave a visible gap the moment the git provider lands.
public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path }
guard !refs.isEmpty else { return }
// The store's `rootURL` rather than the ref's path: a board renamed while open keeps the
// path it was opened with, and reopening it there would open nothing (`BoardStore.rootURL`).
var roots: [URL] = []
for ref in refs {
guard let session = sessions[ref] else { continue }
roots.append(session.store.rootURL)
await closeBoard(ref: ref, cause: .userClose)
windowDismisser?(value: ref)
}
// One run-loop turn for the dismissals see the note above. `Task.sleep` rather than
// `Task.yield` because the main run loop, not the cooperative pool, is what has to advance.
try? await Task.sleep(for: .milliseconds(150))
for root in roots {
openBoard(at: root)
}
}
/// Quit: the same sequence, once per open board, **sequentially**.
///
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
+14 -7
View File
@@ -379,10 +379,14 @@ private struct RecentBoardRow: View {
/// The app's preferences (, 11-command-nexus.md).
///
/// One control, which is the whole of v1: "Restore open boards at launch". The preference gates only
/// whether the registry's open-now flags are *consulted* at launch the flags themselves are
/// maintained either way, which is what keeps crash recovery working for a user who has restoration
/// turned off and then turns it back on.
/// Two sections. The first is the one control v1 shipped with: "Restore open boards at launch",
/// whose preference gates only whether the registry's open-now flags are *consulted* at launch the
/// flags themselves are maintained either way, which is what keeps crash recovery working for a user
/// who has restoration turned off and then turns it back on.
///
/// The second is **where Lanework Pro lives** (12-editions.md Distribution: "purchased and managed
/// in a Pro section of Settings (,) subscribe, manage, restore purchases"), and the third of the
/// exactly three places the app names Pro at all see `ProSettingsSection` for its own posture.
struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
@@ -395,11 +399,14 @@ struct SettingsView: View {
} footer: {
Text("On, the boards open at your last quit reopen automatically. Off, every launch starts at Welcome.")
}
ProSettingsSection()
}
.formStyle(.grouped)
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip its one
// toggle's footer sentence at a large system text size with no way to resize out of it
// (10-accessibility.md Text scaling).
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip a footer
// sentence at a large system text size with no way to resize out of it
// (10-accessibility.md Text scaling). The height stays free the Pro section's own height
// depends on which of its three states it is in, and pinning it would clip the longest.
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
.fixedSize()
}