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:
@@ -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