|
|
@@ -37,15 +37,11 @@ final class SubscriptionManager: ObservableObject {
|
|
|
@Published private(set) var hasResolvedPremiumStatus = false
|
|
|
@Published private(set) var activePremiumPlan: PaywallPlan?
|
|
|
@Published private(set) var productLoadError: String?
|
|
|
- @Published private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
|
|
|
@Published private(set) var hasEverPurchasedPremium = false
|
|
|
@Published var purchaseError: PurchaseError?
|
|
|
|
|
|
var availablePaywallPlans: [PaywallPlan] {
|
|
|
- if hasEverPurchasedPremium {
|
|
|
- return PaywallPlan.allCases.filter { $0 != .lifetime }
|
|
|
- }
|
|
|
- return PaywallPlan.allCases
|
|
|
+ PaywallPlan.allCases
|
|
|
}
|
|
|
|
|
|
var isResolvingEntitlements: Bool {
|
|
|
@@ -66,21 +62,23 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
private var inFlightProductLoad: Task<Void, Never>?
|
|
|
private var initialEntitlementTask: Task<Void, Never>?
|
|
|
+ private var entitlementRefreshTask: Task<Void, Never>?
|
|
|
private var hasStarted = false
|
|
|
+ private var cancellables = Set<AnyCancellable>()
|
|
|
+ private var lastBackgroundEntitlementRefresh: Date?
|
|
|
+
|
|
|
+ /// Minimum time between entitlement re-checks when the app regains focus.
|
|
|
+ private static let backgroundRefreshInterval: TimeInterval = 120
|
|
|
|
|
|
init() {
|
|
|
- hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumKey)
|
|
|
hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
|
|
|
- if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
|
|
|
- let plan = PaywallPlan(rawValue: raw) {
|
|
|
- activePremiumPlan = plan
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
func start() {
|
|
|
guard !hasStarted else { return }
|
|
|
hasStarted = true
|
|
|
|
|
|
+ observeAppLifecycle()
|
|
|
Task { await listenForTransactionUpdates() }
|
|
|
|
|
|
initialEntitlementTask = Task {
|
|
|
@@ -89,23 +87,33 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- func ensureEntitlementsResolved() async {
|
|
|
- await initialEntitlementTask?.value
|
|
|
+ private func observeAppLifecycle() {
|
|
|
+ NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
|
|
|
+ .sink { [weak self] _ in
|
|
|
+ self?.refreshEntitlementsOnAppActive()
|
|
|
+ }
|
|
|
+ .store(in: &cancellables)
|
|
|
}
|
|
|
|
|
|
- func eligibleIntroOffer(for plan: PaywallPlan) -> Product.SubscriptionOffer? {
|
|
|
- guard let product = product(for: plan) else { return nil }
|
|
|
- return eligibleIntroOffer(for: product)
|
|
|
- }
|
|
|
+ private func refreshEntitlementsOnAppActive() {
|
|
|
+ guard hasStarted else { return }
|
|
|
|
|
|
- func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
|
|
|
- guard !hasEverPurchasedPremium,
|
|
|
- introOfferEligibleByProductID[product.id] == true,
|
|
|
- let offer = product.subscription?.introductoryOffer,
|
|
|
- offer.paymentMode == .freeTrial else {
|
|
|
- return nil
|
|
|
+ if let lastRefresh = lastBackgroundEntitlementRefresh,
|
|
|
+ Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ entitlementRefreshTask?.cancel()
|
|
|
+ entitlementRefreshTask = Task {
|
|
|
+ // Local StoreKit entitlements only — AppStore.sync() prompts for Apple ID
|
|
|
+ // and can loop in Xcode when focus returns after dismissing the dialog.
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ lastBackgroundEntitlementRefresh = Date()
|
|
|
}
|
|
|
- return offer
|
|
|
+ }
|
|
|
+
|
|
|
+ func ensureEntitlementsResolved() async {
|
|
|
+ await initialEntitlementTask?.value
|
|
|
}
|
|
|
|
|
|
func loadProducts(presentLoadingUI: Bool? = nil) async {
|
|
|
@@ -142,16 +150,6 @@ final class SubscriptionManager: ObservableObject {
|
|
|
return plan.fallbackBillingDescription(config: config)
|
|
|
}
|
|
|
|
|
|
- if !hasEverPurchasedPremium,
|
|
|
- let subscription = product.subscription,
|
|
|
- let intro = subscription.introductoryOffer,
|
|
|
- intro.paymentMode == .freeTrial,
|
|
|
- introOfferEligibleByProductID[product.id] == true {
|
|
|
- let trialPeriod = formattedPeriod(intro.period)
|
|
|
- let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
|
|
|
- return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
|
|
|
- }
|
|
|
-
|
|
|
switch plan {
|
|
|
case .monthly:
|
|
|
return "Billed at \(product.displayPrice) every month"
|
|
|
@@ -290,7 +288,6 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
|
productsByID = map
|
|
|
productLoadError = nil
|
|
|
- await refreshIntroOfferEligibility()
|
|
|
await refreshPremiumAccess()
|
|
|
} catch is ProductLoadError {
|
|
|
productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
|
|
|
@@ -349,28 +346,24 @@ final class SubscriptionManager: ObservableObject {
|
|
|
await refreshPurchaseHistory()
|
|
|
}
|
|
|
|
|
|
- /// Re-reads entitlements from StoreKit, then syncs with the App Store when needed.
|
|
|
- /// A verified purchase is never downgraded while the network is still catching up.
|
|
|
+ /// Re-reads entitlements from StoreKit, then syncs with the App Store when a
|
|
|
+ /// purchase was just verified but entitlements have not caught up yet.
|
|
|
private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
|
|
|
await refreshPremiumAccess(trustedPlan: trustedPlan)
|
|
|
|
|
|
guard let trustedPlan, !hasPremiumAccess else { return }
|
|
|
|
|
|
- for attempt in 1...3 {
|
|
|
- do {
|
|
|
- try await syncAppStoreWithTimeout(seconds: 15)
|
|
|
- } catch {
|
|
|
- continue
|
|
|
- }
|
|
|
-
|
|
|
- await refreshPremiumAccess(trustedPlan: trustedPlan)
|
|
|
- if hasPremiumAccess { return }
|
|
|
-
|
|
|
- let delayNanoseconds = UInt64(attempt) * 1_500_000_000
|
|
|
- try? await Task.sleep(nanoseconds: delayNanoseconds)
|
|
|
+ do {
|
|
|
+ try await syncAppStoreWithTimeout(seconds: 15)
|
|
|
+ } catch {
|
|
|
+ applyTrustedPlan(trustedPlan)
|
|
|
+ return
|
|
|
}
|
|
|
|
|
|
- applyTrustedPlan(trustedPlan)
|
|
|
+ await refreshPremiumAccess(trustedPlan: trustedPlan)
|
|
|
+ if !hasPremiumAccess {
|
|
|
+ applyTrustedPlan(trustedPlan)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
|
|
|
@@ -394,6 +387,13 @@ final class SubscriptionManager: ObservableObject {
|
|
|
persistPremiumCache()
|
|
|
}
|
|
|
|
|
|
+ private func revokePremiumAccess() {
|
|
|
+ activePremiumPlan = nil
|
|
|
+ hasPremiumAccess = false
|
|
|
+ hasResolvedPremiumStatus = true
|
|
|
+ persistPremiumCache()
|
|
|
+ }
|
|
|
+
|
|
|
private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
|
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
|
group.addTask {
|
|
|
@@ -438,22 +438,11 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
private func persistPremiumCache() {
|
|
|
UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
|
|
|
- UserDefaults.standard.set(activePremiumPlan?.rawValue, forKey: Self.cachedPremiumPlanKey)
|
|
|
- }
|
|
|
-
|
|
|
- private func refreshIntroOfferEligibility() async {
|
|
|
- var eligibility: [String: Bool] = [:]
|
|
|
-
|
|
|
- for product in productsByID.values {
|
|
|
- guard let subscription = product.subscription,
|
|
|
- subscription.introductoryOffer != nil else {
|
|
|
- continue
|
|
|
- }
|
|
|
- let groupID = subscription.subscriptionGroupID
|
|
|
- eligibility[product.id] = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
|
|
|
+ if let plan = activePremiumPlan?.rawValue {
|
|
|
+ UserDefaults.standard.set(plan, forKey: Self.cachedPremiumPlanKey)
|
|
|
+ } else {
|
|
|
+ UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
|
|
|
}
|
|
|
-
|
|
|
- introOfferEligibleByProductID = eligibility
|
|
|
}
|
|
|
|
|
|
private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
|
|
|
@@ -474,10 +463,10 @@ final class SubscriptionManager: ObservableObject {
|
|
|
if current == .lifetime || candidate == .lifetime {
|
|
|
return .lifetime
|
|
|
}
|
|
|
- if let current {
|
|
|
- if current == .yearly || candidate == .yearly {
|
|
|
- return .yearly
|
|
|
- }
|
|
|
+ if current == .yearly || candidate == .yearly {
|
|
|
+ return .yearly
|
|
|
+ }
|
|
|
+ if current == .monthly || candidate == .monthly {
|
|
|
return .monthly
|
|
|
}
|
|
|
return candidate
|
|
|
@@ -500,89 +489,36 @@ final class SubscriptionManager: ObservableObject {
|
|
|
resolvedPlan = preferredPlan(resolvedPlan, plan)
|
|
|
}
|
|
|
|
|
|
- if resolvedPlan == nil, await hasActiveSubscriptionStatus() {
|
|
|
- resolvedPlan = await subscriptionPlanFromProductStatus()
|
|
|
- }
|
|
|
-
|
|
|
return resolvedPlan
|
|
|
}
|
|
|
|
|
|
- private func subscriptionPlanFromProductStatus() async -> PaywallPlan? {
|
|
|
- if productsByID.isEmpty {
|
|
|
- do {
|
|
|
- let loaded = try await Product.products(for: SubscriptionProductID.all)
|
|
|
- var map: [String: Product] = [:]
|
|
|
- for product in loaded { map[product.id] = product }
|
|
|
- productsByID = map
|
|
|
- } catch {
|
|
|
- return nil
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- for product in productsByID.values {
|
|
|
- guard let subscription = product.subscription else { continue }
|
|
|
- guard let plan = SubscriptionProductID.plan(for: product.id), plan != .lifetime else { continue }
|
|
|
-
|
|
|
- do {
|
|
|
- let statuses = try await subscription.status
|
|
|
- let isActive = statuses.contains { status in
|
|
|
- switch status.state {
|
|
|
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
- return true
|
|
|
- case .expired, .revoked:
|
|
|
- return false
|
|
|
- default:
|
|
|
- return false
|
|
|
- }
|
|
|
- }
|
|
|
- if isActive { return plan }
|
|
|
- } catch {
|
|
|
- continue
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return nil
|
|
|
- }
|
|
|
-
|
|
|
- private func hasActiveSubscriptionStatus() async -> Bool {
|
|
|
- await subscriptionPlanFromProductStatus() != nil
|
|
|
- }
|
|
|
-
|
|
|
private func listenForTransactionUpdates() async {
|
|
|
for await update in Transaction.updates {
|
|
|
guard case .verified(let transaction) = update else { continue }
|
|
|
|
|
|
+ if isRevokedAppTransaction(transaction) {
|
|
|
+ revokePremiumAccess()
|
|
|
+ await transaction.finish()
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
let trustedPlan = planGrantingPremium(from: transaction)
|
|
|
if let trustedPlan {
|
|
|
applyPremiumAccess(from: transaction)
|
|
|
}
|
|
|
|
|
|
await transaction.finish()
|
|
|
- await reconcilePremiumAccess(trustedPlan: trustedPlan)
|
|
|
- }
|
|
|
- }
|
|
|
|
|
|
- private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
|
|
|
- switch period.unit {
|
|
|
- case .day where period.value == 1: "1 Day"
|
|
|
- case .day: "\(period.value) Days"
|
|
|
- case .week where period.value == 1: "1 Week"
|
|
|
- case .week: "\(period.value) Weeks"
|
|
|
- case .month where period.value == 1: "1 Month"
|
|
|
- case .month: "\(period.value) Months"
|
|
|
- case .year where period.value == 1: "1 Year"
|
|
|
- case .year: "\(period.value) Years"
|
|
|
- @unknown default: "\(period.value) Days"
|
|
|
+ if trustedPlan != nil {
|
|
|
+ await reconcilePremiumAccess(trustedPlan: trustedPlan)
|
|
|
+ } else {
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
|
|
|
- switch period.unit {
|
|
|
- case .day: period.value == 1 ? "day" : "\(period.value) days"
|
|
|
- case .week: period.value == 1 ? "week" : "\(period.value) weeks"
|
|
|
- case .month: period.value == 1 ? "month" : "\(period.value) months"
|
|
|
- case .year: period.value == 1 ? "year" : "\(period.value) years"
|
|
|
- @unknown default: "billing period"
|
|
|
- }
|
|
|
+ private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
|
|
|
+ SubscriptionProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
|
|
|
}
|
|
|
}
|