Files
Termind/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift
T
Kaede Akino 82d871c4fe refactor: separate feature policies and UI workflows
Move connection policy out of ProAccess, keep terminal uploads with their
session, and share question interaction and highlight editing operations.
Consolidate CPU usage and settings snapshot handling, and remove obsolete
terminal selection, theme helpers, and hard-coded latest-schema assertions.

Fix draining connection-pool recovery and stale pane upload bindings. Keep
screenshot matrices opt-in and retain behavioral migration coverage.

Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both
SSH forwarding regressions passed against the local OpenSSH fixture.
Both app builds, Swift formatting, AppStrings, and Chinese localization
checks passed. Parallel test runs still exhibit intermittent timeouts.
2026-10-02 16:40:27 +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.hasProAccess != next.hasProAccess
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()
}
}
}
}