krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.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 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}