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? 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() } #if DEBUG // Local Xcode runs should exercise AI tools without a StoreKit purchase. if !hasPremium { hasPremium = true } #endif 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? private var cancellables = Set() 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() } }