513 lines
21 KiB
Swift
513 lines
21 KiB
Swift
import Foundation
|
|
import StoreKit
|
|
import TermindDomain
|
|
|
|
@MainActor
|
|
@Observable
|
|
public final class ProAccessController {
|
|
public static let productID = "com.akinokaede.termind.pro"
|
|
public static let annualProductID = "com.akinokaede.termind.pro.annual"
|
|
/// Retained only to honor transactions created by the retired standalone trial.
|
|
public static let trialProductID = "com.akinokaede.termind.pro.trial"
|
|
|
|
private let defaults: UserDefaults?
|
|
private let now: () -> Date
|
|
private var transactionUpdates: Task<Void, Never>?
|
|
private var refreshTask: Task<Void, Never>?
|
|
private var accessTask: Task<EntitlementStatus, Never>?
|
|
private var metadataTask: Task<Void, Never>?
|
|
private var productsTask: Task<[Product], any Error>?
|
|
private var entitlementExpirationTask: Task<Void, Never>?
|
|
private var refreshGeneration: UInt64 = 0
|
|
private var metadataError: ProAccessControllerError?
|
|
|
|
public private(set) var status: EntitlementStatus
|
|
/// The permanent, non-consumable product. Kept under the existing name for source compatibility.
|
|
public private(set) var product: Product?
|
|
public private(set) var annualProduct: Product?
|
|
public private(set) var isEligibleForAnnualIntroOffer: Bool?
|
|
public private(set) var error: ProAccessControllerError?
|
|
public private(set) var isProcessing = false
|
|
public private(set) var pendingProductID: String?
|
|
public private(set) var workspaceCompositionGrantedForLaunch: Bool
|
|
public var isPaywallPresented = false
|
|
public var requestedFeature: ProFeature?
|
|
|
|
public var displayPrice: String? { product?.displayPrice }
|
|
public var annualDisplayPrice: String? { annualProduct?.displayPrice }
|
|
public var annualBillingPeriod: DateComponents? {
|
|
annualProduct?.subscription.map { Self.dateComponents(for: $0.subscriptionPeriod, count: 1) }
|
|
}
|
|
public var annualIntroOfferDuration: DateComponents? {
|
|
guard let offer = Self.freeIntroductoryOffer(for: annualProduct) else { return nil }
|
|
return Self.dateComponents(for: offer.period, count: offer.periodCount)
|
|
}
|
|
public var hasProAccess: Bool { status.hasProAccess }
|
|
public var canPurchase: Bool { canPurchase(.lifetime) }
|
|
public var canPurchaseAnnual: Bool { canPurchase(.annual) }
|
|
public var canStartTrial: Bool {
|
|
canPurchase(.annual) && isEligibleForAnnualIntroOffer == true
|
|
&& Self.freeIntroductoryOffer(for: annualProduct) != nil
|
|
}
|
|
public var message: String? {
|
|
if let error { return error.localizedDescription }
|
|
if pendingProductID != nil { return ProAccessControllerError.purchasePending.localizedDescription }
|
|
if status.canPurchase, let metadataError { return metadataError.localizedDescription }
|
|
return nil
|
|
}
|
|
|
|
public static func live() -> ProAccessController { ProAccessController(defaults: .standard) }
|
|
|
|
public static func unrestrictedForTesting() -> ProAccessController {
|
|
testing(status: .unrestrictedForTesting)
|
|
}
|
|
|
|
public static func testing(status: EntitlementStatus) -> ProAccessController {
|
|
let controller = ProAccessController(defaults: nil, observeStore: false)
|
|
controller.status = status
|
|
controller.workspaceCompositionGrantedForLaunch = status.hasProAccess
|
|
return controller
|
|
}
|
|
|
|
public init(
|
|
defaults: UserDefaults?,
|
|
now: @escaping () -> Date = { .now },
|
|
observeStore: Bool = true
|
|
) {
|
|
self.defaults = defaults
|
|
self.now = now
|
|
status = .loading
|
|
workspaceCompositionGrantedForLaunch = false
|
|
guard observeStore else { return }
|
|
transactionUpdates = Task { [weak self] in
|
|
for await update in Transaction.updates {
|
|
guard !Task.isCancelled, let self else { return }
|
|
await self.handleTransactionUpdate(update)
|
|
}
|
|
}
|
|
refreshTask = Task { [weak self] in await self?.refresh() }
|
|
metadataTask = Task { [weak self] in await self?.reloadProducts() }
|
|
}
|
|
|
|
isolated deinit {
|
|
transactionUpdates?.cancel()
|
|
refreshTask?.cancel()
|
|
accessTask?.cancel()
|
|
metadataTask?.cancel()
|
|
productsTask?.cancel()
|
|
entitlementExpirationTask?.cancel()
|
|
}
|
|
|
|
public func canUse(_ feature: ProFeature) -> Bool {
|
|
if feature == .workspaceComposition { return workspaceCompositionGrantedForLaunch }
|
|
return status.hasProAccess
|
|
}
|
|
|
|
public func canPurchase(_ plan: ProPurchasePlan) -> Bool {
|
|
guard status.canPurchase, !isProcessing else { return false }
|
|
switch plan {
|
|
case .annual:
|
|
return pendingProductID != Self.annualProductID && annualProduct != nil
|
|
case .lifetime:
|
|
return pendingProductID != Self.productID && product != nil
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
public func require(_ feature: ProFeature) -> Bool {
|
|
guard !canUse(feature) else { return true }
|
|
requestedFeature = feature
|
|
isPaywallPresented = true
|
|
return false
|
|
}
|
|
|
|
public func presentPaywall(for feature: ProFeature? = nil) {
|
|
requestedFeature = feature
|
|
isPaywallPresented = true
|
|
}
|
|
|
|
/// StoreKit's local transactions resolve access independently of network product metadata.
|
|
public func refresh() async { await refresh(including: nil) }
|
|
|
|
private func refresh(including transaction: Transaction?) async {
|
|
if status == .unrestrictedForTesting { return }
|
|
if accessTask == nil || transaction != nil {
|
|
accessTask?.cancel()
|
|
refreshGeneration &+= 1
|
|
accessTask = Task { [weak self] in
|
|
await self?.currentStatus(including: transaction) ?? .loading
|
|
}
|
|
}
|
|
let generation = refreshGeneration
|
|
guard let task = accessTask else { return }
|
|
let resolved = await task.value
|
|
guard generation == refreshGeneration else {
|
|
if accessTask != nil { await refresh() }
|
|
return
|
|
}
|
|
guard accessTask != nil else { return }
|
|
accessTask = nil
|
|
switch resolved {
|
|
case .purchased:
|
|
pendingProductID = nil
|
|
case .subscription(let subscription) where subscription.phase.hasProAccess:
|
|
if pendingProductID == Self.annualProductID { pendingProductID = nil }
|
|
case .trialActive where pendingProductID == Self.trialProductID:
|
|
pendingProductID = nil
|
|
default:
|
|
break
|
|
}
|
|
apply(resolved)
|
|
}
|
|
|
|
/// Purchases the annual subscription. StoreKit applies its introductory offer for eligible accounts.
|
|
public func purchaseAnnual() async { await purchaseProduct(Self.annualProductID) }
|
|
public func purchaseLifetime() async { await purchaseProduct(Self.productID) }
|
|
|
|
private func purchaseProduct(_ id: String) async {
|
|
guard !isProcessing, pendingProductID != id, status.canPurchase else { return }
|
|
isProcessing = true
|
|
error = nil
|
|
defer { isProcessing = false }
|
|
await refresh()
|
|
guard status.canPurchase else { return }
|
|
do {
|
|
try await fetchProducts()
|
|
let selected: Product
|
|
switch id {
|
|
case Self.annualProductID:
|
|
guard let annualProduct else { throw ProAccessControllerError.productUnavailable }
|
|
selected = annualProduct
|
|
case Self.productID:
|
|
guard let product else { throw ProAccessControllerError.productUnavailable }
|
|
selected = product
|
|
default:
|
|
throw ProAccessControllerError.productUnavailable
|
|
}
|
|
switch try await selected.purchase() {
|
|
case .success(let verification):
|
|
guard case .verified(let transaction) = verification, transaction.productID == id else {
|
|
error = .purchaseVerificationFailed
|
|
return
|
|
}
|
|
await refresh(including: transaction)
|
|
await transaction.finish()
|
|
if status.hasProAccess { isPaywallPresented = false }
|
|
case .pending:
|
|
pendingProductID = id
|
|
case .userCancelled:
|
|
break
|
|
@unknown default:
|
|
error = .purchaseVerificationFailed
|
|
}
|
|
} catch StoreKitError.userCancelled {
|
|
// Cancellation is not an entitlement change or a failure to display.
|
|
} catch let purchaseError {
|
|
error = purchaseError as? ProAccessControllerError ?? .operationFailed(purchaseError.localizedDescription)
|
|
}
|
|
}
|
|
|
|
public func restorePurchases() async {
|
|
guard !isProcessing else { return }
|
|
isProcessing = true
|
|
error = nil
|
|
defer { isProcessing = false }
|
|
do {
|
|
try await AppStore.sync()
|
|
await refresh()
|
|
switch status {
|
|
case .purchased, .trialActive:
|
|
isPaywallPresented = false
|
|
case .subscription(let subscription) where subscription.phase.hasProAccess:
|
|
isPaywallPresented = false
|
|
case .verificationFailed:
|
|
error = .purchaseVerificationFailed
|
|
default:
|
|
error = .purchaseNotFound
|
|
}
|
|
} catch {
|
|
self.error = .operationFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
public func reloadProducts() async {
|
|
do {
|
|
try await fetchProducts()
|
|
} catch {
|
|
product = nil
|
|
annualProduct = nil
|
|
isEligibleForAnnualIntroOffer = nil
|
|
metadataError = .productMetadataUnavailable
|
|
}
|
|
}
|
|
|
|
private func fetchProducts() async throws {
|
|
let task = productsTask ?? Task { try await Product.products(for: [Self.productID, Self.annualProductID]) }
|
|
productsTask = task
|
|
defer { productsTask = nil }
|
|
let loaded = try await task.value
|
|
product = loaded.first { Self.isLifetimeProduct($0) }
|
|
annualProduct = loaded.first { Self.isAnnualSubscription($0) }
|
|
if let subscription = annualProduct?.subscription,
|
|
Self.freeIntroductoryOffer(for: annualProduct) != nil
|
|
{
|
|
isEligibleForAnnualIntroOffer = await subscription.isEligibleForIntroOffer
|
|
} else {
|
|
isEligibleForAnnualIntroOffer = false
|
|
}
|
|
if product == nil, annualProduct == nil {
|
|
metadataError = .productUnavailable
|
|
} else {
|
|
metadataError = nil
|
|
}
|
|
if case .free = status {
|
|
apply(.free(trialAvailable: isEligibleForAnnualIntroOffer == true))
|
|
}
|
|
}
|
|
|
|
public static func isLifetimeProduct(_ product: Product) -> Bool {
|
|
product.id == productID && product.type == .nonConsumable && product.isFamilyShareable
|
|
}
|
|
|
|
public static func isAnnualSubscription(_ product: Product) -> Bool {
|
|
guard product.id == annualProductID, product.type == .autoRenewable,
|
|
product.isFamilyShareable, let subscription = product.subscription
|
|
else { return false }
|
|
return subscription.subscriptionPeriod.unit == .year && subscription.subscriptionPeriod.value == 1
|
|
}
|
|
|
|
public static func freeIntroductoryOffer(for product: Product?) -> Product.SubscriptionOffer? {
|
|
guard let offer = product?.subscription?.introductoryOffer,
|
|
offer.type == .introductory, offer.paymentMode == .freeTrial, offer.price == .zero
|
|
else { return nil }
|
|
return offer
|
|
}
|
|
|
|
private func currentStatus(including update: Transaction?) async -> EntitlementStatus {
|
|
let lifetimeResult = await currentLifetimeTransaction(including: update)
|
|
if let lifetime = lifetimeResult.transaction {
|
|
return .purchased(Self.ownership(of: lifetime))
|
|
}
|
|
|
|
var verificationFailed = lifetimeResult.verificationFailed
|
|
let annualResult: VerificationResult<Transaction>?
|
|
if let update, update.productID == Self.annualProductID {
|
|
annualResult = .verified(update)
|
|
} else {
|
|
annualResult = await Transaction.latest(for: Self.annualProductID)
|
|
}
|
|
if let annualResult {
|
|
switch annualResult {
|
|
case .verified(let transaction):
|
|
return .subscription(await subscriptionStatus(for: transaction))
|
|
case .unverified:
|
|
verificationFailed = true
|
|
}
|
|
}
|
|
|
|
var legacyTrials: [ProTrial] = []
|
|
if let update, update.productID == Self.trialProductID {
|
|
legacyTrials.append(Self.trialRecord(update))
|
|
}
|
|
for await result in Transaction.all {
|
|
if case .verified(let transaction) = result, transaction.productID == Self.trialProductID {
|
|
legacyTrials.append(Self.trialRecord(transaction))
|
|
}
|
|
}
|
|
if let trial = ProTrial.earliest(legacyTrials) {
|
|
let legacyStatus = trial.status(at: TrialClock(defaults: defaults).effectiveNow(for: trial, now: now()))
|
|
if legacyStatus.hasProAccess { return legacyStatus }
|
|
}
|
|
return verificationFailed ? .verificationFailed : .free(trialAvailable: isEligibleForAnnualIntroOffer == true)
|
|
}
|
|
|
|
private func currentLifetimeTransaction(
|
|
including update: Transaction?
|
|
) async -> (transaction: Transaction?, verificationFailed: Bool) {
|
|
if let update, update.productID == Self.productID, update.productType == .nonConsumable,
|
|
update.revocationDate == nil
|
|
{
|
|
return (update, false)
|
|
}
|
|
var verificationFailed = false
|
|
for await result in Transaction.currentEntitlements {
|
|
switch result {
|
|
case .verified(let transaction)
|
|
where
|
|
transaction.productID == Self.productID && transaction.revocationDate == nil
|
|
&& transaction.productType == .nonConsumable
|
|
&& !(update?.productID == Self.productID && update?.revocationDate != nil
|
|
&& update?.originalID == transaction.originalID):
|
|
return (transaction, false)
|
|
case .unverified(let transaction, _) where transaction.productID == Self.productID:
|
|
verificationFailed = true
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
if let latest = await Transaction.latest(for: Self.productID) {
|
|
switch latest {
|
|
case .verified(let transaction)
|
|
where transaction.revocationDate == nil && transaction.productType == .nonConsumable
|
|
&& !(update?.productID == Self.productID && update?.revocationDate != nil
|
|
&& update?.originalID == transaction.originalID):
|
|
return (transaction, false)
|
|
case .unverified:
|
|
verificationFailed = true
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return (nil, verificationFailed)
|
|
}
|
|
|
|
private func subscriptionStatus(for transaction: Transaction) async -> ProSubscriptionStatus {
|
|
let ownership = Self.ownership(of: transaction)
|
|
if transaction.revocationDate != nil || transaction.productType != .autoRenewable {
|
|
return ProSubscriptionStatus(
|
|
phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false,
|
|
ownership: ownership)
|
|
}
|
|
|
|
let storeStatus = await transaction.subscriptionStatus
|
|
let renewalInfo: Product.SubscriptionInfo.RenewalInfo?
|
|
if let storeStatus, case .verified(let verified) = storeStatus.renewalInfo {
|
|
renewalInfo = verified
|
|
} else {
|
|
renewalInfo = nil
|
|
}
|
|
|
|
if let expiration = transaction.expirationDate, expiration > now(), !transaction.isUpgraded {
|
|
let phase: ProSubscriptionPhase = Self.isIntroductoryTrial(transaction) ? .introductoryTrial : .subscribed
|
|
return ProSubscriptionStatus(
|
|
phase: phase, expiresAt: expiration, willAutoRenew: renewalInfo?.willAutoRenew,
|
|
ownership: ownership)
|
|
}
|
|
|
|
switch storeStatus?.state {
|
|
case .inGracePeriod:
|
|
if let graceExpiration = renewalInfo?.gracePeriodExpirationDate, graceExpiration > now() {
|
|
return ProSubscriptionStatus(
|
|
phase: .gracePeriod, expiresAt: graceExpiration, willAutoRenew: renewalInfo?.willAutoRenew,
|
|
ownership: ownership)
|
|
}
|
|
return ProSubscriptionStatus(
|
|
phase: .billingRetry, expiresAt: transaction.expirationDate,
|
|
willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership)
|
|
case .inBillingRetryPeriod:
|
|
return ProSubscriptionStatus(
|
|
phase: .billingRetry, expiresAt: transaction.expirationDate,
|
|
willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership)
|
|
case .revoked:
|
|
return ProSubscriptionStatus(
|
|
phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false,
|
|
ownership: ownership)
|
|
case .subscribed:
|
|
return ProSubscriptionStatus(
|
|
phase: .subscribed, expiresAt: transaction.expirationDate,
|
|
willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership)
|
|
case .expired, .none:
|
|
return ProSubscriptionStatus(
|
|
phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false,
|
|
ownership: ownership)
|
|
default:
|
|
return ProSubscriptionStatus(
|
|
phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false,
|
|
ownership: ownership)
|
|
}
|
|
}
|
|
|
|
private static func isIntroductoryTrial(_ transaction: Transaction) -> Bool {
|
|
transaction.offer?.type == .introductory && transaction.offer?.paymentMode == .freeTrial
|
|
}
|
|
|
|
private static func ownership(of transaction: Transaction) -> ProOwnership {
|
|
transaction.ownershipType == .familyShared ? .familyShared : .purchased
|
|
}
|
|
|
|
private static func trialRecord(_ transaction: Transaction) -> ProTrial {
|
|
ProTrial(
|
|
originalID: transaction.originalID, purchasedAt: transaction.originalPurchaseDate,
|
|
isRevoked: transaction.revocationDate != nil || transaction.ownershipType != .purchased
|
|
|| transaction.productType != .nonConsumable
|
|
)
|
|
}
|
|
|
|
public func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
|
|
let knownProductIDs = [Self.productID, Self.annualProductID, Self.trialProductID]
|
|
guard knownProductIDs.contains(update.unsafePayloadValue.productID) else { return }
|
|
if case .verified(let transaction) = update {
|
|
error = nil
|
|
if pendingProductID == transaction.productID { pendingProductID = nil }
|
|
await refresh(including: transaction)
|
|
await transaction.finish()
|
|
} else {
|
|
error = .purchaseVerificationFailed
|
|
await refresh()
|
|
}
|
|
}
|
|
|
|
private func apply(_ next: EntitlementStatus) {
|
|
status = next
|
|
if next.hasProAccess { workspaceCompositionGrantedForLaunch = true }
|
|
entitlementExpirationTask?.cancel()
|
|
let expiresAt: Date?
|
|
switch next {
|
|
case .trialActive(let expiration):
|
|
expiresAt = expiration
|
|
case .subscription(let subscription) where subscription.phase.hasProAccess:
|
|
expiresAt = subscription.expiresAt
|
|
default:
|
|
expiresAt = nil
|
|
}
|
|
if let expiresAt {
|
|
entitlementExpirationTask = Task { [weak self] in
|
|
let remaining = min(60, max(0, expiresAt.timeIntervalSince(self?.now() ?? .now)))
|
|
try? await Task.sleep(for: .seconds(remaining))
|
|
guard !Task.isCancelled else { return }
|
|
await self?.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func dateComponents(for period: Product.SubscriptionPeriod, count: Int) -> DateComponents {
|
|
let value = period.value * count
|
|
var components = DateComponents()
|
|
switch period.unit {
|
|
case .day: components.day = value
|
|
case .week: components.weekOfYear = value
|
|
case .month: components.month = value
|
|
case .year: components.year = value
|
|
@unknown default: break
|
|
}
|
|
return components
|
|
}
|
|
|
|
}
|
|
|
|
public nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sendable {
|
|
case productUnavailable
|
|
case productMetadataUnavailable
|
|
case purchaseVerificationFailed
|
|
case purchasePending
|
|
case operationFailed(String)
|
|
case purchaseNotFound
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .productUnavailable:
|
|
String(localized: "Termind Pro is not available from the App Store right now.")
|
|
case .productMetadataUnavailable:
|
|
String(localized: "The App Store is temporarily unavailable. You can keep using already unlocked features.")
|
|
case .operationFailed(let description):
|
|
description
|
|
case .purchaseVerificationFailed:
|
|
String(localized: "The App Store could not verify this purchase.")
|
|
case .purchasePending:
|
|
String(localized: "The purchase is waiting for App Store approval.")
|
|
case .purchaseNotFound:
|
|
String(localized: "No active Termind Pro purchase or subscription was found for this App Store account.")
|
|
}
|
|
}
|
|
}
|