Ei kuvausta

SubscriptionStore.swift 6.0KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  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. func product(forPlanKey planKey: String) -> Product? {
  49. guard let id = SubscriptionProductIDs.productID(planKey: planKey) else { return nil }
  50. return productsByID[id]
  51. }
  52. func purchase(planKey: String) async throws -> Bool {
  53. guard let product = product(forPlanKey: planKey) else {
  54. throw SubscriptionStoreError.productUnavailable
  55. }
  56. let result = try await product.purchase()
  57. switch result {
  58. case .success(let verification):
  59. let transaction = try checkVerified(verification)
  60. await transaction.finish()
  61. await refreshEntitlements(deep: true)
  62. return true
  63. case .userCancelled:
  64. return false
  65. case .pending:
  66. return false
  67. @unknown default:
  68. return false
  69. }
  70. }
  71. /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
  72. func restorePurchases() async throws {
  73. try await AppStore.sync()
  74. await refreshEntitlements(deep: true)
  75. }
  76. /// Whether the user has an active subscription for one of this app’s Pro product IDs.
  77. func hasActiveSubscription() async -> Bool {
  78. await refreshEntitlements(deep: true)
  79. return isProActive
  80. }
  81. private func computeProEntitlementFromStore() async -> Bool {
  82. for await result in Transaction.currentEntitlements {
  83. guard case .verified(let transaction) = result else { continue }
  84. if transaction.revocationDate != nil { continue }
  85. if SubscriptionProductIDs.all.contains(transaction.productID) {
  86. return true
  87. }
  88. }
  89. return false
  90. }
  91. /// Fallback when `currentEntitlements` is empty or lagging (common right after install, account changes, or macOS StoreKit edge cases).
  92. private func computeProFromLoadedSubscriptionProducts() async -> Bool {
  93. do {
  94. let products = try await Product.products(for: SubscriptionProductIDs.all)
  95. for product in products {
  96. guard let subscription = product.subscription else { continue }
  97. let statuses = try await subscription.status
  98. for status in statuses {
  99. switch status.state {
  100. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  101. return true
  102. case .expired, .revoked:
  103. break
  104. default:
  105. break
  106. }
  107. }
  108. }
  109. } catch {
  110. return false
  111. }
  112. return false
  113. }
  114. private func listenForTransactions() async {
  115. for await result in Transaction.updates {
  116. guard case .verified(let transaction) = result else { continue }
  117. await transaction.finish()
  118. await refreshEntitlements(deep: true)
  119. }
  120. }
  121. private nonisolated func checkVerified(_ result: VerificationResult<Transaction>) throws -> Transaction {
  122. switch result {
  123. case .unverified(_, let error):
  124. throw error
  125. case .verified(let transaction):
  126. return transaction
  127. }
  128. }
  129. }
  130. enum SubscriptionStoreError: LocalizedError {
  131. /// No `Product` was returned for this ID (wrong IDs, products not approved in ASC, or StoreKit Configuration not selected in the scheme).
  132. case productUnavailable
  133. var errorDescription: String? {
  134. switch self {
  135. case .productUnavailable:
  136. return "That subscription isn’t available from the App Store right now."
  137. }
  138. }
  139. var recoverySuggestion: String? {
  140. switch self {
  141. case .productUnavailable:
  142. return """
  143. For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose ProSubscriptions.storekit.
  144. For TestFlight / App Store: In App Store Connect, create auto-renewable subscriptions whose Product IDs exactly match SubscriptionProductIDs.swift (same spelling as com.mqldev.appforindeed.pro.*), then submit them with the app version.
  145. """
  146. }
  147. }
  148. }