|
|
@@ -99,6 +99,12 @@ enum StoreProductID {
|
|
|
static let all = AppStoreConfig.ProductID.all
|
|
|
}
|
|
|
|
|
|
+enum PremiumAccessKind: String {
|
|
|
+ case none
|
|
|
+ case subscription
|
|
|
+ case lifetime
|
|
|
+}
|
|
|
+
|
|
|
enum StoreError: LocalizedError {
|
|
|
case productNotFound
|
|
|
case failedVerification
|
|
|
@@ -118,10 +124,21 @@ final class StoreManager {
|
|
|
static let shared = StoreManager()
|
|
|
|
|
|
private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
|
|
|
+ private static let cachedPremiumKindKey = "StoreManager.cachedPremiumAccessKind"
|
|
|
|
|
|
private(set) var products: [Product] = []
|
|
|
private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
|
|
|
+ private(set) var premiumAccessKind: PremiumAccessKind = {
|
|
|
+ guard let raw = UserDefaults.standard.string(forKey: cachedPremiumKindKey),
|
|
|
+ let kind = PremiumAccessKind(rawValue: raw) else {
|
|
|
+ return .none
|
|
|
+ }
|
|
|
+ return kind
|
|
|
+ }()
|
|
|
+
|
|
|
var isPro: Bool { isPremium }
|
|
|
+ var hasActiveSubscription: Bool { premiumAccessKind == .subscription }
|
|
|
+ var hasLifetimeAccess: Bool { premiumAccessKind == .lifetime }
|
|
|
private(set) var isLoadingProducts = false
|
|
|
private(set) var isPurchasing = false
|
|
|
private(set) var isResolvingEntitlements = true
|
|
|
@@ -284,18 +301,21 @@ final class StoreManager {
|
|
|
}
|
|
|
|
|
|
func refreshEntitlementsOnly() async {
|
|
|
- applyPremiumStatus(await hasActivePremiumAccess())
|
|
|
+ applyPremiumStatus(await resolvePremiumAccessKind())
|
|
|
}
|
|
|
|
|
|
func refreshPremiumStatus() async {
|
|
|
await refreshIntroOfferEligibility()
|
|
|
- applyPremiumStatus(await hasActivePremiumAccess())
|
|
|
+ applyPremiumStatus(await resolvePremiumAccessKind())
|
|
|
}
|
|
|
|
|
|
- private func applyPremiumStatus(_ hasPremium: Bool) {
|
|
|
- let didChange = hasPremium != isPremium
|
|
|
+ private func applyPremiumStatus(_ kind: PremiumAccessKind) {
|
|
|
+ let hasPremium = kind != .none
|
|
|
+ let didChange = hasPremium != isPremium || kind != premiumAccessKind
|
|
|
isPremium = hasPremium
|
|
|
+ premiumAccessKind = hasPremium ? kind : .none
|
|
|
UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
|
|
|
+ UserDefaults.standard.set(premiumAccessKind.rawValue, forKey: Self.cachedPremiumKindKey)
|
|
|
|
|
|
if didChange {
|
|
|
NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
@@ -303,38 +323,35 @@ final class StoreManager {
|
|
|
postStoreStateDidChange()
|
|
|
}
|
|
|
|
|
|
- private func hasActivePremiumAccess() async -> Bool {
|
|
|
- if await hasEntitlementFromCurrentEntitlements() {
|
|
|
- return true
|
|
|
+ private func resolvePremiumAccessKind() async -> PremiumAccessKind {
|
|
|
+ if await hasActiveEntitlement(for: StoreProductID.lifetime) {
|
|
|
+ return .lifetime
|
|
|
+ }
|
|
|
+ if await hasActiveEntitlement(for: StoreProductID.monthly) {
|
|
|
+ return .subscription
|
|
|
}
|
|
|
- if await hasEntitlementFromLatestTransactions() {
|
|
|
- return true
|
|
|
+ if await hasActiveEntitlement(for: StoreProductID.yearly) {
|
|
|
+ return .subscription
|
|
|
}
|
|
|
if await hasEntitlementFromSubscriptionStatus() {
|
|
|
- return true
|
|
|
+ return .subscription
|
|
|
}
|
|
|
- return false
|
|
|
+ return .none
|
|
|
}
|
|
|
|
|
|
- private func hasEntitlementFromCurrentEntitlements() async -> Bool {
|
|
|
+ private func hasActiveEntitlement(for productID: String) async -> Bool {
|
|
|
for await result in Transaction.currentEntitlements {
|
|
|
guard let transaction = try? checkVerified(result) else { continue }
|
|
|
- if isActivePremiumTransaction(transaction) {
|
|
|
+ if transaction.productID == productID, isActivePremiumTransaction(transaction) {
|
|
|
return true
|
|
|
}
|
|
|
}
|
|
|
- return false
|
|
|
- }
|
|
|
|
|
|
- private func hasEntitlementFromLatestTransactions() async -> Bool {
|
|
|
- for productID in StoreProductID.all {
|
|
|
- guard let result = await Transaction.latest(for: productID),
|
|
|
- let transaction = try? checkVerified(result) else { continue }
|
|
|
- if isActivePremiumTransaction(transaction) {
|
|
|
- return true
|
|
|
- }
|
|
|
+ guard let result = await Transaction.latest(for: productID),
|
|
|
+ let transaction = try? checkVerified(result) else {
|
|
|
+ return false
|
|
|
}
|
|
|
- return false
|
|
|
+ return isActivePremiumTransaction(transaction)
|
|
|
}
|
|
|
|
|
|
private func hasEntitlementFromSubscriptionStatus() async -> Bool {
|
|
|
@@ -1136,6 +1153,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
config: config,
|
|
|
introOffer: store.eligibleIntroOffer(for: selectedPlan)
|
|
|
)
|
|
|
+ refreshTrustItemsForSelectedPlan()
|
|
|
refreshPurchaseState()
|
|
|
}
|
|
|
|
|
|
@@ -1150,14 +1168,37 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
|
|
|
|
private func refreshPrimaryFooterLink() {
|
|
|
- let isPro = StoreManager.shared.isPro
|
|
|
- continueFreePlanLink.isHidden = isPro
|
|
|
- manageSubscriptionLink.isHidden = !isPro
|
|
|
- premiumCloseButton.isHidden = !isPro
|
|
|
+ let store = StoreManager.shared
|
|
|
+ let showContinue = !store.isPro
|
|
|
+ let showManage = store.hasActiveSubscription
|
|
|
+
|
|
|
+ continueFreePlanLink.isHidden = !showContinue
|
|
|
+ manageSubscriptionLink.isHidden = !showManage
|
|
|
+ premiumCloseButton.isHidden = !store.isPro
|
|
|
+ primaryFooterLinkCell?.isHidden = !showContinue && !showManage
|
|
|
primaryFooterLinkCell?.needsLayout = true
|
|
|
primaryFooterLinkCell?.layoutSubtreeIfNeeded()
|
|
|
}
|
|
|
|
|
|
+ private func refreshTrustItemsForSelectedPlan() {
|
|
|
+ guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
|
|
|
+
|
|
|
+ let cancelItem = paywallConfig.trustItems[1]
|
|
|
+ if selectedPlan == .lifetime {
|
|
|
+ trustItemViews[1].update(
|
|
|
+ iconName: "checkmark.seal.fill",
|
|
|
+ title: "One-Time Purchase",
|
|
|
+ subtitle: "Pay once, no recurring charges."
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ trustItemViews[1].update(
|
|
|
+ iconName: cancelItem.icon,
|
|
|
+ title: cancelItem.title,
|
|
|
+ subtitle: cancelItem.subtitle
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
func refreshStoreState() {
|
|
|
refreshProductDisplay()
|
|
|
}
|
|
|
@@ -1464,6 +1505,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
config: paywallConfig,
|
|
|
introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
|
|
|
)
|
|
|
+ refreshTrustItemsForSelectedPlan()
|
|
|
refreshPurchaseState()
|
|
|
}
|
|
|
|