| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- import AppKit
- import Combine
- import Foundation
- import StoreKit
- enum PurchaseError: Equatable {
- case productsLoadTimeout
- case subscriptionUnavailable
- case planUnavailable
- case purchaseNotVerified
- case purchasePending
- case noActiveSubscriptions
- case generic
- var message: String {
- switch self {
- case .productsLoadTimeout:
- "Couldn't load subscription products right now. Check your StoreKit configuration / internet and try again."
- case .subscriptionUnavailable:
- "This subscription is currently unavailable. Please try again in a moment."
- case .planUnavailable:
- "This plan isn't available right now. Please try again later."
- case .purchaseNotVerified:
- "Apple couldn't verify this purchase."
- case .purchasePending:
- "This purchase is waiting for approval (for example, Ask to Buy)."
- case .noActiveSubscriptions:
- "No active subscriptions were found for this Apple ID."
- case .generic:
- "Something went wrong with your subscription. Please try again."
- }
- }
- }
- @MainActor
- final class SubscriptionManager: ObservableObject {
- private enum ProductLoadError: Error {
- case timeout
- }
- @Published private(set) var productsByID: [String: Product] = [:]
- @Published private(set) var isLoadingProducts = false
- @Published private(set) var purchasingPlan: PaywallPlan?
- @Published private(set) var hasPremiumAccess = false
- @Published private(set) var hasResolvedPremiumStatus = false
- @Published var purchaseError: PurchaseError?
- var hasAllProductsLoaded: Bool {
- SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
- }
- private var inFlightProductLoad: Task<Void, Never>?
- init() {
- Task { await listenForTransactionUpdates() }
- Task { await refreshPremiumAccess() }
- Task { await loadProducts(presentLoadingUI: false) }
- }
- func loadProducts(presentLoadingUI: Bool? = nil) async {
- if let existing = inFlightProductLoad {
- await existing.value
- return
- }
- let uiFlag = presentLoadingUI
- let task = Task { @MainActor in
- await performLoadProducts(presentLoadingUI: uiFlag)
- }
- inFlightProductLoad = task
- await task.value
- inFlightProductLoad = nil
- }
- func product(for plan: PaywallPlan) -> Product? {
- productsByID[plan.productID]
- }
- func mainPrice(for plan: PaywallPlan) -> String {
- product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
- }
- func priceSuffix(for plan: PaywallPlan) -> String? {
- switch plan {
- case .monthly: "/mo"
- case .yearly: "/yr"
- case .lifetime: nil
- }
- }
- func billingDescription(for plan: PaywallPlan) -> String {
- guard let product = product(for: plan) else {
- return plan.fallbackBillingDescription
- }
- if let subscription = product.subscription,
- let intro = subscription.introductoryOffer,
- intro.paymentMode == .freeTrial {
- let trialPeriod = formattedPeriod(intro.period)
- let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
- return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
- }
- switch plan {
- case .monthly:
- return "Billed at \(product.displayPrice) every month"
- case .yearly:
- return "Billed at \(product.displayPrice) every year"
- case .lifetime:
- return "One-time payment of \(product.displayPrice)"
- }
- }
- func purchase(_ plan: PaywallPlan) async -> Bool {
- purchaseError = nil
- var product = productsByID[plan.productID]
- if product == nil {
- await loadProducts(presentLoadingUI: false)
- product = productsByID[plan.productID]
- }
- guard let product else {
- let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
- if missingProductIDs.isEmpty {
- purchaseError = .subscriptionUnavailable
- } else {
- purchaseError = .planUnavailable
- #if DEBUG
- print(
- """
- [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
- To test locally, attach `Paywall.storekit` in Xcode:
- Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
- """
- )
- #endif
- }
- return false
- }
- purchasingPlan = plan
- defer { purchasingPlan = nil }
- do {
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- guard case .verified(let transaction) = verification else {
- purchaseError = .purchaseNotVerified
- return false
- }
- await transaction.finish()
- await refreshPremiumAccess()
- return hasPremiumAccess
- case .userCancelled:
- return false
- case .pending:
- purchaseError = .purchasePending
- return false
- @unknown default:
- return false
- }
- } catch {
- purchaseError = .generic
- return false
- }
- }
- func openSubscriptionManagement() {
- guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
- NSWorkspace.shared.open(url)
- }
- func restorePurchases() async {
- purchaseError = nil
- do {
- try await AppStore.sync()
- await refreshPremiumAccess()
- if !hasPremiumAccess {
- purchaseError = .noActiveSubscriptions
- }
- } catch {
- purchaseError = .generic
- }
- }
- private func performLoadProducts(presentLoadingUI: Bool?) async {
- let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
- let shouldShowLoading = presentLoadingUI ?? missingAny
- if !missingAny, presentLoadingUI != true {
- purchaseError = nil
- await refreshPremiumAccess()
- return
- }
- if shouldShowLoading {
- isLoadingProducts = true
- }
- defer {
- if shouldShowLoading {
- isLoadingProducts = false
- }
- }
- purchaseError = nil
- do {
- let loaded = try await loadProductsWithTimeout(seconds: 12)
- var map: [String: Product] = [:]
- for product in loaded {
- map[product.id] = product
- }
- productsByID = map
- await refreshPremiumAccess()
- } catch is ProductLoadError {
- purchaseError = .productsLoadTimeout
- } catch {
- purchaseError = .generic
- }
- }
- private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
- try await withThrowingTaskGroup(of: [Product].self) { group in
- group.addTask {
- try await Product.products(for: SubscriptionProductID.all)
- }
- group.addTask {
- let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
- try await Task.sleep(nanoseconds: timeoutNanoseconds)
- throw ProductLoadError.timeout
- }
- guard let first = try await group.next() else {
- throw ProductLoadError.timeout
- }
- group.cancelAll()
- return first
- }
- }
- private func refreshPremiumAccess() async {
- var hasPremium = false
- for await result in Transaction.currentEntitlements {
- guard case .verified(let transaction) = result else { continue }
- guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
- guard transaction.revocationDate == nil else { continue }
- if let expirationDate = transaction.expirationDate, expirationDate < Date() {
- continue
- }
- hasPremium = true
- break
- }
- if !hasPremium {
- hasPremium = await hasActiveSubscriptionStatus()
- }
- hasPremiumAccess = hasPremium
- hasResolvedPremiumStatus = true
- }
- private func hasActiveSubscriptionStatus() async -> Bool {
- if productsByID.isEmpty {
- do {
- let loaded = try await Product.products(for: SubscriptionProductID.all)
- var map: [String: Product] = [:]
- for product in loaded { map[product.id] = product }
- productsByID = map
- } catch {
- return false
- }
- }
- for product in productsByID.values {
- guard let subscription = product.subscription else { continue }
- do {
- let statuses = try await subscription.status
- let isActive = statuses.contains { status in
- switch status.state {
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
- return true
- case .expired, .revoked:
- return false
- default:
- return false
- }
- }
- if isActive { return true }
- } catch {
- continue
- }
- }
- return false
- }
- private func listenForTransactionUpdates() async {
- for await update in Transaction.updates {
- guard case .verified(let transaction) = update else { continue }
- await transaction.finish()
- await refreshPremiumAccess()
- }
- }
- private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
- switch period.unit {
- case .day where period.value == 1: "1 Day"
- case .day: "\(period.value) Days"
- case .week where period.value == 1: "1 Week"
- case .week: "\(period.value) Weeks"
- case .month where period.value == 1: "1 Month"
- case .month: "\(period.value) Months"
- case .year where period.value == 1: "1 Year"
- case .year: "\(period.value) Years"
- @unknown default: "\(period.value) Days"
- }
- }
- private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
- switch period.unit {
- case .day: period.value == 1 ? "day" : "\(period.value) days"
- case .week: period.value == 1 ? "week" : "\(period.value) weeks"
- case .month: period.value == 1 ? "month" : "\(period.value) months"
- case .year: period.value == 1 ? "year" : "\(period.value) years"
- @unknown default: "billing period"
- }
- }
- }
- // MARK: - App Rating
- enum RatingPromptSource {
- case postPurchase
- case usageMilestone
- }
- @MainActor
- final class AppRatingManager: ObservableObject {
- static let hasRatedKey = "hasRatedApp"
- static let hasShownUsagePromptKey = "hasShownUsageRatingPrompt"
- private static let continuousUsageThreshold: TimeInterval = 30 * 60
- @Published private(set) var showRatingPrompt = false
- @Published private(set) var ratingPromptSource: RatingPromptSource?
- private let defaults: UserDefaults
- private var isPremium = false
- private var continuousSessionStart: Date?
- private var usageTimerTask: Task<Void, Never>?
- private var cancellables = Set<AnyCancellable>()
- private var hasRatedApp: Bool {
- defaults.bool(forKey: Self.hasRatedKey)
- }
- private var hasShownUsagePrompt: Bool {
- defaults.bool(forKey: Self.hasShownUsagePromptKey)
- }
- init(defaults: UserDefaults = .standard) {
- self.defaults = defaults
- observeAppLifecycle()
- }
- func updatePremiumStatus(_ isPremium: Bool) {
- self.isPremium = isPremium
- guard isPremium, !hasRatedApp, !hasShownUsagePrompt else {
- pauseUsageTracking()
- return
- }
- if NSApplication.shared.isActive {
- beginContinuousUsageSession()
- }
- }
- func handleSubscriptionPurchased() {
- guard !hasRatedApp else { return }
- pauseUsageTracking()
- ratingPromptSource = .postPurchase
- showRatingPrompt = true
- }
- func userAcceptedRating() {
- showRatingPrompt = false
- ratingPromptSource = nil
- defaults.set(true, forKey: Self.hasRatedKey)
- pauseUsageTracking()
- requestAppStoreReview()
- }
- func userDeclinedRating() {
- let source = ratingPromptSource
- showRatingPrompt = false
- ratingPromptSource = nil
- if source == .usageMilestone {
- defaults.set(true, forKey: Self.hasShownUsagePromptKey)
- pauseUsageTracking()
- return
- }
- guard isPremium, !hasRatedApp, !hasShownUsagePrompt else { return }
- if NSApplication.shared.isActive {
- beginContinuousUsageSession()
- }
- }
- private func observeAppLifecycle() {
- NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
- .sink { [weak self] _ in
- self?.appDidBecomeActive()
- }
- .store(in: &cancellables)
- NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)
- .sink { [weak self] _ in
- self?.appDidResignActive()
- }
- .store(in: &cancellables)
- }
- private func appDidBecomeActive() {
- guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
- beginContinuousUsageSession()
- }
- private func appDidResignActive() {
- pauseUsageTracking()
- }
- private func beginContinuousUsageSession() {
- guard isPremium, !hasRatedApp, !hasShownUsagePrompt, !showRatingPrompt else { return }
- continuousSessionStart = Date()
- startUsageTimer()
- }
- private func pauseUsageTracking() {
- continuousSessionStart = nil
- usageTimerTask?.cancel()
- usageTimerTask = nil
- }
- private func startUsageTimer() {
- usageTimerTask?.cancel()
- usageTimerTask = Task { [weak self] in
- while !Task.isCancelled {
- try? await Task.sleep(nanoseconds: 1_000_000_000)
- guard !Task.isCancelled else { break }
- self?.checkContinuousUsageThreshold()
- }
- }
- }
- private func checkContinuousUsageThreshold() {
- guard
- let sessionStart = continuousSessionStart,
- isPremium,
- !hasRatedApp,
- !hasShownUsagePrompt,
- !showRatingPrompt
- else {
- return
- }
- let elapsed = Date().timeIntervalSince(sessionStart)
- guard elapsed >= Self.continuousUsageThreshold else { return }
- pauseUsageTracking()
- defaults.set(true, forKey: Self.hasShownUsagePromptKey)
- ratingPromptSource = .usageMilestone
- showRatingPrompt = true
- }
- private func requestAppStoreReview() {
- SKStoreReviewController.requestReview()
- }
- }
|