import Foundation
|
|
public enum ProFeature: String, CaseIterable, Codable, Equatable, Sendable {
|
case windowShortcutActivation
|
}
|
|
public enum TrialInvalidReason: String, Codable, Equatable, Sendable {
|
case corruptedRecord
|
case expiresBeforeStart
|
case startedInFuture
|
case timeRollback
|
}
|
|
public enum TrialState: Codable, Equatable, Sendable {
|
case notInitialized
|
case active(startedAt: Date, expiresAt: Date)
|
case expired(startedAt: Date, expiredAt: Date)
|
case invalidLocalRecord(reason: TrialInvalidReason)
|
}
|
|
public enum LicenseActivationStatus: String, Codable, Equatable, Sendable {
|
case active
|
case disabled
|
case expired
|
}
|
|
public enum LicenseRemoteFailureReason: String, Codable, Equatable, Sendable {
|
case invalid
|
case revoked
|
case disabled
|
case expired
|
case unknown
|
}
|
|
public enum LicenseMismatchReason: String, Codable, Equatable, Sendable {
|
case store
|
case product
|
case variant
|
}
|
|
public enum LicenseState: Codable, Equatable, Sendable {
|
case none
|
case activated(fingerprint: String, instanceID: String, status: LicenseActivationStatus)
|
case deactivatedLocal
|
case revokedOrExpiredRemote(reason: LicenseRemoteFailureReason)
|
case mismatch(reason: LicenseMismatchReason)
|
}
|
|
public enum ValidationHardFailureReason: String, Codable, Equatable, Sendable {
|
case invalid
|
case revoked
|
case disabled
|
case expired
|
case productMismatch
|
case storeMismatch
|
case variantMismatch
|
case serverRejected
|
case unknown
|
}
|
|
public enum ValidationFreshness: Codable, Equatable, Sendable {
|
case neverChecked
|
case valid(lastValidAt: Date, nextCheckAt: Date)
|
case temporaryFailure(lastAttemptAt: Date, graceUntil: Date)
|
case hardFailure(reason: ValidationHardFailureReason)
|
}
|
|
public enum EntitlementAllowedReason: String, Codable, Equatable, Sendable {
|
case trialActive
|
case licenseValid
|
case offlineGrace
|
}
|
|
public enum EntitlementDeniedReason: String, Codable, Equatable, Sendable {
|
case trialNotInitialized
|
case trialExpired
|
case noLicense
|
case licenseDeactivated
|
case licenseRevokedOrExpired
|
case licenseMismatch
|
case licenseDisabled
|
case licenseExpired
|
case verificationRequired
|
case graceExpired
|
case hardFailure
|
case invalidLocalRecord
|
case unsupportedFeature
|
}
|
|
public enum EffectiveEntitlement: Codable, Equatable, Sendable {
|
case proAllowed(reason: EntitlementAllowedReason)
|
case proDenied(reason: EntitlementDeniedReason)
|
|
public var isProAllowed: Bool {
|
switch self {
|
case .proAllowed:
|
return true
|
case .proDenied:
|
return false
|
}
|
}
|
}
|
|
public struct EntitlementSnapshot: Codable, Equatable, Sendable {
|
public var trialState: TrialState
|
public var licenseState: LicenseState
|
public var validationFreshness: ValidationFreshness
|
|
public init(
|
trialState: TrialState,
|
licenseState: LicenseState,
|
validationFreshness: ValidationFreshness
|
) {
|
self.trialState = trialState
|
self.licenseState = licenseState
|
self.validationFreshness = validationFreshness
|
}
|
}
|
|
public protocol ClockProtocol: Sendable {
|
var now: Date { get }
|
}
|
|
public struct SystemClock: ClockProtocol {
|
public init() {}
|
|
public var now: Date {
|
Date()
|
}
|
}
|
|
public protocol LicenseStoreProtocol: Sendable {
|
func loadEntitlementSnapshot() throws -> EntitlementSnapshot
|
func saveEntitlementSnapshot(_ snapshot: EntitlementSnapshot) throws
|
}
|
|
public enum LicenseValidationResult: Equatable, Sendable {
|
case success(lastValidAt: Date, nextCheckAt: Date)
|
case temporaryFailure(lastAttemptAt: Date, graceUntil: Date)
|
case hardFailure(reason: ValidationHardFailureReason)
|
}
|
|
public protocol LicenseValidationClientProtocol: Sendable {
|
func validateLicense(fingerprint: String, instanceID: String) async throws -> LicenseValidationResult
|
}
|
|
public protocol EntitlementProviderProtocol: Sendable {
|
func entitlement(for feature: ProFeature, at now: Date) -> EffectiveEntitlement
|
}
|
|
public enum EntitlementPolicy {
|
public static let defaultTrialDuration: TimeInterval = 30 * 24 * 60 * 60
|
public static let defaultOfflineGraceDuration: TimeInterval = 14 * 24 * 60 * 60
|
|
public static func trialStarted(at startedAt: Date, duration: TimeInterval = defaultTrialDuration) -> TrialState {
|
.active(startedAt: startedAt, expiresAt: startedAt.addingTimeInterval(duration))
|
}
|
|
public static func offlineGrace(
|
lastAttemptAt: Date,
|
duration: TimeInterval = defaultOfflineGraceDuration
|
) -> ValidationFreshness {
|
.temporaryFailure(lastAttemptAt: lastAttemptAt, graceUntil: lastAttemptAt.addingTimeInterval(duration))
|
}
|
|
public static func evaluate(
|
snapshot: EntitlementSnapshot,
|
feature: ProFeature,
|
now: Date
|
) -> EffectiveEntitlement {
|
switch feature {
|
case .windowShortcutActivation:
|
break
|
}
|
|
let licenseDecision = decisionForLicense(
|
state: snapshot.licenseState,
|
freshness: snapshot.validationFreshness,
|
now: now
|
)
|
if case .proAllowed = licenseDecision {
|
return licenseDecision
|
}
|
|
let trialDecision = decisionForTrial(state: snapshot.trialState, now: now)
|
if case .proAllowed = trialDecision {
|
return trialDecision
|
}
|
|
if case .none = snapshot.licenseState {
|
if case .notInitialized = snapshot.trialState {
|
return .proDenied(reason: .noLicense)
|
}
|
return trialDecision
|
}
|
|
return licenseDecision
|
}
|
|
private static func decisionForTrial(state: TrialState, now: Date) -> EffectiveEntitlement {
|
switch state {
|
case .notInitialized:
|
return .proDenied(reason: .trialNotInitialized)
|
case let .active(startedAt, expiresAt):
|
guard expiresAt > startedAt else {
|
return .proDenied(reason: .invalidLocalRecord)
|
}
|
guard now >= startedAt else {
|
return .proDenied(reason: .invalidLocalRecord)
|
}
|
guard now < expiresAt else {
|
return .proDenied(reason: .trialExpired)
|
}
|
return .proAllowed(reason: .trialActive)
|
case .expired:
|
return .proDenied(reason: .trialExpired)
|
case .invalidLocalRecord:
|
return .proDenied(reason: .invalidLocalRecord)
|
}
|
}
|
|
private static func decisionForLicense(
|
state: LicenseState,
|
freshness: ValidationFreshness,
|
now: Date
|
) -> EffectiveEntitlement {
|
switch state {
|
case .none:
|
return .proDenied(reason: .noLicense)
|
case .deactivatedLocal:
|
return .proDenied(reason: .licenseDeactivated)
|
case .revokedOrExpiredRemote:
|
return .proDenied(reason: .licenseRevokedOrExpired)
|
case .mismatch:
|
return .proDenied(reason: .licenseMismatch)
|
case let .activated(_, _, status):
|
switch status {
|
case .active:
|
return decisionForValidationFreshness(freshness, now: now)
|
case .disabled:
|
return .proDenied(reason: .licenseDisabled)
|
case .expired:
|
return .proDenied(reason: .licenseExpired)
|
}
|
}
|
}
|
|
private static func decisionForValidationFreshness(
|
_ freshness: ValidationFreshness,
|
now: Date
|
) -> EffectiveEntitlement {
|
switch freshness {
|
case .neverChecked:
|
return .proDenied(reason: .verificationRequired)
|
case let .valid(_, nextCheckAt):
|
return now <= nextCheckAt
|
? .proAllowed(reason: .licenseValid)
|
: .proDenied(reason: .verificationRequired)
|
case let .temporaryFailure(_, graceUntil):
|
return now < graceUntil
|
? .proAllowed(reason: .offlineGrace)
|
: .proDenied(reason: .graceExpired)
|
case .hardFailure:
|
return .proDenied(reason: .hardFailure)
|
}
|
}
|
}
|
|
public enum EntitlementFixtureState: String, CaseIterable, Codable, Equatable, Sendable {
|
case trialActive
|
case trialExpired
|
case proUnlocked
|
case offlineGrace
|
case verifyFailed
|
}
|
|
public struct FixtureEntitlementProvider: EntitlementProviderProtocol {
|
public let snapshot: EntitlementSnapshot
|
|
public init(fixtureState: EntitlementFixtureState, now: Date) {
|
let startedAt = now.addingTimeInterval(-24 * 60 * 60)
|
let expiredAt = now.addingTimeInterval(-24 * 60 * 60)
|
let expiredTrial = TrialState.expired(
|
startedAt: now.addingTimeInterval(-31 * 24 * 60 * 60),
|
expiredAt: expiredAt
|
)
|
let activeLicense = LicenseState.activated(
|
fingerprint: "fixture-fingerprint",
|
instanceID: "fixture-instance",
|
status: .active
|
)
|
|
switch fixtureState {
|
case .trialActive:
|
snapshot = EntitlementSnapshot(
|
trialState: .active(
|
startedAt: startedAt,
|
expiresAt: now.addingTimeInterval(29 * 24 * 60 * 60)
|
),
|
licenseState: .none,
|
validationFreshness: .neverChecked
|
)
|
case .trialExpired:
|
snapshot = EntitlementSnapshot(
|
trialState: expiredTrial,
|
licenseState: .none,
|
validationFreshness: .neverChecked
|
)
|
case .proUnlocked:
|
snapshot = EntitlementSnapshot(
|
trialState: expiredTrial,
|
licenseState: activeLicense,
|
validationFreshness: .valid(
|
lastValidAt: now,
|
nextCheckAt: now.addingTimeInterval(24 * 60 * 60)
|
)
|
)
|
case .offlineGrace:
|
snapshot = EntitlementSnapshot(
|
trialState: expiredTrial,
|
licenseState: activeLicense,
|
validationFreshness: EntitlementPolicy.offlineGrace(lastAttemptAt: now)
|
)
|
case .verifyFailed:
|
snapshot = EntitlementSnapshot(
|
trialState: expiredTrial,
|
licenseState: activeLicense,
|
validationFreshness: .hardFailure(reason: .serverRejected)
|
)
|
}
|
}
|
|
public func entitlement(for feature: ProFeature, at now: Date) -> EffectiveEntitlement {
|
EntitlementPolicy.evaluate(snapshot: snapshot, feature: feature, now: now)
|
}
|
}
|