Files
Termind/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks.

Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP.

Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2026-10-17 06:13:38 +08:00

139 lines
4.8 KiB
Swift

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 private(set) var isProcessing = false
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
if next.hasProAccess { isProcessing = false }
apply(next)
}
public func beginPaywallOperation() {
isProcessing = true
}
public func cancelPaywallOperation() { isProcessing = false }
public func completePaywallOperation() async {
isProcessing = false
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()
}
}
}
}