Parcourir la Source

Improve Pro subscription detection and paywall actions

- SubscriptionStore: add optional deep refresh via Product.subscription
  status when entitlements are empty; post status notification only when
  isProActive changes; fold redundant posts into refreshEntitlements.
- AppDelegate: refresh entitlements on launch and when the app becomes
  active (debounced) using deep refresh; document why AppStore.sync is
  not called at launch.
- Dashboard: keep the upgrade card visible for all users, switch copy
  and CTA between Try Pro and Manage Subscription, refresh entitlements
  before opening the paywall or App Store subscriptions.
- Premium plans: footer uses Try Pro vs Manage Subscription; Manage
  opens Apple subscription settings even if StoreKit lags; Try Pro no
  longer dismisses the sheet; refresh footer after loading products.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 il y a 3 semaines
Parent
commit
e6a274a0b1

+ 23 - 1
App for Indeed/AppDelegate.swift

@@ -11,6 +11,8 @@ import Cocoa
11 11
 class AppDelegate: NSObject, NSApplicationDelegate {
12 12
 
13 13
     private let minimumWindowSize = NSSize(width: 1120, height: 700)
14
+    /// Avoids hammering StoreKit when `didBecomeActive` fires in quick succession (e.g. after system sheets).
15
+    private var lastSubscriptionRefreshAt: Date?
14 16
 
15 17
     func applicationWillFinishLaunching(_ notification: Notification) {
16 18
         // Dashboard is light-themed; without this, a Dark Mode Mac paints a dark title bar.
@@ -18,8 +20,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
18 20
     }
19 21
 
20 22
     func applicationDidFinishLaunching(_ aNotification: Notification) {
23
+        NotificationCenter.default.addObserver(
24
+            forName: NSApplication.didBecomeActiveNotification,
25
+            object: nil,
26
+            queue: .main
27
+        ) { [weak self] _ in
28
+            Task { @MainActor in
29
+                guard let self else { return }
30
+                let now = Date()
31
+                if let last = self.lastSubscriptionRefreshAt, now.timeIntervalSince(last) < 2.5 {
32
+                    return
33
+                }
34
+                self.lastSubscriptionRefreshAt = now
35
+                await SubscriptionStore.shared.refreshEntitlements(deep: true)
36
+            }
37
+        }
38
+
21 39
         Task { @MainActor in
22
-            await SubscriptionStore.shared.refreshEntitlements()
40
+            // Do not call `AppStore.sync()` here — it prompts "Sign in with Apple Account" in Xcode / StoreKit
41
+            // testing and can repeat when the app re-activates after dismissing the sheet. Sync only from
42
+            // explicit "Restore purchases" in `SubscriptionStore.restorePurchases()`.
43
+            lastSubscriptionRefreshAt = Date()
44
+            await SubscriptionStore.shared.refreshEntitlements(deep: true)
23 45
             NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
24 46
         }
25 47
         NSApp.activate(ignoringOtherApps: true)

+ 25 - 8
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -219,7 +219,9 @@ private final class PremiumPlansViewController: NSViewController {
219 219
                 self?.updatePremiumCloseButtonVisibility()
220 220
             }
221 221
         }
222
-        Task { await loadStoreProducts() }
222
+        Task { @MainActor in
223
+            await loadStoreProducts()
224
+        }
223 225
     }
224 226
 
