SubscriptionManager.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. import AppKit
  2. import Combine
  3. import Foundation
  4. import StoreKit
  5. enum PurchaseError: Equatable {
  6. case productsLoadTimeout
  7. case subscriptionUnavailable
  8. case planUnavailable
  9. case purchaseNotVerified
  10. case purchasePending
  11. case noActiveSubscriptions
  12. case noActivePurchases
  13. case generic
  14. var message: String {
  15. switch self {
  16. case .productsLoadTimeout:
  17. "Couldn't load subscription products right now. Check your StoreKit configuration / internet and try again."
  18. case .subscriptionUnavailable:
  19. "This subscription is currently unavailable. Please try again in a moment."
  20. case .planUnavailable:
  21. "This plan isn't available right now. Please try again later."
  22. case .purchaseNotVerified:
  23. "Apple couldn't verify this purchase."
  24. case .purchasePending:
  25. "This purchase is waiting for approval (for example, Ask to Buy)."
  26. case .noActiveSubscriptions:
  27. "No active subscriptions were found for this Apple ID."
  28. case .noActivePurchases:
  29. "No premium purchases were found for this Apple ID."
  30. case .generic:
  31. "Something went wrong with your subscription. Please try again."
  32. }
  33. }
  34. }
  35. @MainActor
  36. final class SubscriptionManager: ObservableObject {
  37. private enum ProductLoadError: Error {
  38. case timeout
  39. }
  40. @Published private(set) var productsByID: [String: Product] = [:]
  41. @Published private(set) var isLoadingProducts = false
  42. @Published private(set) var purchasingPlan: PaywallPlan?
  43. @Published private(set) var hasPremiumAccess = false
  44. @Published private(set) var hasResolvedPremiumStatus = false
  45. @Published private(set) var activePremiumPlan: PaywallPlan?
  46. @Published var purchaseError: PurchaseError?
  47. var hasLifetimeAccess: Bool {
  48. activePremiumPlan == .lifetime
  49. }
  50. var hasAllProductsLoaded: Bool {
  51. SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
  52. }
  53. private var inFlightProductLoad: Task<Void, Never>?
  54. init() {
  55. Task { await listenForTransactionUpdates() }
  56. Task { await refreshPremiumAccess() }
  57. Task { await loadProducts(presentLoadingUI: false) }
  58. }
  59. func loadProducts(presentLoadingUI: Bool? = nil) async {
  60. if let existing = inFlightProductLoad {
  61. await existing.value
  62. return
  63. }
  64. let uiFlag = presentLoadingUI
  65. let task = Task { @MainActor in
  66. await performLoadProducts(presentLoadingUI: uiFlag)
  67. }
  68. inFlightProductLoad = task
  69. await task.value
  70. inFlightProductLoad = nil
  71. }
  72. func product(for plan: PaywallPlan) -> Product? {
  73. productsByID[plan.productID]
  74. }
  75. func mainPrice(for plan: PaywallPlan) -> String {
  76. product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
  77. }
  78. func priceSuffix(for plan: PaywallPlan) -> String? {
  79. switch plan {
  80. case .monthly: "/mo"
  81. case .yearly: "/yr"
  82. case .lifetime: nil
  83. }
  84. }
  85. func billingDescription(for plan: PaywallPlan) -> String {
  86. guard let product = product(for: plan) else {
  87. return plan.fallbackBillingDescription
  88. }
  89. if let subscription = product.subscription,
  90. let intro = subscription.introductoryOffer,
  91. intro.paymentMode == .freeTrial {
  92. let trialPeriod = formattedPeriod(intro.period)
  93. let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
  94. return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
  95. }
  96. switch plan {
  97. case .monthly:
  98. return "Billed at \(product.displayPrice) every month"
  99. case .yearly:
  100. return "Billed at \(product.displayPrice) every year"
  101. case .lifetime:
  102. return "One-time payment of \(product.displayPrice)"
  103. }
  104. }
  105. func purchase(_ plan: PaywallPlan) async -> Bool {
  106. purchaseError = nil
  107. var product = productsByID[plan.productID]
  108. if product == nil {
  109. await loadProducts(presentLoadingUI: false)
  110. product = productsByID[plan.productID]
  111. }
  112. guard let product else {
  113. let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
  114. if missingProductIDs.isEmpty {
  115. purchaseError = .subscriptionUnavailable
  116. } else {
  117. purchaseError = .planUnavailable
  118. #if DEBUG
  119. print(
  120. """
  121. [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
  122. To test locally, attach `Paywall.storekit` in Xcode:
  123. Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
  124. """
  125. )
  126. #endif
  127. }
  128. return false
  129. }
  130. purchasingPlan = plan
  131. defer { purchasingPlan = nil }
  132. do {
  133. let result = try await product.purchase()
  134. switch result {
  135. case .success(let verification):
  136. guard case .verified(let transaction) = verification else {
  137. purchaseError = .purchaseNotVerified
  138. return false
  139. }
  140. if transactionGrantsPremium(transaction) {
  141. applyPremiumAccess(from: transaction)
  142. }
  143. await transaction.finish()
  144. await refreshPremiumAccess()
  145. return hasPremiumAccess
  146. case .userCancelled:
  147. return false
  148. case .pending:
  149. purchaseError = .purchasePending
  150. return false
  151. @unknown default:
  152. return false
  153. }
  154. } catch {
  155. purchaseError = .generic
  156. return false
  157. }
  158. }
  159. func openSubscriptionManagement() {
  160. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  161. NSWorkspace.shared.open(url)
  162. }
  163. func restorePurchases() async {
  164. purchaseError = nil
  165. do {
  166. try await AppStore.sync()
  167. await refreshPremiumAccess()
  168. if !hasPremiumAccess {
  169. purchaseError = .noActivePurchases
  170. }
  171. } catch {
  172. purchaseError = .generic
  173. }
  174. }
  175. private func performLoadProducts(presentLoadingUI: Bool?) async {
  176. let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
  177. let shouldShowLoading = presentLoadingUI ?? missingAny
  178. if !missingAny, presentLoadingUI != true {
  179. purchaseError = nil
  180. await refreshPremiumAccess()
  181. return
  182. }
  183. if shouldShowLoading {
  184. isLoadingProducts = true
  185. }
  186. defer {
  187. if shouldShowLoading {
  188. isLoadingProducts = false
  189. }
  190. }
  191. purchaseError = nil
  192. do {
  193. let loaded = try await loadProductsWithTimeout(seconds: 12)
  194. var map: [String: Product] = [:]
  195. for product in loaded {
  196. map[product.id] = product
  197. }
  198. productsByID = map
  199. await refreshPremiumAccess()
  200. } catch is ProductLoadError {
  201. purchaseError = .productsLoadTimeout
  202. } catch {
  203. purchaseError = .generic
  204. }
  205. }
  206. private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
  207. try await withThrowingTaskGroup(of: [Product].self) { group in
  208. group.addTask {
  209. try await Product.products(for: SubscriptionProductID.all)
  210. }
  211. group.addTask {
  212. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  213. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  214. throw ProductLoadError.timeout
  215. }
  216. guard let first = try await group.next() else {
  217. throw ProductLoadError.timeout
  218. }
  219. group.cancelAll()
  220. return first
  221. }
  222. }
  223. private func refreshPremiumAccess() async {
  224. var resolvedPlan: PaywallPlan?
  225. if let lifetimePlan = await activeLifetimePlan() {
  226. resolvedPlan = lifetimePlan
  227. }
  228. if resolvedPlan == nil {
  229. for await result in Transaction.currentEntitlements {
  230. guard case .verified(let transaction) = result else { continue }
  231. guard let plan = planGrantingPremium(from: transaction) else { continue }
  232. resolvedPlan = preferredPlan(resolvedPlan, plan)
  233. }
  234. }
  235. if resolvedPlan == nil || resolvedPlan != .lifetime {
  236. if let subscriptionPlan = await activeSubscriptionPlan() {
  237. resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
  238. }
  239. }
  240. activePremiumPlan = resolvedPlan
  241. hasPremiumAccess = resolvedPlan != nil
  242. hasResolvedPremiumStatus = true
  243. }
  244. private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
  245. guard let plan = planGrantingPremium(from: transaction) else { return }
  246. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  247. hasPremiumAccess = true
  248. hasResolvedPremiumStatus = true
  249. }
  250. private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
  251. guard transactionGrantsPremium(transaction) else { return nil }
  252. return SubscriptionProductID.plan(for: transaction.productID)
  253. }
  254. private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
  255. guard SubscriptionProductID.all.contains(transaction.productID) else { return false }
  256. guard transaction.revocationDate == nil else { return false }
  257. if let expirationDate = transaction.expirationDate, expirationDate < Date() {
  258. return false
  259. }
  260. return true
  261. }
  262. private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
  263. if current == .lifetime || candidate == .lifetime {
  264. return .lifetime
  265. }
  266. if let current {
  267. if current == .yearly || candidate == .yearly {
  268. return .yearly
  269. }
  270. return .monthly
  271. }
  272. return candidate
  273. }
  274. private func activeLifetimePlan() async -> PaywallPlan? {
  275. guard let result = await Transaction.latest(for: SubscriptionProductID.lifetime) else {
  276. return nil
  277. }
  278. guard case .verified(let transaction) = result else { return nil }
  279. return planGrantingPremium(from: transaction)
  280. }
  281. private func activeSubscriptionPlan() async -> PaywallPlan? {
  282. var resolvedPlan: PaywallPlan?
  283. for await result in Transaction.currentEntitlements {
  284. guard case .verified(let transaction) = result else { continue }
  285. guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
  286. resolvedPlan = preferredPlan(resolvedPlan, plan)
  287. }
  288. if resolvedPlan == nil, await hasActiveSubscriptionStatus() {
  289. resolvedPlan = await subscriptionPlanFromProductStatus()
  290. }
  291. return resolvedPlan
  292. }
  293. private func subscriptionPlanFromProductStatus() async -> PaywallPlan? {
  294. if productsByID.isEmpty {
  295. do {
  296. let loaded = try await Product.products(for: SubscriptionProductID.all)
  297. var map: [String: Product] = [:]
  298. for product in loaded { map[product.id] = product }
  299. productsByID = map
  300. } catch {
  301. return nil
  302. }
  303. }
  304. for product in productsByID.values {
  305. guard let subscription = product.subscription else { continue }
  306. guard let plan = SubscriptionProductID.plan(for: product.id), plan != .lifetime else { continue }
  307. do {
  308. let statuses = try await subscription.status
  309. let isActive = statuses.contains { status in
  310. switch status.state {
  311. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  312. return true
  313. case .expired, .revoked:
  314. return false
  315. default:
  316. return false
  317. }
  318. }
  319. if isActive { return plan }
  320. } catch {
  321. continue
  322. }
  323. }
  324. return nil
  325. }
  326. private func hasActiveSubscriptionStatus() async -> Bool {
  327. await subscriptionPlanFromProductStatus() != nil
  328. }
  329. private func listenForTransactionUpdates() async {
  330. for await update in Transaction.updates {
  331. guard case .verified(let transaction) = update else { continue }
  332. await transaction.finish()
  333. await refreshPremiumAccess()
  334. }
  335. }
  336. private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
  337. switch period.unit {
  338. case .day where period.value == 1: "1 Day"
  339. case .day: "\(period.value) Days"
  340. case .week where period.value == 1: "1 Week"
  341. case .week: "\(period.value) Weeks"
  342. case .month where period.value == 1: "1 Month"
  343. case .month: "\(period.value) Months"
  344. case .year where period.value == 1: "1 Year"
  345. case .year: "\(period.value) Years"
  346. @unknown default: "\(period.value) Days"
  347. }
  348. }
  349. private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
  350. switch period.unit {
  351. case .day: period.value == 1 ? "day" : "\(period.value) days"
  352. case .week: period.value == 1 ? "week" : "\(period.value) weeks"
  353. case .month: period.value == 1 ? "month" : "\(period.value) months"
  354. case .year: period.value == 1 ? "year" : "\(period.value) years"
  355. @unknown default: "billing period"
  356. }
  357. }
  358. }
  359. // MARK: - App Rating
  360. enum RatingPromptSource {
  361. case postPurchase
  362. case usageMilestone
  363. }
  364. @MainActor
  365. final class AppRatingManager: ObservableObject {
  366. static let hasRatedKey = "hasRatedApp"
  367. static let hasShownUsagePromptKey = "hasShownUsageRatingPrompt"
  368. private static let continuousUsageThreshold: TimeInterval = 30 * 60
  369. @Published private(set) var showRatingPrompt = false
  370. @Published private(set) var ratingPromptSource: RatingPromptSource?
  371. private let defaults: UserDefaults
  372. private var isPremium = false
  373. private var continuousSessionStart: Date?
  374. private var usageTimerTask: Task<Void, Never>?
  375. private var cancellables = Set<AnyCancellable>()
  376. private var hasRatedApp: Bool {
  377. defaults.bool(forKey: Self.hasRatedKey)
  378. }
  379. private var hasShownUsagePrompt: Bool {
  380. defaults.bool(forKey: Self.hasShownUsagePromptKey)
  381. }
  382. init(defaults: UserDefaults = .standard) {
  383. self.defaults = defaults
  384. observeAppLifecycle()
  385. }
  386. func updatePremiumStatus(_ isPremium: Bool) {
  387. self.isPremium = isPremium
  388. guard isPremium, !hasRatedApp, !hasShownUsagePrompt else {
  389. pauseUsageTracking()
  390. return
  391. }
  392. if NSApplication.shared.isActive {
  393. beginContinuousUsageSession()
  394. }
  395. }
  396. func handleSubscriptionPurchased() {
  397. guard !hasRatedApp else { return }
  398. pauseUsageTracking()
  399. ratingPromptSource = .postPurchase
  400. showRatingPrompt = true
  401. }
  402. func userAcceptedRating() {
  403. showRatingPrompt = false
  404. ratingPromptSource = nil
  405. defaults.set(true, forKey: Self.hasRatedKey)
  406. pauseUsageTracking()
  407. requestAppStoreReview()
  408. }
  409. func userDeclinedRating() {
  410. let source = ratingPromptSource
  411. showRatingPrompt = false
  412. ratingPromptSource = nil
  413. if source == .usageMilestone {
  414. defaults.set(true, forKey: Self.hasShownUsagePromptKey)
  415. pauseUsageTracking()
  416. return
  417. }
  418. guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
  419. if NSApplication.shared.isActive {
  420. beginContinuousUsageSession()
  421. }
  422. }
  423. private func observeAppLifecycle() {
  424. NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
  425. .sink { [weak self] _ in
  426. self?.appDidBecomeActive()
  427. }
  428. .store(in: &cancellables)
  429. NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)
  430. .sink { [weak self] _ in
  431. self?.appDidResignActive()
  432. }
  433. .store(in: &cancellables)
  434. }
  435. private func appDidBecomeActive() {
  436. guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
  437. beginContinuousUsageSession()
  438. }
  439. private func appDidResignActive() {
  440. pauseUsageTracking()
  441. }
  442. private func beginContinuousUsageSession() {
  443. guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
  444. continuousSessionStart = Date()
  445. startUsageTimer()
  446. }
  447. private func pauseUsageTracking() {
  448. continuousSessionStart = nil
  449. usageTimerTask?.cancel()
  450. usageTimerTask = nil
  451. }
  452. private func startUsageTimer() {
  453. usageTimerTask?.cancel()
  454. usageTimerTask = Task { [weak self] in
  455. while !Task.isCancelled {
  456. try? await Task.sleep(nanoseconds: 1_000_000_000)
  457. guard !Task.isCancelled else { break }
  458. self?.checkContinuousUsageThreshold()
  459. }
  460. }
  461. }
  462. private func checkContinuousUsageThreshold() {
  463. guard
  464. let sessionStart = continuousSessionStart,
  465. isPremium,
  466. !hasRatedApp,
  467. !hasShownUsagePrompt,
  468. !showRatingPrompt
  469. else {
  470. return
  471. }
  472. let elapsed = Date().timeIntervalSince(sessionStart)
  473. guard elapsed >= Self.continuousUsageThreshold else { return }
  474. pauseUsageTracking()
  475. defaults.set(true, forKey: Self.hasShownUsagePromptKey)
  476. ratingPromptSource = .usageMilestone
  477. showRatingPrompt = true
  478. }
  479. private func requestAppStoreReview() {
  480. SKStoreReviewController.requestReview()
  481. }
  482. }