|
@@ -1,841 +1,6 @@
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
import StoreKit
|
|
import StoreKit
|
|
|
|
|
|
|
|
-// MARK: - StoreKit
|
|
|
|
|
-
|
|
|
|
|
-enum StoreProductID {
|
|
|
|
|
- static let monthly = AppStoreConfig.ProductID.monthly
|
|
|
|
|
- static let yearly = AppStoreConfig.ProductID.yearly
|
|
|
|
|
- static let lifetime = AppStoreConfig.ProductID.lifetime
|
|
|
|
|
-
|
|
|
|
|
- static let all = AppStoreConfig.ProductID.all
|
|
|
|
|
-
|
|
|
|
|
- static func plan(for productID: String) -> PaywallPlan? {
|
|
|
|
|
- switch productID {
|
|
|
|
|
- case monthly: .monthly
|
|
|
|
|
- case yearly: .yearly
|
|
|
|
|
- case lifetime: .lifetime
|
|
|
|
|
- default: nil
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-enum PremiumAccessKind: String {
|
|
|
|
|
- case none
|
|
|
|
|
- case subscription
|
|
|
|
|
- case lifetime
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-enum StoreError: Error {
|
|
|
|
|
- case productNotFound
|
|
|
|
|
- case failedVerification
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-enum PurchaseOutcome: Sendable {
|
|
|
|
|
- case purchased
|
|
|
|
|
- case cancelled
|
|
|
|
|
- case pending
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-@MainActor
|
|
|
|
|
-final class StoreManager {
|
|
|
|
|
- static let shared = StoreManager()
|
|
|
|
|
-
|
|
|
|
|
- private enum ProductLoadError: Error {
|
|
|
|
|
- case timeout
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private enum StoreSyncError: Error {
|
|
|
|
|
- case timeout
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
|
|
|
|
|
- private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
|
|
|
|
|
- private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
|
|
|
|
|
- private static let backgroundRefreshInterval: TimeInterval = 120
|
|
|
|
|
-
|
|
|
|
|
- private(set) var productsByID: [String: Product] = [:]
|
|
|
|
|
- private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
|
|
|
|
|
- private(set) var activePremiumPlan: PaywallPlan?
|
|
|
|
|
- private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
|
|
|
|
|
- private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
|
|
|
|
|
- private(set) var purchaseError: PurchaseError?
|
|
|
|
|
-
|
|
|
|
|
- var isPro: Bool { isPremium }
|
|
|
|
|
- var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly }
|
|
|
|
|
- var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime }
|
|
|
|
|
- var premiumAccessKind: PremiumAccessKind {
|
|
|
|
|
- switch activePremiumPlan {
|
|
|
|
|
- case .lifetime: .lifetime
|
|
|
|
|
- case .monthly, .yearly: .subscription
|
|
|
|
|
- case nil: .none
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- var products: [Product] {
|
|
|
|
|
- availablePlans.compactMap { productsByID[$0.productID] }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- var availablePlans: [PaywallPlan] {
|
|
|
|
|
- PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
|
|
|
|
|
- var hasResolvedPremiumStatus = false
|
|
|
|
|
- var hasAvailablePlans: Bool { !availablePlans.isEmpty }
|
|
|
|
|
- var hasAllProductsLoaded: Bool { hasAvailablePlans }
|
|
|
|
|
-
|
|
|
|
|
- private(set) var isLoadingProducts = false
|
|
|
|
|
- private(set) var productLoadError: String?
|
|
|
|
|
- private(set) var purchasingPlan: PaywallPlan?
|
|
|
|
|
- private(set) var isRestoringPurchases = false
|
|
|
|
|
-
|
|
|
|
|
- var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
|
|
|
|
|
-
|
|
|
|
|
- private var inFlightProductLoad: Task<Void, Never>?
|
|
|
|
|
- private var transactionListener: Task<Void, Never>?
|
|
|
|
|
- private var initialEntitlementTask: Task<Void, Never>?
|
|
|
|
|
- private var entitlementRefreshTask: Task<Void, Never>?
|
|
|
|
|
- private var hasStarted = false
|
|
|
|
|
- private var lastBackgroundEntitlementRefresh: Date?
|
|
|
|
|
- private var appActiveObserver: NSObjectProtocol?
|
|
|
|
|
-
|
|
|
|
|
- private init() {
|
|
|
|
|
- migrateLegacyPremiumCacheIfNeeded()
|
|
|
|
|
- hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
|
|
|
|
|
-
|
|
|
|
|
- if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
|
|
|
|
|
- isPremium = true
|
|
|
|
|
- activePremiumPlan = restoredCachedPremiumPlan()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func migrateLegacyPremiumCacheIfNeeded() {
|
|
|
|
|
- guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
|
|
|
|
|
- guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
|
|
|
|
|
- let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let migratedPlan: String?
|
|
|
|
|
- switch legacyKind {
|
|
|
|
|
- case PremiumAccessKind.lifetime.rawValue:
|
|
|
|
|
- migratedPlan = PaywallPlan.lifetime.rawValue
|
|
|
|
|
- case PremiumAccessKind.subscription.rawValue:
|
|
|
|
|
- migratedPlan = PaywallPlan.yearly.rawValue
|
|
|
|
|
- default:
|
|
|
|
|
- migratedPlan = nil
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if let migratedPlan {
|
|
|
|
|
- UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func restoredCachedPremiumPlan() -> PaywallPlan? {
|
|
|
|
|
- guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
|
|
|
|
|
-
|
|
|
|
|
- if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
|
|
|
|
|
- let plan = PaywallPlan(rawValue: raw) {
|
|
|
|
|
- return plan
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
|
|
|
|
|
- switch legacyKind {
|
|
|
|
|
- case PremiumAccessKind.lifetime.rawValue:
|
|
|
|
|
- return .lifetime
|
|
|
|
|
- case PremiumAccessKind.subscription.rawValue:
|
|
|
|
|
- return .yearly
|
|
|
|
|
- default:
|
|
|
|
|
- break
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return .yearly
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func start() {
|
|
|
|
|
- guard !hasStarted else { return }
|
|
|
|
|
- hasStarted = true
|
|
|
|
|
-
|
|
|
|
|
- observeAppLifecycle()
|
|
|
|
|
- transactionListener = Task { [weak self] in
|
|
|
|
|
- await self?.listenForTransactionUpdates()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- initialEntitlementTask = Task { [weak self] in
|
|
|
|
|
- guard let self else { return }
|
|
|
|
|
- await self.refreshPremiumAccess()
|
|
|
|
|
- await self.loadProducts(presentLoadingUI: false)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func observeAppLifecycle() {
|
|
|
|
|
- appActiveObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
- forName: NSApplication.didBecomeActiveNotification,
|
|
|
|
|
- object: nil,
|
|
|
|
|
- queue: .main
|
|
|
|
|
- ) { [weak self] _ in
|
|
|
|
|
- Task { @MainActor in
|
|
|
|
|
- self?.refreshEntitlementsOnAppActive()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func refreshEntitlementsOnAppActive() {
|
|
|
|
|
- guard hasStarted else { return }
|
|
|
|
|
-
|
|
|
|
|
- if let lastRefresh = lastBackgroundEntitlementRefresh,
|
|
|
|
|
- Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- entitlementRefreshTask?.cancel()
|
|
|
|
|
- entitlementRefreshTask = Task {
|
|
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
- lastBackgroundEntitlementRefresh = Date()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func ensureEntitlementsResolved() async {
|
|
|
|
|
- await initialEntitlementTask?.value
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
|
|
|
|
|
- await ensureEntitlementsResolved()
|
|
|
|
|
- await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
|
|
|
|
|
- await refreshTrialOffers()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func product(for plan: PaywallPlan) -> Product? {
|
|
|
|
|
- productsByID[plan.productID]
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
|
|
|
|
|
- trialDisplayByPlan[plan]
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func clearPurchaseError() {
|
|
|
|
|
- purchaseError = nil
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- var isEligibleForIntroTrial: Bool {
|
|
|
|
|
- !hasEverPurchasedPremium && !isPremium
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- 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
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @discardableResult
|
|
|
|
|
- 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 {
|
|
|
|
|
- purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- purchasingPlan = plan
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- defer {
|
|
|
|
|
- purchasingPlan = nil
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- do {
|
|
|
|
|
- let result = try await product.purchase()
|
|
|
|
|
- switch result {
|
|
|
|
|
- case .success(let verification):
|
|
|
|
|
- guard case .verified(let transaction) = verification else {
|
|
|
|
|
- purchaseError = .purchaseNotVerified
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let trustedPlan = planGrantingPremium(from: transaction)
|
|
|
|
|
- if let trustedPlan {
|
|
|
|
|
- applyPremiumAccess(from: transaction)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- await transaction.finish()
|
|
|
|
|
- await reconcilePremiumAccess(trustedPlan: trustedPlan)
|
|
|
|
|
- return isPremium
|
|
|
|
|
- case .userCancelled:
|
|
|
|
|
- return false
|
|
|
|
|
- case .pending:
|
|
|
|
|
- purchaseError = .purchasePending
|
|
|
|
|
- return false
|
|
|
|
|
- @unknown default:
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
- } catch {
|
|
|
|
|
- purchaseError = .generic
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @discardableResult
|
|
|
|
|
- func restorePurchases() async -> Bool {
|
|
|
|
|
- purchaseError = nil
|
|
|
|
|
- isRestoringPurchases = true
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- defer {
|
|
|
|
|
- isRestoringPurchases = false
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- do {
|
|
|
|
|
- try await syncAppStoreWithTimeout(seconds: 30)
|
|
|
|
|
- } catch is StoreSyncError {
|
|
|
|
|
- purchaseError = .restoreSyncTimeout
|
|
|
|
|
- return false
|
|
|
|
|
- } catch {
|
|
|
|
|
- purchaseError = .generic
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- await reconcilePremiumAccess(trustedPlan: nil)
|
|
|
|
|
- await syncPurchaseHistory(allowReset: true)
|
|
|
|
|
- if !isPremium {
|
|
|
|
|
- purchaseError = .noActivePurchases
|
|
|
|
|
- }
|
|
|
|
|
- return isPremium
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func showAlert(title: String, message: String, on window: NSWindow?) {
|
|
|
|
|
- let alert = NSAlert()
|
|
|
|
|
- alert.messageText = title
|
|
|
|
|
- alert.informativeText = message
|
|
|
|
|
- alert.alertStyle = .informational
|
|
|
|
|
- alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
|
|
|
|
|
- if let window {
|
|
|
|
|
- alert.beginSheetModal(for: window)
|
|
|
|
|
- } else {
|
|
|
|
|
- alert.runModal()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func showManageSubscriptions() {
|
|
|
|
|
- let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
|
|
|
|
|
- guard let url = URL(string: urlString) else { return }
|
|
|
|
|
- NSWorkspace.shared.open(url)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
|
|
|
|
|
- let config = PaywallConfigService.shared.config
|
|
|
|
|
- showAlert(
|
|
|
|
|
- title: config.messages.purchaseFailedTitle,
|
|
|
|
|
- message: config.message(for: error),
|
|
|
|
|
- on: window
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func performLoadProducts(presentLoadingUI: Bool?) async {
|
|
|
|
|
- let shouldReload = presentLoadingUI == true || productsByID.isEmpty
|
|
|
|
|
- let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
|
|
|
|
|
-
|
|
|
|
|
- if !shouldReload, presentLoadingUI != true {
|
|
|
|
|
- purchaseError = nil
|
|
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
- await refreshTrialOffers()
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if shouldShowLoading {
|
|
|
|
|
- isLoadingProducts = true
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- }
|
|
|
|
|
- defer {
|
|
|
|
|
- if shouldShowLoading {
|
|
|
|
|
- isLoadingProducts = false
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- purchaseError = nil
|
|
|
|
|
- productLoadError = nil
|
|
|
|
|
- do {
|
|
|
|
|
- let loaded = try await loadProductsWithTimeout(seconds: 12)
|
|
|
|
|
- guard !loaded.isEmpty else {
|
|
|
|
|
- productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
|
|
|
|
|
- productsByID = [:]
|
|
|
|
|
- await refreshTrialOffers()
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- var map: [String: Product] = [:]
|
|
|
|
|
- for product in loaded {
|
|
|
|
|
- map[product.id] = product
|
|
|
|
|
- }
|
|
|
|
|
- productsByID = map
|
|
|
|
|
- productLoadError = nil
|
|
|
|
|
- NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
|
|
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
- await refreshTrialOffers()
|
|
|
|
|
- } catch is ProductLoadError {
|
|
|
|
|
- productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
|
|
|
|
|
- purchaseError = .productsLoadTimeout
|
|
|
|
|
- await refreshTrialOffers()
|
|
|
|
|
- } catch {
|
|
|
|
|
- productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
|
|
|
|
|
- purchaseError = .generic
|
|
|
|
|
- await refreshTrialOffers()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
|
|
|
|
|
- try await withThrowingTaskGroup(of: [Product].self) { group in
|
|
|
|
|
- group.addTask {
|
|
|
|
|
- try await Product.products(for: StoreProductID.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(trustedPlan: PaywallPlan? = nil) async {
|
|
|
|
|
- var resolvedPlan: PaywallPlan?
|
|
|
|
|
-
|
|
|
|
|
- if let lifetimePlan = await activeLifetimePlan() {
|
|
|
|
|
- resolvedPlan = lifetimePlan
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if resolvedPlan == nil {
|
|
|
|
|
- for await result in Transaction.currentEntitlements {
|
|
|
|
|
- guard case .verified(let transaction) = result else { continue }
|
|
|
|
|
- guard let plan = planGrantingPremium(from: transaction) else { continue }
|
|
|
|
|
- resolvedPlan = preferredPlan(resolvedPlan, plan)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if resolvedPlan == nil || resolvedPlan != .lifetime {
|
|
|
|
|
- if let subscriptionPlan = await activeSubscriptionPlan() {
|
|
|
|
|
- resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if resolvedPlan == nil {
|
|
|
|
|
- if let statusPlan = await activePlanFromSubscriptionStatus() {
|
|
|
|
|
- resolvedPlan = statusPlan
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
|
|
|
|
|
-
|
|
|
|
|
- applyPremiumStatus(plan: finalPlan)
|
|
|
|
|
- hasResolvedPremiumStatus = true
|
|
|
|
|
- NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
|
|
|
|
|
- await syncPurchaseHistory(allowReset: false)
|
|
|
|
|
- await refreshTrialOffers()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
|
|
|
|
|
- await refreshPremiumAccess(trustedPlan: trustedPlan)
|
|
|
|
|
-
|
|
|
|
|
- guard let trustedPlan, !isPremium else { return }
|
|
|
|
|
-
|
|
|
|
|
- do {
|
|
|
|
|
- try await syncAppStoreWithTimeout(seconds: 15)
|
|
|
|
|
- } catch {
|
|
|
|
|
- applyTrustedPlan(trustedPlan)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- await refreshPremiumAccess(trustedPlan: trustedPlan)
|
|
|
|
|
- if !isPremium {
|
|
|
|
|
- applyTrustedPlan(trustedPlan)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
|
|
|
|
|
- switch (resolvedPlan, trustedPlan) {
|
|
|
|
|
- case (nil, nil):
|
|
|
|
|
- nil
|
|
|
|
|
- case (let resolved?, nil):
|
|
|
|
|
- resolved
|
|
|
|
|
- case (nil, let trusted?):
|
|
|
|
|
- trusted
|
|
|
|
|
- case (let resolved?, let trusted?):
|
|
|
|
|
- preferredPlan(resolved, trusted)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func applyTrustedPlan(_ plan: PaywallPlan) {
|
|
|
|
|
- activePremiumPlan = preferredPlan(activePremiumPlan, plan)
|
|
|
|
|
- applyPremiumStatus(plan: activePremiumPlan)
|
|
|
|
|
- hasResolvedPremiumStatus = true
|
|
|
|
|
- markAsHavingPurchasedPremium()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func revokePremiumAccess() {
|
|
|
|
|
- applyPremiumStatus(plan: nil)
|
|
|
|
|
- hasResolvedPremiumStatus = true
|
|
|
|
|
- trialDisplayByPlan = [:]
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
|
|
|
|
|
- try await withThrowingTaskGroup(of: Void.self) { group in
|
|
|
|
|
- group.addTask {
|
|
|
|
|
- try await AppStore.sync()
|
|
|
|
|
- }
|
|
|
|
|
- group.addTask {
|
|
|
|
|
- let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
|
|
|
|
|
- try await Task.sleep(nanoseconds: timeoutNanoseconds)
|
|
|
|
|
- throw StoreSyncError.timeout
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try await group.next()
|
|
|
|
|
- group.cancelAll()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
|
|
|
|
|
- guard let plan = planGrantingPremium(from: transaction) else { return }
|
|
|
|
|
- activePremiumPlan = preferredPlan(activePremiumPlan, plan)
|
|
|
|
|
- applyPremiumStatus(plan: activePremiumPlan)
|
|
|
|
|
- hasResolvedPremiumStatus = true
|
|
|
|
|
- markAsHavingPurchasedPremium()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func markAsHavingPurchasedPremium() {
|
|
|
|
|
- guard !hasEverPurchasedPremium else { return }
|
|
|
|
|
- hasEverPurchasedPremium = true
|
|
|
|
|
- trialDisplayByPlan = [:]
|
|
|
|
|
- UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func syncPurchaseHistory(allowReset: Bool) async {
|
|
|
|
|
- var hasRecordedPurchase = false
|
|
|
|
|
-
|
|
|
|
|
- for await result in Transaction.all {
|
|
|
|
|
- guard case .verified(let transaction) = result else { continue }
|
|
|
|
|
- guard StoreProductID.all.contains(transaction.productID) else { continue }
|
|
|
|
|
- hasRecordedPurchase = true
|
|
|
|
|
- break
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if hasRecordedPurchase {
|
|
|
|
|
- if !hasEverPurchasedPremium {
|
|
|
|
|
- hasEverPurchasedPremium = true
|
|
|
|
|
- UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
|
|
|
|
|
- }
|
|
|
|
|
- } else if allowReset, hasEverPurchasedPremium {
|
|
|
|
|
- resetToNeverPurchasedState()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func resetToNeverPurchasedState() {
|
|
|
|
|
- hasEverPurchasedPremium = false
|
|
|
|
|
- UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
|
|
|
|
|
- trialDisplayByPlan = [:]
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func refreshTrialOffers() async {
|
|
|
|
|
- guard !isPremium else {
|
|
|
|
|
- trialDisplayByPlan = [:]
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
|
|
|
|
|
- trialDisplayByPlan = [:]
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if let display = await resolveTrialDisplay(for: eligiblePlan) {
|
|
|
|
|
- trialDisplayByPlan = [eligiblePlan: display]
|
|
|
|
|
- } else {
|
|
|
|
|
- trialDisplayByPlan = [:]
|
|
|
|
|
- }
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
|
|
|
|
|
- guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
|
|
|
|
|
- guard !isPremium else { return nil }
|
|
|
|
|
-
|
|
|
|
|
- let config = PaywallConfigService.shared.config
|
|
|
|
|
-
|
|
|
|
|
- guard let product = product(for: plan),
|
|
|
|
|
- let subscription = product.subscription else {
|
|
|
|
|
- return fallbackTrialDisplay(config: config)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- guard await subscription.isEligibleForIntroOffer else { return nil }
|
|
|
|
|
-
|
|
|
|
|
- if let offer = subscription.introductoryOffer {
|
|
|
|
|
- return PaywallTrialDisplay.from(offer: offer, config: config)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return fallbackTrialDisplay(config: config)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
|
|
|
|
|
- guard !isPremium else { return nil }
|
|
|
|
|
- guard let fallback = config.trial.fallbackDuration else { return nil }
|
|
|
|
|
- return PaywallTrialDisplay.from(fallback: fallback, config: config)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func applyPremiumStatus(plan: PaywallPlan?) {
|
|
|
|
|
- let hasPremium = plan != nil
|
|
|
|
|
- let kind: PremiumAccessKind
|
|
|
|
|
- switch plan {
|
|
|
|
|
- case .lifetime: kind = .lifetime
|
|
|
|
|
- case .monthly, .yearly: kind = .subscription
|
|
|
|
|
- case nil: kind = .none
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let previousKind = premiumAccessKind
|
|
|
|
|
- let previousPlan = activePremiumPlan
|
|
|
|
|
- let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
|
|
|
|
|
-
|
|
|
|
|
- isPremium = hasPremium
|
|
|
|
|
- activePremiumPlan = plan
|
|
|
|
|
- UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
|
|
|
|
|
- if let plan {
|
|
|
|
|
- UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey)
|
|
|
|
|
- } else {
|
|
|
|
|
- UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if didChange {
|
|
|
|
|
- NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
|
|
- }
|
|
|
|
|
- postStoreStateDidChange()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
|
|
|
|
|
- guard transactionGrantsPremium(transaction) else { return nil }
|
|
|
|
|
- return StoreProductID.plan(for: transaction.productID)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
|
|
|
|
|
- guard StoreProductID.all.contains(transaction.productID) else { return false }
|
|
|
|
|
- guard transaction.revocationDate == nil else { return false }
|
|
|
|
|
- if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
- return true
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
|
|
|
|
|
- if current == .lifetime || candidate == .lifetime {
|
|
|
|
|
- return .lifetime
|
|
|
|
|
- }
|
|
|
|
|
- if current == .yearly || candidate == .yearly {
|
|
|
|
|
- return .yearly
|
|
|
|
|
- }
|
|
|
|
|
- if current == .monthly || candidate == .monthly {
|
|
|
|
|
- return .monthly
|
|
|
|
|
- }
|
|
|
|
|
- return candidate
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func activeLifetimePlan() async -> PaywallPlan? {
|
|
|
|
|
- guard let result = await Transaction.latest(for: StoreProductID.lifetime) else {
|
|
|
|
|
- return nil
|
|
|
|
|
- }
|
|
|
|
|
- guard case .verified(let transaction) = result else { return nil }
|
|
|
|
|
- return planGrantingPremium(from: transaction)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func activeSubscriptionPlan() async -> PaywallPlan? {
|
|
|
|
|
- var resolvedPlan: PaywallPlan?
|
|
|
|
|
-
|
|
|
|
|
- for await result in Transaction.currentEntitlements {
|
|
|
|
|
- guard case .verified(let transaction) = result else { continue }
|
|
|
|
|
- guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
|
|
|
|
|
- resolvedPlan = preferredPlan(resolvedPlan, plan)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return resolvedPlan
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
|
|
|
|
|
- let productIDs = [StoreProductID.monthly, StoreProductID.yearly]
|
|
|
|
|
- for productID in productIDs {
|
|
|
|
|
- let product: Product?
|
|
|
|
|
- if let cached = productsByID[productID] {
|
|
|
|
|
- product = cached
|
|
|
|
|
- } else {
|
|
|
|
|
- product = try? await Product.products(for: [productID]).first
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- guard let product,
|
|
|
|
|
- let subscription = product.subscription,
|
|
|
|
|
- let statuses = try? await subscription.status else {
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- for status in statuses {
|
|
|
|
|
- switch status.state {
|
|
|
|
|
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
|
|
- return StoreProductID.plan(for: productID)
|
|
|
|
|
- default:
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return nil
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func listenForTransactionUpdates() async {
|
|
|
|
|
- for await update in Transaction.updates {
|
|
|
|
|
- guard case .verified(let transaction) = update else { continue }
|
|
|
|
|
-
|
|
|
|
|
- if isRevokedAppTransaction(transaction) {
|
|
|
|
|
- revokePremiumAccess()
|
|
|
|
|
- await transaction.finish()
|
|
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let trustedPlan = planGrantingPremium(from: transaction)
|
|
|
|
|
- if let trustedPlan {
|
|
|
|
|
- applyPremiumAccess(from: transaction)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- await transaction.finish()
|
|
|
|
|
-
|
|
|
|
|
- if trustedPlan != nil {
|
|
|
|
|
- await reconcilePremiumAccess(trustedPlan: trustedPlan)
|
|
|
|
|
- } else {
|
|
|
|
|
- await refreshPremiumAccess()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
|
|
|
|
|
- StoreProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func postStoreStateDidChange() {
|
|
|
|
|
- NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-extension Notification.Name {
|
|
|
|
|
- static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
|
|
|
|
|
- static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
|
|
|
|
|
- static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
|
|
|
|
|
- static let storeStateDidChange = Notification.Name("storeStateDidChange")
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// MARK: - Premium Access
|
|
|
|
|
-
|
|
|
|
|
-enum PremiumAccess {
|
|
|
|
|
- @MainActor
|
|
|
|
|
- static func canAccess(_ feature: AppFeature) -> Bool {
|
|
|
|
|
- let store = StoreManager.shared
|
|
|
|
|
- return FreeTierManager.canAccess(feature, isPremium: store.isPremium)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @MainActor
|
|
|
|
|
- @discardableResult
|
|
|
|
|
- static func require(
|
|
|
|
|
- feature: AppFeature,
|
|
|
|
|
- from window: NSWindow?,
|
|
|
|
|
- returnToHomeOnDismiss: Bool = false
|
|
|
|
|
- ) -> Bool {
|
|
|
|
|
- guard canAccess(feature) else {
|
|
|
|
|
- presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
- return true
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @MainActor
|
|
|
|
|
- @discardableResult
|
|
|
|
|
- static func requireForAction(
|
|
|
|
|
- feature: AppFeature,
|
|
|
|
|
- from window: NSWindow?,
|
|
|
|
|
- returnToHomeOnDismiss: Bool = false,
|
|
|
|
|
- recordFreeUse: Bool = true
|
|
|
|
|
- ) -> Bool {
|
|
|
|
|
- guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else {
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
- if recordFreeUse {
|
|
|
|
|
- recordFreeUseIfNeeded()
|
|
|
|
|
- }
|
|
|
|
|
- return true
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @MainActor
|
|
|
|
|
- static func recordFreeUseIfNeeded() {
|
|
|
|
|
- guard !StoreManager.shared.isPremium else { return }
|
|
|
|
|
- FreeTierManager.recordUseIfNeeded(isPremium: false)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @MainActor
|
|
|
|
|
- @discardableResult
|
|
|
|
|
- static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
|
|
|
|
|
- require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @MainActor
|
|
|
|
|
- private static func presentPaywallOrLimitAlert(
|
|
|
|
|
- for feature: AppFeature,
|
|
|
|
|
- from window: NSWindow?,
|
|
|
|
|
- returnToHomeOnDismiss: Bool
|
|
|
|
|
- ) {
|
|
|
|
|
- if StoreManager.shared.isResolvingEntitlements {
|
|
|
|
|
- Task { @MainActor in
|
|
|
|
|
- await StoreManager.shared.ensureEntitlementsResolved()
|
|
|
|
|
- guard !canAccess(feature) else { return }
|
|
|
|
|
- presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
|
|
|
|
|
- }
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- guard let viewController = mainViewController(from: window) else { return }
|
|
|
|
|
- viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @MainActor
|
|
|
|
|
- static func mainViewController(from window: NSWindow?) -> ViewController? {
|
|
|
|
|
- if let viewController = window?.contentViewController as? ViewController {
|
|
|
|
|
- return viewController
|
|
|
|
|
- }
|
|
|
|
|
- if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
|
|
|
|
|
- return viewController
|
|
|
|
|
- }
|
|
|
|
|
- for appWindow in NSApp.windows where appWindow.canBecomeMain {
|
|
|
|
|
- if let viewController = appWindow.contentViewController as? ViewController {
|
|
|
|
|
- return viewController
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return nil
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// MARK: - Left Panel
|
|
// MARK: - Left Panel
|
|
|
|
|
|
|
|
private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
|
|
private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
|