Sfoglia il codice sorgente

Stabilize StoreKit paywall and entitlement handling.

Fix recurring StoreKit product availability failures by correcting local subscription configuration, adding retry/fallback logic for product loading and entitlements, and wiring restore purchases plus safer paywall UI states so premium users are not incorrectly locked out.

Made-with: Cursor
huzaifahayat12 3 mesi fa
parent
commit
52bd5c0983
3 ha cambiato i file con 217 aggiunte e 61 eliminazioni
  1. 4 2
      zoom_app.xcodeproj/project.pbxproj
  2. 76 50
      zoom_app/StoreKit.storekit
  3. 137 9
      zoom_app/ViewController.swift

+ 4 - 2
zoom_app.xcodeproj/project.pbxproj

@@ -252,13 +252,14 @@
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = 5AT3P29RBZ;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
-				INFOPLIST_KEY_ZoomOAuthClientId = "isvIAKPhSPOhBxFUkiY2A";
+				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
@@ -285,13 +286,14 @@
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = 5AT3P29RBZ;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
-				INFOPLIST_KEY_ZoomOAuthClientId = "isvIAKPhSPOhBxFUkiY2A";
+				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",

+ 76 - 50
zoom_app/StoreKit.storekit

@@ -2,51 +2,6 @@
   "identifier" : "D0E98E7D-0C63-4D0F-A999-EC6AAB6E3B7C",
   "nonRenewingSubscriptions" : [],
   "products" : [
-    {
-      "displayPrice" : "1100.00",
-      "familyShareable" : false,
-      "internalID" : "64D1B757-30B3-4D8A-AE2A-83B6E2B9B4A7",
-      "localizations" : [
-        {
-          "description" : "Unlock premium features with weekly access.",
-          "displayName" : "Premium Weekly",
-          "locale" : "en_PK"
-        }
-      ],
-      "productID" : "com.mqldev.zoomapp.premium.weekly",
-      "referenceName" : "Premium Weekly",
-      "type" : "NonConsumable"
-    },
-    {
-      "displayPrice" : "2500.00",
-      "familyShareable" : false,
-      "internalID" : "5F0659F3-9E3E-4E7A-95A4-87F68F63AE64",
-      "localizations" : [
-        {
-          "description" : "Unlock premium features with monthly access.",
-          "displayName" : "Premium Monthly",
-          "locale" : "en_PK"
-        }
-      ],
-      "productID" : "com.mqldev.zoomapp.premium.monthly",
-      "referenceName" : "Premium Monthly",
-      "type" : "NonConsumable"
-    },
-    {
-      "displayPrice" : "9900.00",
-      "familyShareable" : false,
-      "internalID" : "CE6E8B9A-65D4-4E3A-9EB2-5B81A410F1E0",
-      "localizations" : [
-        {
-          "description" : "Unlock premium features with yearly access.",
-          "displayName" : "Premium Yearly",
-          "locale" : "en_PK"
-        }
-      ],
-      "productID" : "com.mqldev.zoomapp.premium.yearly",
-      "referenceName" : "Premium Yearly",
-      "type" : "NonConsumable"
-    },
     {
       "displayPrice" : "14900.00",
       "familyShareable" : false,
@@ -55,7 +10,7 @@
         {
           "description" : "One-time premium purchase for lifetime access.",
           "displayName" : "Premium Lifetime",
-          "locale" : "en_PK"
+          "locale" : "en_US"
         }
       ],
       "productID" : "com.mqldev.zoomapp.premium.lifetime",
@@ -69,14 +24,85 @@
     "_disableDialogs" : false,
     "_failTransactionsEnabled" : false,
     "_lastSynchronizedDate" : 0,
-    "_locale" : "en_PK",
+    "_locale" : "en_US",
     "_renewalRate" : 0,
-    "_storefront" : "PAK",
+    "_storefront" : "USA",
     "_timeRate" : 0
   },
