krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.0: 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 if case .networkError = storeKitError {
292 return "The App Store is offline right now."
293 }
294 return fallback
295 }
296
297 let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
298 return message.isEmpty ? fallback : message
299 }
300
301 #if DEBUG
302 private static var debugForcedEntitlement: CachedEntitlement? {
303 let arguments = ProcessInfo.processInfo.arguments
304
305 if arguments.contains(debugForceFreeArgument) {
306 return CachedEntitlement(
307 tier: .free,
308 activeProductID: nil,
309 updatedAt: .distantPast
310 )
311 }
312
313 if arguments.contains(debugForceProPlusArgument) {
314 return CachedEntitlement(
315 tier: .proPlus,
316 activeProductID: proPlusMonthlyProductID,
317 updatedAt: .distantPast
318 )
319 }
320
321 if arguments.contains(debugForceProArgument) {
322 return CachedEntitlement(
323 tier: .pro,
324 activeProductID: monthlyProductID,
325 updatedAt: .distantPast
326 )
327 }
328
329 return nil
330 }
331 #endif
332}