StoreManager.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728
  1. import Cocoa
  2. import StoreKit
  3. enum PremiumAccessKind: String {
  4. case none
  5. case subscription
  6. case lifetime
  7. }
  8. enum StoreError: Error {
  9. case productNotFound
  10. case failedVerification
  11. }
  12. enum PurchaseOutcome: Sendable {
  13. case purchased
  14. case cancelled
  15. case pending
  16. }
  17. @MainActor
  18. final class StoreManager {
  19. static let shared = StoreManager()
  20. private enum ProductLoadError: Error {
  21. case timeout
  22. }
  23. private enum StoreSyncError: Error {
  24. case timeout
  25. }
  26. private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
  27. private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
  28. private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
  29. private static let backgroundRefreshInterval: TimeInterval = 120
  30. private var productCatalog: PaywallConfig { PaywallConfig.loadBundled() }
  31. private(set) var productsByID: [String: Product] = [:]
  32. private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
  33. private(set) var activePremiumPlan: PaywallPlan?
  34. private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
  35. private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
  36. private(set) var purchaseError: PurchaseError?
  37. var isPro: Bool { isPremium }
  38. var hasActiveSubscription: Bool { activePremiumPlan?.isSubscription == true }
  39. var hasLifetimeAccess: Bool { activePremiumPlan?.isLifetime == true }
  40. var premiumAccessKind: PremiumAccessKind {
  41. switch activePremiumPlan?.kind {
  42. case .lifetime: .lifetime
  43. case .monthly, .yearly: .subscription
  44. case nil: .none
  45. }
  46. }
  47. var products: [Product] {
  48. availablePlans.compactMap { productsByID[$0.productID] }
  49. }
  50. var availablePlans: [PaywallPlan] {
  51. PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
  52. }
  53. var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
  54. var hasResolvedPremiumStatus = false
  55. var hasAvailablePlans: Bool { !availablePlans.isEmpty }
  56. var hasAllProductsLoaded: Bool { hasAvailablePlans }
  57. private(set) var isLoadingProducts = false
  58. private(set) var productLoadError: String?
  59. private(set) var purchasingPlan: PaywallPlan?
  60. private(set) var isRestoringPurchases = false
  61. var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
  62. private var inFlightProductLoad: Task<Void, Never>?
  63. private var transactionListener: Task<Void, Never>?
  64. private var initialEntitlementTask: Task<Void, Never>?
  65. private var entitlementRefreshTask: Task<Void, Never>?
  66. private var hasStarted = false
  67. private var lastBackgroundEntitlementRefresh: Date?
  68. private var appActiveObserver: NSObjectProtocol?
  69. private init() {
  70. migrateLegacyPremiumCacheIfNeeded()
  71. hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
  72. if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
  73. isPremium = true
  74. activePremiumPlan = restoredCachedPremiumPlan()
  75. }
  76. }
  77. private func migrateLegacyPremiumCacheIfNeeded() {
  78. guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
  79. guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
  80. let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
  81. return
  82. }
  83. let migratedPlan: String?
  84. switch legacyKind {
  85. case PremiumAccessKind.lifetime.rawValue:
  86. migratedPlan = StoreKitCatalog.shared.productID(for: .lifetime)
  87. case PremiumAccessKind.subscription.rawValue:
  88. migratedPlan = StoreKitCatalog.shared.productID(for: .yearly)
  89. default:
  90. migratedPlan = nil
  91. }
  92. if let migratedPlan {
  93. UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
  94. }
  95. }
  96. private func restoredCachedPremiumPlan() -> PaywallPlan? {
  97. guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
  98. if let stored = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
  99. let plan = PaywallPlan.fromStoredValue(stored) {
  100. return plan
  101. }
  102. if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
  103. switch legacyKind {
  104. case PremiumAccessKind.lifetime.rawValue:
  105. return StoreKitCatalog.shared.productID(for: .lifetime).map(PaywallPlan.init(productID:))
  106. case PremiumAccessKind.subscription.rawValue:
  107. return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
  108. default:
  109. break
  110. }
  111. }
  112. return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
  113. }
  114. func start() {
  115. guard !hasStarted else { return }
  116. hasStarted = true
  117. observeAppLifecycle()
  118. transactionListener = Task { [weak self] in
  119. await self?.listenForTransactionUpdates()
  120. }
  121. initialEntitlementTask = Task { [weak self] in
  122. guard let self else { return }
  123. await self.refreshPremiumAccess()
  124. await self.loadProducts(presentLoadingUI: false)
  125. }
  126. }
  127. private func observeAppLifecycle() {
  128. appActiveObserver = NotificationCenter.default.addObserver(
  129. forName: NSApplication.didBecomeActiveNotification,
  130. object: nil,
  131. queue: .main
  132. ) { [weak self] _ in
  133. Task { @MainActor in
  134. self?.refreshEntitlementsOnAppActive()
  135. }
  136. }
  137. }
  138. private func refreshEntitlementsOnAppActive() {
  139. guard hasStarted else { return }
  140. if let lastRefresh = lastBackgroundEntitlementRefresh,
  141. Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
  142. return
  143. }
  144. entitlementRefreshTask?.cancel()
  145. entitlementRefreshTask = Task {
  146. await refreshPremiumAccess()
  147. lastBackgroundEntitlementRefresh = Date()
  148. }
  149. }
  150. func ensureEntitlementsResolved() async {
  151. await initialEntitlementTask?.value
  152. }
  153. func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
  154. await ensureEntitlementsResolved()
  155. await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
  156. await refreshTrialOffers()
  157. }
  158. func product(for plan: PaywallPlan) -> Product? {
  159. productsByID[plan.productID]
  160. }
  161. func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
  162. trialDisplayByPlan[plan]
  163. }
  164. func clearPurchaseError() {
  165. purchaseError = nil
  166. }
  167. var isEligibleForIntroTrial: Bool {
  168. !hasEverPurchasedPremium && !isPremium
  169. }
  170. func loadProducts(presentLoadingUI: Bool? = nil) async {
  171. if let existing = inFlightProductLoad {
  172. await existing.value
  173. return
  174. }
  175. let uiFlag = presentLoadingUI
  176. let task = Task { @MainActor in
  177. await performLoadProducts(presentLoadingUI: uiFlag)
  178. }
  179. inFlightProductLoad = task
  180. await task.value
  181. inFlightProductLoad = nil
  182. }
  183. @discardableResult
  184. func purchase(plan: PaywallPlan) async -> Bool {
  185. purchaseError = nil
  186. var product = productsByID[plan.productID]
  187. if product == nil {
  188. await loadProducts(presentLoadingUI: false)
  189. product = productsByID[plan.productID]
  190. }
  191. guard let product else {
  192. purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
  193. return false
  194. }
  195. purchasingPlan = plan
  196. postStoreStateDidChange()
  197. defer {
  198. purchasingPlan = nil
  199. postStoreStateDidChange()
  200. }
  201. do {
  202. let result = try await product.purchase()
  203. switch result {
  204. case .success(let verification):
  205. guard case .verified(let transaction) = verification else {
  206. purchaseError = .purchaseNotVerified
  207. return false
  208. }
  209. let trustedPlan = planGrantingPremium(from: transaction)
  210. if let trustedPlan {
  211. applyPremiumAccess(from: transaction)
  212. }
  213. await transaction.finish()
  214. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  215. return isPremium
  216. case .userCancelled:
  217. return false
  218. case .pending:
  219. purchaseError = .purchasePending
  220. return false
  221. @unknown default:
  222. return false
  223. }
  224. } catch {
  225. purchaseError = .generic
  226. return false
  227. }
  228. }
  229. @discardableResult
  230. func restorePurchases() async -> Bool {
  231. purchaseError = nil
  232. isRestoringPurchases = true
  233. postStoreStateDidChange()
  234. defer {
  235. isRestoringPurchases = false
  236. postStoreStateDidChange()
  237. }
  238. do {
  239. try await syncAppStoreWithTimeout(seconds: 30)
  240. } catch is StoreSyncError {
  241. purchaseError = .restoreSyncTimeout
  242. return false
  243. } catch {
  244. purchaseError = .generic
  245. return false
  246. }
  247. await reconcilePremiumAccess(trustedPlan: nil)
  248. await syncPurchaseHistory(allowReset: true)
  249. if !isPremium {
  250. purchaseError = .noActivePurchases
  251. }
  252. return isPremium
  253. }
  254. func showAlert(title: String, message: String, on window: NSWindow?) {
  255. let alert = NSAlert()
  256. alert.messageText = title
  257. alert.informativeText = message
  258. alert.alertStyle = .informational
  259. alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
  260. if let window {
  261. alert.beginSheetModal(for: window)
  262. } else {
  263. alert.runModal()
  264. }
  265. }
  266. func showManageSubscriptions() {
  267. let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
  268. guard let url = URL(string: urlString) else { return }
  269. NSWorkspace.shared.open(url)
  270. }
  271. func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
  272. let config = PaywallConfigService.shared.config
  273. showAlert(
  274. title: config.messages.purchaseFailedTitle,
  275. message: config.message(for: error),
  276. on: window
  277. )
  278. }
  279. private func performLoadProducts(presentLoadingUI: Bool?) async {
  280. let shouldReload = presentLoadingUI == true || productsByID.isEmpty
  281. let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
  282. if !shouldReload, presentLoadingUI != true {
  283. purchaseError = nil
  284. await refreshPremiumAccess()
  285. await refreshTrialOffers()
  286. postStoreStateDidChange()
  287. return
  288. }
  289. if shouldShowLoading {
  290. isLoadingProducts = true
  291. postStoreStateDidChange()
  292. }
  293. defer {
  294. if shouldShowLoading {
  295. isLoadingProducts = false
  296. postStoreStateDidChange()
  297. }
  298. }
  299. purchaseError = nil
  300. productLoadError = nil
  301. do {
  302. let loaded = try await loadProductsWithTimeout(seconds: 12)
  303. guard !loaded.isEmpty else {
  304. productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
  305. productsByID = [:]
  306. await refreshTrialOffers()
  307. return
  308. }
  309. var map: [String: Product] = [:]
  310. for product in loaded {
  311. map[product.id] = product
  312. }
  313. productsByID = map
  314. productLoadError = nil
  315. NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
  316. await refreshPremiumAccess()
  317. await refreshTrialOffers()
  318. } catch is ProductLoadError {
  319. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  320. purchaseError = .productsLoadTimeout
  321. await refreshTrialOffers()
  322. } catch {
  323. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  324. purchaseError = .generic
  325. await refreshTrialOffers()
  326. }
  327. }
  328. private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
  329. let productIDs = productCatalog.allProductIDs
  330. return try await withThrowingTaskGroup(of: [Product].self) { group in
  331. group.addTask {
  332. try await Product.products(for: productIDs)
  333. }
  334. group.addTask {
  335. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  336. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  337. throw ProductLoadError.timeout
  338. }
  339. guard let first = try await group.next() else {
  340. throw ProductLoadError.timeout
  341. }
  342. group.cancelAll()
  343. return first
  344. }
  345. }
  346. private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
  347. var resolvedPlan: PaywallPlan?
  348. if let lifetimePlan = await activeLifetimePlan() {
  349. resolvedPlan = lifetimePlan
  350. }
  351. if resolvedPlan == nil {
  352. for await result in Transaction.currentEntitlements {
  353. guard case .verified(let transaction) = result else { continue }
  354. guard let plan = planGrantingPremium(from: transaction) else { continue }
  355. resolvedPlan = preferredPlan(resolvedPlan, plan)
  356. }
  357. }
  358. if resolvedPlan == nil || resolvedPlan?.isLifetime != true {
  359. if let subscriptionPlan = await activeSubscriptionPlan() {
  360. resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
  361. }
  362. }
  363. if resolvedPlan == nil {
  364. if let statusPlan = await activePlanFromSubscriptionStatus() {
  365. resolvedPlan = statusPlan
  366. }
  367. }
  368. let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
  369. applyPremiumStatus(plan: finalPlan)
  370. hasResolvedPremiumStatus = true
  371. NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
  372. await syncPurchaseHistory(allowReset: false)
  373. await refreshTrialOffers()
  374. }
  375. private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
  376. await refreshPremiumAccess(trustedPlan: trustedPlan)
  377. guard let trustedPlan, !isPremium else { return }
  378. do {
  379. try await syncAppStoreWithTimeout(seconds: 15)
  380. } catch {
  381. applyTrustedPlan(trustedPlan)
  382. return
  383. }
  384. await refreshPremiumAccess(trustedPlan: trustedPlan)
  385. if !isPremium {
  386. applyTrustedPlan(trustedPlan)
  387. }
  388. }
  389. private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
  390. switch (resolvedPlan, trustedPlan) {
  391. case (nil, nil):
  392. nil
  393. case (let resolved?, nil):
  394. resolved
  395. case (nil, let trusted?):
  396. trusted
  397. case (let resolved?, let trusted?):
  398. preferredPlan(resolved, trusted)
  399. }
  400. }
  401. private func applyTrustedPlan(_ plan: PaywallPlan) {
  402. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  403. applyPremiumStatus(plan: activePremiumPlan)
  404. hasResolvedPremiumStatus = true
  405. markAsHavingPurchasedPremium()
  406. }
  407. private func revokePremiumAccess() {
  408. applyPremiumStatus(plan: nil)
  409. hasResolvedPremiumStatus = true
  410. trialDisplayByPlan = [:]
  411. }
  412. private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
  413. try await withThrowingTaskGroup(of: Void.self) { group in
  414. group.addTask {
  415. try await AppStore.sync()
  416. }
  417. group.addTask {
  418. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  419. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  420. throw StoreSyncError.timeout
  421. }
  422. try await group.next()
  423. group.cancelAll()
  424. }
  425. }
  426. private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
  427. guard let plan = planGrantingPremium(from: transaction) else { return }
  428. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  429. applyPremiumStatus(plan: activePremiumPlan)
  430. hasResolvedPremiumStatus = true
  431. markAsHavingPurchasedPremium()
  432. }
  433. private func markAsHavingPurchasedPremium() {
  434. guard !hasEverPurchasedPremium else { return }
  435. hasEverPurchasedPremium = true
  436. trialDisplayByPlan = [:]
  437. UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
  438. }
  439. private func syncPurchaseHistory(allowReset: Bool) async {
  440. var hasRecordedPurchase = false
  441. let knownProductIDs = productCatalog.allProductIDs
  442. for await result in Transaction.all {
  443. guard case .verified(let transaction) = result else { continue }
  444. guard knownProductIDs.contains(transaction.productID) else { continue }
  445. hasRecordedPurchase = true
  446. break
  447. }
  448. if hasRecordedPurchase {
  449. if !hasEverPurchasedPremium {
  450. hasEverPurchasedPremium = true
  451. UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
  452. }
  453. } else if allowReset, hasEverPurchasedPremium {
  454. resetToNeverPurchasedState()
  455. }
  456. }
  457. private func resetToNeverPurchasedState() {
  458. hasEverPurchasedPremium = false
  459. UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
  460. trialDisplayByPlan = [:]
  461. }
  462. private func refreshTrialOffers() async {
  463. guard !isPremium else {
  464. trialDisplayByPlan = [:]
  465. postStoreStateDidChange()
  466. return
  467. }
  468. guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
  469. trialDisplayByPlan = [:]
  470. postStoreStateDidChange()
  471. return
  472. }
  473. if let display = await resolveTrialDisplay(for: eligiblePlan) {
  474. trialDisplayByPlan = [eligiblePlan: display]
  475. } else {
  476. trialDisplayByPlan = [:]
  477. }
  478. postStoreStateDidChange()
  479. }
  480. private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
  481. guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
  482. guard !isPremium else { return nil }
  483. let config = PaywallConfigService.shared.config
  484. guard let product = product(for: plan),
  485. let subscription = product.subscription else {
  486. return fallbackTrialDisplay(config: config)
  487. }
  488. guard await subscription.isEligibleForIntroOffer else { return nil }
  489. if let offer = subscription.introductoryOffer {
  490. return PaywallTrialDisplay.from(offer: offer, config: config)
  491. }
  492. return fallbackTrialDisplay(config: config)
  493. }
  494. private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
  495. guard !isPremium else { return nil }
  496. guard let fallback = config.trial.fallbackDuration else { return nil }
  497. return PaywallTrialDisplay.from(fallback: fallback, config: config)
  498. }
  499. private func applyPremiumStatus(plan: PaywallPlan?) {
  500. let hasPremium = plan != nil
  501. let kind: PremiumAccessKind
  502. switch plan?.kind {
  503. case .lifetime: kind = .lifetime
  504. case .monthly, .yearly: kind = .subscription
  505. case nil: kind = .none
  506. }
  507. let previousKind = premiumAccessKind
  508. let previousPlan = activePremiumPlan
  509. let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
  510. isPremium = hasPremium
  511. activePremiumPlan = plan
  512. UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
  513. if let plan {
  514. UserDefaults.standard.set(plan.productID, forKey: Self.cachedPremiumPlanKey)
  515. } else {
  516. UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
  517. }
  518. if didChange {
  519. NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
  520. }
  521. postStoreStateDidChange()
  522. }
  523. private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
  524. guard transactionGrantsPremium(transaction) else { return nil }
  525. return productCatalog.plan(for: transaction.productID)
  526. }
  527. private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
  528. guard productCatalog.allProductIDs.contains(transaction.productID) else { return false }
  529. guard transaction.revocationDate == nil else { return false }
  530. if let expirationDate = transaction.expirationDate, expirationDate < Date() {
  531. return false
  532. }
  533. return true
  534. }
  535. private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
  536. StoreKitCatalog.shared.preferredPlan(current, candidate)
  537. }
  538. private func activeLifetimePlan() async -> PaywallPlan? {
  539. guard let lifetimeProductID = StoreKitCatalog.shared.productID(for: .lifetime) else {
  540. return nil
  541. }
  542. guard let result = await Transaction.latest(for: lifetimeProductID) else {
  543. return nil
  544. }
  545. guard case .verified(let transaction) = result else { return nil }
  546. return planGrantingPremium(from: transaction)
  547. }
  548. private func activeSubscriptionPlan() async -> PaywallPlan? {
  549. var resolvedPlan: PaywallPlan?
  550. for await result in Transaction.currentEntitlements {
  551. guard case .verified(let transaction) = result else { continue }
  552. guard let plan = planGrantingPremium(from: transaction), !plan.isLifetime else { continue }
  553. resolvedPlan = preferredPlan(resolvedPlan, plan)
  554. }
  555. return resolvedPlan
  556. }
  557. private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
  558. for plan in StoreKitCatalog.shared.subscriptionPlans {
  559. let productID = plan.productID
  560. let product: Product?
  561. if let cached = productsByID[productID] {
  562. product = cached
  563. } else {
  564. product = try? await Product.products(for: [productID]).first
  565. }
  566. guard let product,
  567. let subscription = product.subscription,
  568. let statuses = try? await subscription.status else {
  569. continue
  570. }
  571. for status in statuses {
  572. switch status.state {
  573. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  574. return plan
  575. default:
  576. continue
  577. }
  578. }
  579. }
  580. return nil
  581. }
  582. private func listenForTransactionUpdates() async {
  583. for await update in Transaction.updates {
  584. guard case .verified(let transaction) = update else { continue }
  585. if isRevokedAppTransaction(transaction) {
  586. revokePremiumAccess()
  587. await transaction.finish()
  588. await refreshPremiumAccess()
  589. continue
  590. }
  591. let trustedPlan = planGrantingPremium(from: transaction)
  592. if let trustedPlan {
  593. applyPremiumAccess(from: transaction)
  594. }
  595. await transaction.finish()
  596. if trustedPlan != nil {
  597. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  598. } else {
  599. await refreshPremiumAccess()
  600. }
  601. }
  602. }
  603. private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
  604. productCatalog.allProductIDs.contains(transaction.productID) && transaction.revocationDate != nil
  605. }
  606. private func postStoreStateDidChange() {
  607. NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
  608. }
  609. }
  610. extension Notification.Name {
  611. static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
  612. static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
  613. static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
  614. static let storeStateDidChange = Notification.Name("storeStateDidChange")
  615. }