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