|
|
@@ -62,9 +62,9 @@ final class SubscriptionManager: ObservableObject {
|
|
|
max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
|
|
|
}
|
|
|
|
|
|
- /// Premium access confirmed by StoreKit after launch or purchase.
|
|
|
+ /// Premium access confirmed by StoreKit or a short post-purchase grace window.
|
|
|
var isPremiumActive: Bool {
|
|
|
- hasResolvedPremiumStatus && hasPremiumAccess
|
|
|
+ hasPremiumAccess
|
|
|
}
|
|
|
|
|
|
var canUseAIFeatures: Bool {
|
|
|
@@ -72,25 +72,133 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
|
|
|
|
private var inFlightProductLoad: Task<Void, Never>?
|
|
|
+ private var launchEntitlementTask: Task<Void, Never>?
|
|
|
+ private var entitlementRefreshTask: Task<Void, Never>?
|
|
|
+ private var isLaunchEntitlementRefreshInProgress = false
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
private let defaults: UserDefaults
|
|
|
+ private let entitlementCache: SubscriptionEntitlementCache
|
|
|
/// Keeps premium UI in sync after a verified purchase until StoreKit entitlements catch up.
|
|
|
private var pendingVerifiedPurchase: (plan: PaywallPlan, purchasedAt: Date)?
|
|
|
+ private enum PremiumRefreshMode {
|
|
|
+ /// StoreKit is the source of truth; lapse premium when nothing is active.
|
|
|
+ case authoritative
|
|
|
+ /// During launch only: may replay verified cache while StoreKit syncs.
|
|
|
+ case optimistic
|
|
|
+ }
|
|
|
+
|
|
|
private static let pendingPurchaseGraceInterval: TimeInterval = 24 * 60 * 60
|
|
|
|
|
|
- init(defaults: UserDefaults = .standard) {
|
|
|
+ init(
|
|
|
+ defaults: UserDefaults = .standard,
|
|
|
+ entitlementCache: SubscriptionEntitlementCache = SubscriptionEntitlementCache()
|
|
|
+ ) {
|
|
|
self.defaults = defaults
|
|
|
+ self.entitlementCache = entitlementCache
|
|
|
freeAIUsesConsumed = min(
|
|
|
defaults.integer(forKey: Self.freeAIUsesConsumedKey),
|
|
|
Self.maxFreeAIUses
|
|
|
)
|
|
|
+ applyVerifiedCacheIfAvailable(markResolved: true)
|
|
|
+ restorePersistedPendingPurchase()
|
|
|
|
|
|
observeAIFeatureUsage()
|
|
|
|
|
|
+ launchEntitlementTask = Task { await refreshPremiumAccessOnLaunch() }
|
|
|
Task { await listenForTransactionUpdates() }
|
|
|
- Task { await refreshPremiumAccess() }
|
|
|
Task { await loadProducts(presentLoadingUI: false) }
|
|
|
observeAppLifecycle()
|
|
|
+ observeAppTermination()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func observeAppTermination() {
|
|
|
+ NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
|
|
|
+ .sink { [weak self] _ in
|
|
|
+ self?.persistPremiumStateBeforeTermination()
|
|
|
+ }
|
|
|
+ .store(in: &cancellables)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func persistPremiumStateBeforeTermination() {
|
|
|
+ guard isPremiumActive,
|
|
|
+ let plan = activeRecurringPlan ?? activePlan,
|
|
|
+ let expiration = entitlementCache.loadVerified()?.expiration else { return }
|
|
|
+
|
|
|
+ entitlementCache.saveVerified(plan: plan, expiration: expiration)
|
|
|
+ }
|
|
|
+
|
|
|
+ @discardableResult
|
|
|
+ private func applyVerifiedCacheIfAvailable(markResolved: Bool) -> Bool {
|
|
|
+ guard let stored = entitlementCache.loadVerified(), stored.isValid else { return false }
|
|
|
+
|
|
|
+ hasPremiumAccess = true
|
|
|
+ hasManageableSubscription = true
|
|
|
+ if let plan = stored.paywallPlan {
|
|
|
+ activePlan = plan
|
|
|
+ activeRecurringPlan = plan
|
|
|
+ }
|
|
|
+ if markResolved {
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ private func activatePremium(
|
|
|
+ plan: PaywallPlan?,
|
|
|
+ expiration: Date?,
|
|
|
+ persist: Bool
|
|
|
+ ) {
|
|
|
+ hasPremiumAccess = true
|
|
|
+ hasManageableSubscription = true
|
|
|
+ activePlan = plan
|
|
|
+ activeRecurringPlan = plan
|
|
|
+ if persist, let plan, let expiration, expiration > Date() {
|
|
|
+ entitlementCache.saveVerified(plan: plan, expiration: expiration)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func isPendingPurchaseGraceActive() -> Bool {
|
|
|
+ guard let pending = pendingVerifiedPurchase else { return false }
|
|
|
+ return Date().timeIntervalSince(pending.purchasedAt) < Self.pendingPurchaseGraceInterval
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyPendingPurchaseGraceIfNeeded() -> Bool {
|
|
|
+ guard isPendingPurchaseGraceActive(), let pending = pendingVerifiedPurchase else { return false }
|
|
|
+
|
|
|
+ let fallbackExpiration = Date().addingTimeInterval(Self.pendingPurchaseGraceInterval)
|
|
|
+ activatePremium(plan: pending.plan, expiration: fallbackExpiration, persist: false)
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ private func restorePersistedPendingPurchase() {
|
|
|
+ guard let pending = entitlementCache.loadPendingPurchase() else { return }
|
|
|
+
|
|
|
+ let age = Date().timeIntervalSince(pending.purchasedAt)
|
|
|
+ guard age < Self.pendingPurchaseGraceInterval else {
|
|
|
+ entitlementCache.clearPendingPurchase()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ pendingVerifiedPurchase = pending
|
|
|
+ if !isPremiumActive {
|
|
|
+ applyPendingPurchaseGraceIfNeeded()
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func clearPersistedPendingPurchase() {
|
|
|
+ pendingVerifiedPurchase = nil
|
|
|
+ entitlementCache.clearPendingPurchase()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func deactivatePremium(clearStoredEntitlement: Bool) {
|
|
|
+ hasPremiumAccess = false
|
|
|
+ hasManageableSubscription = false
|
|
|
+ activePlan = nil
|
|
|
+ activeRecurringPlan = nil
|
|
|
+ if clearStoredEntitlement {
|
|
|
+ entitlementCache.clear()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
func recordSuccessfulAIUse() {
|
|
|
@@ -111,6 +219,8 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
|
|
|
|
func loadProducts(presentLoadingUI: Bool? = nil) async {
|
|
|
+ await launchEntitlementTask?.value
|
|
|
+
|
|
|
if let existing = inFlightProductLoad {
|
|
|
await existing.value
|
|
|
return
|
|
|
@@ -280,15 +390,15 @@ final class SubscriptionManager: ObservableObject {
|
|
|
purchaseError = .purchaseNotVerified
|
|
|
return false
|
|
|
}
|
|
|
- await transaction.finish()
|
|
|
applyOptimisticPremiumAccess(for: transaction)
|
|
|
+ await transaction.finish()
|
|
|
await refreshPremiumAccessWithRetry()
|
|
|
- if hasPremiumAccess {
|
|
|
+ if isPremiumActive {
|
|
|
NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
|
|
|
} else {
|
|
|
subscriptionSuccessMessage = "Purchase successful. If premium features don't unlock right away, tap Restore Purchases."
|
|
|
}
|
|
|
- return hasPremiumAccess
|
|
|
+ return isPremiumActive
|
|
|
case .userCancelled:
|
|
|
return false
|
|
|
case .pending:
|
|
|
@@ -315,8 +425,8 @@ final class SubscriptionManager: ObservableObject {
|
|
|
let hadPremium = isPremiumActive
|
|
|
do {
|
|
|
try await AppStore.sync()
|
|
|
- await refreshPremiumAccess()
|
|
|
- if hasPremiumAccess {
|
|
|
+ await enqueuePremiumRefresh(mode: .authoritative)
|
|
|
+ if isPremiumActive {
|
|
|
if !hadPremium {
|
|
|
subscriptionSuccessMessage = "Your purchases have been restored."
|
|
|
}
|
|
|
@@ -339,7 +449,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
if !missingAny, presentLoadingUI != true {
|
|
|
purchaseError = nil
|
|
|
await refreshIntroOfferEligibility()
|
|
|
- await refreshPremiumAccess()
|
|
|
+ await enqueuePremiumRefresh()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -361,7 +471,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
|
productsByID = map
|
|
|
await refreshIntroOfferEligibility()
|
|
|
- await refreshPremiumAccess()
|
|
|
+ await enqueuePremiumRefresh()
|
|
|
} catch is ProductLoadError {
|
|
|
if presentLoadingUI == true {
|
|
|
purchaseError = .productsLoadTimeout
|
|
|
@@ -392,56 +502,189 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func refreshPremiumAccess() async {
|
|
|
+ private func enqueuePremiumRefresh(mode: PremiumRefreshMode = .authoritative) async {
|
|
|
+ if let existing = entitlementRefreshTask {
|
|
|
+ await existing.value
|
|
|
+ }
|
|
|
+
|
|
|
+ let task = Task { @MainActor in
|
|
|
+ await performPremiumRefresh(mode: mode)
|
|
|
+ }
|
|
|
+ entitlementRefreshTask = task
|
|
|
+ await task.value
|
|
|
+ entitlementRefreshTask = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private struct ResolvedEntitlement {
|
|
|
var hasPremium = false
|
|
|
var manageableSubscription = false
|
|
|
- var resolvedPlan: PaywallPlan?
|
|
|
- var resolvedRecurringPlan: PaywallPlan?
|
|
|
- var revokedPendingPlan = false
|
|
|
+ var plan: PaywallPlan?
|
|
|
+ var recurringPlan: PaywallPlan?
|
|
|
+ var expiration: Date?
|
|
|
+ var storeKitConfirmedInactive = false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func performPremiumRefresh(mode: PremiumRefreshMode) async {
|
|
|
+ var resolved = await resolveEntitlementsFromStoreKit()
|
|
|
+
|
|
|
+ if resolved.hasPremium {
|
|
|
+ clearPersistedPendingPurchase()
|
|
|
+ activatePremium(
|
|
|
+ plan: resolved.recurringPlan ?? resolved.plan,
|
|
|
+ expiration: resolved.expiration,
|
|
|
+ persist: true
|
|
|
+ )
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- for await result in Transaction.currentEntitlements {
|
|
|
- guard case .verified(let transaction) = result else { continue }
|
|
|
- guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
|
|
|
+ if applyPendingPurchaseGraceIfNeeded() {
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- if transaction.revocationDate != nil {
|
|
|
- if pendingVerifiedPurchase?.plan == SubscriptionProductID.plan(for: transaction.productID) {
|
|
|
- revokedPendingPlan = true
|
|
|
- pendingVerifiedPurchase = nil
|
|
|
- }
|
|
|
+ if mode == .optimistic, applyVerifiedCacheIfAvailable(markResolved: false) {
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if resolved.storeKitConfirmedInactive || mode == .authoritative {
|
|
|
+ deactivatePremium(clearStoredEntitlement: true)
|
|
|
+ } else {
|
|
|
+ hasPremiumAccess = false
|
|
|
+ hasManageableSubscription = false
|
|
|
+ activePlan = nil
|
|
|
+ activeRecurringPlan = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resolveEntitlementsFromStoreKit() async -> ResolvedEntitlement {
|
|
|
+ let current = await scanEntitlements(in: Transaction.currentEntitlements)
|
|
|
+ if current.hasPremium {
|
|
|
+ return current
|
|
|
+ }
|
|
|
+
|
|
|
+ let fromStatus = await resolveFromSubscriptionStatuses()
|
|
|
+ if fromStatus.hasPremium {
|
|
|
+ return fromStatus
|
|
|
+ }
|
|
|
+
|
|
|
+ var resolved = fromStatus
|
|
|
+ resolved.storeKitConfirmedInactive = fromStatus.storeKitConfirmedInactive
|
|
|
+ || (!fromStatus.hasPremium && hasAllProductsLoaded)
|
|
|
+ return resolved
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resolveFromSubscriptionStatuses() async -> ResolvedEntitlement {
|
|
|
+ var resolved = ResolvedEntitlement()
|
|
|
+ var sawAnyStatus = false
|
|
|
+ var sawInactiveStatus = false
|
|
|
+
|
|
|
+ for plan in PaywallPlan.allCases {
|
|
|
+ guard let product = productsByID[plan.productID],
|
|
|
+ let subscription = product.subscription else { continue }
|
|
|
+
|
|
|
+ let statuses: [Product.SubscriptionInfo.Status]
|
|
|
+ do {
|
|
|
+ statuses = try await subscription.status
|
|
|
+ } catch {
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
+ for status in statuses {
|
|
|
+ sawAnyStatus = true
|
|
|
+ guard case .verified(let transaction) = status.transaction else { continue }
|
|
|
+
|
|
|
+ switch status.state {
|
|
|
+ case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
+ resolved.hasPremium = true
|
|
|
+ resolved.manageableSubscription = true
|
|
|
+ resolved.plan = plan
|
|
|
+ resolved.recurringPlan = plan
|
|
|
+
|
|
|
+ if let expiration = transaction.expirationDate {
|
|
|
+ resolved.expiration = latestExpiration(
|
|
|
+ current: resolved.expiration,
|
|
|
+ candidate: expiration
|
|
|
+ )
|
|
|
+ } else if case .verified(let renewalInfo) = status.renewalInfo {
|
|
|
+ resolved.expiration = latestExpiration(
|
|
|
+ current: resolved.expiration,
|
|
|
+ candidate: renewalInfo.renewalDate
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case .expired, .revoked:
|
|
|
+ sawInactiveStatus = true
|
|
|
+ default:
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if !resolved.hasPremium, sawAnyStatus, sawInactiveStatus {
|
|
|
+ resolved.storeKitConfirmedInactive = true
|
|
|
+ }
|
|
|
+
|
|
|
+ return resolved
|
|
|
+ }
|
|
|
+
|
|
|
+ private func scanEntitlements(in transactions: Transaction.Transactions) async -> ResolvedEntitlement {
|
|
|
+ var resolved = ResolvedEntitlement()
|
|
|
+ var bestMatch: (plan: PaywallPlan, expiration: Date)?
|
|
|
+
|
|
|
+ for await result in transactions {
|
|
|
+ guard case .verified(let transaction) = result else { continue }
|
|
|
+ guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
|
|
|
+ guard transaction.revocationDate == nil else { continue }
|
|
|
+
|
|
|
guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
|
|
|
- guard await isRecurringSubscriptionEntitled(transaction: transaction) else {
|
|
|
+ guard isTransactionEntitled(transaction) else { continue }
|
|
|
+
|
|
|
+ let expiration = transaction.expirationDate ?? .distantFuture
|
|
|
+ if let bestMatch, bestMatch.expiration >= expiration {
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
- manageableSubscription = true
|
|
|
- hasPremium = true
|
|
|
+ bestMatch = (plan, expiration)
|
|
|
+ resolved.hasPremium = true
|
|
|
+ resolved.manageableSubscription = true
|
|
|
pendingVerifiedPurchase = nil
|
|
|
- if resolvedRecurringPlan == nil {
|
|
|
- resolvedRecurringPlan = plan
|
|
|
- }
|
|
|
- if resolvedPlan == nil {
|
|
|
- resolvedPlan = plan
|
|
|
- }
|
|
|
+ resolved.expiration = latestExpiration(current: resolved.expiration, candidate: transaction.expirationDate)
|
|
|
+ resolved.recurringPlan = plan
|
|
|
+ resolved.plan = plan
|
|
|
}
|
|
|
|
|
|
- if !hasPremium,
|
|
|
- !revokedPendingPlan,
|
|
|
- let pending = pendingVerifiedPurchase,
|
|
|
- Date().timeIntervalSince(pending.purchasedAt) < Self.pendingPurchaseGraceInterval {
|
|
|
- hasPremium = true
|
|
|
- manageableSubscription = true
|
|
|
- resolvedPlan = pending.plan
|
|
|
- resolvedRecurringPlan = pending.plan
|
|
|
+ return resolved
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshPremiumAccess(mode: PremiumRefreshMode = .authoritative) async {
|
|
|
+ await enqueuePremiumRefresh(mode: mode)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func latestExpiration(current: Date?, candidate: Date?) -> Date? {
|
|
|
+ switch (current, candidate) {
|
|
|
+ case let (current?, candidate?) where candidate > current:
|
|
|
+ candidate
|
|
|
+ case let (current?, _):
|
|
|
+ current
|
|
|
+ case (nil, let candidate?):
|
|
|
+ candidate
|
|
|
+ case (nil, nil):
|
|
|
+ nil
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- hasPremiumAccess = hasPremium
|
|
|
- hasManageableSubscription = manageableSubscription
|
|
|
- activePlan = resolvedPlan
|
|
|
- activeRecurringPlan = resolvedRecurringPlan
|
|
|
- hasResolvedPremiumStatus = true
|
|
|
+ private func isTransactionEntitled(_ transaction: Transaction) -> Bool {
|
|
|
+ guard transaction.revocationDate == nil else { return false }
|
|
|
+
|
|
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
+ return expirationDate > Date()
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verified, non-revoked current entitlements without an expiration are active.
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
private func applyOptimisticPremiumAccess(for transaction: Transaction) {
|
|
|
@@ -450,11 +693,10 @@ final class SubscriptionManager: ObservableObject {
|
|
|
let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
|
|
|
|
|
|
pendingVerifiedPurchase = (plan: plan, purchasedAt: Date())
|
|
|
- hasPremiumAccess = true
|
|
|
hasResolvedPremiumStatus = true
|
|
|
- activePlan = plan
|
|
|
- hasManageableSubscription = true
|
|
|
- activeRecurringPlan = plan
|
|
|
+ entitlementCache.savePendingPurchase(plan: plan)
|
|
|
+ let expiration = transaction.expirationDate ?? Date().addingTimeInterval(30 * 24 * 60 * 60)
|
|
|
+ activatePremium(plan: plan, expiration: expiration, persist: true)
|
|
|
}
|
|
|
|
|
|
private func refreshIntroOfferEligibility() async {
|
|
|
@@ -472,74 +714,56 @@ final class SubscriptionManager: ObservableObject {
|
|
|
introOfferEligibleByPlan = eligibility
|
|
|
}
|
|
|
|
|
|
- private func refreshPremiumAccessWithRetry() async {
|
|
|
- await refreshPremiumAccess()
|
|
|
- guard !hasPremiumAccess else { return }
|
|
|
+ private func refreshPremiumAccessWithRetry(mode: PremiumRefreshMode = .authoritative) async {
|
|
|
+ await refreshPremiumAccess(mode: mode)
|
|
|
+ guard !isPremiumActive else { return }
|
|
|
|
|
|
for _ in 0..<6 {
|
|
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
|
- await refreshPremiumAccess()
|
|
|
- if hasPremiumAccess { return }
|
|
|
+ await refreshPremiumAccess(mode: mode)
|
|
|
+ if isPremiumActive { return }
|
|
|
}
|
|
|
|
|
|
- if let pending = pendingVerifiedPurchase,
|
|
|
- Date().timeIntervalSince(pending.purchasedAt) < Self.pendingPurchaseGraceInterval {
|
|
|
- hasPremiumAccess = true
|
|
|
+ if applyPendingPurchaseGraceIfNeeded() {
|
|
|
hasResolvedPremiumStatus = true
|
|
|
- hasManageableSubscription = true
|
|
|
- activePlan = pending.plan
|
|
|
- activeRecurringPlan = pending.plan
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func isRecurringSubscriptionEntitled(transaction: Transaction) async -> Bool {
|
|
|
- guard transaction.revocationDate == nil else { return false }
|
|
|
+ private func refreshPremiumAccessOnLaunch() async {
|
|
|
+ isLaunchEntitlementRefreshInProgress = true
|
|
|
+ defer { isLaunchEntitlementRefreshInProgress = false }
|
|
|
|
|
|
- let productID = transaction.productID
|
|
|
- let product: Product?
|
|
|
- if let cached = productsByID[productID] {
|
|
|
- product = cached
|
|
|
- } else if let loaded = try? await Product.products(for: [productID]).first {
|
|
|
- product = loaded
|
|
|
- } else {
|
|
|
- product = nil
|
|
|
- }
|
|
|
+ applyVerifiedCacheIfAvailable(markResolved: true)
|
|
|
|
|
|
- if let product, let subscription = product.subscription {
|
|
|
- do {
|
|
|
- let statuses = try await subscription.status
|
|
|
- if statuses.contains(where: {
|
|
|
- switch $0.state {
|
|
|
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
- return true
|
|
|
- case .expired, .revoked:
|
|
|
- return false
|
|
|
- default:
|
|
|
- return false
|
|
|
- }
|
|
|
- }) {
|
|
|
- return true
|
|
|
- }
|
|
|
- if statuses.contains(where: { $0.state == .expired || $0.state == .revoked }) {
|
|
|
- return false
|
|
|
- }
|
|
|
- } catch {
|
|
|
- // Fall through to expiration-date check when status is unavailable.
|
|
|
- }
|
|
|
+ for _ in 0..<6 where !isPremiumActive {
|
|
|
+ await enqueuePremiumRefresh(mode: .optimistic)
|
|
|
+ if isPremiumActive { break }
|
|
|
+ try? await Task.sleep(nanoseconds: 500_000_000)
|
|
|
}
|
|
|
|
|
|
- // Verified entitlement with a future expiration is still active when metadata/status is unreachable.
|
|
|
- if let expirationDate = transaction.expirationDate {
|
|
|
- return expirationDate > Date()
|
|
|
+ await enqueuePremiumRefresh(mode: .authoritative)
|
|
|
+
|
|
|
+ if !hasResolvedPremiumStatus {
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
}
|
|
|
|
|
|
- return false
|
|
|
+ #if DEBUG
|
|
|
+ let stored = entitlementCache.loadVerified()
|
|
|
+ print(
|
|
|
+ """
|
|
|
+ [SubscriptionManager] Launch entitlement resolved: \
|
|
|
+ premium=\(isPremiumActive), stored=\(stored?.isValid == true), \
|
|
|
+ plan=\(activePlan?.rawValue ?? "none")
|
|
|
+ """
|
|
|
+ )
|
|
|
+ #endif
|
|
|
}
|
|
|
|
|
|
private func observeAppLifecycle() {
|
|
|
NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
|
|
|
.sink { [weak self] _ in
|
|
|
- Task { await self?.refreshPremiumAccess() }
|
|
|
+ guard let self, !self.isLaunchEntitlementRefreshInProgress else { return }
|
|
|
+ Task { await self.enqueuePremiumRefresh(mode: .authoritative) }
|
|
|
}
|
|
|
.store(in: &cancellables)
|
|
|
}
|
|
|
@@ -548,13 +772,12 @@ final class SubscriptionManager: ObservableObject {
|
|
|
for await update in Transaction.updates {
|
|
|
switch update {
|
|
|
case .verified(let transaction):
|
|
|
- if transaction.revocationDate != nil,
|
|
|
- let plan = SubscriptionProductID.plan(for: transaction.productID),
|
|
|
- pendingVerifiedPurchase?.plan == plan {
|
|
|
+ if SubscriptionProductID.all.contains(transaction.productID),
|
|
|
+ transaction.revocationDate != nil {
|
|
|
pendingVerifiedPurchase = nil
|
|
|
}
|
|
|
await transaction.finish()
|
|
|
- await refreshPremiumAccess()
|
|
|
+ await enqueuePremiumRefresh(mode: .authoritative)
|
|
|
case .unverified(_, let error):
|
|
|
#if DEBUG
|
|
|
print("[SubscriptionManager] Unverified transaction update: \(error)")
|