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:
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import StoreKit
|
||||
import os
|
||||
|
||||
/// **Lanework Pro's entitlement** — the thin StoreKit 2 adapter over the pure decision in
|
||||
/// `Tier.resolve(from:now:)` (12-editions.md ▸ The entitlement).
|
||||
///
|
||||
/// ### The open path never waits on this, and never reaches the network through it
|
||||
///
|
||||
/// "Pro state is read from StoreKit's signed on-device transaction store at board-session
|
||||
/// composition — the open path gains no network dependency" (12). That is enforced by shape, not by
|
||||
/// care: what a composing board session reads is `tier`, a synchronous computed property over
|
||||
/// `facts`, which is an in-memory value restored from `UserDefaults` at init. No `await`, no
|
||||
/// `AsyncSequence`, no `AppStore.sync()`, no product load. The two methods that *do* touch StoreKit
|
||||
/// — `start()` and `refreshFromLocalTransactions()` — are called from the app's launch and from the
|
||||
/// Settings Pro section, and both only ever *write* `facts` for a later composition to read.
|
||||
///
|
||||
/// Even those two are local reads. `Transaction.currentEntitlements` is StoreKit's own signed
|
||||
/// on-device transaction store; it answers offline, which is the whole reason 12 could rule the open
|
||||
/// path network-free while still gating on a subscription. The genuinely networked calls — loading
|
||||
/// products for their localized price, `AppStore.sync()` — live in `ProStorefront`, which only the
|
||||
/// Settings section ever builds.
|
||||
///
|
||||
/// ### A lapse never rebinds an open session
|
||||
///
|
||||
/// "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`" (12). This type is `@Observable`
|
||||
/// because the Settings section renders from it — and *nothing else* observes it. `AppModel` reads
|
||||
/// it through `currentTier`, once, inside `beginSession`; the answer is then a `let` on
|
||||
/// `AppModel.BoardSession`. There is deliberately no path from a `facts` change back into an open
|
||||
/// session: no observer registration, no notification, no delegate. Adding one would be the bug, not
|
||||
/// the feature.
|
||||
///
|
||||
/// ### Why the facts are cached rather than re-derived
|
||||
///
|
||||
/// See `SubscriptionFacts` — in short, StoreKit ages an expired subscription out of
|
||||
/// `currentEntitlements` locally, so an offline device and a real lapse are indistinguishable from
|
||||
/// that property alone, and 12's offline grace has to tell them apart. The cache is what "the last
|
||||
/// known state" means, and `adopt(_:)` is what "until StoreKit actually refreshes and answers" means.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class ProEntitlement {
|
||||
|
||||
// MARK: The facts
|
||||
|
||||
/// What StoreKit last told the app, cached across launches.
|
||||
///
|
||||
/// `private(set)`: every write goes through `adopt(_:)`, which is also the write-through to
|
||||
/// `UserDefaults`. A second way to set this would be a second way for the cache and the memory to
|
||||
/// disagree.
|
||||
public private(set) var facts: SubscriptionFacts
|
||||
|
||||
/// **The composition-time read.** Synchronous, local, and the only member the board-open path
|
||||
/// ever touches — see the type's note.
|
||||
///
|
||||
/// `Date()` rather than an injected clock: the *decision* takes its date as a parameter and is
|
||||
/// tested that way (`Tier.resolve`), so the one place a real clock has to enter is here, at the
|
||||
/// edge, where there is nothing left to get wrong.
|
||||
public var tier: Tier {
|
||||
Tier.resolve(from: facts, now: Date())
|
||||
}
|
||||
|
||||
// MARK: Storage
|
||||
|
||||
@ObservationIgnored
|
||||
private let defaults: UserDefaults
|
||||
|
||||
/// The `Transaction.updates` listener, held so it can be cancelled and so `start()` is idempotent.
|
||||
@ObservationIgnored
|
||||
private var updatesTask: Task<Void, Never>?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "entitlement")
|
||||
|
||||
/// The app builds one of these over `UserDefaults.standard`; a test passes its own, for the
|
||||
/// reason every other injected store in this app takes its home as a parameter (`AppStateHome`)
|
||||
/// — a suite that wrote the real domain would be editing the developer's own subscription state.
|
||||
///
|
||||
/// **The initializer reads the cache and nothing else.** It starts no task and touches no
|
||||
/// StoreKit API, so constructing an `AppModel` — which a unit-test host does on every launch —
|
||||
/// costs one `UserDefaults` read.
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
facts = Self.cachedFacts(in: defaults)
|
||||
}
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Adopting an answer
|
||||
|
||||
/// Records what StoreKit said, in memory and in the cache.
|
||||
///
|
||||
/// **This is the "until StoreKit actually refreshes and answers" of 12's offline-grace rule**, in
|
||||
/// both directions. Handed live facts it moves the expiry forward, so a renewal the device just
|
||||
/// learned about ends any hold that was standing. Handed `.none` it ends the entitlement outright,
|
||||
/// which is what a *definitive* withdrawal looks like: a refund, a revocation, or a subscription
|
||||
/// group whose status reads expired (`ProStorefront.reconcile`). What it is never called with is
|
||||
/// "`currentEntitlements` came back empty" — see `refreshFromLocalTransactions()` for why that is
|
||||
/// silence rather than an answer.
|
||||
///
|
||||
/// Public because the Settings section's storefront is the other half of this: the networked
|
||||
/// reads live over there (12: the open path stays local), and this is where their conclusions
|
||||
/// land.
|
||||
public func adopt(_ newFacts: SubscriptionFacts) {
|
||||
guard newFacts != facts else { return }
|
||||
facts = newFacts
|
||||
Self.cache(newFacts, in: defaults)
|
||||
Self.logger.debug("entitlement facts adopted; tier is now \(self.tier.rawValue, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - StoreKit
|
||||
|
||||
/// Begins listening for transaction changes, and takes one local reading.
|
||||
///
|
||||
/// Called once, from the app's launch (`AppDelegate.applicationDidFinishLaunching`) — never from
|
||||
/// `AppModel.init`, so that a unit-test host constructing a model does not acquire a StoreKit
|
||||
/// listener as a side effect.
|
||||
///
|
||||
/// `Transaction.updates` is StoreKit's out-of-band channel: a renewal that landed while the app
|
||||
/// was closed, a purchase made on another device, a refund the App Store processed. It is *not*
|
||||
/// polling and it is not a network call this app makes — it is a delivery. Handling it here is
|
||||
/// what keeps the cached facts from needing anybody to visit Settings.
|
||||
///
|
||||
/// Idempotent: a second call is a no-op, because a second listener would finish every transaction
|
||||
/// twice.
|
||||
public func start() {
|
||||
guard updatesTask == nil else { return }
|
||||
updatesTask = Task { [weak self] in
|
||||
for await result in Transaction.updates {
|
||||
guard let self else { return }
|
||||
await handle(result)
|
||||
}
|
||||
}
|
||||
Task { [weak self] in
|
||||
await self?.refreshFromLocalTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads StoreKit's **local** signed transaction store and adopts what it finds.
|
||||
///
|
||||
/// ### An empty result is silence, not an answer
|
||||
///
|
||||
/// The one subtle line in this file. `Transaction.currentEntitlements` yields only entitlements
|
||||
/// StoreKit currently considers valid, and that validity is computed on-device from the last
|
||||
/// signed transaction it holds — so a subscription drops out of it the moment its cached expiry
|
||||
/// passes, **whether or not the device has been able to ask the App Store whether it renewed**.
|
||||
/// Treating an empty result as "the subscription is over" would therefore lapse every offline
|
||||
/// user at their renewal date, which is precisely the outcome 12 ▸ The entitlement rules out:
|
||||
/// "an on-disk expiry passing while offline, with the last known state active and auto-renew on,
|
||||
/// holds the entitlement until StoreKit actually refreshes and answers."
|
||||
///
|
||||
/// So an empty result **leaves the cache alone** and the pure decision does the rest: an expiry
|
||||
/// still in the future keeps the user Pro (rule 2), a passed expiry with auto-renew on holds
|
||||
/// (rule 3), and a passed expiry with auto-renew off — a cancellation, which StoreKit told us
|
||||
/// about *before* it ran out — lapses on its own (rule 4). The definitive endings arrive through
|
||||
/// `adopt(.none)`: a revocation seen here, or the subscription-group status the Settings section
|
||||
/// reads when the user goes looking.
|
||||
///
|
||||
/// The honest residual is 12's own accepted trade: a user who cancels while offline, quits, and
|
||||
/// never opens Settings again holds Pro until StoreKit delivers an update. "A wrong hold gives
|
||||
/// away days of local commits — Apple's own billing grace makes the same trade."
|
||||
public func refreshFromLocalTransactions() async {
|
||||
var newest: SubscriptionFacts?
|
||||
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case let .verified(transaction) = result else {
|
||||
// An unverified transaction is not evidence of anything. Skipped rather than treated
|
||||
// as a lapse, for the empty-result reason above.
|
||||
continue
|
||||
}
|
||||
guard ProProducts.all.contains(transaction.productID) else { continue }
|
||||
|
||||
if transaction.revocationDate != nil {
|
||||
// A refund or a family-sharing withdrawal — StoreKit answering, definitively.
|
||||
adopt(.none)
|
||||
return
|
||||
}
|
||||
|
||||
let candidate = await Self.facts(of: transaction)
|
||||
if newest == nil || Self.expiry(of: candidate) > Self.expiry(of: newest!) {
|
||||
newest = candidate
|
||||
}
|
||||
}
|
||||
|
||||
guard let newest else {
|
||||
Self.logger.debug("no current Pro entitlement in the local store; the cached facts stand")
|
||||
return
|
||||
}
|
||||
adopt(newest)
|
||||
}
|
||||
|
||||
/// One current entitlement, reduced to the two facts the decision needs.
|
||||
///
|
||||
/// `willAutoRenew` comes from the subscription group's renewal info, which is the only place
|
||||
/// StoreKit publishes it. When it cannot be read — an unverified renewal info, a status call that
|
||||
/// throws — the answer defaults to `true`, which is the paying user's direction and the same one
|
||||
/// 12's grace rule already chose everywhere else it had to pick.
|
||||
///
|
||||
/// A current entitlement with **no** expiration date is given `.distantFuture` rather than `nil`,
|
||||
/// because `nil` means "no cached transaction" to `SubscriptionFacts` and this is the opposite of
|
||||
/// that: a live entitlement that does not expire. Not a state an auto-renewable subscription
|
||||
/// reaches today; spelled out so it cannot become one silently.
|
||||
private static func facts(of transaction: Transaction) async -> SubscriptionFacts {
|
||||
var willAutoRenew = true
|
||||
if let status = try? await transaction.subscriptionStatus,
|
||||
case let .verified(renewalInfo) = status.renewalInfo {
|
||||
willAutoRenew = renewalInfo.willAutoRenew
|
||||
}
|
||||
return SubscriptionFacts(
|
||||
expiration: transaction.expirationDate ?? .distantFuture,
|
||||
willAutoRenew: willAutoRenew
|
||||
)
|
||||
}
|
||||
|
||||
/// A transaction delivered out of band. Finished, then folded into the facts by a fresh local
|
||||
/// read — the same read every other path uses, so there is one reduction rule and not two.
|
||||
///
|
||||
/// Finishing is not optional bookkeeping: an unfinished transaction is redelivered forever, and
|
||||
/// for an auto-renewable subscription there is no content to deliver first — the entitlement
|
||||
/// *is* the delivery.
|
||||
private func handle(_ result: VerificationResult<Transaction>) async {
|
||||
guard case let .verified(transaction) = result else { return }
|
||||
guard ProProducts.all.contains(transaction.productID) else { return }
|
||||
await transaction.finish()
|
||||
await refreshFromLocalTransactions()
|
||||
}
|
||||
|
||||
private static func expiry(of facts: SubscriptionFacts) -> Date {
|
||||
facts.expiration ?? .distantPast
|
||||
}
|
||||
|
||||
// MARK: - The cache
|
||||
|
||||
/// Read at init, written by `adopt(_:)`. JSON in a scalar default, beside the app's other
|
||||
/// scalars (`AppPreferences`) rather than in `AppStateHome` — this is two fields, not a file
|
||||
/// store, and `AppStateHome`'s own note draws that line.
|
||||
///
|
||||
/// A cache that will not decode is treated as no cache at all: the tier reads free and the next
|
||||
/// refresh rebuilds it. Losing it costs a user with an active subscription nothing (StoreKit's
|
||||
/// own store still holds the transaction) and costs a user mid-grace their hold, which is the
|
||||
/// safe direction to fail in.
|
||||
private static func cachedFacts(in defaults: UserDefaults) -> SubscriptionFacts {
|
||||
guard let data = defaults.data(forKey: AppPreferences.subscriptionFactsKey),
|
||||
let decoded = try? JSONDecoder().decode(SubscriptionFacts.self, from: data) else {
|
||||
return .none
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func cache(_ facts: SubscriptionFacts, in defaults: UserDefaults) {
|
||||
guard let data = try? JSONEncoder().encode(facts) else {
|
||||
logger.error("subscription facts could not be encoded; the cache is unchanged")
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: AppPreferences.subscriptionFactsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
/// **The App Store Connect configuration this app mirrors in code** — the subscription's product
|
||||
/// identifier and the group it belongs to.
|
||||
///
|
||||
/// ### These strings are configuration, not invention
|
||||
///
|
||||
/// Nothing here is created at runtime. A subscription group and its products are made **by hand** in
|
||||
/// App Store Connect, against the record `dev.rzen.indie.Kanban` (12-editions.md ▸ Distribution;
|
||||
/// RELEASE.md ▸ Lanework Pro), and StoreKit will only ever return products whose identifiers match
|
||||
/// what that account actually declares. This file is the local half of that agreement: change a
|
||||
/// string here without changing it there and the Settings Pro section quietly reports that it cannot
|
||||
/// reach the App Store, which is exactly what a mismatched identifier looks like from inside the app.
|
||||
/// `Configuration.storekit` at the repo root is the third copy — the Xcode-local one — and it exists
|
||||
/// so the purchase flow can be exercised on a machine with no App Store Connect access at all.
|
||||
///
|
||||
/// ### The identifier's shape
|
||||
///
|
||||
/// `dev.rzen.indie.kanban.pro.monthly` follows the family's reverse-DNS style, the same one the three
|
||||
/// pasteboard types already use (`dev.rzen.indie.kanban.cards`, `…lanes`, `…clipboard` —
|
||||
/// `PasteboardTypes.swift`): the lowercase `kanban` codename segment, then what the thing *is*. The
|
||||
/// bundle id keeps its capital `K` because that is what the 1.x App Store record ships under; the
|
||||
/// identifiers the app coins for itself do not, and consistency inside that set is what matters.
|
||||
///
|
||||
/// **One product to start.** A monthly subscription is the whole storefront until there is evidence
|
||||
/// for a second — an annual tier, a family plan and an introductory offer are all additions to the
|
||||
/// same group and none of them changes a line of the entitlement, which reads *whatever* transaction
|
||||
/// the group produced (`ProEntitlement`). `all` is the seam that keeps that true: everything that
|
||||
/// matches a transaction against this app's products asks this set, never the single constant.
|
||||
public enum ProProducts {
|
||||
|
||||
/// Lanework Pro, billed monthly — the one auto-renewable subscription product.
|
||||
public static let monthly = "dev.rzen.indie.kanban.pro.monthly"
|
||||
|
||||
/// The subscription group's **reference name** in App Store Connect: "Lanework Pro".
|
||||
///
|
||||
/// Deliberately *not* the group's numeric id. That number is minted by App Store Connect when the
|
||||
/// group is created and cannot be known until then, so hardcoding one would be a value invented
|
||||
/// at the wrong end. Where StoreKit needs the numeric id — reading a group's subscription status
|
||||
/// — it is read off a loaded `Product` (`product.subscription?.subscriptionGroupID`), which is
|
||||
/// the account's own answer rather than this file's guess.
|
||||
public static let subscriptionGroupName = "Lanework Pro"
|
||||
|
||||
/// Every product identifier this app's entitlement recognises.
|
||||
///
|
||||
/// The set, rather than the constant, is what `ProEntitlement` matches transactions against and
|
||||
/// what `ProStorefront` loads — so adding an annual product is one line here and nothing else.
|
||||
public static let all: Set<String> = [monthly]
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
|
||||
/// **The Settings Pro section** — subscribe, manage, restore (12-editions.md ▸ Distribution), and
|
||||
/// the third and last place the app names Lanework Pro (12 ▸ Tier naming; the other two are
|
||||
/// `AboutBox`'s line and `BoardGitNote`'s contextual one).
|
||||
///
|
||||
/// ### Calm, because the rule says calm
|
||||
///
|
||||
/// "The free tier presents as a complete app, not a demo ... Nothing on the welcome screen, nothing
|
||||
/// in banners." This section is where the subscription actually lives, so it is allowed to describe
|
||||
/// itself — and that is all it does: what Pro adds, in the same sentence the About box uses, then the
|
||||
/// price and the buttons. No badge, no comparison table, no countdown, no second sentence selling the
|
||||
/// first. A user who never subscribes should be able to read this pane and feel they are looking at a
|
||||
/// feature they do not need, not an ad they have to dismiss.
|
||||
///
|
||||
/// ### Three states, all of them sentences
|
||||
///
|
||||
/// - **Subscribed** — says so, with whatever renewal or expiry date StoreKit exposes, and offers
|
||||
/// Manage. Restore is not shown: there is nothing to restore onto an account that is already
|
||||
/// entitled here.
|
||||
/// - **Not subscribed, product loaded** — the localized price and Subscribe, with Restore Purchases
|
||||
/// beside it for the account that owns a subscription this Mac has never seen.
|
||||
/// - **Not subscribed, App Store unreachable** — one quiet line and Try Again, never an indefinite
|
||||
/// spinner (`ProStorefront`'s own note). Restore stays available: it is the button whose whole job
|
||||
/// is to reach the App Store, so hiding it in the state where the App Store could not be reached
|
||||
/// would remove the one thing worth pressing.
|
||||
///
|
||||
/// ### The reopen offer
|
||||
///
|
||||
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
|
||||
/// boards so the upgrade feels immediate" (12 ▸ The entitlement). The offer is raised **once**, from
|
||||
/// here, on the one outcome that means an active subscription just landed — and declining costs
|
||||
/// nothing, today least of all: both tiers bind the native history stack until pro-m1 builds the git
|
||||
/// provider (`AppModel.makeHistoryProvider`).
|
||||
struct ProSettingsSection: View {
|
||||
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// Built on appearance rather than at init, because the entitlement it wraps comes from the
|
||||
/// environment — and dropped when the pane goes, which is what keeps the networked half of Pro
|
||||
/// out of every other part of the app (`ProStorefront`'s note).
|
||||
@State private var storefront: ProStorefront?
|
||||
|
||||
/// The reopen offer, raised at most once per activation.
|
||||
@State private var isOfferingReopen = false
|
||||
|
||||
/// How many boards the offer was raised over. Captured when the offer is made, like the trash
|
||||
/// confirmations capture their counts (`TrashConfirmations`): the user is being asked about the
|
||||
/// boards that were open when they subscribed, and a count recomputed at render time could
|
||||
/// disagree with the sentence they are reading.
|
||||
@State private var openBoardCount = 0
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
content
|
||||
} header: {
|
||||
Text("Lanework Pro")
|
||||
} footer: {
|
||||
Text("Lanework Pro adds git-backed board history and sync. It takes effect the next time each board opens.")
|
||||
}
|
||||
.task {
|
||||
let storefront = storefront ?? ProStorefront(entitlement: appModel.entitlement)
|
||||
self.storefront = storefront
|
||||
await storefront.load()
|
||||
}
|
||||
.alert("Reopen your open boards?", isPresented: $isOfferingReopen) {
|
||||
Button("Reopen") {
|
||||
Task { await appModel.reopenOpenBoards() }
|
||||
}
|
||||
Button("Not Now", role: .cancel) {}
|
||||
} message: {
|
||||
Text(reopenMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Content
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if appModel.entitlement.tier == .pro {
|
||||
subscribedRows
|
||||
} else {
|
||||
unsubscribedRows
|
||||
}
|
||||
}
|
||||
|
||||
/// The subscribed state. One status line and one button — the App Store owns everything else
|
||||
/// about a subscription (price changes, cancellation, billing), and duplicating any of it here
|
||||
/// would be a second, staler answer.
|
||||
@ViewBuilder
|
||||
private var subscribedRows: some View {
|
||||
// One `Text`, deliberately, rather than a status word beside a date: VoiceOver reads it as
|
||||
// the one sentence it is, and nothing about the state is carried by layout
|
||||
// (10-accessibility.md — nothing is ever said by position or colour alone).
|
||||
Text(subscriptionStatusLine)
|
||||
|
||||
Button("Manage Subscription…") {
|
||||
storefront?.openManageSubscriptions()
|
||||
}
|
||||
.accessibilityHint("Opens your subscriptions in the App Store.")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var unsubscribedRows: some View {
|
||||
switch storefront?.availability ?? .idle {
|
||||
case .idle, .loading:
|
||||
// A determinate, one-line placeholder rather than a progress spinner: the load is short,
|
||||
// and a spinner that resolves into a price reads as a slower price.
|
||||
Text("Checking the App Store…")
|
||||
.foregroundStyle(.secondary)
|
||||
case let .ready(product):
|
||||
HStack {
|
||||
Text(product.displayPrice)
|
||||
Spacer()
|
||||
Button("Subscribe") {
|
||||
Task { await activate { await storefront?.subscribe() } }
|
||||
}
|
||||
.disabled(storefront?.isBusy ?? false)
|
||||
.accessibilityLabel("Subscribe to Lanework Pro, \(product.displayPrice)")
|
||||
}
|
||||
case .unreachable:
|
||||
Text("Can't reach the App Store right now.")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Try Again") {
|
||||
Task { await storefront?.load() }
|
||||
}
|
||||
.disabled(storefront?.isBusy ?? false)
|
||||
}
|
||||
|
||||
Button("Restore Purchases") {
|
||||
Task { await activate { await storefront?.restore() } }
|
||||
}
|
||||
.disabled(storefront?.isBusy ?? false)
|
||||
.accessibilityHint("Checks this Apple Account for a Lanework Pro subscription.")
|
||||
|
||||
if let message = storefront?.message {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Wording
|
||||
|
||||
/// What a subscriber's status line says, built from exactly what StoreKit cached — and nothing
|
||||
/// more. An unknown expiry says the plain fact and stops, rather than inventing a date.
|
||||
private var subscriptionStatusLine: String {
|
||||
let facts = appModel.entitlement.facts
|
||||
guard let expiration = facts.expiration, expiration < .distantFuture else {
|
||||
return "Subscribed."
|
||||
}
|
||||
let date = expiration.formatted(date: .abbreviated, time: .omitted)
|
||||
return facts.willAutoRenew ? "Subscribed — renews \(date)." : "Subscribed — ends \(date)."
|
||||
}
|
||||
|
||||
private var reopenMessage: String {
|
||||
let boards = openBoardCount == 1 ? "the board you have open" : "the \(openBoardCount) boards you have open"
|
||||
return "Pro takes effect at each board's next open. Lanework can close and reopen \(boards) now."
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
/// Runs a purchase or a restore and raises the reopen offer on the one outcome that earns it.
|
||||
///
|
||||
/// The count is read here, before the alert exists, for the reason `TrashConfirmations` captures
|
||||
/// its phrasing at request time. And the offer is skipped outright when nothing is open, because
|
||||
/// an offer to reopen no boards is a dialog with nothing behind it.
|
||||
private func activate(_ work: () async -> ProStorefront.Outcome?) async {
|
||||
guard await work() == .activated else { return }
|
||||
let count = appModel.storeRegistry.openBoardCount
|
||||
guard count > 0 else { return }
|
||||
openBoardCount = count
|
||||
isOfferingReopen = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import StoreKit
|
||||
import os
|
||||
|
||||
/// **The networked half of Lanework Pro** — loading the product for its localized price, buying it,
|
||||
/// restoring it, and pointing at the system's manage-subscription surface (12-editions.md
|
||||
/// ▸ Distribution: "purchased and managed in a Pro section of Settings (⌘,) — subscribe, manage,
|
||||
/// restore purchases").
|
||||
///
|
||||
/// ### Why it is a separate type from `ProEntitlement`
|
||||
///
|
||||
/// Because the network is. 12 ▸ The entitlement makes the *entitlement* a local read so the
|
||||
/// board-open path gains no network dependency, and the cleanest way to keep a promise like that is
|
||||
/// to put everything that could break it somewhere the open path cannot reach. Nothing constructs a
|
||||
/// `ProStorefront` except the Settings Pro section, which builds one when the pane appears and drops
|
||||
/// it when the pane goes; `AppModel` has no reference to it and no way to acquire one. The split is
|
||||
/// the enforcement.
|
||||
///
|
||||
/// What crosses back the other way is narrow and one-directional: this type hands `ProEntitlement`
|
||||
/// the conclusions StoreKit reached (`adopt(_:)`), which a *later* board composition may read. It
|
||||
/// never reaches into an open session — see `ProEntitlement`'s note on why a lapse cannot rebind one.
|
||||
///
|
||||
/// ### Offline is a sentence, not a spinner
|
||||
///
|
||||
/// A product load that fails leaves `availability` at `.unreachable`, which the section renders as
|
||||
/// one quiet line with a Try Again button. There is deliberately no retry loop and no indefinite
|
||||
/// progress view: the App Store being unreachable is an ordinary, temporary, user-legible condition,
|
||||
/// and a subscriber's *entitlement* is unaffected by it — the cached facts already answered that
|
||||
/// question before this type existed.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class ProStorefront {
|
||||
|
||||
// MARK: Availability
|
||||
|
||||
/// Whether the subscription product can be shown, and at what price.
|
||||
public enum Availability: Equatable {
|
||||
|
||||
/// Nothing has been asked for yet — the state the pane is built in.
|
||||
case idle
|
||||
|
||||
/// A product load is in flight.
|
||||
case loading
|
||||
|
||||
/// Loaded. The `Product` carries its own localized `displayPrice`, which is the only place
|
||||
/// a price may come from: a price written into the app would be wrong in most of the world
|
||||
/// and out of date in the rest.
|
||||
case ready(Product)
|
||||
|
||||
/// The App Store could not be reached, or answered with no such product. **One case for
|
||||
/// both**, because they are one sentence to the user and neither is actionable beyond
|
||||
/// trying again — a missing product id is a configuration mistake that shows up in
|
||||
/// development, never in a shipped build.
|
||||
case unreachable
|
||||
}
|
||||
|
||||
/// What just happened, for the section to react to. Distinct from `availability`, which is about
|
||||
/// the *product*; this is about the last thing the user asked for.
|
||||
public enum Outcome: Equatable {
|
||||
|
||||
/// A purchase or restore left an active subscription in the cache — the one outcome that
|
||||
/// raises the reopen offer (12: "the purchase flow offers to reopen open boards").
|
||||
case activated
|
||||
|
||||
/// Ask to Buy, or a payment the App Store has not settled. Nothing to do but wait; the
|
||||
/// entitlement will arrive through `Transaction.updates` when it does.
|
||||
case pending
|
||||
|
||||
/// The user backed out of the App Store's sheet. Not an error and not worth a word.
|
||||
case cancelled
|
||||
|
||||
/// A restore that reached the App Store and found nothing to restore.
|
||||
case nothingToRestore
|
||||
|
||||
/// Anything else, carrying the sentence to show.
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public private(set) var availability: Availability = .idle
|
||||
|
||||
/// Whether a purchase or a restore is in flight — what the buttons disable on.
|
||||
public private(set) var isBusy = false
|
||||
|
||||
/// The last outcome's sentence, or `nil`. Rendered as one quiet line under the buttons.
|
||||
public private(set) var message: String?
|
||||
|
||||
@ObservationIgnored
|
||||
private let entitlement: ProEntitlement
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "storefront")
|
||||
|
||||
public init(entitlement: ProEntitlement) {
|
||||
self.entitlement = entitlement
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Loads the subscription product and reconciles the cached facts against what the App Store
|
||||
/// says.
|
||||
///
|
||||
/// **The reconciliation is the point, as much as the price is.** This is the moment
|
||||
/// `ProEntitlement`'s offline-grace hold can end honestly: the app has demonstrably reached the
|
||||
/// App Store (the product came back), so the subscription group's status is StoreKit *answering*
|
||||
/// rather than StoreKit computing locally from a stale transaction — which is exactly what 12
|
||||
/// ▸ The entitlement makes the hold wait for.
|
||||
///
|
||||
/// Run from the section's `.task`, so opening Settings is what triggers it. That is also the one
|
||||
/// place a user who has been offline for a while goes looking when they wonder about their
|
||||
/// subscription, which makes it the right door for this to be behind.
|
||||
public func load() async {
|
||||
availability = .loading
|
||||
do {
|
||||
let products = try await Product.products(for: ProProducts.all)
|
||||
guard let product = products.first(where: { $0.id == ProProducts.monthly }) else {
|
||||
Self.logger.error("the App Store returned no product for \(ProProducts.monthly, privacy: .public)")
|
||||
availability = .unreachable
|
||||
return
|
||||
}
|
||||
availability = .ready(product)
|
||||
await reconcile(with: product)
|
||||
} catch {
|
||||
Self.logger.error("product load failed: \(error.localizedDescription, privacy: .public)")
|
||||
availability = .unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds the subscription group's status into the cached facts.
|
||||
///
|
||||
/// Three outcomes, and the middle one is the whole reason this method exists:
|
||||
///
|
||||
/// - **A live status** (subscribed, in grace, in billing retry) → re-read the local transactions,
|
||||
/// which moves the cached expiry forward to whatever the renewal actually is.
|
||||
/// - **Every status expired or revoked** → `adopt(.none)`. StoreKit has answered, so any hold
|
||||
/// standing on "we haven't heard" is over.
|
||||
/// - **No statuses at all** → also `adopt(.none)`, and for the same reason rather than a weaker
|
||||
/// one: the account demonstrably reached the App Store, and the App Store knows of no
|
||||
/// subscription in this group. For a user who never subscribed this is a no-op on facts that
|
||||
/// are already empty.
|
||||
///
|
||||
/// A status read that throws changes nothing. That is silence again, not an answer — the same
|
||||
/// posture `ProEntitlement.refreshFromLocalTransactions()` takes toward an empty local store.
|
||||
private func reconcile(with product: Product) async {
|
||||
guard let subscription = product.subscription else { return }
|
||||
guard let statuses = try? await subscription.status else {
|
||||
Self.logger.debug("subscription status unavailable; the cached facts stand")
|
||||
return
|
||||
}
|
||||
|
||||
let isLive = statuses.contains { status in
|
||||
switch status.state {
|
||||
case .subscribed, .inGracePeriod, .inBillingRetryPeriod: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
if isLive {
|
||||
await entitlement.refreshFromLocalTransactions()
|
||||
} else {
|
||||
entitlement.adopt(.none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Buying
|
||||
|
||||
/// The Subscribe button. Returns what happened, so the section can raise the reopen offer on
|
||||
/// exactly one outcome.
|
||||
///
|
||||
/// The transaction is **finished** on the way through. An auto-renewable subscription has no
|
||||
/// content to deliver — the entitlement is the delivery — so an unfinished one is simply a
|
||||
/// transaction StoreKit redelivers forever.
|
||||
@discardableResult
|
||||
public func subscribe() async -> Outcome {
|
||||
guard case let .ready(product) = availability, !isBusy else { return .cancelled }
|
||||
isBusy = true
|
||||
message = nil
|
||||
defer { isBusy = false }
|
||||
|
||||
do {
|
||||
switch try await product.purchase() {
|
||||
case let .success(verification):
|
||||
guard case let .verified(transaction) = verification else {
|
||||
return report(.failed("This purchase couldn't be verified."))
|
||||
}
|
||||
await transaction.finish()
|
||||
await entitlement.refreshFromLocalTransactions()
|
||||
return report(entitlement.tier == .pro ? .activated : .pending)
|
||||
case .pending:
|
||||
return report(.pending)
|
||||
case .userCancelled:
|
||||
return report(.cancelled)
|
||||
@unknown default:
|
||||
return report(.failed("The App Store returned an unexpected answer."))
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error("purchase failed: \(error.localizedDescription, privacy: .public)")
|
||||
return report(.failed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore Purchases — `AppStore.sync()`, then the same reconciliation the load runs.
|
||||
///
|
||||
/// This is the app's **only** deliberate App Store refresh, and it is behind a button the user
|
||||
/// pressed, which is where 12 puts the network. It exists for the account that owns a
|
||||
/// subscription this device's transaction store has never seen: a new Mac, a reinstall, a signed
|
||||
/// out-and-in Apple Account.
|
||||
@discardableResult
|
||||
public func restore() async -> Outcome {
|
||||
guard !isBusy else { return .cancelled }
|
||||
isBusy = true
|
||||
message = nil
|
||||
defer { isBusy = false }
|
||||
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
} catch {
|
||||
// A cancelled authentication sheet arrives here too, and is not a failure worth a
|
||||
// sentence — the user closed a dialog.
|
||||
Self.logger.error("App Store sync failed: \(error.localizedDescription, privacy: .public)")
|
||||
return report(.failed(error.localizedDescription))
|
||||
}
|
||||
|
||||
await entitlement.refreshFromLocalTransactions()
|
||||
if case let .ready(product) = availability {
|
||||
await reconcile(with: product)
|
||||
}
|
||||
return report(entitlement.tier == .pro ? .activated : .nothingToRestore)
|
||||
}
|
||||
|
||||
// MARK: - Managing
|
||||
|
||||
/// Opens the system's subscription-management surface.
|
||||
///
|
||||
/// **The Mac App Store's account page, not a StoreKit sheet.** StoreKit 2's
|
||||
/// `AppStore.showManageSubscriptions(in:)` takes a `UIWindowScene` and has no macOS counterpart;
|
||||
/// on the Mac the surface is the App Store app's Account ▸ Subscriptions, and the
|
||||
/// `macappstore:` URL is how an app asks for it. The `https:` form is the fallback for a machine
|
||||
/// where that scheme is unhandled, and lands on the same page in a browser.
|
||||
public func openManageSubscriptions() {
|
||||
let candidates = [
|
||||
"macappstore://apps.apple.com/account/subscriptions",
|
||||
"https://apps.apple.com/account/subscriptions"
|
||||
]
|
||||
for candidate in candidates {
|
||||
guard let url = URL(string: candidate) else { continue }
|
||||
if NSWorkspace.shared.open(url) { return }
|
||||
}
|
||||
Self.logger.error("no handler for the App Store subscriptions page")
|
||||
}
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
/// Records an outcome's sentence and hands the outcome straight back, so every return site is
|
||||
/// one line.
|
||||
@discardableResult
|
||||
private func report(_ outcome: Outcome) -> Outcome {
|
||||
message = Self.sentence(for: outcome)
|
||||
return outcome
|
||||
}
|
||||
|
||||
/// The one place outcome wording lives. Calm and factual — 12 ▸ Tier naming keeps this whole
|
||||
/// section free of upsell, and that applies to its failure lines as much as to its heading.
|
||||
static func sentence(for outcome: Outcome) -> String? {
|
||||
switch outcome {
|
||||
case .activated: nil
|
||||
case .pending: "This subscription is waiting for approval."
|
||||
case .cancelled: nil
|
||||
case .nothingToRestore: "No subscription was found for this Apple Account."
|
||||
case let .failed(message): message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Tier
|
||||
|
||||
/// Which tier a board session composes under (12-editions.md ▸ The tiers).
|
||||
///
|
||||
/// **Two cases, and there will never be a third here.** Lanework Teams is deferred and will "never
|
||||
/// share an app group or any cross-app state with Lanework" (12 ▸ The tiers, ruled 2026-07-30) —
|
||||
/// whatever it becomes, it is a different app, not a third case of this enum.
|
||||
///
|
||||
/// Nothing about this type is a *feature flag*. It is the answer to one question — free or Pro —
|
||||
/// asked once per board session at composition (`AppModel.beginSession`), recorded on the session,
|
||||
/// and never asked again for that board. What consumes it is the provider seam
|
||||
/// (12 ▸ The provider seam); see `AppModel.makeHistoryProvider`.
|
||||
public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {
|
||||
|
||||
/// Lanework. Boards are plain folders, mode `none` everywhere, undo is macOS-native
|
||||
/// (13-native-undo.md), and any `.git` the app meets is inert (12 ▸ The free tier and `.git`).
|
||||
///
|
||||
/// **This is also the lapsed tier.** "Unsubscribed and lapsed are one state — the inert posture,
|
||||
/// nothing lost, histories frozen not forfeited" (12 ▸ The entitlement). There is deliberately no
|
||||
/// `.lapsed` case: a case nothing may act on differently is a distinction the design forbids from
|
||||
/// existing at all.
|
||||
case free
|
||||
|
||||
/// Lanework Pro — an active auto-renewable subscription. Binds the git history provider when
|
||||
/// pro-m1 builds it (06-history-undo.md, 07-sync-collab.md).
|
||||
case pro
|
||||
}
|
||||
|
||||
// MARK: - SubscriptionFacts
|
||||
|
||||
/// **What the app knows locally about the subscription** — the whole input to the tier decision,
|
||||
/// beside a date.
|
||||
///
|
||||
/// ### Why a cached fact struct rather than a live StoreKit read
|
||||
///
|
||||
/// 12-editions.md ▸ The entitlement makes two demands that pull in the same direction. Pro state is
|
||||
/// "a local read, never a network call ... the open path gains no network dependency"; and offline
|
||||
/// grace "resolves toward the paying user" — "an on-disk expiry passing while offline, with the last
|
||||
/// known state *active and auto-renew on*, holds the entitlement until StoreKit actually refreshes
|
||||
/// and answers."
|
||||
///
|
||||
/// The second demand is the reason this type exists as *stored* state rather than as a view onto
|
||||
/// `Transaction.currentEntitlements`. StoreKit computes entitlement validity locally, so a
|
||||
/// subscription whose expiry has passed drops out of `currentEntitlements` **whether or not the
|
||||
/// device has been able to ask the App Store about it** — an offline device and a genuinely lapsed
|
||||
/// subscription look identical from that property alone. Holding the last answer ourselves is what
|
||||
/// lets the two be told apart in the only direction the design cares about: a *cancellation* (auto
|
||||
/// renew off) lapses at its expiry with no network needed, while a *renewal we simply have not heard
|
||||
/// about yet* keeps the user paid-up until StoreKit says otherwise (`ProEntitlement.adopt`).
|
||||
///
|
||||
/// ### Never-subscribed and never-online are one shape, on purpose
|
||||
///
|
||||
/// `expiration == nil` means "no cached transaction" and covers both the user who has never
|
||||
/// subscribed and the fresh install that "has no cached transactions and reads as the free tier
|
||||
/// until the first refresh — honest and self-correcting" (12). Nothing distinguishes them because
|
||||
/// nothing may: they are the same tier, reached by the same route, correcting themselves the same
|
||||
/// way.
|
||||
///
|
||||
/// `Codable` because these facts are cached across launches in `UserDefaults`
|
||||
/// (`AppPreferences.subscriptionFactsKey`) — that cache *is* the "local read" the open path performs.
|
||||
public struct SubscriptionFacts: Codable, Sendable, Equatable {
|
||||
|
||||
/// When the current subscription period ends, as StoreKit last reported it.
|
||||
///
|
||||
/// `nil` is the no-cached-transaction state — see the type's note. A non-`nil` value is never
|
||||
/// evidence on its own that the subscription is *live*: an expiry in the past is either a lapse
|
||||
/// or an offline hold, and `willAutoRenew` is what decides which.
|
||||
public var expiration: Date?
|
||||
|
||||
/// Whether the subscription was set to renew, at the last moment StoreKit told us anything.
|
||||
///
|
||||
/// This is the whole of the offline-grace rule. Auto-renew **on** with a passed expiry is a
|
||||
/// renewal the device has not heard about — hold. Auto-renew **off** with a passed expiry is a
|
||||
/// cancellation that has run out — lapse, "offline or not" (12 ▸ The entitlement).
|
||||
public var willAutoRenew: Bool
|
||||
|
||||
public init(expiration: Date?, willAutoRenew: Bool) {
|
||||
self.expiration = expiration
|
||||
self.willAutoRenew = willAutoRenew
|
||||
}
|
||||
|
||||
/// No cached transaction: never subscribed, never online, or an entitlement StoreKit has
|
||||
/// definitively withdrawn (a refund, a revocation). All three read as the free tier, and that is
|
||||
/// the point — see the type's note.
|
||||
public static let none = SubscriptionFacts(expiration: nil, willAutoRenew: false)
|
||||
}
|
||||
|
||||
// MARK: - The decision
|
||||
|
||||
public extension Tier {
|
||||
|
||||
/// **The tier decision, as a pure function of cached facts and a date.**
|
||||
///
|
||||
/// Every semantic here is 12-editions.md ▸ The entitlement's, in its own order:
|
||||
///
|
||||
/// 1. **No cached transaction → free.** "A fresh install that has never been online has no
|
||||
/// cached transactions and reads as the free tier until the first refresh." The
|
||||
/// never-subscribed user takes the identical branch, which is what makes unsubscribed and
|
||||
/// lapsed one state.
|
||||
/// 2. **Expiry in the future → Pro.** "Offline with an active subscription is indistinguishable
|
||||
/// from online" — there is no reachability term in this function because there is no
|
||||
/// reachability term in the rule.
|
||||
/// 3. **Expiry passed, auto-renew on → Pro.** The offline-grace hold: "an on-disk expiry passing
|
||||
/// while offline, with the last known state active and auto-renew on, holds the entitlement
|
||||
/// until StoreKit actually refreshes and answers." The *answering* is `ProEntitlement`'s job
|
||||
/// — this function's job is only to resolve toward the paying user until it happens.
|
||||
/// 4. **Expiry passed, auto-renew off → free.** "A cancellation (auto-renew off) lapses at
|
||||
/// expiry, offline or not."
|
||||
///
|
||||
/// The design weighs both wrong-for-a-window directions and accepts them: "a wrong lapse pauses
|
||||
/// auto-commits into one catch-up commit; a wrong hold gives away days of local commits — Apple's
|
||||
/// own billing grace makes the same trade."
|
||||
///
|
||||
/// `nonisolated` and `static` because it is exactly as pure as that reads: no stored state, no
|
||||
/// clock of its own, no StoreKit. `now` is a parameter rather than a `Date()` inside for the
|
||||
/// reason `AppModel.shouldRestoreAtLaunch` takes its two `Bool`s — a decision worth this much
|
||||
/// prose is worth being provable without a machine in a particular state.
|
||||
///
|
||||
/// The expiry comparison is strict (`>`), so an expiry falling exactly on `now` is *past*: a
|
||||
/// StoreKit expiration date is the instant the period ends, not the last instant it covers, and
|
||||
/// resolving the boundary the other way would extend every subscription by a tick for no reason.
|
||||
/// At that boundary rule 3 is usually what answers anyway, which is the paying user's direction.
|
||||
static func resolve(from facts: SubscriptionFacts, now: Date) -> Tier {
|
||||
guard let expiration = facts.expiration else { return .free }
|
||||
if expiration > now { return .pro }
|
||||
return facts.willAutoRenew ? .pro : .free
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user