diff options
| author | Christian Cleberg <[email protected]> | 2026-07-25 12:14:58 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-25 12:19:37 -0500 |
| commit | 963ce3fb34bd6c6bd72f1aa956ce602267b90e9c (patch) | |
| tree | ba2c18e4c2bebba8d07a6715e07bd1fccbea4581 /DomainDig/PurchaseService.swift | |
| parent | 7156c088a438526182c517fafa35d31990d44f18 (diff) | |
| download | domain-dig-5.0.1.tar.gz domain-dig-5.0.1.tar.bz2 domain-dig-5.0.1.zip | |
feat: owner Pro+ allowlist via CloudKit, cut v5.0.1v5.0.1
Grants the app owner Pro+ without a purchase, keyed to their CloudKit
user-record ID so it works on the release App Store build.
- OwnerAccess holds the owner's CloudKit user-record ID (opaque, per-Apple-ID,
scoped to the app's container; safe to publish — CloudKit verifies identity
server-side, so it can't be presented by anyone else).
- PurchaseService resolves the allowlist against CloudKit once per launch and,
on a match, records a persisted owner grant so it applies instantly and
offline thereafter. The grant only ever elevates the tier to .proPlus and
defers to the existing #if DEBUG overrides, so real purchases and free/pro
testing are unaffected. cachedTier / cachedEntitlement were refactored to
fall back to the owner grant only when no debug override or stored purchase
applies.
- Supersedes the DEBUG record-ID reveal (PR #60): its only purpose was to read
the owner's ID, which is now hardcoded, so the reveal is not shipped.
Release cut: MARKETING_VERSION 5.0.0 -> 5.0.1, CURRENT_PROJECT_VERSION 45 -> 46,
AppVersion.current -> 5.0.1, roadmap updated. App builds clean; unit suite 63/63.
Diffstat (limited to 'DomainDig/PurchaseService.swift')
| -rw-r--r-- | DomainDig/PurchaseService.swift | 53 |
1 files changed, 50 insertions, 3 deletions
diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift index 4ab8ef4..91ce211 100644 --- a/DomainDig/PurchaseService.swift +++ b/DomainDig/PurchaseService.swift @@ -34,6 +34,20 @@ final class PurchaseService { private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" #endif + private static let ownerEntitlementKey = "purchase.ownerEntitlement" + + /// Whether the owner allowlist has confirmed this device's iCloud user as the + /// owner. Persisted so the grant is instant on later launches and survives + /// offline, when CloudKit cannot be reached. + static var ownerEntitlementGranted: Bool { + UserDefaults.standard.bool(forKey: ownerEntitlementKey) + } + + private static var storedEntitlement: CachedEntitlement? { + guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } + return try? JSONDecoder().decode(CachedEntitlement.self, from: data) + } + static var cachedEntitlement: CachedEntitlement? { #if DEBUG if let forcedEntitlement = debugForcedEntitlement { @@ -41,12 +55,17 @@ final class PurchaseService { } #endif - guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } - return try? JSONDecoder().decode(CachedEntitlement.self, from: data) + return storedEntitlement } static var cachedTier: FeatureTier { - cachedEntitlement?.tier ?? .free + #if DEBUG + // A debug override wins outright so free/pro tiers remain testable on the + // owner's own device. + if let forcedEntitlement = debugForcedEntitlement { return forcedEntitlement.tier } + #endif + if ownerEntitlementGranted { return .proPlus } + return storedEntitlement?.tier ?? .free } var products: [Product] = [] @@ -64,8 +83,10 @@ final class PurchaseService { currentTier = Self.cachedTier activeProductID = Self.cachedEntitlement?.activeProductID applyDebugOverrideIfNeeded() + applyOwnerOverrideIfNeeded() updatesTask = observeTransactionUpdates() Task { + await resolveOwnerEntitlementIfNeeded() await refreshProducts() await refreshEntitlements() } @@ -118,6 +139,7 @@ final class PurchaseService { currentTier = tier(for: activeProductID) persistCurrentEntitlement() applyDebugOverrideIfNeeded() + applyOwnerOverrideIfNeeded() } func purchase(_ product: Product) async { @@ -251,6 +273,31 @@ final class PurchaseService { #endif } + /// Elevates the current tier to Pro+ when the device's iCloud user has been + /// confirmed as the owner. Only ever elevates, and defers to a debug override + /// so free/pro tiers stay testable on the owner's own device. + private func applyOwnerOverrideIfNeeded() { + #if DEBUG + if Self.debugForcedEntitlement != nil { return } + #endif + guard Self.ownerEntitlementGranted else { return } + currentTier = .proPlus + } + + /// Resolves the owner allowlist against CloudKit once per launch. On a match + /// it records the grant so future launches apply it synchronously and offline. + private func resolveOwnerEntitlementIfNeeded() async { + guard OwnerAccess.isConfigured else { return } + if Self.ownerEntitlementGranted { + applyOwnerOverrideIfNeeded() + return + } + if await OwnerAccess.isOwner() { + UserDefaults.standard.set(true, forKey: Self.ownerEntitlementKey) + applyOwnerOverrideIfNeeded() + } + } + private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { switch result { case .verified(let transaction): |
