瀏覽代碼

Add StoreKit 2 purchases to the paywall with premium gating.

Wire up monthly, yearly, and lifetime plans with restore support, live pricing, and a local StoreKit configuration for testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 5 小時之前
父節點
當前提交
91fdff8b17

+ 163 - 0
Products.storekit

@@ -0,0 +1,163 @@
1
+{
2
+  "appPolicies" : {
3
+    "eula" : "",
4
+    "policies" : [
5
+      {
6
+        "locale" : "en_US",
7
+        "policyText" : "",
8
+        "policyURL" : ""
9
+      }
10
+    ]
11
+  },
12
+  "identifier" : "F3A1B2C4",
13
+  "nonRenewingSubscriptions" : [
14
+
15
+  ],
16
+  "products" : [
17
+    {
18
+      "displayPrice" : "99.99",
19
+      "familyShareable" : false,
20
+      "internalID" : "6738291001",
21
+      "localizations" : [
22
+        {
23
+          "description" : "Lifetime access to all premium features.",
24
+          "displayName" : "Lifetime Premium",
25
+          "locale" : "en_US"
26
+        }
27
+      ],
28
+      "productID" : "MQL-DEV.smart-printer.premium.lifetime",
29
+      "referenceName" : "Lifetime Premium",
30
+      "type" : "NonConsumable"
31
+    }
32
+  ],
33
+  "settings" : {
34
+    "_failTransactionsEnabled" : false,
35
+    "_storeKitErrors" : [
36
+      {
37
+        "current" : null,
38
+        "enabled" : false,
39
+        "name" : "Load Products"
40
+      },
41
+      {
42
+        "current" : null,
43
+        "enabled" : false,
44
+        "name" : "Purchase"
45
+      },
46
+      {
47
+        "current" : null,
48
+        "enabled" : false,
49
+        "name" : "Verification"
50
+      },
51
+      {
52
+        "current" : null,
53
+        "enabled" : false,
54
+        "name" : "App Store Sync"
55
+      },
56
+      {
57
+        "current" : null,
58
+        "enabled" : false,
59
+        "name" : "Subscription Status"
60
+      },
61
+      {
62
+        "current" : null,
63
+        "enabled" : false,
64
+        "name" : "App Transaction"
65
+      },
66
+      {
67
+        "current" : null,
68
+        "enabled" : false,
69
+        "name" : "Manage Subscriptions Sheet"
70
+      },
71
+      {
72
+        "current" : null,
73
+        "enabled" : false,
74
+        "name" : "Refund Request Sheet"
75
+      },
76
+      {
77
+        "current" : null,
78
+        "enabled" : false,
79
+        "name" : "Offer Code Redeem Sheet"
80
+      }
81
+    ]
82
+  },
83
+  "subscriptionGroups" : [
84
+    {
85
+      "id" : "21502901",
86
+      "localizations" : [
87
+        {
88
+          "description" : "Premium subscription for Smart Printer.",
89
+          "displayName" : "Premium",
90
+          "locale" : "en_US"
91
+        }
92
+      ],
93
+      "name" : "Premium",
94
+      "subscriptions" : [
95
+        {
96
+          "adHocOffers" : [
97
+
98
+          ],
99
+          "codeOffers" : [
100
+
101
+          ],
102
+          "displayPrice" : "4.99",
103
+          "familyShareable" : false,
104
+          "groupNumber" : 1,
105
+          "internalID" : "6738291002",
106
+          "introductoryOffer" : null,
107
+          "localizations" : [
108
+            {
109
+              "description" : "Monthly access to all premium features.",
110
+              "displayName" : "Monthly Premium",
111
+              "locale" : "en_US"
112
+            }
113
+          ],
114
+          "productID" : "MQL-DEV.smart-printer.premium.monthly",
115
+          "recurringSubscriptionPeriod" : "P1M",
116
+          "referenceName" : "Monthly Premium",
117
+          "subscriptionGroupID" : "21502901",
118
+          "type" : "RecurringSubscription",
119
+          "winbackOffers" : [
120
+
121
+          ]
122
+        },
123
+        {
124
+          "adHocOffers" : [
125
+
126
+          ],
127
+          "codeOffers" : [
128
+
129
+          ],
130
+          "displayPrice" : "29.99",
131
+          "familyShareable" : false,
132
+          "groupNumber" : 1,
133
+          "internalID" : "6738291003",
134
+          "introductoryOffer" : {
135
+            "internalID" : "6738291004",
136
+            "numberOfPeriods" : 1,
137
+            "paymentMode" : "free",
138
+            "subscriptionPeriod" : "P1W"
139
+          },
140
+          "localizations" : [
141
+            {
142
+              "description" : "Yearly access to all premium features with a free trial.",
143
+              "displayName" : "Yearly Premium",
144
+              "locale" : "en_US"
145
+            }
146
+          ],
147
+          "productID" : "MQL-DEV.smart-printer.premium.yearly",
148
+          "recurringSubscriptionPeriod" : "P1Y",
149
+          "referenceName" : "Yearly Premium",
150
+          "subscriptionGroupID" : "21502901",
151
+          "type" : "RecurringSubscription",
152
+          "winbackOffers" : [
153
+
154
+          ]
155
+        }
156
+      ]
157
+    }
158
+  ],
159
+  "version" : {
160
+    "major" : 4,
161
+    "minor" : 0
162
+  }
163
+}

