暫無描述

SubscriptionStore.swift 4.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  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 `Transaction.currentEntitlements`. Call on launch and after StoreKit events.
  24. func refreshEntitlements() async {
  25. isProActive = await computeProEntitlementFromStore()
  26. }
  27. /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
  28. func loadProducts() async {
  29. do {
  30. let products = try await Product.products(for: SubscriptionProductIDs.all)
  31. var map: [String: Product] = [:]
  32. for product in products {
  33. map[product.id] = product
  34. }
  35. productsByID = map
  36. } catch {
  37. productsByID = [:]
  38. }
  39. }
  40. func product(forPlanKey planKey: String) -> Product? {
  41. guard let id = SubscriptionProductIDs.productID(planKey: planKey) else { return nil }
  42. return productsByID[id]
  43. }
  44. func purchase(planKey: String) async throws -> Bool {
  45. guard let product = product(forPlanKey: planKey) else {
  46. throw SubscriptionStoreError.productUnavailable
  47. }
  48. let result = try await product.purchase()
  49. switch result {
  50. case .success(let verification):
  51. let transaction = try checkVerified(verification)
  52. await transaction.finish()
  53. await refreshEntitlements()
  54. NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
  55. return true
  56. case .userCancelled:
  57. return false
  58. case .pending:
  59. return false
  60. @unknown default:
  61. return false
  62. }
  63. }
  64. /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
  65. func restorePurchases() async throws {
  66. try await AppStore.sync()
  67. await refreshEntitlements()
  68. NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
  69. }
  70. /// Whether the user has an active subscription for one of this app’s Pro product IDs.
  71. func hasActiveSubscription() async -> Bool {
  72. await refreshEntitlements()
  73. return isProActive
  74. }
  75. private func computeProEntitlementFromStore() async -> Bool {
  76. for await result in Transaction.currentEntitlements {
  77. guard case .verified(let transaction) = result else { continue }
  78. if transaction.revocationDate != nil { continue }
  79. guard transaction.productType == .autoRenewable else { continue }
  80. if SubscriptionProductIDs.all.contains(transaction.productID) {
  81. return true
  82. }
  83. }
  84. return false
  85. }
  86. private func listenForTransactions() async {
  87. for await result in Transaction.updates {
  88. guard case .verified(let transaction) = result else { continue }
  89. await transaction.finish()
  90. await refreshEntitlements()
  91. NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
  92. }
  93. }
  94. private nonisolated func checkVerified(_ result: VerificationResult<Transaction>) throws -> Transaction {
  95. switch result {
  96. case .unverified(_, let error):
  97. throw error
  98. case .verified(let transaction):
  99. return transaction
  100. }
  101. }
  102. }
  103. enum SubscriptionStoreError: LocalizedError {
  104. /// No `Product` was returned for this ID (wrong IDs, products not approved in ASC, or StoreKit Configuration not selected in the scheme).
  105. case productUnavailable
  106. var errorDescription: String? {
  107. switch self {
  108. case .productUnavailable:
  109. return "That subscription isn’t available from the App Store right now."
  110. }
  111. }
  112. var recoverySuggestion: String? {
  113. switch self {
  114. case .productUnavailable:
  115. return """
  116. For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose ProSubscriptions.storekit.
  117. 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.
  118. """
  119. }
  120. }
  121. }