|
@@ -0,0 +1,728 @@
|
|
|
|
|
+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 [weak self] 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 trustedPlan != nil {
|
|
|
|
|
+ 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 trustedPlan != nil {
|
|
|
|
|
+ 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")
|
|
|
|
|
+}
|