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