+ 4 - 0
smart_printer.xcodeproj/project.pbxproj

@@ -8,6 +8,7 @@
8 8
 
9 9
 /* Begin PBXFileReference section */
10 10
 		272FF22A2FD19A2200A87B72 /* smart_printer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = smart_printer.app; sourceTree = BUILT_PRODUCTS_DIR; };
11
+		272FF22E2FD19A2200A87B72 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Products.storekit; sourceTree = "<group>"; };
11 12
 /* End PBXFileReference section */
12 13
 
13 14
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -32,6 +33,7 @@
32 33
 		272FF2212FD19A2200A87B72 = {
33 34
 			isa = PBXGroup;
34 35
 			children = (
36
+				272FF22E2FD19A2200A87B72 /* Products.storekit */,
35 37
 				272FF22C2FD19A2200A87B72 /* smart_printer */,
36 38
 				272FF22B2FD19A2200A87B72 /* Products */,
37 39
 			);
@@ -248,6 +250,7 @@
248 250
 			buildSettings = {
249 251
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
250 252
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
253
+				CODE_SIGN_ENTITLEMENTS = smart_printer/smart_printer.entitlements;
251 254
 				CODE_SIGN_STYLE = Automatic;
252 255
 				COMBINE_HIDPI_IMAGES = YES;
253 256
 				CURRENT_PROJECT_VERSION = 1;
@@ -279,6 +282,7 @@
279 282
 			buildSettings = {
280 283
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
281 284
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
285
+				CODE_SIGN_ENTITLEMENTS = smart_printer/smart_printer.entitlements;
282 286
 				CODE_SIGN_STYLE = Automatic;
283 287
 				COMBINE_HIDPI_IMAGES = YES;
284 288
 				CURRENT_PROJECT_VERSION = 1;

+ 82 - 0
smart_printer.xcodeproj/xcshareddata/xcschemes/smart_printer.xcscheme

@@ -0,0 +1,82 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<Scheme
3
+   LastUpgradeVersion = "2640"
4
+   version = "1.7">
5
+   <BuildAction
6
+      parallelizeBuildables = "YES"
7
+      buildImplicitDependencies = "YES"
8
+      buildArchitectures = "Automatic">
9
+      <BuildActionEntries>
10
+         <BuildActionEntry
11
+            buildForTesting = "YES"
12
+            buildForRunning = "YES"
13
+            buildForProfiling = "YES"
14
+            buildForArchiving = "YES"
15
+            buildForAnalyzing = "YES">
16
+            <BuildableReference
17
+               BuildableIdentifier = "primary"
18
+               BlueprintIdentifier = "272FF2292FD19A2200A87B72"
19
+               BuildableName = "smart_printer.app"
20
+               BlueprintName = "smart_printer"
21
+               ReferencedContainer = "container:smart_printer.xcodeproj">
22
+            </BuildableReference>
23
+         </BuildActionEntry>
24
+      </BuildActionEntries>
25
+   </BuildAction>
26
+   <TestAction
27
+      buildConfiguration = "Debug"
28
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30
+      shouldUseLaunchSchemeArgsEnv = "YES"
31
+      shouldAutocreateTestPlan = "YES">
32
+   </TestAction>
33
+   <LaunchAction
34
+      buildConfiguration = "Debug"
35
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
36
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
37
+      launchStyle = "0"
38
+      useCustomWorkingDirectory = "NO"
39
+      ignoresPersistentStateOnLaunch = "NO"
40
+      debugDocumentVersioning = "YES"
41
+      debugServiceExtension = "internal"
42
+      allowLocationSimulation = "YES"
43
+      queueDebuggingEnableBacktraceRecording = "Yes">
44
+      <BuildableProductRunnable
45
+         runnableDebuggingMode = "0">
46
+         <BuildableReference
47
+            BuildableIdentifier = "primary"
48
+            BlueprintIdentifier = "272FF2292FD19A2200A87B72"
49
+            BuildableName = "smart_printer.app"
50
+            BlueprintName = "smart_printer"
51
+            ReferencedContainer = "container:smart_printer.xcodeproj">
52
+         </BuildableReference>
53
+      </BuildableProductRunnable>
54
+      <StoreKitConfigurationFileReference
55
+         identifier = "../../Products.storekit">
56
+      </StoreKitConfigurationFileReference>
57
+   </LaunchAction>
58
+   <ProfileAction
59
+      buildConfiguration = "Release"
60
+      shouldUseLaunchSchemeArgsEnv = "YES"
61
+      savedToolIdentifier = ""
62
+      useCustomWorkingDirectory = "NO"
63
+      debugDocumentVersioning = "YES">
64
+      <BuildableProductRunnable
65
+         runnableDebuggingMode = "0">
66
+         <BuildableReference
67
+            BuildableIdentifier = "primary"
68
+            BlueprintIdentifier = "272FF2292FD19A2200A87B72"
69
+            BuildableName = "smart_printer.app"
70
+            BlueprintName = "smart_printer"
71
+            ReferencedContainer = "container:smart_printer.xcodeproj">
72
+         </BuildableReference>
73
+      </BuildableProductRunnable>
74
+   </ProfileAction>
75
+   <AnalyzeAction
76
+      buildConfiguration = "Debug">
77
+   </AnalyzeAction>
78
+   <ArchiveAction
79
+      buildConfiguration = "Release"
80
+      revealArchiveInOrganizer = "YES">
81
+   </ArchiveAction>
82
+</Scheme>

+ 1 - 0
smart_printer/AppDelegate.swift

@@ -15,6 +15,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
15 15
     }
16 16
 
17 17
     func applicationDidFinishLaunching(_ notification: Notification) {
18
+        StoreManager.shared.start()
18 19
         resolveMainWindowController()
19 20
         configureMainWindow()
20 21
         configurePreferencesMenu()

+ 334 - 11
smart_printer/PaywallView.swift

@@ -1,4 +1,5 @@
1 1
 import Cocoa
2
+import StoreKit
2 3
 
3 4
 // MARK: - Plan Model
4 5
 
@@ -7,6 +8,14 @@ enum PaywallPlan: CaseIterable {
7 8
     case yearly
8 9
     case lifetime
9 10
 
11
+    var productID: String {
12
+        switch self {
13
+        case .monthly: StoreProductID.monthly
14
+        case .yearly: StoreProductID.yearly
15
+        case .lifetime: StoreProductID.lifetime
16
+        }
17
+    }
18
+
10 19
     var title: String {
11 20
         switch self {
12 21
         case .monthly: "Monthly"
@@ -38,6 +47,246 @@ enum PaywallPlan: CaseIterable {
38 47
         case .lifetime: "Buy Lifetime Access"
39 48
         }
40 49
     }
50
+
51
+    func localizedPrice(from product: Product?) -> String {
52
+        product?.displayPrice ?? price
53
+    }
54
+
55
+    func localizedSubtitle(from product: Product?) -> String {
56
+        guard let product else { return subtitle }
57
+
58
+        switch self {
59
+        case .monthly:
60
+            return "\(product.displayPrice) / month, cancel anytime"
61
+        case .yearly:
62
+            if product.subscription?.introductoryOffer != nil {
63
+                return "Eligible new subscribers get 7 days free, then \(product.displayPrice) / year"
64
+            }
65
+            return "\(product.displayPrice) / year, cancel anytime"
66
+        case .lifetime:
67
+            return "\(product.displayPrice) once, lifetime access"
68
+        }
69
+    }
70
+
71
+    func localizedCTATitle(from product: Product?) -> String {
72
+        guard let product else { return ctaTitle }
73
+
74
+        switch self {
75
+        case .monthly:
76
+            return "Subscribe for \(product.displayPrice) / Month"
77
+        case .yearly:
78
+            if product.subscription?.introductoryOffer != nil {
79
+                return "Start 7-Day Free Trial"
80
+            }
81
+            return "Subscribe for \(product.displayPrice) / Year"
82
+        case .lifetime:
83
+            return "Buy Lifetime Access for \(product.displayPrice)"
84
+        }
85
+    }
86
+}
87
+
88
+// MARK: - StoreKit
89
+
90
+enum StoreProductID {
91
+    static let monthly = "MQL-DEV.smart-printer.premium.monthly"
92
+    static let yearly = "MQL-DEV.smart-printer.premium.yearly"
93
+    static let lifetime = "MQL-DEV.smart-printer.premium.lifetime"
94
+
95
+    static let all: Set<String> = [monthly, yearly, lifetime]
96
+}
97
+
98
+enum StoreError: LocalizedError {
99
+    case productNotFound
100
+    case failedVerification
101
+
102
+    var errorDescription: String? {
103
+        switch self {
104
+        case .productNotFound:
105
+            "The selected plan is not available right now. Please try again later."
106
+        case .failedVerification:
107
+            "We couldn't verify your purchase. Please contact support."
108
+        }
109
+    }
110
+}
111
+
112
+@MainActor
113
+final class StoreManager {
114
+    static let shared = StoreManager()
115
+
116
+    private(set) var products: [Product] = []
117
+    private(set) var isPremium = false
118
+    private(set) var isLoadingProducts = false
119
+    private(set) var isPurchasing = false
120
+
121
+    private var transactionListener: Task<Void, Never>?
122
+    private var hasStarted = false
123
+
124
+    private init() {}
125
+
126
+    func start() {
127
+        guard !hasStarted else { return }
128
+        hasStarted = true
129
+
130
+        transactionListener = Task { [weak self] in
131
+            for await update in Transaction.updates {
132
+                await self?.handleTransactionUpdate(update)
133
+            }
134
+        }
135
+
136
+        Task {
137
+            await loadProducts()
138
+            await refreshPremiumStatus()
139
+        }
140
+    }
141
+
142
+    func product(for plan: PaywallPlan) -> Product? {
143
+        products.first { $0.id == plan.productID }
144
+    }
145
+
146
+    func loadProducts() async {
147
+        isLoadingProducts = true
148
+        postStoreStateDidChange()
149
+        defer {
150
+            isLoadingProducts = false
151
+            postStoreStateDidChange()
152
+        }
153
+
154
+        do {
155
+            products = try await Product.products(for: StoreProductID.all)
156
+                .sorted { lhs, rhs in
157
+                    productSortOrder(for: lhs.id) < productSortOrder(for: rhs.id)
158
+                }
159
+            NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
160
+        } catch {
161
+            NSLog("Failed to load products: \(error.localizedDescription)")
162
+        }
163
+    }
164
+
165
+    @discardableResult
166
+    func purchase(plan: PaywallPlan) async throws -> Bool {
167
+        if products.isEmpty {
168
+            await loadProducts()
169
+        }
170
+
171
+        guard let product = product(for: plan) else {
172
+            throw StoreError.productNotFound
173
+        }
174
+
175
+        isPurchasing = true
176
+        postStoreStateDidChange()
177
+        defer {
178
+            isPurchasing = false
179
+            postStoreStateDidChange()
180
+        }
181
+
182
+        let result = try await product.purchase()
183
+
184
+        switch result {
185
+        case .success(let verification):
186
+            let transaction = try checkVerified(verification)
187
+            await transaction.finish()
188
+            await refreshPremiumStatus()
189
+            return isPremium
190
+        case .userCancelled, .pending:
191
+            return false
192
+        @unknown default:
193
+            return false
194
+        }
195
+    }
196
+
197
+    @discardableResult
198
+    func restorePurchases() async throws -> Bool {
199
+        isPurchasing = true
200
+        postStoreStateDidChange()
201
+        defer {
202
+            isPurchasing = false
203
+            postStoreStateDidChange()
204
+        }
205
+
206
+        try await AppStore.sync()
207
+        await refreshPremiumStatus()
208
+        return isPremium
209
+    }
210
+
211
+    func showAlert(title: String, message: String, on window: NSWindow?) {
212
+        let alert = NSAlert()
213
+        alert.messageText = title
214
+        alert.informativeText = message
215
+        alert.alertStyle = .informational
216
+        alert.addButton(withTitle: "OK")
217
+        if let window {
218
+            alert.beginSheetModal(for: window)
219
+        } else {
220
+            alert.runModal()
221
+        }
222
+    }
223
+
224
+    func showPurchaseError(_ error: Error, on window: NSWindow?) {
225
+        if let storeError = error as? StoreError {
226
+            showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
227
+            return
228
+        }
229
+
230
+        if let storeKitError = error as? StoreKitError, case .userCancelled = storeKitError {
231
+            return
232
+        }
233
+
234
+        showAlert(title: "Purchase Failed", message: error.localizedDescription, on: window)
235
+    }
236
+
237
+    private func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
238
+        do {
239
+            let transaction = try checkVerified(update)
240
+            await transaction.finish()
241
+            await refreshPremiumStatus()
242
+        } catch {
243
+            NSLog("Transaction verification failed: \(error.localizedDescription)")
244
+        }
245
+    }
246
+
247
+    private func refreshPremiumStatus() async {
248
+        var hasPremium = false
249
+
250
+        for await result in Transaction.currentEntitlements {
251
+            guard let transaction = try? checkVerified(result) else { continue }
252
+            if StoreProductID.all.contains(transaction.productID) {
253
+                hasPremium = true
254
+                break
255
+            }
256
+        }
257
+
258
+        guard hasPremium != isPremium else { return }
259
+        isPremium = hasPremium
260
+        NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
261
+    }
262
+
263
+    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
264
+        switch result {
265
+        case .unverified:
266
+            throw StoreError.failedVerification
267
+        case .verified(let safe):
268
+            return safe
269
+        }
270
+    }
271
+
272
+    private func productSortOrder(for productID: String) -> Int {
273
+        switch productID {
274
+        case StoreProductID.monthly: 0
275
+        case StoreProductID.yearly: 1
276
+        case StoreProductID.lifetime: 2
277
+        default: 99
278
+        }
279
+    }
280
+
281
+    private func postStoreStateDidChange() {
282
+        NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
283
+    }
284
+}
285
+
286
+extension Notification.Name {
287
+    static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
288
+    static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
289
+    static let storeStateDidChange = Notification.Name("storeStateDidChange")
41 290
 }
42 291
 
43 292
 // MARK: - Left Panel
@@ -275,6 +524,11 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
275 524
     @available(*, unavailable)
276 525
     required init?(coder: NSCoder) { nil }
277 526
 
527
+    func updateDisplay(product: Product?) {
528
+        subtitleLabel.stringValue = plan.localizedSubtitle(from: product)
529
+        priceLabel.stringValue = plan.localizedPrice(from: product)
530
+    }
531
+
278 532
     func refreshAppearance() {
279 533
         updateAppearance()
280 534
         subtitleLabel.refreshThemeLabelColor()
@@ -474,8 +728,7 @@ private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
474 728
 
475 729
 final class PaywallView: NSView, AppearanceRefreshable {
476 730
     var onClose: (() -> Void)?
477
-    var onPurchase: ((PaywallPlan) -> Void)?
478
-    var onRestore: (() -> Void)?
731
+    var onPurchaseSucceeded: (() -> Void)?
479 732
 
480 733
     private var selectedPlan: PaywallPlan = .yearly
481 734
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
@@ -484,6 +737,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
484 737
     private var rightTitle: NSTextField!
485 738
     private var rightSubtitle: NSTextField!
486 739
     private var trustStack: NSStackView!
740
+    private var storeObservers: [NSObjectProtocol] = []
487 741
 
488 742
     init() {
489 743
         super.init(frame: .zero)
@@ -491,9 +745,46 @@ final class PaywallView: NSView, AppearanceRefreshable {
491 745
         wantsLayer = true
492 746
         layer?.cornerRadius = 0
493 747
         setup()
748
+        observeStoreUpdates()
749
+        refreshProductDisplay()
494 750
         refreshAppearance()
495 751
     }
496 752
 
753
+    deinit {
754
+        storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
755
+    }
756
+
757
+    private func observeStoreUpdates() {
758
+        let center = NotificationCenter.default
759
+        storeObservers = [
760
+            center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
761
+                self?.refreshProductDisplay()
762
+            },
763
+            center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
764
+                self?.refreshPurchaseState()
765
+            },
766
+            center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
767
+                self?.refreshPurchaseState()
768
+            },
769
+        ]
770
+    }
771
+
772
+    private func refreshProductDisplay() {
773
+        let store = StoreManager.shared
774
+        for (plan, card) in planCards {
775
+            card.updateDisplay(product: store.product(for: plan))
776
+        }
777
+        ctaButton.title = selectedPlan.localizedCTATitle(from: store.product(for: selectedPlan))
778
+        refreshPurchaseState()
779
+    }
780
+
781
+    private func refreshPurchaseState() {
782
+        let store = StoreManager.shared
783
+        let isBusy = store.isPurchasing || store.isLoadingProducts
784
+        ctaButton.isEnabled = !isBusy
785
+        ctaButton.alphaValue = isBusy ? 0.65 : 1
786
+    }
787
+
497 788
     func refreshAppearance() {
498 789
         layer?.backgroundColor = AppTheme.paywallBackground.cgColor
499 790
         leftPanelTitle?.refreshThemeLabelColor()
@@ -607,7 +898,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
607 898
             plansStack.addArrangedSubview(card)
608 899
         }
609 900
 
610
-        ctaButton.title = selectedPlan.ctaTitle
901
+        ctaButton.title = selectedPlan.localizedCTATitle(from: StoreManager.shared.product(for: selectedPlan))
611 902
         ctaButton.target = self
612 903
         ctaButton.action = #selector(purchaseTapped)
613 904
         ctaButton.translatesAutoresizingMaskIntoConstraints = false
@@ -756,15 +1047,45 @@ final class PaywallView: NSView, AppearanceRefreshable {
756 1047
         for (key, card) in planCards {
757 1048
             card.isChosen = key == plan
758 1049
         }
759
-        ctaButton.title = plan.ctaTitle
1050
+        ctaButton.title = plan.localizedCTATitle(from: StoreManager.shared.product(for: plan))
760 1051
     }
761 1052
 
762 1053
     @objc private func purchaseTapped() {
763
-        onPurchase?(selectedPlan)
1054
+        Task { @MainActor in
1055
+            refreshPurchaseState()
1056
+            do {
1057
+                let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
1058
+                refreshPurchaseState()
1059
+                if succeeded {
1060
+                    onPurchaseSucceeded?()
1061
+                }
1062
+            } catch {
1063
+                refreshPurchaseState()
1064
+                StoreManager.shared.showPurchaseError(error, on: window)
1065
+            }
1066
+        }
764 1067
     }
765 1068
 
766 1069
     @objc private func restoreTapped() {
767
-        onRestore?()
1070
+        Task { @MainActor in
1071
+            refreshPurchaseState()
1072
+            do {
1073
+                let restored = try await StoreManager.shared.restorePurchases()
1074
+                refreshPurchaseState()
1075
+                if restored {
1076
+                    onPurchaseSucceeded?()
1077
+                } else {
1078
+                    StoreManager.shared.showAlert(
1079
+                        title: "No Purchases Found",
1080
+                        message: "We couldn't find any previous purchases for this Apple ID.",
1081
+                        on: window
1082
+                    )
1083
+                }
1084
+            } catch {
1085
+                refreshPurchaseState()
1086
+                StoreManager.shared.showPurchaseError(error, on: window)
1087
+            }
1088
+        }
768 1089
     }
769 1090
 
770 1091
     @objc private func continueWithFreePlanTapped() {
@@ -815,11 +1136,8 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
815 1136
 
816 1137
         paywallView.translatesAutoresizingMaskIntoConstraints = false
817 1138
         paywallView.onClose = { [weak self] in self?.dismiss() }
818
-        paywallView.onPurchase = { plan in
819
-            NSLog("Purchase tapped: \(plan.title)")
820
-        }
821
-        paywallView.onRestore = {
822
-            NSLog("Restore purchases tapped")
1139
+        paywallView.onPurchaseSucceeded = { [weak self] in
1140
+            self?.dismiss()
823 1141
         }
824 1142
 
825 1143
         addSubview(blurView)
@@ -871,6 +1189,11 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
871 1189
             ])
872 1190
         }
873 1191
         alphaValue = 0
1192
+        Task { @MainActor in
1193
+            if StoreManager.shared.products.isEmpty {
1194
+                await StoreManager.shared.loadProducts()
1195
+            }
1196
+        }
874 1197
         NSAnimationContext.runAnimationGroup { context in
875 1198
             context.duration = 0.2
876 1199
             animator().alphaValue = 1

+ 13 - 1
smart_printer/ViewController.swift

@@ -43,6 +43,12 @@ class ViewController: NSViewController {
43 43
             name: .appearanceDidChange,
44 44
             object: nil
45 45
         )
46
+        NotificationCenter.default.addObserver(
47
+            self,
48
+            selector: #selector(premiumStatusDidChange),
49
+            name: .premiumStatusDidChange,
50
+            object: nil
51
+        )
46 52
     }
47 53
 
48 54
     deinit {
@@ -57,6 +63,12 @@ class ViewController: NSViewController {
57 63
         refreshAppearance()
58 64
     }
59 65
 
66
+    @objc private func premiumStatusDidChange() {
67
+        if StoreManager.shared.isPremium, paywallOverlay != nil {
68
+            dismissPaywall()
69
+        }
70
+    }
71
+
60 72
     private func refreshAppearance() {
61 73
         view.layer?.backgroundColor = AppTheme.background.cgColor
62 74
         mainContentView?.layer?.backgroundColor = AppTheme.background.cgColor
@@ -153,7 +165,7 @@ class ViewController: NSViewController {
153 165
         contentTopBelowHeader.isActive = !isSettings
154 166
         contentTopBelowWindow.isActive = isSettings
155 167
 
156
-        if destination == .scanAndHome {
168
+        if destination == .scanAndHome, !StoreManager.shared.isPremium {
157 169
             presentPaywall()
158 170
         } else {
159 171
             dismissPaywall()

+ 12 - 0
smart_printer/smart_printer.entitlements

@@ -0,0 +1,12 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+	<key>com.apple.security.app-sandbox</key>
6
+	<true/>
7
+	<key>com.apple.security.files.user-selected.read-only</key>
8
+	<true/>
9
+	<key>com.apple.security.network.client</key>
10
+	<true/>
11
+</dict>
12
+</plist>