Files

133 lines
4.6 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindDomain
@MainActor
@Observable
public final class ProAccessController {
private let purchaseService: (any ProPurchaseService)?
private let now: () -> Date
private var customerInfoUpdates: Task<Void, Never>?
private var initialRefreshTask: Task<Void, Never>?
private var accessTask: Task<EntitlementStatus, any Error>?
private var entitlementExpirationTask: Task<Void, Never>?
private var refreshGeneration: UInt64 = 0
public private(set) var status: EntitlementStatus
public var isPaywallPresented = false
/// App-owned runtime reconciliation; does not require a visible SwiftUI surface.
public var accessDidChange: (() -> Void)?
public var usesRevenueCat: Bool { purchaseService != nil }
public var hasProAccess: Bool { status.hasProAccess }
public static func live() -> ProAccessController {
let service = RevenueCatConfiguration.bundled().map { RevenueCatProPurchaseService(configuration: $0) }
return ProAccessController(purchaseService: service)
}
public static func unrestrictedForTesting() -> ProAccessController {
testing(status: .unrestrictedForTesting)
}
public static func testing(status: EntitlementStatus) -> ProAccessController {
let controller = ProAccessController(purchaseService: nil, observeUpdates: false)
controller.status = status
return controller
}
init(
purchaseService: (any ProPurchaseService)?,
now: @escaping () -> Date = { .now },
observeUpdates: Bool = true
) {
self.purchaseService = purchaseService
self.now = now
status = purchaseService == nil ? .verificationFailed : .loading
guard observeUpdates, let purchaseService else { return }
let updates = purchaseService.updates
customerInfoUpdates = Task { [weak self] in
for await status in updates {
guard !Task.isCancelled, let self else { return }
self.acceptServiceStatus(status)
}
}
initialRefreshTask = Task { [weak self] in await self?.refresh() }
}
isolated deinit {
customerInfoUpdates?.cancel()
initialRefreshTask?.cancel()
accessTask?.cancel()
entitlementExpirationTask?.cancel()
}
public func canUse(_: ProFeature) -> Bool { hasProAccess }
@discardableResult
public func require(_ feature: ProFeature) -> Bool {
guard !canUse(feature) else { return true }
presentPaywall()
return false
}
public func presentPaywall() {
isPaywallPresented = true
}
/// RevenueCat owns purchases, receipt synchronization, cached access, and subscription updates.
public func refresh() async {
guard status != .unrestrictedForTesting else { return }
guard let purchaseService else {
apply(.verificationFailed)
return
}
if accessTask == nil {
refreshGeneration &+= 1
accessTask = Task { try await purchaseService.currentStatus() }
}
let generation = refreshGeneration
guard let task = accessTask else { return }
let result = await task.result
// A newer CustomerInfo update takes precedence over an earlier request.
guard generation == refreshGeneration, accessTask != nil else { return }
accessTask = nil
switch result {
case .success(let resolved):
apply(resolved)
case .failure:
if status == .loading { apply(.verificationFailed) }
}
}
private func acceptServiceStatus(_ next: EntitlementStatus) {
refreshGeneration &+= 1
accessTask?.cancel()
accessTask = nil
apply(next)
}
public func completePaywallOperation() async {
await refresh()
}
private func apply(_ next: EntitlementStatus) {
let changed = status != next
status = next
if changed { accessDidChange?() }
entitlementExpirationTask?.cancel()
if case .subscription(let subscription) = next,
subscription.phase.hasProAccess, let expiresAt = subscription.expiresAt, expiresAt > now()
{
entitlementExpirationTask = Task { [weak self] in
let remaining = max(0, expiresAt.timeIntervalSince(self?.now() ?? .now))
try? await Task.sleep(for: .seconds(remaining))
guard !Task.isCancelled else { return }
await self?.refresh()
}
}
}
}