Няма описание

SubscriptionStore.swift 5.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. //
  2. // SubscriptionStore.swift
  3. // App for Indeed
  4. //
  5. import Foundation
  6. import StoreKit
  7. extension Notification.Name {
  8. static let subscriptionStatusDidChange = Notification.Name("subscriptionStatusDidChange")
  9. }
  10. @MainActor
  11. final class SubscriptionStore {
  12. static let shared = SubscriptionStore()
  13. private(set) var productsByID: [String: Product] = [:]
  14. /// Mirrors StoreKit entitlements; safe to read on the main thread after `refreshEntitlements()` or a `.subscriptionStatusDidChange` notification.
  15. private(set) var isProActive: Bool = false
  16. private var transactionListenerTask: Task<Void, Never>?
  17. private init() {
  18. transactionListenerTask = Task { await listenForTransactions() }
  19. }
  20. deinit {
  21. transactionListenerTask?.cancel()
  22. }
  23. /// Syncs `isProActive` with StoreKit. Set `deep` to also consult subscription status via `Product.products` when entitlements are empty (after sync, restore, or opening the paywall).
  24. /// Posts `.subscriptionStatusDidChange` when the value changes.
  25. func refreshEntitlements(deep: Bool = false) async {
  26. let before = isProActive
  27. isProActive = await computeProEntitlementFromStore()
  28. if !isProActive, deep {
  29. isProActive = await computeProFromLoadedSubscriptionProducts()
  30. }
  31. if before != isProActive {
  32. NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
  33. }
  34. }
  35. /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
  36. func loadProducts() async {
  37. do {
  38. let products = try await Product.products(for: SubscriptionProductIDs.all)
  39. var map: [String: Product] = [:]
  40. for product in products {
  41. map[product.id] = product
  42. }
  43. productsByID = map
  44. } catch {
  45. productsByID = [:]
  46. }
  47. }
  48. /// Fetches the App Store product catalog only when it is not already in memory (e.g. after launch preload).
  49. func ensureProductsLoaded() async {
  50. guard productsByID.isEmpty else { return }
  51. await loadProducts()
  52. }
  53. func product(forPlanKey planKey: String) -> Product? {
  54. guard let id = SubscriptionProductIDs.productID(planKey: planKey) else { return nil }
  55. return productsByID[id]
  56. }
  57. func purchase(planKey: String) async throws -> Bool {
  58. guard let product = product(forPlanKey: planKey) else {
  59. throw SubscriptionStoreError.productUnavailable
  60. }
  61. let result = try await product.purchase()
  62. switch result {
  63. case .success(let verification):
  64. let transaction = try checkVerified(verification)
  65. await transaction.finish()
  66. await refreshEntitlements(deep: true)
  67. return true
  68. case .userCancelled:
  69. return false
  70. case .pending:
  71. return false
  72. @unknown default:
  73. return false
  74. }
  75. }
  76. /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
  77. func restorePurchases() async throws {
  78. try await AppStore.sync()
  79. await refreshEntitlements(deep: true)
  80. }
  81. /// Whether the user has an active subscription for one of this app’s Pro product IDs.
  82. func hasActiveSubscription() async -> Bool {
  83. await refreshEntitlements(deep: true)
  84. return isProActive
  85. }
  86. private func computeProEntitlementFromStore() async -> Bool {
  87. for await result in Transaction.currentEntitlements {
  88. guard case .verified(let transaction) = result else { continue }
  89. if transaction.revocationDate != nil { continue }
  90. if SubscriptionProductIDs.all.contains(transaction.productID) {
  91. return true
  92. }
  93. }
  94. return false
  95. }
  96. /// Fallback when `currentEntitlements` is empty or lagging (common right after install, account changes, or macOS StoreKit edge cases).
  97. private func computeProFromLoadedSubscriptionProducts() async -> Bool {
  98. do {
  99. let products = try await Product.products(for: SubscriptionProductIDs.all)
  100. for product in products {
  101. guard let subscription = product.subscription else { continue }
  102. let statuses = try await subscription.status
  103. for status in statuses {
  104. switch status.state {
  105. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  106. return true
  107. case .expired, .revoked:
  108. break
  109. default:
  110. break
  111. }
  112. }
  113. }
  114. } catch {
  115. return false
  116. }
  117. return false
  118. }
  119. private func listenForTransactions() async {
  120. for await result in Transaction.updates {
  121. guard case .verified(let transaction) = result else { continue }
  122. await transaction.finish()
  123. await refreshEntitlements(deep: true)
  124. }
  125. }
  126. private nonisolated func checkVerified(_ result: VerificationResult<Transaction>) throws -> Transaction {
  127. switch result {
  128. case .unverified(_, let error):
  129. throw error
  130. case .verified(let transaction):
  131. return transaction
  132. }
  133. }
  134. }
  135. enum SubscriptionStoreError: LocalizedError {
  136. /// No `Product` was returned for this ID (wrong IDs, products not approved in ASC, or StoreKit Configuration not selected in the scheme).
  137. case productUnavailable
  138. var errorDescription: String? {
  139. switch self {
  140. case .productUnavailable:
  141. return L("That subscription isn’t available from the App Store right now.")
  142. }
  143. }
  144. /// Developer setup notes belong in docs or console logs—not in customer-facing alerts.
  145. var recoverySuggestion: String? { nil }
  146. }