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:
+134
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user