-  "subscriptionGroups" : [],
+  "subscriptionGroups" : [
+    {
+      "id" : "58A86C6F-B84D-48F2-923A-B4432B5B9E8B",
+      "localizations" : [],
+      "name" : "Premium Plans",
+      "subscriptions" : [
+        {
+          "adHocOffers" : [],
+          "codeOffers" : [],
+          "displayPrice" : "1100.00",
+          "familyShareable" : false,
+          "groupNumber" : 3,
+          "internalID" : "64D1B757-30B3-4D8A-AE2A-83B6E2B9B4A7",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Unlock premium features with weekly access.",
+              "displayName" : "Premium Weekly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.mqldev.zoomapp.premium.weekly",
+          "recurringSubscriptionPeriod" : "P1W",
+          "referenceName" : "Premium Weekly",
+          "subscriptionGroupID" : "58A86C6F-B84D-48F2-923A-B4432B5B9E8B",
+          "type" : "RecurringSubscription"
+        },
+        {
+          "adHocOffers" : [],
+          "codeOffers" : [],
+          "displayPrice" : "2500.00",
+          "familyShareable" : false,
+          "groupNumber" : 2,
+          "internalID" : "5F0659F3-9E3E-4E7A-95A4-87F68F63AE64",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Unlock premium features with monthly access.",
+              "displayName" : "Premium Monthly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.mqldev.zoomapp.premium.monthly",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Premium Monthly",
+          "subscriptionGroupID" : "58A86C6F-B84D-48F2-923A-B4432B5B9E8B",
+          "type" : "RecurringSubscription"
+        },
+        {
+          "adHocOffers" : [],
+          "codeOffers" : [],
+          "displayPrice" : "9900.00",
+          "familyShareable" : false,
+          "groupNumber" : 1,
+          "internalID" : "CE6E8B9A-65D4-4E3A-9EB2-5B81A410F1E0",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Unlock premium features with yearly access.",
+              "displayName" : "Premium Yearly",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "com.mqldev.zoomapp.premium.yearly",
+          "recurringSubscriptionPeriod" : "P1Y",
+          "referenceName" : "Premium Yearly",
+          "subscriptionGroupID" : "58A86C6F-B84D-48F2-923A-B4432B5B9E8B",
+          "type" : "RecurringSubscription"
+        }
+      ]
+    }
+  ],
   "version" : {
-    "major" : 3,
+    "major" : 2,
     "minor" : 0
   }
 }

+ 137 - 9
zoom_app/ViewController.swift

@@ -283,6 +283,7 @@ class ViewController: NSViewController {
     private weak var paywallContinueLabel: NSTextField?
     private weak var paywallContinueButton: NSView?
     private var paywallPurchaseTask: Task<Void, Never>?
+    private var paywallRestoreTask: Task<Void, Never>?
     private var paywallContinueEnabled = true
     private var lastKnownPremiumAccess = false
     private var allScheduledMeetings: [ScheduledMeeting] = []
@@ -359,6 +360,11 @@ class ViewController: NSViewController {
             case failed(String)
         }
 
+        private enum CacheKey {
+            static let verificationTimes = "storekit.cachedVerificationTimes"
+        }
+        private let subscriptionFallbackWindow: TimeInterval = 72 * 60 * 60
+
         private(set) var productsByID: [String: Product] = [:]
         private(set) var activeProductIDs = Set<String>()
 
@@ -375,11 +381,31 @@ class ViewController: NSViewController {
         }
 
         func refreshProducts() async {
-            do {
-                let products = try await Product.products(for: PremiumPlan.allCases.map(\.rawValue))
-                productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
-            } catch {
-                productsByID = [:]
+            let productIDs = PremiumPlan.allCases.map(\.rawValue)
+            var loadedProducts: [Product] = []
+            var attempts = 0
+            let maxAttempts = 3
+
+            while attempts < maxAttempts {
+                attempts += 1
+                do {
+                    loadedProducts = try await Product.products(for: productIDs)
+                } catch {
+                    loadedProducts = []
+                }
+
+                if loadedProducts.isEmpty == false {
+                    break
+                }
+
+                if attempts < maxAttempts {
+                    // StoreKit can transiently return empty right after launch/network changes.
+                    try? await Task.sleep(nanoseconds: UInt64(attempts) * 500_000_000)
+                }
+            }
+
+            if loadedProducts.isEmpty == false {
+                productsByID = Dictionary(uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) })
             }
         }
 