225 227
     override func viewDidLayout() {
@@ -574,8 +576,13 @@ private final class PremiumPlansViewController: NSViewController {
574 576
         return (container, button)
575 577
     }
576 578
 
579
+    private enum PrimaryFooterSubscriptionTitle {
580
+        static let manage = "Manage Subscription"
581
+        static let tryPro = "Try Pro"
582
+    }
583
+
577 584
     private func subscriptionPrimaryFooterTitle() -> String {
578
-        subscriptionStore.isProActive ? "Manage Subscription" : "Continue with free plan"
585
+        subscriptionStore.isProActive ? PrimaryFooterSubscriptionTitle.manage : PrimaryFooterSubscriptionTitle.tryPro
579 586
     }
580 587
 
581 588
     private func updateSubscriptionPrimaryFooter() {
@@ -626,12 +633,19 @@ private final class PremiumPlansViewController: NSViewController {
626 633
         Task { await purchasePlan(planKey: planKey) }
627 634
     }
628 635
 
629
-    @objc private func didTapPrimaryFooterSubscriptionAction() {
630
-        if subscriptionStore.isProActive {
631
-            guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
632
-            NSWorkspace.shared.open(url)
633
-        } else {
634
-            didTapClose()
636
+    @objc private func didTapPrimaryFooterSubscriptionAction(_ sender: NSButton) {
637
+        let userTappedManage = (sender.title == PrimaryFooterSubscriptionTitle.manage)
638
+        Task { @MainActor [weak self] in
639
+            guard let self else { return }
640
+            await subscriptionStore.refreshEntitlements(deep: true)
641
+            updateSubscriptionPrimaryFooter()
642
+            let active = subscriptionStore.isProActive
643
+            if active || userTappedManage {
644
+                guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
645
+                NSWorkspace.shared.open(url)
646
+                return
647
+            }
648
+            // "Try Pro" while this paywall is visible: stay on the paywall (do not dismiss the sheet).
635 649
         }
636 650
     }
637 651
 
@@ -665,8 +679,11 @@ private final class PremiumPlansViewController: NSViewController {
665 679
     }
666 680
 
667 681
     private func loadStoreProducts() async {
682
+        await subscriptionStore.refreshEntitlements(deep: true)
668 683
         await subscriptionStore.loadProducts()
669 684
         applyStorePricing()
685
+        updateSubscriptionPrimaryFooter()
686
+        updatePremiumCloseButtonVisibility()
670 687
     }
671 688
 
672 689
     private func applyStorePricing() {

+ 38 - 10
App for Indeed/Subscription/SubscriptionStore.swift

@@ -27,9 +27,17 @@ final class SubscriptionStore {
27 27
         transactionListenerTask?.cancel()
28 28
     }
29 29
 
30
-    /// Syncs `isProActive` with `Transaction.currentEntitlements`. Call on launch and after StoreKit events.
31
-    func refreshEntitlements() async {
30
+    /// Syncs `isProActive` with StoreKit. Set `deep` to also consult subscription status via `Product.products` when entitlements are empty (after sync, restore, or opening the paywall).
31
+    /// Posts `.subscriptionStatusDidChange` when the value changes.
32
+    func refreshEntitlements(deep: Bool = false) async {
33
+        let before = isProActive
32 34
         isProActive = await computeProEntitlementFromStore()
35
+        if !isProActive, deep {
36
+            isProActive = await computeProFromLoadedSubscriptionProducts()
37
+        }
38
+        if before != isProActive {
39
+            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
40
+        }
33 41
     }
34 42
 
35 43
     /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
@@ -60,8 +68,7 @@ final class SubscriptionStore {
60 68
         case .success(let verification):
61 69
             let transaction = try checkVerified(verification)
62 70
             await transaction.finish()
63
-            await refreshEntitlements()
64
-            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
71
+            await refreshEntitlements(deep: true)
65 72
             return true
66 73
         case .userCancelled:
67 74
             return false
@@ -75,13 +82,12 @@ final class SubscriptionStore {
75 82
     /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
76 83
     func restorePurchases() async throws {
77 84
         try await AppStore.sync()
78
-        await refreshEntitlements()
79
-        NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
85
+        await refreshEntitlements(deep: true)
80 86
     }
81 87
 
82 88
     /// Whether the user has an active subscription for one of this app’s Pro product IDs.
83 89
     func hasActiveSubscription() async -> Bool {
84
-        await refreshEntitlements()
90
+        await refreshEntitlements(deep: true)
85 91
         return isProActive
86 92
     }
87 93
 
@@ -89,7 +95,6 @@ final class SubscriptionStore {
89 95
         for await result in Transaction.currentEntitlements {
90 96
             guard case .verified(let transaction) = result else { continue }
91 97
             if transaction.revocationDate != nil { continue }
92
-            guard transaction.productType == .autoRenewable else { continue }
93 98
             if SubscriptionProductIDs.all.contains(transaction.productID) {
94 99
                 return true
95 100
             }
@@ -97,12 +102,35 @@ final class SubscriptionStore {
97 102
         return false
98 103
     }
99 104
 
105
+    /// Fallback when `currentEntitlements` is empty or lagging (common right after install, account changes, or macOS StoreKit edge cases).
106
+    private func computeProFromLoadedSubscriptionProducts() async -> Bool {
107
+        do {
108
+            let products = try await Product.products(for: SubscriptionProductIDs.all)
109
+            for product in products {
110
+                guard let subscription = product.subscription else { continue }
111
+                let statuses = try await subscription.status
112
+                for status in statuses {
113
+                    switch status.state {
114
+                    case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
115
+                        return true
116
+                    case .expired, .revoked:
117
+                        break
118
+                    default:
119
+                        break
120
+                    }
121
+                }
122
+            }
123
+        } catch {
124
+            return false
125
+        }
126
+        return false
127
+    }
128
+
100 129
     private func listenForTransactions() async {
101 130
         for await result in Transaction.updates {
102 131
             guard case .verified(let transaction) = result else { continue }
103 132
             await transaction.finish()
104
-            await refreshEntitlements()
105
-            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
133
+            await refreshEntitlements(deep: true)
106 134
         }
107 135
     }
108 136
 

+ 36 - 4
App for Indeed/Views/DashboardView.swift

@@ -127,6 +127,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
127 127
     private let jobSearchService = OpenAIJobSearchService()
128 128
     private var premiumPlansWindowController: PremiumPlansWindowController?
129 129
     private weak var sidebarUpgradeCard: NSView?
130
+    private weak var sidebarUpgradeHeadline: NSTextField?
131
+    private weak var sidebarUpgradeDescription: NSTextField?
132
+    private weak var sidebarUpgradeButton: HoverableButton?
130 133
     private var subscriptionObserver: NSObjectProtocol?
131 134
 
132 135
     /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
@@ -158,7 +161,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
158 161
         super.viewDidMoveToWindow()
159 162
         guard window != nil else { return }
160 163
         Task { @MainActor in
161
-            await SubscriptionStore.shared.refreshEntitlements()
164
+            await SubscriptionStore.shared.refreshEntitlements(deep: true)
162 165
             self.applyProSubscriptionToSidebar()
163 166
         }
164 167
     }
@@ -363,7 +366,24 @@ final class DashboardView: NSView, NSTextFieldDelegate {
363 366
 
364 367
     private func applyProSubscriptionToSidebar() {
365 368
         let active = SubscriptionStore.shared.isProActive
366
-        sidebarUpgradeCard?.isHidden = active
369
+        sidebarUpgradeCard?.isHidden = false
370
+
371
+        guard let headline = sidebarUpgradeHeadline,
372
+              let upgradeDescription = sidebarUpgradeDescription,
373
+              let upgradeButton = sidebarUpgradeButton else { return }
374
+
375
+        let descriptionWidth: CGFloat = 158
376
+        if active {
377
+            headline.stringValue = "You're on Pro"
378
+            upgradeDescription.stringValue = "Manage billing, renewals, and plans in the App Store."
379
+            upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
380
+            upgradeButton.title = "Manage Subscription"
381
+        } else {
382
+            headline.stringValue = "Upgrade to Pro"
383
+            upgradeDescription.stringValue = "Unlimited AI matches, smart alerts, and interview prep—all in one place."
384
+            upgradeDescription.preferredMaxLayoutWidth = descriptionWidth
385
+            upgradeButton.title = "Try Pro"
386
+        }
367 387
     }
368 388
 
369 389
     private func presentPremiumPlansSheet() {
@@ -2073,7 +2093,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
2073 2093
         let innerContentWidth = cardWidth - 28
2074 2094
         upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
2075 2095
 
2076
-        let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
2096
+        let upgradeButton = HoverableButton(title: "Try Pro", target: self, action: #selector(didTapUpgradeToPro))
2077 2097
         upgradeButton.isBordered = false
2078 2098
         upgradeButton.bezelStyle = .rounded
2079 2099
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
@@ -2115,11 +2135,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
2115 2135
 
2116 2136
         sidebar.addArrangedSubview(upgradeCard)
2117 2137
         sidebarUpgradeCard = upgradeCard
2138
+        sidebarUpgradeHeadline = headline
2139
+        sidebarUpgradeDescription = upgradeDescription
2140
+        sidebarUpgradeButton = upgradeButton
2118 2141
         applyProSubscriptionToSidebar()
2119 2142
     }
2120 2143
 
2121 2144
     @objc private func didTapUpgradeToPro() {
2122
-        presentPremiumPlansSheet()
2145
+        Task { @MainActor in
2146
+            await SubscriptionStore.shared.refreshEntitlements(deep: true)
2147
+            applyProSubscriptionToSidebar()
2148
+            guard SubscriptionStore.shared.isProActive else {
2149
+                presentPremiumPlansSheet()
2150
+                return
2151
+            }
2152
+            guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
2153
+            NSWorkspace.shared.open(url)
2154
+        }
2123 2155
     }
2124 2156
 
2125 2157
     @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) {