krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.8.1: 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    static var cachedEntitlement: CachedEntitlement? {
 38        #if DEBUG
 39        if let forcedEntitlement = debugForcedEntitlement {
 40            return forcedEntitlement
 41        }
 42        #endif
 43
 44        guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
 45        return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
 46    }
 47
 48    static var cachedTier: FeatureTier {
 49        cachedEntitlement?.tier ?? .free
 50    }
 51
 52    var products: [Product] = []
 53    var currentTier: FeatureTier
 54    var activeProductID: String?
 55    var isLoadingProducts = false
 56    var isPurchasing = false
 57    var isRestoring = false
 58    var statusMessage: String?
 59    var errorMessage: String?
 60
 61    private var updatesTask: Task<Void, Never>?
 62
 63    private init() {
 64        currentTier = Self.cachedTier
 65        activeProductID = Self.cachedEntitlement?.activeProductID
 66        applyDebugOverrideIfNeeded()
 67        updatesTask = observeTransactionUpdates()
 68        Task {
 69            await refreshProducts()
 70            await refreshEntitlements()
 71        }
 72    }
 73
 74    var hasProAccess: Bool {
 75        currentTier != .free
 76    }
 77
 78    var hasProPlusAccess: Bool {
 79        currentTier == .proPlus
 80    }
 81
 82    func refreshProducts() async {
 83        isLoadingProducts = true
 84        errorMessage = nil
 85
 86        do {
 87            let fetchedProducts = try await Product.products(for: Self.productIDs)
 88            products = fetchedProducts.sorted { lhs, rhs in
 89                productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id)
 90            }
 91        } catch {
 92            products = []
 93            errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.")
 94        }
 95
 96        isLoadingProducts = false
 97    }
 98
 99    func refreshEntitlements() async {
100        var activeTransactions: [Transaction] = []
101
102        for await result in Transaction.currentEntitlements {
103            guard case .verified(let transaction) = result else {
104                continue
105            }
106            guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
107                continue
108            }
109            activeTransactions.append(transaction)
110        }
111
112        let activeProductID = activeTransactions
113            .sorted { $0.purchaseDate > $1.purchaseDate }
114            .first?
115            .productID
116
117        self.activeProductID = activeProductID
118        currentTier = tier(for: activeProductID)
119        persistCurrentEntitlement()
120        applyDebugOverrideIfNeeded()
121    }
122
123    func purchase(_ product: Product) async {
124        isPurchasing = true
125        statusMessage = nil
126        errorMessage = nil
127
128        do {
129            let result = try await product.purchase()
130
131            switch result {
132            case .success(let verification):
133                let transaction = try verifiedTransaction(from: verification)
134                apply(transaction: transaction)
135                await transaction.finish()
136                await refreshEntitlements()
137                statusMessage = currentTier == .proPlus ? "Pro+ is active." : "Pro is active."
138            case .userCancelled:
139                break
140            case .pending:
141                statusMessage = "Purchase is pending approval."
142            @unknown default:
143                errorMessage = "The purchase could not be completed."
144            }
145        } catch {
146            errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.")
147        }
148
149        isPurchasing = false
150    }
151
152    func restorePurchases() async {
153        isRestoring = true
154        statusMessage = nil
155        errorMessage = nil
156
157        do {
158            try await AppStore.sync()
159            await refreshEntitlements()
160            statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found."
161        } catch {
162            errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.")
163        }
164
165        isRestoring = false
166    }
167
168    func manageSubscription() async {
169        errorMessage = nil
170
171        #if canImport(UIKit)
172        if ProcessInfo.processInfo.isiOSAppOnMac {
173            errorMessage = "Manage Subscription is not available on this device."
174            return
175        }
176
177        guard let scene = UIApplication.shared.connectedScenes
178            .compactMap({ $0 as? UIWindowScene })
179            .first(where: { $0.activationState == .foregroundActive }) else {
180            errorMessage = "Manage Subscription is not available right now."
181            return
182        }
183
184        do {
185            try await AppStore.showManageSubscriptions(in: scene)
186        } catch {
187            errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.")
188        }
189        #else
190        errorMessage = "Manage Subscription is not available on this platform."
191        #endif
192    }
193
194    func clearMessages() {
195        statusMessage = nil
196        errorMessage = nil
197    }
198
199    func resetCachedStateAfterLocalWipe() {
200        currentTier = Self.cachedTier
201        activeProductID = Self.cachedEntitlement?.activeProductID
202        statusMessage = nil
203        errorMessage = nil
204        applyDebugOverrideIfNeeded()
205    }
206
207    private func apply(transaction: Transaction) {
208        guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
209            return
210        }
211
212        activeProductID = transaction.productID
213        currentTier = tier(for: transaction.productID)
214        persistCurrentEntitlement()
215        applyDebugOverrideIfNeeded()
216    }
217
218    private func observeTransactionUpdates() -> Task<Void, Never> {
219        Task.detached(priority: .background) { [weak self] in
220            for await result in Transaction.updates {
221                guard let self else { return }
222                await self.handleTransactionUpdate(result)
223            }
224        }
225    }
226
227    private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
228        guard case .verified(let transaction) = result else { return }
229        apply(transaction: transaction)
230        await transaction.finish()
231        await refreshEntitlements()
232    }
233
234    private func persistCurrentEntitlement() {
235        let cachedEntitlement = CachedEntitlement(
236            tier: currentTier,
237            activeProductID: activeProductID,
238            updatedAt: Date()
239        )
240
241        if let data = try? JSONEncoder().encode(cachedEntitlement) {
242            UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey)
243        }
244    }
245
246    private func applyDebugOverrideIfNeeded() {
247        #if DEBUG
248        guard let forcedEntitlement = Self.debugForcedEntitlement else { return }
249        currentTier = forcedEntitlement.tier
250        activeProductID = forcedEntitlement.activeProductID
251        #endif
252    }
253
254    private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
255        switch result {
256        case .verified(let transaction):
257            return transaction
258        case .unverified:
259            throw StoreKitError.notEntitled
260        }
261    }
262
263    private func productSortIndex(for productID: String) -> Int {
264        switch productID {
265        case Self.monthlyProductID:
266            return 0
267        case Self.yearlyProductID:
268            return 1
269        case Self.proPlusMonthlyProductID:
270            return 2
271        case Self.proPlusYearlyProductID:
272            return 3
273        default:
274            return Int.max
275        }
276    }
277
278    private func tier(for productID: String?) -> FeatureTier {
279        switch productID {
280        case Self.monthlyProductID, Self.yearlyProductID:
281            return .pro
282        case Self.proPlusMonthlyProductID, Self.proPlusYearlyProductID:
283            return .proPlus
284        default:
285            return .free
286        }
287    }
288
289    private func storeMessage(for error: Error, fallback: String) -> String {
290        if let storeKitError = error as? StoreKitError {
291            switch storeKitError {
292            case .networkError:
293                return "The App Store is offline right now."
294            default:
295                return fallback
296            }
297        }
298
299        let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
300        return message.isEmpty ? fallback : message
301    }
302
303    #if DEBUG
304    private static var debugForcedEntitlement: CachedEntitlement? {
305        let arguments = ProcessInfo.processInfo.arguments
306
307        if arguments.contains(debugForceFreeArgument) {
308            return CachedEntitlement(
309                tier: .free,
310                activeProductID: nil,
311                updatedAt: .distantPast
312            )
313        }
314
315        if arguments.contains(debugForceProPlusArgument) {
316            return CachedEntitlement(
317                tier: .proPlus,
318                activeProductID: proPlusMonthlyProductID,
319                updatedAt: .distantPast
320            )
321        }
322
323        if arguments.contains(debugForceProArgument) {
324            return CachedEntitlement(
325                tier: .pro,
326                activeProductID: monthlyProductID,
327                updatedAt: .distantPast
328            )
329        }
330
331        return nil
332    }
333    #endif
334}