Просмотр исходного кода

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 недель назад: 3
Родитель
Сommit
e6a274a0b1

+ 23 - 1
App for Indeed/AppDelegate.swift

@@ -11,6 +11,8 @@ import Cocoa
11
 class AppDelegate: NSObject, NSApplicationDelegate {
11
 class AppDelegate: NSObject, NSApplicationDelegate {
12
 
12
 
13
     private let minimumWindowSize = NSSize(width: 1120, height: 700)
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
     func applicationWillFinishLaunching(_ notification: Notification) {
17
     func applicationWillFinishLaunching(_ notification: Notification) {
16
         // Dashboard is light-themed; without this, a Dark Mode Mac paints a dark title bar.
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
     func applicationDidFinishLaunching(_ aNotification: Notification) {
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
         Task { @MainActor in
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
             NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
45
             NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
24
         }
46
         }
25
         NSApp.activate(ignoringOtherApps: true)
47
         NSApp.activate(ignoringOtherApps: true)

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

@@ -219,7 +219,9 @@ private final class PremiumPlansViewController: NSViewController {
219
                 self?.updatePremiumCloseButtonVisibility()
219
                 self?.updatePremiumCloseButtonVisibility()
220
             }
220
             }
221
         }
221
         }
222
-        Task { await loadStoreProducts() }
222
+        Task { @MainActor in
223
+            await loadStoreProducts()
224
+        }
223
     }
225
     }
224
 
226
 
225
     override func viewDidLayout() {
227
     override func viewDidLayout() {
@@ -574,8 +576,13 @@ private final class PremiumPlansViewController: NSViewController {
574
         return (container, button)
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
     private func subscriptionPrimaryFooterTitle() -> String {
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
     private func updateSubscriptionPrimaryFooter() {
588
     private func updateSubscriptionPrimaryFooter() {
@@ -626,12 +633,19 @@ private final class PremiumPlansViewController: NSViewController {
626
         Task { await purchasePlan(planKey: planKey) }
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
     private func loadStoreProducts() async {
681
     private func loadStoreProducts() async {
682
+        await subscriptionStore.refreshEntitlements(deep: true)
668
         await subscriptionStore.loadProducts()
683
         await subscriptionStore.loadProducts()
669
         applyStorePricing()
684
         applyStorePricing()
685
+        updateSubscriptionPrimaryFooter()
686
+        updatePremiumCloseButtonVisibility()
670
     }
687
     }
671
 
688
 
672
     private func applyStorePricing() {
689
     private func applyStorePricing() {

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

@@ -27,9 +27,17 @@ final class SubscriptionStore {
27
         transactionListenerTask?.cancel()
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
         isProActive = await computeProEntitlementFromStore()
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
     /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
43
     /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
@@ -60,8 +68,7 @@ final class SubscriptionStore {
60
         case .success(let verification):
68
         case .success(let verification):
61
             let transaction = try checkVerified(verification)
69
             let transaction = try checkVerified(verification)
62
             await transaction.finish()
70
             await transaction.finish()
63
-            await refreshEntitlements()
64
-            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
71
+            await refreshEntitlements(deep: true)
65
             return true
72
             return true
66
         case .userCancelled:
73
         case .userCancelled:
67
             return false
74
             return false
@@ -75,13 +82,12 @@ final class SubscriptionStore {
75
     /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
82
     /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
76
     func restorePurchases() async throws {
83
     func restorePurchases() async throws {
77
         try await AppStore.sync()
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
     /// Whether the user has an active subscription for one of this app’s Pro product IDs.
88
     /// Whether the user has an active subscription for one of this app’s Pro product IDs.
83
     func hasActiveSubscription() async -> Bool {
89
     func hasActiveSubscription() async -> Bool {
84
-        await refreshEntitlements()
90
+        await refreshEntitlements(deep: true)
85
         return isProActive
91
         return isProActive
86
     }
92
     }
87
 
93
 
@@ -89,7 +95,6 @@ final class SubscriptionStore {
89
         for await result in Transaction.currentEntitlements {
95
         for await result in Transaction.currentEntitlements {
90
             guard case .verified(let transaction) = result else { continue }
96
             guard case .verified(let transaction) = result else { continue }
91
             if transaction.revocationDate != nil { continue }
97
             if transaction.revocationDate != nil { continue }
92
-            guard transaction.productType == .autoRenewable else { continue }
93
             if SubscriptionProductIDs.all.contains(transaction.productID) {
98
             if SubscriptionProductIDs.all.contains(transaction.productID) {
94
                 return true
99
                 return true
95
             }
100
             }
@@ -97,12 +102,35 @@ final class SubscriptionStore {
97
         return false
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
     private func listenForTransactions() async {
129
     private func listenForTransactions() async {
101
         for await result in Transaction.updates {
130
         for await result in Transaction.updates {
102
             guard case .verified(let transaction) = result else { continue }
131
             guard case .verified(let transaction) = result else { continue }
103
             await transaction.finish()
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
     private let jobSearchService = OpenAIJobSearchService()
127
     private let jobSearchService = OpenAIJobSearchService()
128
     private var premiumPlansWindowController: PremiumPlansWindowController?
128
     private var premiumPlansWindowController: PremiumPlansWindowController?
129
     private weak var sidebarUpgradeCard: NSView?
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
     private var subscriptionObserver: NSObjectProtocol?
133
     private var subscriptionObserver: NSObjectProtocol?
131
 
134
 
132
     /// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
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
         super.viewDidMoveToWindow()
161
         super.viewDidMoveToWindow()
159
         guard window != nil else { return }
162
         guard window != nil else { return }
160
         Task { @MainActor in
163
         Task { @MainActor in
161
-            await SubscriptionStore.shared.refreshEntitlements()
164
+            await SubscriptionStore.shared.refreshEntitlements(deep: true)
162
             self.applyProSubscriptionToSidebar()
165
             self.applyProSubscriptionToSidebar()
163
         }
166
         }
164
     }
167
     }
@@ -363,7 +366,24 @@ final class DashboardView: NSView, NSTextFieldDelegate {
363
 
366
 
364
     private func applyProSubscriptionToSidebar() {
367
     private func applyProSubscriptionToSidebar() {
365
         let active = SubscriptionStore.shared.isProActive
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
     private func presentPremiumPlansSheet() {
389
     private func presentPremiumPlansSheet() {
@@ -2073,7 +2093,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
2073
         let innerContentWidth = cardWidth - 28
2093
         let innerContentWidth = cardWidth - 28
2074
         upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
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
         upgradeButton.isBordered = false
2097
         upgradeButton.isBordered = false
2078
         upgradeButton.bezelStyle = .rounded
2098
         upgradeButton.bezelStyle = .rounded
2079
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
2099
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
@@ -2115,11 +2135,23 @@ final class DashboardView: NSView, NSTextFieldDelegate {
2115
 
2135
 
2116
         sidebar.addArrangedSubview(upgradeCard)
2136
         sidebar.addArrangedSubview(upgradeCard)
2117
         sidebarUpgradeCard = upgradeCard
2137
         sidebarUpgradeCard = upgradeCard
2138
+        sidebarUpgradeHeadline = headline
2139
+        sidebarUpgradeDescription = upgradeDescription
2140
+        sidebarUpgradeButton = upgradeButton
2118
         applyProSubscriptionToSidebar()
2141
         applyProSubscriptionToSidebar()
2119
     }
2142
     }
2120
 
2143
 
2121
     @objc private func didTapUpgradeToPro() {
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
     @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) {
2157
     @objc private func didChangeThemeSelection(_ sender: NSSegmentedControl) {