krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.3: DomainDig/PurchaseService.swift · raw

  1import Foundation
  2import StoreKit
  3
  4#if canImport(UIKit)
  5import UIKit
  6#endif
  7
  8@MainActor
  9@Observable
 10final class PurchaseService {
 11    struct CachedEntitlement: Codable {
 12        let tier: FeatureTier
 13        let activeProductID: String?
 14        let updatedAt: Date
 15    }
 16
 17    static let shared = PurchaseService()
 18    static let monthlyProductID = "domaindig.pro.month"
 19    static let yearlyProductID = "domaindig.pro.annually"
 20    static let proPlusMonthlyProductID = "domaindig.proplus.monthly"
 21    static let proPlusYearlyProductID = "domaindig.proplus.annually"
 22    static let productIDs = [
 23        monthlyProductID,
 24        yearlyProductID,
 25        proPlusMonthlyProductID,
 26        proPlusYearlyProductID
 27    ]
 28
 29    private static let entitlementCacheKey = "purchase.cachedEntitlement"
 30    #if DEBUG
 31    // Local-only screenshot/testing override. Release builds always use StoreKit entitlements.
 32    private static let debugForceFreeArgument = "DOMAIN_DIG_FORCE_FREE"
 33    private static let debugForceProArgument = "DOMAIN_DIG_FORCE_PRO"
 34    private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
 35    #endif
 36
 37    private static let ownerEntitlementKey = "purchase.ownerEntitlement"
 38
 39    /// Whether the owner allowlist has confirmed this device's iCloud user as the
 40    /// owner. Persisted so the grant is instant on later launches and survives
 41    /// offline, when CloudKit cannot be reached.
 42    static var ownerEntitlementGranted: Bool {
 43        UserDefaults.standard.bool(forKey: ownerEntitlementKey)
 44    }
 45
 46    private static var storedEntitlement: CachedEntitlement? {
 47        guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
 48        return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
 49    }
 50
 51    static var cachedEntitlement: CachedEntitlement? {
 52        #if DEBUG
 53        if let forcedEntitlement = debugForcedEntitlement {
 54            return forcedEntitlement
 55        }
 56        #endif
 57
 58        return storedEntitlement
 59    }
 60
 61    static var cachedTier: FeatureTier {
 62        #if DEBUG
 63        // A debug override wins outright so free/pro tiers remain testable on the
 64        // owner's own device.
 65        if let forcedEntitlement = debugForcedEntitlement { return forcedEntitlement.tier }
 66        #endif
 67        if ownerEntitlementGranted { return .proPlus }
 68        return storedEntitlement?.tier ?? .free
 69    }
 70
 71    var products: [Product] = []
 72    var currentTier: FeatureTier
 73    var activeProductID: String?
 74    var isLoadingProducts = false
 75    var isPurchasing = false
 76    var isRestoring = false
 77    var statusMessage: String?
 78    var errorMessage: String?
 79
 80    private var updatesTask: Task<Void, Never>?
 81
 82    private init() {
 83        currentTier = Self.cachedTier
 84        activeProductID = Self.cachedEntitlement?.activeProductID
 85        applyDebugOverrideIfNeeded()
 86        applyOwnerOverrideIfNeeded()
 87        updatesTask = observeTransactionUpdates()
 88        Task {
 89            await resolveOwnerEntitlementIfNeeded()
 90            await refreshProducts()
 91            await refreshEntitlements()
 92        }
 93    }
 94
 95    var hasProAccess: Bool {
 96        currentTier != .free
 97    }
 98
 99    var hasProPlusAccess: Bool {
100        currentTier == .proPlus
101    }
102
103    func refreshProducts() async {
104        isLoadingProducts = true
105        errorMessage = nil
106
107        do {
108            let fetchedProducts = try await Product.products(for: Self.productIDs)
109            products = fetchedProducts.sorted { lhs, rhs in
110                productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id)
111            }
112        } catch {
113            products = []
114            errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.")
115        }
116
117        isLoadingProducts = false
118    }
119
120    func refreshEntitlements() async {
121        var activeTransactions: [Transaction] = []
122
123        for await result in Transaction.currentEntitlements {
124            guard case .verified(let transaction) = result else {
125                continue
126            }
127            guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
128                continue
129            }
130            activeTransactions.append(transaction)
131        }
132
133        let activeProductID = activeTransactions
134            .sorted { $0.purchaseDate > $1.purchaseDate }
135            .first?
136            .productID
137
138        self.activeProductID = activeProductID
139        currentTier = tier(for: activeProductID)
140        persistCurrentEntitlement()
141        applyDebugOverrideIfNeeded()
142        applyOwnerOverrideIfNeeded()
143    }
144
145    func purchase(_ product: Product) async {
146        isPurchasing = true
147        statusMessage = nil
148        errorMessage = nil
149
150        do {
151            let result = try await product.purchase()
152
153            switch result {
154            case .success(let verification):
155                let transaction = try verifiedTransaction(from: verification)
156                apply(transaction: transaction)
157                await transaction.finish()
158                await refreshEntitlements()
159                statusMessage = currentTier == .proPlus ? "Pro+ is active." : "Pro is active."
160            case .userCancelled:
161                break
162            case .pending:
163                statusMessage = "Purchase is pending approval."
164            @unknown default:
165                errorMessage = "The purchase could not be completed."
166            }
167        } catch {
168            errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.")
169        }
170
171        isPurchasing = false
172    }
173
174    func restorePurchases() async {
175        isRestoring = true
176        statusMessage = nil
177        errorMessage = nil
178
179        do {
180            try await AppStore.sync()
181            await refreshEntitlements()
182            statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found."
183        } catch {
184            errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.")
185        }
186
187        isRestoring = false
188    }
189
190    func manageSubscription() async {
191        errorMessage = nil
192
193        #if canImport(UIKit)
194        if ProcessInfo.processInfo.isiOSAppOnMac {
195            errorMessage = "Manage Subscription is not available on this device."
196            return
197        }
198
199        guard let scene = UIApplication.shared.connectedScenes
200            .compactMap({ $0 as? UIWindowScene })
201            .first(where: { $0.activationState == .foregroundActive }) else {
202            errorMessage = "Manage Subscription is not available right now."
203            return
204        }
205
206        do {
207            try await AppStore.showManageSubscriptions(in: scene)
208        } catch {
209            errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.")
210        }
211        #else
212        errorMessage = "Manage Subscription is not available on this platform."
213        #endif
214    }
215
216    func clearMessages() {
217        statusMessage = nil
218        errorMessage = nil
219    }
220
221    func resetCachedStateAfterLocalWipe() {
222        currentTier = Self.cachedTier
223        activeProductID = Self.cachedEntitlement?.activeProductID
224        statusMessage = nil
225        errorMessage = nil
226        applyDebugOverrideIfNeeded()
227    }
228
229    private func apply(transaction: Transaction) {
230        guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
231            return
232        }
233
234        activeProductID = transaction.productID
235        currentTier = tier(for: transaction.productID)
236        persistCurrentEntitlement()
237        applyDebugOverrideIfNeeded()
238    }
239
240    private func observeTransactionUpdates() -> Task<Void, Never> {
241        Task.detached(priority: .background) { [weak self] in
242            for await result in Transaction.updates {
243                guard let self else { return }
244                await self.handleTransactionUpdate(result)
245            }
246        }
247    }
248
249    private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
250        guard case .verified(let transaction) = result else { return }
251        apply(transaction: transaction)
252        await transaction.finish()
253        await refreshEntitlements()
254    }
255
256    private func persistCurrentEntitlement() {
257        let cachedEntitlement = CachedEntitlement(
258            tier: currentTier,
259            activeProductID: activeProductID,
260            updatedAt: Date()
261        )
262
263        if let data = try? JSONEncoder().encode(cachedEntitlement) {
264            UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey)
265        }
266    }
267
268    private func applyDebugOverrideIfNeeded() {
269        #if DEBUG
270        guard let forcedEntitlement = Self.debugForcedEntitlement else { return }
271        currentTier = forcedEntitlement.tier
272        activeProductID = forcedEntitlement.activeProductID
273        #endif
274    }
275
276    /// Elevates the current tier to Pro+ when the device's iCloud user has been
277    /// confirmed as the owner. Only ever elevates, and defers to a debug override
278    /// so free/pro tiers stay testable on the owner's own device.
279    private func applyOwnerOverrideIfNeeded() {
280        #if DEBUG
281        if Self.debugForcedEntitlement != nil { return }
282        #endif
283        guard Self.ownerEntitlementGranted else { return }
284        currentTier = .proPlus
285    }
286
287    /// Resolves the owner allowlist against CloudKit once per launch. On a match
288    /// it records the grant so future launches apply it synchronously and offline.
289    private func resolveOwnerEntitlementIfNeeded() async {
290        guard OwnerAccess.isConfigured else { return }
291        if Self.ownerEntitlementGranted {
292            applyOwnerOverrideIfNeeded()
293            return
294        }
295        if await OwnerAccess.isOwner() {
296            UserDefaults.standard.set(true, forKey: Self.ownerEntitlementKey)
297            applyOwnerOverrideIfNeeded()
298        }
299    }
300
301    private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
302        switch result {
303        case .verified(let transaction):
304            return transaction
305        case .unverified:
306            throw StoreKitError.notEntitled
307        }
308    }
309
310    private func productSortIndex(for productID: String) -> Int {
311        switch productID {
312        case Self.monthlyProductID:
313            return 0
314        case Self.yearlyProductID:
315            return 1
316        case Self.proPlusMonthlyProductID:
317            return 2
318        case Self.proPlusYearlyProductID:
319            return 3
320        default:
321            return Int.max
322        }
323    }
324
325    private func tier(for productID: String?) -> FeatureTier {
326        switch productID {
327        case Self.monthlyProductID, Self.yearlyProductID:
328            return .pro
329        case Self.proPlusMonthlyProductID, Self.proPlusYearlyProductID:
330            return .proPlus
331        default:
332            return .free
333        }
334    }
335
336    private func storeMessage(for error: Error, fallback: String) -> String {
337        if let storeKitError = error as? StoreKitError {
338            if case .networkError = storeKitError {
339                return "The App Store is offline right now."
340            }
341            return fallback
342        }
343
344        let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
345        return message.isEmpty ? fallback : message
346    }
347
348    #if DEBUG
349    private static var debugForcedEntitlement: CachedEntitlement? {
350        let arguments = ProcessInfo.processInfo.arguments
351
352        if arguments.contains(debugForceFreeArgument) {
353            return CachedEntitlement(
354                tier: .free,
355                activeProductID: nil,
356                updatedAt: .distantPast
357            )
358        }
359
360        if arguments.contains(debugForceProPlusArgument) {
361            return CachedEntitlement(
362                tier: .proPlus,
363                activeProductID: proPlusMonthlyProductID,
364                updatedAt: .distantPast
365            )
366        }
367
368        if arguments.contains(debugForceProArgument) {
369            return CachedEntitlement(
370                tier: .pro,
371                activeProductID: monthlyProductID,
372                updatedAt: .distantPast
373            )
374        }
375
376        return nil
377    }
378    #endif
379}