| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728 |
- import Cocoa
- import StoreKit
- enum PremiumAccessKind: String {
- case none
- case subscription
- case lifetime
- }
- enum StoreError: Error {
- case productNotFound
- case failedVerification
- }
- enum PurchaseOutcome: Sendable {
- case purchased
- case cancelled
- case pending
- }
- @MainActor
- final class StoreManager {
- static let shared = StoreManager()
- private enum ProductLoadError: Error {
- case timeout
- }
- private enum StoreSyncError: Error {
- case timeout
- }
- private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
- private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
- private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
- private static let backgroundRefreshInterval: TimeInterval = 120
- private var productCatalog: PaywallConfig { PaywallConfig.loadBundled() }
- private(set) var productsByID: [String: Product] = [:]
- private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
- private(set) var activePremiumPlan: PaywallPlan?
- private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
- private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
- private(set) var purchaseError: PurchaseError?
- var isPro: Bool { isPremium }
- var hasActiveSubscription: Bool { activePremiumPlan?.isSubscription == true }
- var hasLifetimeAccess: Bool { activePremiumPlan?.isLifetime == true }
- var premiumAccessKind: PremiumAccessKind {
- switch activePremiumPlan?.kind {
- case .lifetime: .lifetime
- case .monthly, .yearly: .subscription
- case nil: .none
- }
- }
- var products: [Product] {
- availablePlans.compactMap { productsByID[$0.productID] }
- }
- var availablePlans: [PaywallPlan] {
- PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
- }
- var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
- var hasResolvedPremiumStatus = false
- var hasAvailablePlans: Bool { !availablePlans.isEmpty }
- var hasAllProductsLoaded: Bool { hasAvailablePlans }
- private(set) var isLoadingProducts = false
- private(set) var productLoadError: String?
- private(set) var purchasingPlan: PaywallPlan?
- private(set) var isRestoringPurchases = false
- var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
- private var inFlightProductLoad: Task<Void, Never>?
- private var transactionListener: Task<Void, Never>?
- private var initialEntitlementTask: Task<Void, Never>?
- private var entitlementRefreshTask: Task<Void, Never>?
- private var hasStarted = false
- private var lastBackgroundEntitlementRefresh: Date?
- private var appActiveObserver: NSObjectProtocol?
- private init() {
- migrateLegacyPremiumCacheIfNeeded()
- hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
- if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
- isPremium = true
- activePremiumPlan = restoredCachedPremiumPlan()
- }
- }
- private func migrateLegacyPremiumCacheIfNeeded() {
- guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
- guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
- let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
- return
- }
- let migratedPlan: String?
- switch legacyKind {
- case PremiumAccessKind.lifetime.rawValue:
- migratedPlan = StoreKitCatalog.shared.productID(for: .lifetime)
- case PremiumAccessKind.subscription.rawValue:
- migratedPlan = StoreKitCatalog.shared.productID(for: .yearly)
- default:
- migratedPlan = nil
- }
- if let migratedPlan {
- UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
- }
- }
- private func restoredCachedPremiumPlan() -> PaywallPlan? {
- guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
- if let stored = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
- let plan = PaywallPlan.fromStoredValue(stored) {
- return plan
- }
- if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
- switch legacyKind {
- case PremiumAccessKind.lifetime.rawValue:
- return StoreKitCatalog.shared.productID(for: .lifetime).map(PaywallPlan.init(productID:))
- case PremiumAccessKind.subscription.rawValue:
- return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
- default:
- break
- }
- }
- return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
- }
- func start() {
- guard !hasStarted else { return }
- hasStarted = true
- observeAppLifecycle()
- transactionListener = Task { [weak self] in
- await self?.listenForTransactionUpdates()
- }
- initialEntitlementTask = Task { [weak self] in
- guard let self else { return }
- await self.refreshPremiumAccess()
- await self.loadProducts(presentLoadingUI: false)
- }
- }
- private func observeAppLifecycle() {
- appActiveObserver = NotificationCenter.default.addObserver(
- forName: NSApplication.didBecomeActiveNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- Task { @MainActor in
- self?.refreshEntitlementsOnAppActive()
- }
- }
- }
- private func refreshEntitlementsOnAppActive() {
- guard hasStarted else { return }
- if let lastRefresh = lastBackgroundEntitlementRefresh,
- Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
- return
- }
- entitlementRefreshTask?.cancel()
- entitlementRefreshTask = Task {
- await refreshPremiumAccess()
- lastBackgroundEntitlementRefresh = Date()
- }
- }
- func ensureEntitlementsResolved() async {
- await initialEntitlementTask?.value
- }
- func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
- await ensureEntitlementsResolved()
- await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
- await refreshTrialOffers()
- }
- func product(for plan: PaywallPlan) -> Product? {
- productsByID[plan.productID]
- }
- func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
- trialDisplayByPlan[plan]
- }
- func clearPurchaseError() {
- purchaseError = nil
- }
- var isEligibleForIntroTrial: Bool {
- !hasEverPurchasedPremium && !isPremium
- }
- func loadProducts(presentLoadingUI: Bool? = nil) async {
- if let existing = inFlightProductLoad {
- await existing.value
- return
- }
- let uiFlag = presentLoadingUI
- let task = Task { @MainActor in
- await performLoadProducts(presentLoadingUI: uiFlag)
- }
- inFlightProductLoad = task
- await task.value
- inFlightProductLoad = nil
- }
- @discardableResult
- func purchase(plan: PaywallPlan) async -> Bool {
- purchaseError = nil
- var product = productsByID[plan.productID]
- if product == nil {
- await loadProducts(presentLoadingUI: false)
- product = productsByID[plan.productID]
- }
- guard let product else {
- purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
- return false
- }
- purchasingPlan = plan
- postStoreStateDidChange()
- defer {
- purchasingPlan = nil
- postStoreStateDidChange()
- }
- do {
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- guard case .verified(let transaction) = verification else {
- purchaseError = .purchaseNotVerified
- return false
- }
- let trustedPlan = planGrantingPremium(from: transaction)
- if let trustedPlan {
- applyPremiumAccess(from: transaction)
- }
- await transaction.finish()
- await reconcilePremiumAccess(trustedPlan: trustedPlan)
- return isPremium
- case .userCancelled:
- return false
- case .pending:
- purchaseError = .purchasePending
- return false
- @unknown default:
- return false
- }
- } catch {
- purchaseError = .generic
- return false
- }
- }
- @discardableResult
- func restorePurchases() async -> Bool {
- purchaseError = nil
- isRestoringPurchases = true
- postStoreStateDidChange()
- defer {
- isRestoringPurchases = false
- postStoreStateDidChange()
- }
- do {
- try await syncAppStoreWithTimeout(seconds: 30)
- } catch is StoreSyncError {
- purchaseError = .restoreSyncTimeout
- return false
- } catch {
- purchaseError = .generic
- return false
- }
- await reconcilePremiumAccess(trustedPlan: nil)
- await syncPurchaseHistory(allowReset: true)
- if !isPremium {
- purchaseError = .noActivePurchases
- }
- return isPremium
- }
- func showAlert(title: String, message: String, on window: NSWindow?) {
- let alert = NSAlert()
- alert.messageText = title
- alert.informativeText = message
- alert.alertStyle = .informational
- alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
- if let window {
- alert.beginSheetModal(for: window)
- } else {
- alert.runModal()
- }
- }
- func showManageSubscriptions() {
- let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
- guard let url = URL(string: urlString) else { return }
- NSWorkspace.shared.open(url)
- }
- func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
- let config = PaywallConfigService.shared.config
- showAlert(
- title: config.messages.purchaseFailedTitle,
- message: config.message(for: error),
- on: window
- )
- }
- private func performLoadProducts(presentLoadingUI: Bool?) async {
- let shouldReload = presentLoadingUI == true || productsByID.isEmpty
- let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
- if !shouldReload, presentLoadingUI != true {
- purchaseError = nil
- await refreshPremiumAccess()
- await refreshTrialOffers()
- postStoreStateDidChange()
- return
- }
- if shouldShowLoading {
- isLoadingProducts = true
- postStoreStateDidChange()
- }
- defer {
- if shouldShowLoading {
- isLoadingProducts = false
- postStoreStateDidChange()
- }
- }
- purchaseError = nil
- productLoadError = nil
- do {
- let loaded = try await loadProductsWithTimeout(seconds: 12)
- guard !loaded.isEmpty else {
- productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
- productsByID = [:]
- await refreshTrialOffers()
- return
- }
- var map: [String: Product] = [:]
- for product in loaded {
- map[product.id] = product
- }
- productsByID = map
- productLoadError = nil
- NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
- await refreshPremiumAccess()
- await refreshTrialOffers()
- } catch is ProductLoadError {
- productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
- purchaseError = .productsLoadTimeout
- await refreshTrialOffers()
- } catch {
- productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
- purchaseError = .generic
- await refreshTrialOffers()
- }
- }
- private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
- let productIDs = productCatalog.allProductIDs
- return try await withThrowingTaskGroup(of: [Product].self) { group in
- group.addTask {
- try await Product.products(for: productIDs)
- }
- group.addTask {
- let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
- try await Task.sleep(nanoseconds: timeoutNanoseconds)
- throw ProductLoadError.timeout
- }
- guard let first = try await group.next() else {
- throw ProductLoadError.timeout
- }
- group.cancelAll()
- return first
- }
- }
- private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
- var resolvedPlan: PaywallPlan?
- if let lifetimePlan = await activeLifetimePlan() {
- resolvedPlan = lifetimePlan
- }
- if resolvedPlan == nil {
- for await result in Transaction.currentEntitlements {
- guard case .verified(let transaction) = result else { continue }
- guard let plan = planGrantingPremium(from: transaction) else { continue }
- resolvedPlan = preferredPlan(resolvedPlan, plan)
- }
- }
- if resolvedPlan == nil || resolvedPlan?.isLifetime != true {
- if let subscriptionPlan = await activeSubscriptionPlan() {
- resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
- }
- }
- if resolvedPlan == nil {
- if let statusPlan = await activePlanFromSubscriptionStatus() {
- resolvedPlan = statusPlan
- }
- }
- let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
- applyPremiumStatus(plan: finalPlan)
- hasResolvedPremiumStatus = true
- NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
- await syncPurchaseHistory(allowReset: false)
- await refreshTrialOffers()
- }
- private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
- await refreshPremiumAccess(trustedPlan: trustedPlan)
- guard let trustedPlan, !isPremium else { return }
- do {
- try await syncAppStoreWithTimeout(seconds: 15)
- } catch {
- applyTrustedPlan(trustedPlan)
- return
- }
- await refreshPremiumAccess(trustedPlan: trustedPlan)
- if !isPremium {
- applyTrustedPlan(trustedPlan)
- }
- }
- private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
- switch (resolvedPlan, trustedPlan) {
- case (nil, nil):
- nil
- case (let resolved?, nil):
- resolved
- case (nil, let trusted?):
- trusted
- case (let resolved?, let trusted?):
- preferredPlan(resolved, trusted)
- }
- }
- private func applyTrustedPlan(_ plan: PaywallPlan) {
- activePremiumPlan = preferredPlan(activePremiumPlan, plan)
- applyPremiumStatus(plan: activePremiumPlan)
- hasResolvedPremiumStatus = true
- markAsHavingPurchasedPremium()
- }
- private func revokePremiumAccess() {
- applyPremiumStatus(plan: nil)
- hasResolvedPremiumStatus = true
- trialDisplayByPlan = [:]
- }
- private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
- try await withThrowingTaskGroup(of: Void.self) { group in
- group.addTask {
- try await AppStore.sync()
- }
- group.addTask {
- let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
- try await Task.sleep(nanoseconds: timeoutNanoseconds)
- throw StoreSyncError.timeout
- }
- try await group.next()
- group.cancelAll()
- }
- }
- private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
- guard let plan = planGrantingPremium(from: transaction) else { return }
- activePremiumPlan = preferredPlan(activePremiumPlan, plan)
- applyPremiumStatus(plan: activePremiumPlan)
- hasResolvedPremiumStatus = true
- markAsHavingPurchasedPremium()
- }
- private func markAsHavingPurchasedPremium() {
- guard !hasEverPurchasedPremium else { return }
- hasEverPurchasedPremium = true
- trialDisplayByPlan = [:]
- UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
- }
- private func syncPurchaseHistory(allowReset: Bool) async {
- var hasRecordedPurchase = false
- let knownProductIDs = productCatalog.allProductIDs
- for await result in Transaction.all {
- guard case .verified(let transaction) = result else { continue }
- guard knownProductIDs.contains(transaction.productID) else { continue }
- hasRecordedPurchase = true
- break
- }
- if hasRecordedPurchase {
- if !hasEverPurchasedPremium {
- hasEverPurchasedPremium = true
- UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
- }
- } else if allowReset, hasEverPurchasedPremium {
- resetToNeverPurchasedState()
- }
- }
- private func resetToNeverPurchasedState() {
- hasEverPurchasedPremium = false
- UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
- trialDisplayByPlan = [:]
- }
- private func refreshTrialOffers() async {
- guard !isPremium else {
- trialDisplayByPlan = [:]
- postStoreStateDidChange()
- return
- }
- guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
- trialDisplayByPlan = [:]
- postStoreStateDidChange()
- return
- }
- if let display = await resolveTrialDisplay(for: eligiblePlan) {
- trialDisplayByPlan = [eligiblePlan: display]
- } else {
- trialDisplayByPlan = [:]
- }
- postStoreStateDidChange()
- }
- private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
- guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
- guard !isPremium else { return nil }
- let config = PaywallConfigService.shared.config
- guard let product = product(for: plan),
- let subscription = product.subscription else {
- return fallbackTrialDisplay(config: config)
- }
- guard await subscription.isEligibleForIntroOffer else { return nil }
- if let offer = subscription.introductoryOffer {
- return PaywallTrialDisplay.from(offer: offer, config: config)
- }
- return fallbackTrialDisplay(config: config)
- }
- private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
- guard !isPremium else { return nil }
- guard let fallback = config.trial.fallbackDuration else { return nil }
- return PaywallTrialDisplay.from(fallback: fallback, config: config)
- }
- private func applyPremiumStatus(plan: PaywallPlan?) {
- let hasPremium = plan != nil
- let kind: PremiumAccessKind
- switch plan?.kind {
- case .lifetime: kind = .lifetime
- case .monthly, .yearly: kind = .subscription
- case nil: kind = .none
- }
- let previousKind = premiumAccessKind
- let previousPlan = activePremiumPlan
- let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
- isPremium = hasPremium
- activePremiumPlan = plan
- UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
- if let plan {
- UserDefaults.standard.set(plan.productID, forKey: Self.cachedPremiumPlanKey)
- } else {
- UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
- }
- if didChange {
- NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
- }
- postStoreStateDidChange()
- }
- private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
- guard transactionGrantsPremium(transaction) else { return nil }
- return productCatalog.plan(for: transaction.productID)
- }
- private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
- guard productCatalog.allProductIDs.contains(transaction.productID) else { return false }
- guard transaction.revocationDate == nil else { return false }
- if let expirationDate = transaction.expirationDate, expirationDate < Date() {
- return false
- }
- return true
- }
- private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
- StoreKitCatalog.shared.preferredPlan(current, candidate)
- }
- private func activeLifetimePlan() async -> PaywallPlan? {
- guard let lifetimeProductID = StoreKitCatalog.shared.productID(for: .lifetime) else {
- return nil
- }
- guard let result = await Transaction.latest(for: lifetimeProductID) else {
- return nil
- }
- guard case .verified(let transaction) = result else { return nil }
- return planGrantingPremium(from: transaction)
- }
- private func activeSubscriptionPlan() async -> PaywallPlan? {
- var resolvedPlan: PaywallPlan?
- for await result in Transaction.currentEntitlements {
- guard case .verified(let transaction) = result else { continue }
- guard let plan = planGrantingPremium(from: transaction), !plan.isLifetime else { continue }
- resolvedPlan = preferredPlan(resolvedPlan, plan)
- }
- return resolvedPlan
- }
- private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
- for plan in StoreKitCatalog.shared.subscriptionPlans {
- let productID = plan.productID
- let product: Product?
- if let cached = productsByID[productID] {
- product = cached
- } else {
- product = try? await Product.products(for: [productID]).first
- }
- guard let product,
- let subscription = product.subscription,
- let statuses = try? await subscription.status else {
- continue
- }
- for status in statuses {
- switch status.state {
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
- return plan
- default:
- continue
- }
- }
- }
- return nil
- }
- private func listenForTransactionUpdates() async {
- for await update in Transaction.updates {
- guard case .verified(let transaction) = update else { continue }
- if isRevokedAppTransaction(transaction) {
- revokePremiumAccess()
- await transaction.finish()
- await refreshPremiumAccess()
- continue
- }
- let trustedPlan = planGrantingPremium(from: transaction)
- if let trustedPlan {
- applyPremiumAccess(from: transaction)
- }
- await transaction.finish()
- if trustedPlan != nil {
- await reconcilePremiumAccess(trustedPlan: trustedPlan)
- } else {
- await refreshPremiumAccess()
- }
- }
- }
- private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
- productCatalog.allProductIDs.contains(transaction.productID) && transaction.revocationDate != nil
- }
- private func postStoreStateDidChange() {
- NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
- }
- }
- extension Notification.Name {
- static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
- static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
- static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
- static let storeStateDidChange = Notification.Name("storeStateDidChange")
- }
|