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
274 lines
12 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|