Skip to content

Commit 0dc5f83

Browse files
authored
store actor (#28)
1 parent 8a14e34 commit 0dc5f83

File tree

9 files changed

+303
-421
lines changed

9 files changed

+303
-421
lines changed

LICENSE

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2023 SpaceNation Inc.
3+
Copyright © 2023 SpaceNation Inc.
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal
@@ -20,6 +20,10 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
SOFTWARE.
2222

23+
---
24+
25+
MIT License
26+
2327
Copyright © 2022 Apple Inc.
2428

2529
Permission is hereby granted, free of charge, to any person obtaining a copy

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import PackageDescription
44
let package = Package(
55
name: "Storefront",
66
platforms: [
7-
.iOS(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9)
7+
.iOS(.v15), .macOS(.v12), .tvOS(.v15), .watchOS(.v8)
88
],
99
products: [
1010
.library(name: "Storefront", targets: ["Storefront"])

Sources/Storefront/Store.swift

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import Foundation
2+
@_exported import StoreKit
3+
4+
public typealias Transaction = StoreKit.Transaction
5+
public typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
6+
public typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
7+
8+
public enum StoreError: Error {
9+
case failedVerification
10+
}
11+
12+
@MainActor public final class Store: ObservableObject {
13+
public enum PurchaseFinishedAction {
14+
case dismissStore
15+
case noAction
16+
case displayError
17+
}
18+
19+
private let productIdentifiers: Set<String>
20+
21+
@Published public private(set) var nonConsumables: [Product]
22+
@Published public private(set) var subscriptions: [Product]
23+
24+
@Published public private(set) var purchasedNonConsumables: [Product] = []
25+
@Published public private(set) var purchasedSubscriptions: [Product] = []
26+
27+
@Published public private(set) var purchasedProductIdentifiers: Set<String>
28+
29+
@Published public private(set) var purchaseError: (any LocalizedError)?
30+
31+
///
32+
private var lastLoadError: Error?
33+
34+
private var productLoadingTask: Task<Void, Never>?
35+
private var transactionUpdatesTask: Task<Void, Never>?
36+
private var statusUpdatesTask: Task<Void, Never>?
37+
private var storefrontUpdatesTask: Task<Void, Never>?
38+
private let userDefaults: UserDefaults
39+
40+
public init(productIdentifiers: Set<String>, userDefaults: UserDefaults = .standard) {
41+
self.productIdentifiers = productIdentifiers
42+
self.userDefaults = userDefaults
43+
let purchasedProductsArray = userDefaults.object(forKey: "purchasedProducts") as? [String]
44+
self.purchasedProductIdentifiers = Set(purchasedProductsArray ?? [])
45+
print("Persisted Purchased Products:", Set(purchasedProductsArray ?? []))
46+
47+
nonConsumables = []
48+
subscriptions = []
49+
50+
setupListenerTasksIfNecessary()
51+
52+
Task(priority: .background) {
53+
//During store initialization, request products from the App Store.
54+
await self.requestProducts()
55+
56+
//Deliver products that the customer purchases.
57+
await self.updateCustomerProductStatus()
58+
}
59+
}
60+
61+
deinit {
62+
productLoadingTask?.cancel()
63+
transactionUpdatesTask?.cancel()
64+
statusUpdatesTask?.cancel()
65+
storefrontUpdatesTask?.cancel()
66+
}
67+
68+
@MainActor
69+
func updateCustomerProductStatus() async {
70+
var purchasedNonConsumables: [Product] = []
71+
var purchasedSubscriptions: [Product] = []
72+
73+
//Iterate through all of the user's purchased products.
74+
for await result in Transaction.currentEntitlements {
75+
do {
76+
//Check whether the transaction is verified. If it isn’t, catch `failedVerification` error.
77+
let transaction = try checkVerified(result)
78+
79+
//Check the `productType` of the transaction and get the corresponding product from the store.
80+
switch transaction.productType {
81+
case .nonConsumable:
82+
if let nonConsumable = nonConsumables.first(where: { $0.id == transaction.productID }) {
83+
purchasedNonConsumables.append(nonConsumable)
84+
}
85+
case .autoRenewable:
86+
if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) {
87+
purchasedSubscriptions.append(subscription)
88+
}
89+
default:
90+
break
91+
}
92+
} catch {
93+
print("Transaction failed verification")
94+
}
95+
}
96+
97+
//Update the store information with the purchased products.
98+
self.purchasedNonConsumables = purchasedNonConsumables
99+
100+
//Update the store information with auto-renewable subscription products.
101+
self.purchasedSubscriptions = purchasedSubscriptions
102+
103+
//Update locally persisted identifiers
104+
let purchasedProductIdentifiers = (purchasedNonConsumables + purchasedSubscriptions).map { $0.id }
105+
self.purchasedProductIdentifiers = Set(purchasedProductIdentifiers)
106+
userDefaults.set(purchasedProductIdentifiers, forKey: "purchasedProducts")
107+
print("Updated Purchased Products:", Set(purchasedProductIdentifiers))
108+
}
109+
110+
public func removePersistedPurchasedProducts() {
111+
userDefaults.removeObject(forKey: "purchasedProducts")
112+
}
113+
114+
public func purchase(option product: Product) async -> PurchaseFinishedAction {
115+
let action: PurchaseFinishedAction
116+
do {
117+
let result = try await product.purchase()
118+
switch result {
119+
case .success(let verification):
120+
//Check whether the transaction is verified. If it isn't,
121+
//this function rethrows the verification error.
122+
let transaction = try checkVerified(verification)
123+
124+
//The transaction is verified. Deliver content to the user.
125+
await updateCustomerProductStatus()
126+
127+
//Always finish a transaction.
128+
await transaction.finish()
129+
action = .dismissStore
130+
case .pending:
131+
print("Purchase pending user action")
132+
action = .noAction
133+
case .userCancelled:
134+
print("User cancelled purchase")
135+
action = .noAction
136+
@unknown default:
137+
print("Unknown result: \(result)")
138+
action = .noAction
139+
}
140+
} catch let error as LocalizedError {
141+
purchaseError = error
142+
action = .displayError
143+
} catch {
144+
print("Purchase failed: \(error)")
145+
action = .noAction
146+
}
147+
return action
148+
}
149+
150+
private func setupListenerTasksIfNecessary() {
151+
if transactionUpdatesTask == nil {
152+
transactionUpdatesTask = Task(priority: .background) {
153+
for await result in Transaction.updates {
154+
do {
155+
let transaction = try self.checkVerified(result)
156+
157+
//Deliver products to the user.
158+
await self.updateCustomerProductStatus()
159+
160+
//Always finish a transaction.
161+
await transaction.finish()
162+
} catch {
163+
//StoreKit has a transaction that fails verification. Don't deliver content to the user.
164+
print("Transaction failed verification")
165+
}
166+
}
167+
}
168+
}
169+
if statusUpdatesTask == nil {
170+
statusUpdatesTask = Task(priority: .background) {
171+
for await update in Product.SubscriptionInfo.Status.updates {
172+
do {
173+
let transaction = try self.checkVerified(update.transaction)
174+
let _ = try self.checkVerified(update.renewalInfo)
175+
176+
//Deliver products to the user.
177+
await self.updateCustomerProductStatus()
178+
179+
//Always finish a transaction.
180+
await transaction.finish()
181+
} catch {
182+
//StoreKit has a transaction that fails verification. Don't deliver content to the user.
183+
print("Transaction failed verification")
184+
}
185+
}
186+
}
187+
}
188+
if storefrontUpdatesTask == nil {
189+
storefrontUpdatesTask = Task(priority: .background) {
190+
for await update in Storefront.updates {
191+
print("Storefront changed to \(update)")
192+
// Cancel existing loading task if necessary.
193+
if let task = productLoadingTask {
194+
task.cancel()
195+
}
196+
// Load products again.
197+
productLoadingTask = Task(priority: .utility) {
198+
await self.requestProducts()
199+
}
200+
}
201+
}
202+
}
203+
}
204+
205+
private func requestProducts() async {
206+
do {
207+
//Request products from the App Store using the identifiers.
208+
let storeProducts = try await Product.products(for: productIdentifiers)
209+
210+
var newNonConsumable: [Product] = []
211+
var newSubscriptions: [Product] = []
212+
213+
//Filter the products into categories based on their type.
214+
for product in storeProducts {
215+
switch product.type {
216+
case .nonConsumable:
217+
newNonConsumable.append(product)
218+
case .autoRenewable:
219+
newSubscriptions.append(product)
220+
default:
221+
//Ignore this product.
222+
print("Unknown product")
223+
}
224+
}
225+
226+
//Sort each product category by price, lowest to highest, to update the store.
227+
nonConsumables = sortByPrice(newNonConsumable)
228+
subscriptions = sortByPrice(newSubscriptions)
229+
} catch {
230+
print("Failed to get in-app products: \(error)")
231+
lastLoadError = error
232+
}
233+
productLoadingTask = nil
234+
}
235+
236+
func sortByPrice(_ products: [Product]) -> [Product] {
237+
products.sorted(by: { return $0.price < $1.price })
238+
}
239+
240+
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
241+
//Check whether the JWS passes StoreKit verification.
242+
switch result {
243+
case .unverified:
244+
//StoreKit parses the JWS, but it fails verification.
245+
throw StoreError.failedVerification
246+
case .verified(let safe):
247+
//The result is verified. Return the unwrapped value.
248+
return safe
249+
}
250+
}
251+
252+
}
253+
254+
public extension Transaction {
255+
var isRevoked: Bool {
256+
// The revocation date is never in the future.
257+
revocationDate != nil
258+
}
259+
}
260+
261+
public extension Product {
262+
var subscriptionInfo: Product.SubscriptionInfo {
263+
subscription.unsafelyUnwrapped
264+
}
265+
266+
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
267+
var priceText: String {
268+
"\(self.displayPrice)/\(self.subscriptionInfo.subscriptionPeriod.unit.localizedDescription.lowercased())"
269+
}
270+
}

0 commit comments

Comments
 (0)