mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* feat(checkpoints): Resolve checkpoints from remote configuration * fix(checkpoints): Guard resolution config generation * fix(checkpoints): Distinguish unknown checkpoints * fix(checkpoints): Report unavailable remote config * fix(checkpoints): Propagate resolution cancellation * Use stable unknown checkpoint identifier
104 lines
3.3 KiB
Swift
104 lines
3.3 KiB
Swift
//
|
|
// CheckpointsConfigProvider.swift
|
|
// RevenueCat
|
|
//
|
|
// Created by Facundo Menzella.
|
|
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
|
|
|
|
import Foundation
|
|
|
|
protocol CheckpointsConfigProviderType {
|
|
|
|
func rules(for identifier: String) async throws -> CheckpointRulesSnapshot?
|
|
func isCurrent(_ snapshot: CheckpointRulesSnapshot) -> Bool
|
|
|
|
}
|
|
|
|
struct CheckpointRulesSnapshot {
|
|
|
|
let ruleSet: CheckpointRuleSet
|
|
let configGeneration: Int
|
|
|
|
}
|
|
|
|
enum CheckpointRulesProviderError: Error, Equatable {
|
|
|
|
case remoteConfigDisabled
|
|
case payloadUnavailable
|
|
|
|
}
|
|
|
|
/// The topic-specific front door for checkpoints, reading through `RemoteConfigManager`'s `checkpoint_rules` topic.
|
|
///
|
|
/// Items are keyed by checkpoint identifier, so rules load with a single `blobData` read: no topic-index
|
|
/// scan, and `blobData` already handles inlined versus downloaded blobs and identity invalidation.
|
|
final class CheckpointsConfigProvider: CheckpointsConfigProviderType {
|
|
|
|
private let manager: RemoteConfigManagerType
|
|
|
|
init(manager: RemoteConfigManagerType) {
|
|
self.manager = manager
|
|
}
|
|
|
|
func rules(for identifier: String) async throws -> CheckpointRulesSnapshot? {
|
|
while true {
|
|
try Task.checkCancellation()
|
|
let configGeneration = self.manager.configGeneration
|
|
|
|
do {
|
|
let rules = try await self.loadRules(for: identifier)
|
|
guard self.manager.configGeneration == configGeneration else { continue }
|
|
|
|
return rules.map {
|
|
CheckpointRulesSnapshot(ruleSet: $0, configGeneration: configGeneration)
|
|
}
|
|
} catch {
|
|
guard self.manager.configGeneration == configGeneration else { continue }
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
func isCurrent(_ snapshot: CheckpointRulesSnapshot) -> Bool {
|
|
return self.manager.configGeneration == snapshot.configGeneration
|
|
}
|
|
|
|
private func loadRules(for identifier: String) async throws -> CheckpointRuleSet? {
|
|
do {
|
|
if let checkpoint = try await self.manager.blobData(
|
|
for: .checkpointRules,
|
|
itemKey: identifier,
|
|
as: CheckpointRuleSet.self
|
|
) {
|
|
return checkpoint
|
|
}
|
|
} catch {
|
|
Logger.error(Strings.codable.decoding_error(error, CheckpointRuleSet.self))
|
|
}
|
|
|
|
// The blob read above self-primes remote config on a cold cache. Classifying afterwards prevents an
|
|
// existing checkpoint from briefly looking unconfigured while that initial refresh is still in flight.
|
|
if self.manager.isDisabled {
|
|
throw CheckpointRulesProviderError.remoteConfigDisabled
|
|
}
|
|
|
|
let topic = await self.manager.topic(.checkpointRules)
|
|
if self.manager.isDisabled {
|
|
throw CheckpointRulesProviderError.remoteConfigDisabled
|
|
}
|
|
|
|
guard let topic else {
|
|
guard await self.manager.hasCommittedConfig() else {
|
|
throw CheckpointRulesProviderError.payloadUnavailable
|
|
}
|
|
return nil
|
|
}
|
|
|
|
guard topic[identifier] != nil else { return nil }
|
|
throw CheckpointRulesProviderError.payloadUnavailable
|
|
}
|
|
|
|
}
|
|
|
|
extension CheckpointsConfigProvider: @unchecked Sendable {}
|