Files
lanework/Kanban/Tier/ProStorefront.swift
rzen 3b19883593 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
2026-07-30 18:14:58 -04:00

274 lines
12 KiB
Swift

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
}
}
}