@@ -410,6 +436,7 @@ class ViewController: NSViewController {
                 switch result {
                 case .success(let verification):
                     guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
+                    cacheVerifiedPremiumTransaction(transaction)
                     await transaction.finish()
                     await refreshEntitlements()
                     return .success
@@ -443,6 +470,7 @@ class ViewController: NSViewController {
             let previousHasPremiumAccess = hasPremiumAccess
             let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
             var active = Set<String>()
+            var latestInactiveIDs = Set<String>()
 
             for await entitlement in Transaction.currentEntitlements {
                 guard case .verified(let transaction) = entitlement else { continue }
@@ -456,9 +484,22 @@ class ViewController: NSViewController {
             // verified transaction exists for a non-consumable. Merge in latest transactions.
             for productID in allIDs {
                 guard let latest = await Transaction.latest(for: productID),
-                      case .verified(let transaction) = latest,
-                      Self.isTransactionActive(transaction) else { continue }
-                active.insert(productID)
+                      case .verified(let transaction) = latest else { continue }
+                if Self.isTransactionActive(transaction) {
+                    active.insert(productID)
+                } else {
+                    latestInactiveIDs.insert(productID)
+                }
+            }
+
+            if latestInactiveIDs.isEmpty == false {
+                removeCachedProductIDs(latestInactiveIDs)
+            }
+
+            if active.isEmpty {
+                active = cachedFallbackActiveProductIDs(validProductIDs: allIDs)
+            } else {
+                cacheActiveProductIDs(active)
             }
 
             activeProductIDs = active
@@ -468,6 +509,53 @@ class ViewController: NSViewController {
             }
         }
 
+        private func cacheVerifiedPremiumTransaction(_ transaction: Transaction) {
+            let productID = transaction.productID
+            guard PremiumPlan.allCases.map(\.rawValue).contains(productID) else { return }
+            cacheActiveProductIDs([productID])
+        }
+
+        private func cacheActiveProductIDs(_ ids: Set<String>) {
+            var cached = cachedVerificationTimes()
+            let now = Date().timeIntervalSince1970
+            for id in ids {
+                cached[id] = now
+            }
+            saveCachedVerificationTimes(cached)
+        }
+
+        private func removeCachedProductIDs(_ ids: Set<String>) {
+            var cached = cachedVerificationTimes()
+            for id in ids {
+                cached.removeValue(forKey: id)
+            }
+            saveCachedVerificationTimes(cached)
+        }
+
+        private func cachedFallbackActiveProductIDs(validProductIDs: Set<String>) -> Set<String> {
+            let cached = cachedVerificationTimes()
+            let now = Date().timeIntervalSince1970
+            let lifetimeID = PremiumPlan.lifetime.rawValue
+            return Set(
+                cached.compactMap { (productID, verifiedAt) in
+                    guard validProductIDs.contains(productID) else { return nil }
+                    if productID == lifetimeID {
+                        return productID
+                    }
+                    let age = now - verifiedAt
+                    return age <= subscriptionFallbackWindow ? productID : nil
+                }
+            )
+        }
+
+        private func cachedVerificationTimes() -> [String: TimeInterval] {
+            UserDefaults.standard.dictionary(forKey: CacheKey.verificationTimes) as? [String: TimeInterval] ?? [:]
+        }
+
+        private func saveCachedVerificationTimes(_ map: [String: TimeInterval]) {
+            UserDefaults.standard.set(map, forKey: CacheKey.verificationTimes)
+        }
+
         private static func isTransactionActive(_ transaction: Transaction) -> Bool {
             if transaction.revocationDate != nil { return false }
             if let expirationDate = transaction.expirationDate {
@@ -3482,6 +3570,18 @@ class ViewController: NSViewController {
             secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
         ])
         contentStack.addArrangedSubview(secureWrap)
+        contentStack.setCustomSpacing(8, after: secureWrap)
+
+        let restoreButton = NSButton(title: "Restore Purchases", target: self, action: #selector(paywallRestorePurchasesClicked(_:)))
+        restoreButton.translatesAutoresizingMaskIntoConstraints = false
+        restoreButton.isBordered = false
+        restoreButton.bezelStyle = .shadowlessSquare
+        restoreButton.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
+        restoreButton.contentTintColor = accentBlue
+        restoreButton.alignment = .center
+        restoreButton.heightAnchor.constraint(equalToConstant: 24).isActive = true
+        restoreButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
+        contentStack.addArrangedSubview(restoreButton)
 
         NSLayoutConstraint.activate([
             contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
@@ -3673,6 +3773,7 @@ class ViewController: NSViewController {
             applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
         }
         paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
+        updatePaywallContinueState(isLoading: false)
     }
 
     private func paywallOfferText(for plan: PremiumPlan) -> String {
@@ -3716,6 +3817,21 @@ class ViewController: NSViewController {
         startSelectedPlanPurchase()
     }
 
+    @objc private func paywallRestorePurchasesClicked(_ sender: Any?) {
+        paywallRestoreTask?.cancel()
+        updatePaywallContinueState(isLoading: true)
+        paywallRestoreTask = Task { [weak self] in
+            guard let self else { return }
+            let restoreResult = await self.storeKitCoordinator.restorePurchases()
+            await MainActor.run {
+                self.updatePaywallContinueState(isLoading: false)
+                self.refreshPaywallStoreUI()
+                self.updatePremiumButtons()
+                self.showSimpleAlert(title: "Restore Purchases", message: restoreResult)
+            }
+        }
+    }
+
     private func startSelectedPlanPurchase() {
         guard paywallContinueEnabled else { return }
         paywallPurchaseTask?.cancel()
@@ -3723,6 +3839,9 @@ class ViewController: NSViewController {
         let selectedPlan = selectedPremiumPlan
         paywallPurchaseTask = Task { [weak self] in
             guard let self else { return }
+            if self.storeKitCoordinator.productsByID[selectedPlan.rawValue] == nil {
+                await self.storeKitCoordinator.refreshProducts()
+            }
             let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
             await MainActor.run {
                 self.updatePaywallContinueState(isLoading: false)
@@ -3749,7 +3868,12 @@ class ViewController: NSViewController {
                 case .cancelled:
                     break
                 case .failed(let message):
-                    self.showSimpleAlert(title: "Purchase Failed", message: message)
+                    let fallbackMessage = "StoreKit did not return products for this plan yet. Wait a few seconds and try again, or reopen the app."
+                    if message.contains("Product not available") {
+                        self.showSimpleAlert(title: "Purchase Failed", message: fallbackMessage)
+                    } else {
+                        self.showSimpleAlert(title: "Purchase Failed", message: message)
+                    }
                 }
             }
         }
@@ -3766,6 +3890,10 @@ class ViewController: NSViewController {
             paywallContinueEnabled = false
             paywallContinueLabel?.stringValue = "Lifetime Premium Active"
             paywallContinueButton?.alphaValue = 0.75
+        } else if storeKitCoordinator.productsByID[selectedPremiumPlan.rawValue] == nil {
+            paywallContinueEnabled = false
+            paywallContinueLabel?.stringValue = "Store Unavailable"
+            paywallContinueButton?.alphaValue = 0.75
         } else {
             paywallContinueEnabled = true
             paywallContinueLabel?.stringValue = "Continue"