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:
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
|
||||
defer { tearDown() }
|
||||
|
||||
let bound = FakeHistoryProvider()
|
||||
model.makeHistoryProvider = { _ in bound }
|
||||
model.makeHistoryProvider = { _, _ in bound }
|
||||
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
@@ -596,6 +596,72 @@ struct BoardSessionHistoryTests {
|
||||
session.undoManager.undo()
|
||||
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
|
||||
}
|
||||
|
||||
@Test("The tier reaches the composition root, and the session records what it composed under")
|
||||
func theTierIsAComposedFact() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
// 12-editions.md ▸ The entitlement: the tier is read at composition, once, and handed to the
|
||||
// root that binds the provider. Both tiers bind the native stack until pro-m1 — what this
|
||||
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
|
||||
var seen: [Tier] = []
|
||||
model.currentTier = { .pro }
|
||||
model.makeHistoryProvider = { _, tier in
|
||||
seen.append(tier)
|
||||
return NativeHistoryProvider()
|
||||
}
|
||||
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
|
||||
#expect(seen == [.pro])
|
||||
#expect(session.tier == .pro, "the session carries the fact it composed under")
|
||||
}
|
||||
|
||||
@Test("A tier change never reaches a session that is already open")
|
||||
func aLapseNeverRebindsAnOpenSession() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
model.currentTier = { .pro }
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
#expect(try #require(model.session(for: ref)).tier == .pro)
|
||||
|
||||
// The subscription lapses mid-session — the one thing 12 ▸ The entitlement says must not
|
||||
// disturb a board that is already on screen: "an open board finishes with the provider it
|
||||
// composed; the next open composes the native stack over inert `.git`".
|
||||
model.currentTier = { .free }
|
||||
|
||||
#expect(try #require(model.session(for: ref)).tier == .pro)
|
||||
}
|
||||
|
||||
@Test("The purchase flow's reopen ends every session, because that is what recomposing means")
|
||||
func reopeningEndsTheSessions() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
#expect(model.hasOpenBoards)
|
||||
|
||||
// 12 ▸ The entitlement: "Subscribe takes effect at each board's next open ... The purchase
|
||||
// flow offers to reopen open boards." There is no rebinding-in-place to test for, and that
|
||||
// is the finding: reopening *is* ending the session and composing a new one, so what this
|
||||
// pins is the ending. The reopen half needs SwiftUI's window actions, which this host has
|
||||
// none of — the URLs simply buffer until an opener exists (`AppModel.openBoard`), which is
|
||||
// the same path a cold-launch Finder open already takes.
|
||||
await model.reopenOpenBoards()
|
||||
|
||||
#expect(model.session(for: ref) == nil)
|
||||
#expect(model.hasOpenBoards == false)
|
||||
#expect(model.storeRegistry.openBoardCount == 0, "the store and its watcher went with the session")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The command surface
|
||||
|
||||
Reference in New Issue
Block a user