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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user