Browse Source

Harden subscription entitlement checks to prevent stale premium access.

Gate features on verified StoreKit status, fail closed on unverifiable recurring subscriptions with an expiration-date fallback, and show a neutral checking state in the UI while verification completes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hussain Afzal 3 tuần trước cách đây
mục cha
commit
8410cc6a76

+ 38 - 25
gramora/Managers/SubscriptionManager.swift

@@ -41,8 +41,6 @@ final class SubscriptionManager: ObservableObject {
         case timeout
     }
 
-    private static let cachedPremiumAccessKey = "subscription.cachedPremiumAccess"
-
     @Published private(set) var productsByID: [String: Product] = [:]
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var purchasingPlan: PaywallPlan?
@@ -66,8 +64,13 @@ final class SubscriptionManager: ObservableObject {
         max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
     }
 
+    /// Premium access confirmed by StoreKit after launch or purchase.
+    var isPremiumActive: Bool {
+        hasResolvedPremiumStatus && hasPremiumAccess
+    }
+
     var canUseAIFeatures: Bool {
-        hasPremiumAccess || remainingFreeAIUses > 0
+        isPremiumActive || remainingFreeAIUses > 0
     }
 
     private var inFlightProductLoad: Task<Void, Never>?
@@ -76,7 +79,6 @@ final class SubscriptionManager: ObservableObject {
 
     init(defaults: UserDefaults = .standard) {
         self.defaults = defaults
-        hasPremiumAccess = defaults.bool(forKey: Self.cachedPremiumAccessKey)
         freeAIUsesConsumed = min(
             defaults.integer(forKey: Self.freeAIUsesConsumedKey),
             Self.maxFreeAIUses
@@ -91,7 +93,7 @@ final class SubscriptionManager: ObservableObject {
     }
 
     func recordSuccessfulAIUse() {
-        guard !hasPremiumAccess else { return }
+        guard !isPremiumActive else { return }
         guard freeAIUsesConsumed < Self.maxFreeAIUses else { return }
 
         freeAIUsesConsumed += 1
@@ -239,7 +241,7 @@ final class SubscriptionManager: ObservableObject {
     func purchase(_ plan: PaywallPlan) async -> Bool {
         purchaseError = nil
 
-        if hasPremiumAccess {
+        if isPremiumActive {
             if !hasManageableSubscription {
                 return true
             }
@@ -324,7 +326,7 @@ final class SubscriptionManager: ObservableObject {
     func restorePurchases() async {
         purchaseError = nil
         subscriptionSuccessMessage = nil
-        let hadPremium = hasPremiumAccess
+        let hadPremium = isPremiumActive
         do {
             try await AppStore.sync()
             await refreshPremiumAccess()
@@ -431,7 +433,7 @@ final class SubscriptionManager: ObservableObject {
             guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
 
             if SubscriptionProductID.recurring.contains(transaction.productID) {
-                guard await isRecurringSubscriptionEntitled(productID: transaction.productID) else {
+                guard await isRecurringSubscriptionEntitled(transaction: transaction) else {
                     continue
                 }
                 manageableSubscription = true
@@ -451,7 +453,6 @@ final class SubscriptionManager: ObservableObject {
         activePlan = resolvedPlan
         activeRecurringPlan = resolvedRecurringPlan
         hasResolvedPremiumStatus = true
-        defaults.set(hasPremium, forKey: Self.cachedPremiumAccessKey)
     }
 
     private func applyOptimisticPremiumAccess(for transaction: Transaction) {
@@ -460,12 +461,12 @@ final class SubscriptionManager: ObservableObject {
               let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
 
         hasPremiumAccess = true
+        hasResolvedPremiumStatus = true
         activePlan = plan
         if SubscriptionProductID.recurring.contains(transaction.productID) {
             hasManageableSubscription = true
             activeRecurringPlan = plan
         }
-        defaults.set(true, forKey: Self.cachedPremiumAccessKey)
     }
 
     private func refreshIntroOfferEligibility() async {
@@ -494,34 +495,46 @@ final class SubscriptionManager: ObservableObject {
         }
     }
 
-    private func isRecurringSubscriptionEntitled(productID: String) async -> Bool {
+    private func isRecurringSubscriptionEntitled(transaction: Transaction) async -> Bool {
+        let productID = transaction.productID
         let product: Product?
         if let cached = productsByID[productID] {
             product = cached
         } else if let loaded = try? await Product.products(for: [productID]).first {
             product = loaded
         } else {
-            // Entitlement is already in currentEntitlements; defer to StoreKit when product metadata is unavailable.
-            return true
+            product = nil
         }
 
-        guard let product, let subscription = product.subscription else { return true }
-
-        do {
-            let statuses = try await subscription.status
-            return statuses.contains { status in
-                switch status.state {
-                case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+        if let product, let subscription = product.subscription {
+            do {
+                let statuses = try await subscription.status
+                if statuses.contains(where: {
+                    switch $0.state {
+                    case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+                        return true
+                    case .expired, .revoked:
+                        return false
+                    default:
+                        return false
+                    }
+                }) {
                     return true
-                case .expired, .revoked:
-                    return false
-                default:
+                }
+                if statuses.contains(where: { $0.state == .expired || $0.state == .revoked }) {
                     return false
                 }
+            } catch {
+                // Fall through to expiration-date check when status is unavailable.
             }
-        } catch {
-            return false
         }
+
+        // Verified entitlement with a future expiration is still active when metadata/status is unreachable.
+        if let expirationDate = transaction.expirationDate {
+            return expirationDate > Date()
+        }
+
+        return false
     }
 
     private func observeAppLifecycle() {

+ 53 - 22
gramora/Views/Components/PremiumCardView.swift

@@ -1,6 +1,7 @@
 import SwiftUI
 
 struct PremiumCardView: View {
+    let isCheckingSubscription: Bool
     let isPremium: Bool
     let hasManageableSubscription: Bool
     let remainingFreeAIUses: Int
@@ -8,12 +9,20 @@ struct PremiumCardView: View {
     let onManageSubscriptionTapped: () -> Void
     @State private var isUpgradeHovered = false
 
+    private var title: String {
+        if isCheckingSubscription { return "Checking subscription…" }
+        return isPremium ? AppBrand.premiumName : "Unlock Premium"
+    }
+
     private var premiumButtonTitle: String {
         if !isPremium { return "Upgrade Now" }
         return hasManageableSubscription ? "Manage Subscription" : "View Premium"
     }
 
     private var subtitle: String {
+        if isCheckingSubscription {
+            return "Verifying your plan with the App Store."
+        }
         if isPremium {
             return "You have unlimited checks, advanced features and an ad-free experience."
         }
@@ -29,9 +38,13 @@ struct PremiumCardView: View {
             HStack(spacing: 8) {
                 Image(systemName: "crown.fill")
                     .font(.system(size: 14))
-                    .foregroundStyle(Color(red: 0.95, green: 0.75, blue: 0.20))
+                    .foregroundStyle(
+                        isCheckingSubscription
+                            ? AppTheme.textSecondary
+                            : Color(red: 0.95, green: 0.75, blue: 0.20)
+                    )
 
-                Text(isPremium ? AppBrand.premiumName : "Unlock Premium")
+                Text(title)
                     .font(.system(size: 13, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
             }
@@ -42,35 +55,53 @@ struct PremiumCardView: View {
                 .lineSpacing(2)
                 .fixedSize(horizontal: false, vertical: true)
 
-            Button(action: isPremium ? onManageSubscriptionTapped : onUpgradeTapped) {
-                HStack(spacing: 6) {
-                    Text(premiumButtonTitle)
+            if isCheckingSubscription {
+                HStack(spacing: 8) {
+                    ProgressView()
+                        .controlSize(.small)
+                    Text("Please wait")
                         .font(.system(size: 12, weight: .semibold))
-
-                    if !isPremium {
-                        Image(systemName: "arrow.up.right")
-                            .font(.system(size: 10, weight: .semibold))
-                    }
+                        .foregroundStyle(AppTheme.textSecondary)
                 }
-                .foregroundStyle(isUpgradeHovered ? AppTheme.toolbarForegroundHover : AppTheme.textPrimary)
                 .frame(maxWidth: .infinity)
                 .padding(.vertical, 9)
-                .background(isUpgradeHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
+                .background(AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: 8))
                 .overlay(
                     RoundedRectangle(cornerRadius: 8)
-                        .stroke(isUpgradeHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
+                        .stroke(AppTheme.border, lineWidth: 1)
                 )
-                .shadow(
-                    color: isUpgradeHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
-                    radius: isUpgradeHovered ? 4 : 2,
-                    y: isUpgradeHovered ? 2 : 1
-                )
-                .scaleEffect(isUpgradeHovered ? 1.02 : 1.0)
-                .animation(.easeOut(duration: 0.15), value: isUpgradeHovered)
+            } else {
+                Button(action: isPremium ? onManageSubscriptionTapped : onUpgradeTapped) {
+                    HStack(spacing: 6) {
+                        Text(premiumButtonTitle)
+                            .font(.system(size: 12, weight: .semibold))
+
+                        if !isPremium {
+                            Image(systemName: "arrow.up.right")
+                                .font(.system(size: 10, weight: .semibold))
+                        }
+                    }
+                    .foregroundStyle(isUpgradeHovered ? AppTheme.toolbarForegroundHover : AppTheme.textPrimary)
+                    .frame(maxWidth: .infinity)
+                    .padding(.vertical, 9)
+                    .background(isUpgradeHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 8)
+                            .stroke(isUpgradeHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
+                    )
+                    .shadow(
+                        color: isUpgradeHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
+                        radius: isUpgradeHovered ? 4 : 2,
+                        y: isUpgradeHovered ? 2 : 1
+                    )
+                    .scaleEffect(isUpgradeHovered ? 1.02 : 1.0)
+                    .animation(.easeOut(duration: 0.15), value: isUpgradeHovered)
+                }
+                .buttonStyle(.plain)
+                .onHover { isUpgradeHovered = $0 }
             }
-            .buttonStyle(.plain)
-            .onHover { isUpgradeHovered = $0 }
         }
         .padding(14)
         .background(AppTheme.premiumBackground)

+ 4 - 3
gramora/Views/MainView.swift

@@ -16,7 +16,8 @@ struct MainView: View {
                     }
                 ),
                 isShowingSettings: viewModel.isShowingSettings,
-                isPremium: subscriptions.hasPremiumAccess,
+                isCheckingSubscription: !subscriptions.hasResolvedPremiumStatus,
+                isPremium: subscriptions.isPremiumActive,
                 hasManageableSubscription: subscriptions.hasManageableSubscription,
                 remainingFreeAIUses: subscriptions.remainingFreeAIUses,
                 onUpgradeTapped: viewModel.showPaywall,
@@ -77,14 +78,14 @@ struct MainView: View {
         .id(themeManager.isDarkMode)
         .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingPaywall)
         .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingSettings)
-        .onChange(of: subscriptions.hasPremiumAccess) { hasPremium in
+        .onChange(of: subscriptions.isPremiumActive) { hasPremium in
             if hasPremium {
                 viewModel.hidePaywall()
             }
             appRating.updatePremiumStatus(hasPremium)
         }
         .onAppear {
-            appRating.updatePremiumStatus(subscriptions.hasPremiumAccess)
+            appRating.updatePremiumStatus(subscriptions.isPremiumActive)
         }
         .alert("Enjoying \(AppBrand.shortName)?", isPresented: Binding(
             get: { appRating.showRatingPrompt },

+ 2 - 2
gramora/Views/PaywallView.swift

@@ -15,7 +15,7 @@ struct PaywallView: View {
     }
 
     private var isPremium: Bool {
-        subscriptions.hasPremiumAccess
+        subscriptions.isPremiumActive
     }
 
     private var isSelectedPlanLifetime: Bool {
@@ -261,7 +261,7 @@ struct PaywallView: View {
                         if subscriptions.showsCancelSubscriptionReminder {
                             return
                         }
-                        if subscriptions.hasPremiumAccess {
+                        if subscriptions.isPremiumActive {
                             onClose()
                         }
                     }

+ 33 - 25
gramora/Views/SettingsView.swift

@@ -143,29 +143,31 @@ struct SettingsView: View {
                     }
                 }
 
-                if subscriptions.hasPremiumAccess {
-                    SettingsRowDivider()
-
-                    SettingsActionRow(
-                        iconName: "crown.fill",
-                        title: subscriptions.hasManageableSubscription ? "Manage Subscription" : "View Premium",
-                        subtitle: "View plans, upgrade to lifetime, or manage billing",
-                        actionTitle: "Open",
-                        isDestructive: false
-                    ) {
-                        onShowPaywall()
-                    }
-                } else {
-                    SettingsRowDivider()
-
-                    SettingsActionRow(
-                        iconName: "crown.fill",
-                        title: "Upgrade to Premium",
-                        subtitle: "Unlock all premium features",
-                        actionTitle: "View Plans",
-                        isDestructive: false
-                    ) {
-                        onShowPaywall()
+                if subscriptions.hasResolvedPremiumStatus {
+                    if subscriptions.isPremiumActive {
+                        SettingsRowDivider()
+
+                        SettingsActionRow(
+                            iconName: "crown.fill",
+                            title: subscriptions.hasManageableSubscription ? "Manage Subscription" : "View Premium",
+                            subtitle: "View plans, upgrade to lifetime, or manage billing",
+                            actionTitle: "Open",
+                            isDestructive: false
+                        ) {
+                            onShowPaywall()
+                        }
+                    } else {
+                        SettingsRowDivider()
+
+                        SettingsActionRow(
+                            iconName: "crown.fill",
+                            title: "Upgrade to Premium",
+                            subtitle: "Unlock all premium features",
+                            actionTitle: "View Plans",
+                            isDestructive: false
+                        ) {
+                            onShowPaywall()
+                        }
                     }
                 }
             }
@@ -173,14 +175,20 @@ struct SettingsView: View {
     }
 
     private var subscriptionStatusTitle: String {
-        if subscriptions.hasPremiumAccess {
+        if !subscriptions.hasResolvedPremiumStatus {
+            return "Checking subscription…"
+        }
+        if subscriptions.isPremiumActive {
             return AppBrand.premiumName
         }
         return "Free Plan"
     }
 
     private var subscriptionStatusSubtitle: String {
-        if subscriptions.hasPremiumAccess {
+        if !subscriptions.hasResolvedPremiumStatus {
+            return "Verifying your plan with the App Store."
+        }
+        if subscriptions.isPremiumActive {
             if subscriptions.hasManageableSubscription, let plan = subscriptions.activeRecurringPlan ?? subscriptions.activePlan {
                 return "\(subscriptions.displayTitle(for: plan)) plan is active"
             }

+ 2 - 0
gramora/Views/SidebarView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct SidebarView: View {
     @Binding var selectedDestination: NavigationDestination
     let isShowingSettings: Bool
+    let isCheckingSubscription: Bool
     let isPremium: Bool
     let hasManageableSubscription: Bool
     let remainingFreeAIUses: Int
@@ -31,6 +32,7 @@ struct SidebarView: View {
             Spacer(minLength: 12)
 
             PremiumCardView(
+                isCheckingSubscription: isCheckingSubscription,
                 isPremium: isPremium,
                 hasManageableSubscription: hasManageableSubscription,
                 remainingFreeAIUses: remainingFreeAIUses,