Prechádzať zdrojové kódy

Fix entitlement race that briefly showed paywall to subscribers on launch.

Cache last-known premium status and defer paywall gating until StoreKit finishes its initial entitlement check.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 4 týždňov pred
rodič
commit
d049de5b31

+ 38 - 6
smart_printer/PaywallView.swift

@@ -117,14 +117,18 @@ enum StoreError: LocalizedError {
 final class StoreManager {
     static let shared = StoreManager()
 
+    private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
+
     private(set) var products: [Product] = []
-    private(set) var isPremium = false
+    private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
     var isPro: Bool { isPremium }
     private(set) var isLoadingProducts = false
     private(set) var isPurchasing = false
+    private(set) var isResolvingEntitlements = true
     private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
 
     private var transactionListener: Task<Void, Never>?
+    private var initialEntitlementTask: Task<Void, Never>?
     private var hasStarted = false
 
     private init() {}
@@ -139,12 +143,20 @@ final class StoreManager {
             }
         }
 
-        Task {
-            await loadProducts()
-            await refreshPremiumStatus()
+        initialEntitlementTask = Task { [weak self] in
+            guard let self else { return }
+            await self.refreshEntitlementsOnly()
+            self.isResolvingEntitlements = false
+            NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
+            await self.loadProducts()
+            await self.refreshPremiumStatus()
         }
     }
 
+    func ensureEntitlementsResolved() async {
+        await initialEntitlementTask?.value
+    }
+
     func product(for plan: PaywallPlan) -> Product? {
         products.first { $0.id == plan.productID }
     }
@@ -271,11 +283,19 @@ final class StoreManager {
         }
     }
 
+    func refreshEntitlementsOnly() async {
+        applyPremiumStatus(await hasActivePremiumAccess())
+    }
+
     func refreshPremiumStatus() async {
         await refreshIntroOfferEligibility()
-        let hasPremium = await hasActivePremiumAccess()
+        applyPremiumStatus(await hasActivePremiumAccess())
+    }
+
+    private func applyPremiumStatus(_ hasPremium: Bool) {
         let didChange = hasPremium != isPremium
         isPremium = hasPremium
+        UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
 
         if didChange {
             NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
@@ -382,6 +402,7 @@ final class StoreManager {
 
 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")
 }
@@ -398,6 +419,17 @@ enum PremiumAccess {
     @discardableResult
     static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
         guard !isGranted else { return true }
+
+        if StoreManager.shared.isResolvingEntitlements {
+            Task { @MainActor in
+                await StoreManager.shared.ensureEntitlementsResolved()
+                guard !isGranted else { return }
+                guard let viewController = mainViewController(from: window) else { return }
+                viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
+            }
+            return false
+        }
+
         guard let viewController = mainViewController(from: window) else { return false }
         viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
         return false
@@ -1604,10 +1636,10 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
         }
         alphaValue = 0
         Task { @MainActor in
+            await StoreManager.shared.ensureEntitlementsResolved()
             if StoreManager.shared.products.isEmpty {
                 await StoreManager.shared.loadProducts()
             }
-            await StoreManager.shared.refreshPremiumStatus()
             await PaywallConfigService.shared.refreshFromRemote()
             paywallView.refreshStoreState()
             alphaValue = 1

+ 21 - 2
smart_printer/ViewController.swift

@@ -442,6 +442,17 @@ class ViewController: NSViewController {
     }
 
     private func showDestination(_ destination: SidebarDestination) {
+        if destination == .scan || destination == .print || destination == .scanAndHome {
+            Task { @MainActor in
+                await StoreManager.shared.ensureEntitlementsResolved()
+                self.showDestinationAfterEntitlementCheck(destination)
+            }
+            return
+        }
+        showDestinationAfterEntitlementCheck(destination)
+    }
+
+    private func showDestinationAfterEntitlementCheck(_ destination: SidebarDestination) {
         if destination == .scan || destination == .print, !StoreManager.shared.isPremium {
             presentPaywall(returnToHomeOnDismiss: true)
             return
@@ -471,7 +482,7 @@ class ViewController: NSViewController {
     func presentPaywall(returnToHomeOnDismiss: Bool = true) {
         if let paywallOverlay {
             Task { @MainActor in
-                await StoreManager.shared.refreshPremiumStatus()
+                await StoreManager.shared.ensureEntitlementsResolved()
                 paywallOverlay.refreshStoreState()
             }
             return
@@ -496,7 +507,15 @@ class ViewController: NSViewController {
 
     private func guardPremiumFeature() -> Bool {
         guard StoreManager.shared.isPremium else {
-            presentPaywall(returnToHomeOnDismiss: false)
+            if StoreManager.shared.isResolvingEntitlements {
+                Task { @MainActor in
+                    await StoreManager.shared.ensureEntitlementsResolved()
+                    guard !StoreManager.shared.isPremium else { return }
+                    self.presentPaywall(returnToHomeOnDismiss: false)
+                }
+            } else {
+                presentPaywall(returnToHomeOnDismiss: false)
+            }
             return false
         }
         return true