|
|
@@ -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"
|