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
+259
View File
@@ -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)
}
}
+49
View File
@@ -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]
}
+176
View File
@@ -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
}
}
+273
View File
@@ -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
}
}
}
+130
View File
@@ -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
}
}