|
@@ -368,11 +368,6 @@ class ViewController: NSViewController {
|
|
|
case failed(String)
|
|
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 productsByID: [String: Product] = [:]
|
|
|
private(set) var activeProductIDs = Set<String>()
|
|
private(set) var activeProductIDs = Set<String>()
|
|
|
|
|
|
|
@@ -382,6 +377,10 @@ class ViewController: NSViewController {
|
|
|
private var transactionUpdatesTask: Task<Void, Never>?
|
|
private var transactionUpdatesTask: Task<Void, Never>?
|
|
|
var onEntitlementsChanged: ((Bool) -> Void)?
|
|
var onEntitlementsChanged: ((Bool) -> Void)?
|
|
|
|
|
|
|
|
|
|
+ deinit {
|
|
|
|
|
+ transactionUpdatesTask?.cancel()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
func start() async {
|
|
func start() async {
|
|
|
await refreshProducts()
|
|
await refreshProducts()
|
|
|
await refreshEntitlements()
|
|
await refreshEntitlements()
|
|
@@ -444,7 +443,6 @@ class ViewController: NSViewController {
|
|
|
switch result {
|
|
switch result {
|
|
|
case .success(let verification):
|
|
case .success(let verification):
|
|
|
guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
|
|
guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
|
|
|
- cacheVerifiedPremiumTransaction(transaction)
|
|
|
|
|
await transaction.finish()
|
|
await transaction.finish()
|
|
|
await refreshEntitlements()
|
|
await refreshEntitlements()
|
|
|
return .success
|
|
return .success
|
|
@@ -465,9 +463,8 @@ class ViewController: NSViewController {
|
|
|
transactionUpdatesTask = Task { [weak self] in
|
|
transactionUpdatesTask = Task { [weak self] in
|
|
|
for await update in Transaction.updates {
|
|
for await update in Transaction.updates {
|
|
|
guard case .verified(let transaction) = update else { continue }
|
|
guard case .verified(let transaction) = update else { continue }
|
|
|
- if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) {
|
|
|
|
|
- await self?.refreshEntitlements()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
|
|
|
|
|
+ await self?.refreshEntitlements()
|
|
|
await transaction.finish()
|
|
await transaction.finish()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -478,7 +475,6 @@ class ViewController: NSViewController {
|
|
|
let previousHasPremiumAccess = hasPremiumAccess
|
|
let previousHasPremiumAccess = hasPremiumAccess
|
|
|
let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
|
|
let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
|
|
|
var active = Set<String>()
|
|
var active = Set<String>()
|
|
|
- var latestInactiveIDs = Set<String>()
|
|
|
|
|
|
|
|
|
|
for await entitlement in Transaction.currentEntitlements {
|
|
for await entitlement in Transaction.currentEntitlements {
|
|
|
guard case .verified(let transaction) = entitlement else { continue }
|
|
guard case .verified(let transaction) = entitlement else { continue }
|
|
@@ -495,21 +491,9 @@ class ViewController: NSViewController {
|
|
|
case .verified(let transaction) = latest else { continue }
|
|
case .verified(let transaction) = latest else { continue }
|
|
|
if Self.isTransactionActive(transaction) {
|
|
if Self.isTransactionActive(transaction) {
|
|
|
active.insert(productID)
|
|
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
|
|
activeProductIDs = active
|
|
|
let newHasPremiumAccess = hasPremiumAccess
|
|
let newHasPremiumAccess = hasPremiumAccess
|
|
|
if newHasPremiumAccess != previousHasPremiumAccess {
|
|
if newHasPremiumAccess != previousHasPremiumAccess {
|
|
@@ -517,53 +501,6 @@ 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 {
|
|
private static func isTransactionActive(_ transaction: Transaction) -> Bool {
|
|
|
if transaction.revocationDate != nil { return false }
|
|
if transaction.revocationDate != nil { return false }
|
|
|
if let expirationDate = transaction.expirationDate {
|
|
if let expirationDate = transaction.expirationDate {
|
|
@@ -3816,7 +3753,7 @@ class ViewController: NSViewController {
|
|
|
price: paywallDisplayPrice(for: .weekly),
|
|
price: paywallDisplayPrice(for: .weekly),
|
|
|
badge: "Basic",
|
|
badge: "Basic",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: "Billed weekly",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .weekly),
|
|
|
plan: .weekly,
|
|
plan: .weekly,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
)
|
|
)
|
|
@@ -3825,7 +3762,7 @@ class ViewController: NSViewController {
|
|
|
price: paywallDisplayPrice(for: .monthly),
|
|
price: paywallDisplayPrice(for: .monthly),
|
|
|
badge: "Popular",
|
|
badge: "Popular",
|
|
|
badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
|
- subtitle: "Billed monthly",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .monthly),
|
|
|
plan: .monthly,
|
|
plan: .monthly,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
)
|
|
)
|
|
@@ -3834,7 +3771,7 @@ class ViewController: NSViewController {
|
|
|
price: paywallDisplayPrice(for: .yearly),
|
|
price: paywallDisplayPrice(for: .yearly),
|
|
|
badge: "Save 67%",
|
|
badge: "Save 67%",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: "Billed yearly • 3 days free trial",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .yearly),
|
|
|
plan: .yearly,
|
|
plan: .yearly,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
)
|
|
)
|
|
@@ -3843,7 +3780,7 @@ class ViewController: NSViewController {
|
|
|
price: paywallDisplayPrice(for: .lifetime),
|
|
price: paywallDisplayPrice(for: .lifetime),
|
|
|
badge: "Best Value",
|
|
badge: "Best Value",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: "One-time purchase",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .lifetime),
|
|
|
plan: .lifetime,
|
|
plan: .lifetime,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
)
|
|
)
|
|
@@ -3880,16 +3817,16 @@ class ViewController: NSViewController {
|
|
|
price: paywallDisplayPrice(for: .weekly),
|
|
price: paywallDisplayPrice(for: .weekly),
|
|
|
badge: "Basic Deal",
|
|
badge: "Basic Deal",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: "Billed weekly",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .weekly),
|
|
|
plan: .weekly,
|
|
plan: .weekly,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
))
|
|
))
|
|
|
plansRow1.addArrangedSubview(paywallPlanCard(
|
|
plansRow1.addArrangedSubview(paywallPlanCard(
|
|
|
title: "Monthly",
|
|
title: "Monthly",
|
|
|
price: paywallDisplayPrice(for: .monthly),
|
|
price: paywallDisplayPrice(for: .monthly),
|
|
|
- badge: "Free Trial",
|
|
|
|
|
|
|
+ badge: "Popular",
|
|
|
badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
|
- subtitle: "7-day free trial, then monthly",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .monthly),
|
|
|
plan: .monthly,
|
|
plan: .monthly,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
))
|
|
))
|
|
@@ -3905,7 +3842,7 @@ class ViewController: NSViewController {
|
|
|
price: paywallDisplayPrice(for: .yearly),
|
|
price: paywallDisplayPrice(for: .yearly),
|
|
|
badge: "Best Deal",
|
|
badge: "Best Deal",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: "Billed yearly",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .yearly),
|
|
|
plan: .yearly,
|
|
plan: .yearly,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
))
|
|
))
|
|
@@ -3914,7 +3851,7 @@ class ViewController: NSViewController {
|
|
|
price: paywallDisplayPrice(for: .lifetime),
|
|
price: paywallDisplayPrice(for: .lifetime),
|
|
|
badge: "Save 50%",
|
|
badge: "Save 50%",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: "One-time purchase",
|
|
|
|
|
|
|
+ subtitle: paywallPlanSubtitle(for: .lifetime),
|
|
|
plan: .lifetime,
|
|
plan: .lifetime,
|
|
|
strikePrice: nil
|
|
strikePrice: nil
|
|
|
))
|
|
))
|
|
@@ -4378,12 +4315,95 @@ class ViewController: NSViewController {
|
|
|
if storeKitCoordinator.hasLifetimePremiumAccess {
|
|
if storeKitCoordinator.hasLifetimePremiumAccess {
|
|
|
return "Lifetime premium is active on this Apple ID."
|
|
return "Lifetime premium is active on this Apple ID."
|
|
|
}
|
|
}
|
|
|
- let price = paywallDisplayPrice(for: plan)
|
|
|
|
|
|
|
+ guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
|
|
|
|
|
+ let price = paywallDisplayPrice(for: plan)
|
|
|
|
|
+ switch plan {
|
|
|
|
|
+ case .weekly: return "\(price)/week"
|
|
|
|
|
+ case .monthly: return "\(price)/month"
|
|
|
|
|
+ case .yearly: return "\(price)/year"
|
|
|
|
|
+ case .lifetime: return "\(price) one-time purchase"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let offer = product.subscription?.introductoryOffer, offer.paymentMode == .freeTrial {
|
|
|
|
|
+ let trial = paywallSubscriptionPeriodDescription(offer.period)
|
|
|
|
|
+ let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
|
|
|
|
|
+ if let renewal {
|
|
|
|
|
+ return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
|
|
|
|
|
+ }
|
|
|
|
|
+ return "Free for \(trial), then \(product.displayPrice)"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if plan == .lifetime {
|
|
|
|
|
+ return "\(product.displayPrice) one-time purchase"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
|
|
|
|
|
+ return "\(product.displayPrice)/\(renewal)"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let fallbackPrice = paywallDisplayPrice(for: plan)
|
|
|
|
|
+ switch plan {
|
|
|
|
|
+ case .weekly: return "\(fallbackPrice)/week"
|
|
|
|
|
+ case .monthly: return "\(fallbackPrice)/month"
|
|
|
|
|
+ case .yearly: return "\(fallbackPrice)/year"
|
|
|
|
|
+ case .lifetime: return "\(fallbackPrice) one-time purchase"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func paywallHasFreeTrial(for plan: PremiumPlan) -> Bool {
|
|
|
|
|
+ guard let product = storeKitCoordinator.productsByID[plan.rawValue] else { return false }
|
|
|
|
|
+ return product.subscription?.introductoryOffer?.paymentMode == .freeTrial
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
|
|
|
|
|
+ if plan == .lifetime { return "One-time purchase" }
|
|
|
|
|
+
|
|
|
|
|
+ guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
|
|
|
|
|
+ switch plan {
|
|
|
|
|
+ case .weekly: return "Billed weekly"
|
|
|
|
|
+ case .monthly: return "Billed monthly"
|
|
|
|
|
+ case .yearly: return "Billed yearly"
|
|
|
|
|
+ case .lifetime: return "One-time purchase"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let offer = product.subscription?.introductoryOffer, offer.paymentMode == .freeTrial {
|
|
|
|
|
+ let trial = paywallSubscriptionPeriodDescription(offer.period)
|
|
|
|
|
+ if let trial {
|
|
|
|
|
+ if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
|
|
|
|
|
+ return "Free for \(trial), then \(renewal)"
|
|
|
|
|
+ }
|
|
|
|
|
+ return "Free for \(trial)"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
|
|
|
|
|
+ return "Billed \(renewal)"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
switch plan {
|
|
switch plan {
|
|
|
- case .weekly: return "\(price)/week"
|
|
|
|
|
- case .monthly: return "\(price)/month"
|
|
|
|
|
- case .yearly: return "Free for 3 days, then \(price)/year"
|
|
|
|
|
- case .lifetime: return "\(price) one-time purchase"
|
|
|
|
|
|
|
+ case .weekly: return "Billed weekly"
|
|
|
|
|
+ case .monthly: return "Billed monthly"
|
|
|
|
|
+ case .yearly: return "Billed yearly"
|
|
|
|
|
+ case .lifetime: return "One-time purchase"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
|
|
|
|
|
+ guard let period else { return nil }
|
|
|
|
|
+ let value = period.value
|
|
|
|
|
+ switch period.unit {
|
|
|
|
|
+ case .day:
|
|
|
|
|
+ return value == 1 ? "1 day" : "\(value) days"
|
|
|
|
|
+ case .week:
|
|
|
|
|
+ return value == 1 ? "1 week" : "\(value) weeks"
|
|
|
|
|
+ case .month:
|
|
|
|
|
+ return value == 1 ? "1 month" : "\(value) months"
|
|
|
|
|
+ case .year:
|
|
|
|
|
+ return value == 1 ? "1 year" : "\(value) years"
|
|
|
|
|
+ @unknown default:
|
|
|
|
|
+ return nil
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -4397,6 +4417,8 @@ class ViewController: NSViewController {
|
|
|
for plan in PremiumPlan.allCases {
|
|
for plan in PremiumPlan.allCases {
|
|
|
let price = paywallDisplayPrice(for: plan)
|
|
let price = paywallDisplayPrice(for: plan)
|
|
|
paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
|
|
paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
|
|
|
|
|
+ let subtitle = paywallPlanSubtitle(for: plan)
|
|
|
|
|
+ paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -4528,11 +4550,7 @@ class ViewController: NSViewController {
|
|
|
paywallContinueButton?.alphaValue = 0.75
|
|
paywallContinueButton?.alphaValue = 0.75
|
|
|
} else {
|
|
} else {
|
|
|
paywallContinueEnabled = true
|
|
paywallContinueEnabled = true
|
|
|
- if selectedPremiumPlan == .monthly {
|
|
|
|
|
- paywallContinueLabel?.stringValue = "Start with free trial"
|
|
|
|
|
- } else {
|
|
|
|
|
- paywallContinueLabel?.stringValue = "Continue"
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ paywallContinueLabel?.stringValue = paywallHasFreeTrial(for: selectedPremiumPlan) ? "Start with free trial" : "Continue"
|
|
|
paywallContinueButton?.alphaValue = 1.0
|
|
paywallContinueButton?.alphaValue = 1.0
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|