SubscriptionManager.swift 15 KB

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