Skip to content

Commit 8a14e34

Browse files
authored
store actor (#27)
1 parent 7163e89 commit 8a14e34

13 files changed

+527
-274
lines changed

LICENSE

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

3-
Copyright (c) 2020 SpaceNation Inc.
3+
Copyright (c) 2023 SpaceNation Inc.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
22+
23+
Copyright © 2022 Apple Inc.
424

525
Permission is hereby granted, free of charge, to any person obtaining a copy
626
of this software and associated documentation files (the "Software"), to deal

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// swift-tools-version:5.3
1+
// swift-tools-version:5.7
22
import PackageDescription
33

44
let package = Package(
55
name: "Storefront",
66
platforms: [
7-
.iOS(.v14), .macOS(.v11), .tvOS(.v14)
7+
.iOS(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9)
88
],
99
products: [
1010
.library(name: "Storefront", targets: ["Storefront"])
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import Foundation
2+
@_exported import StoreKit
3+
4+
@globalActor public actor StoreActor {
5+
6+
static let subscriptionIDs: Set<String> = {
7+
guard let storefrontPreferences = Bundle.main.object(forInfoDictionaryKey: "Storefront") as? [String: Any], let subscriptions = storefrontPreferences["Subscriptions"] as? [String] else {
8+
print("No Subscriptions in Storefront plist key")
9+
return []
10+
}
11+
12+
print(subscriptions)
13+
return Set(subscriptions)
14+
}()
15+
16+
static let purchaseIDs: Set<String> = {
17+
guard let storefrontPreferences = Bundle.main.object(forInfoDictionaryKey: "Storefront") as? [String: Any], let purchases = storefrontPreferences["Purchases"] as? [String] else {
18+
print("No Purchases in Storefront plist key")
19+
return []
20+
}
21+
22+
precondition(!purchases.isEmpty, "Purchases must contain exactly one element")
23+
24+
print(purchases)
25+
return Set(purchases)
26+
}()
27+
28+
static let allProductIDs: Set<String> = {
29+
subscriptionIDs.union(purchaseIDs)
30+
}()
31+
32+
public static let shared = StoreActor()
33+
34+
private var loadedProducts: [String: Product] = [:]
35+
private var lastLoadError: Error?
36+
private var productLoadingTask: Task<Void, Never>?
37+
38+
private var transactionUpdatesTask: Task<Void, Never>?
39+
private var statusUpdatesTask: Task<Void, Never>?
40+
private var storefrontUpdatesTask: Task<Void, Never>?
41+
42+
public nonisolated let productController: StoreProductController
43+
public nonisolated let subscriptionController: StoreSubscriptionController
44+
45+
init() {
46+
self.productController = StoreProductController(identifiedBy: Self.purchaseIDs)
47+
self.subscriptionController = StoreSubscriptionController(productIDs: Array(Self.subscriptionIDs))
48+
Task(priority: .background) {
49+
await self.setupListenerTasksIfNecessary()
50+
await self.loadProducts()
51+
}
52+
}
53+
54+
public func product(identifiedBy productID: String) async -> Product? {
55+
await waitUntilProductsLoaded()
56+
return loadedProducts[productID]
57+
}
58+
59+
private func setupListenerTasksIfNecessary() {
60+
if transactionUpdatesTask == nil {
61+
transactionUpdatesTask = Task(priority: .background) {
62+
for await update in StoreKit.Transaction.updates {
63+
await self.handle(transaction: update)
64+
}
65+
}
66+
}
67+
if statusUpdatesTask == nil {
68+
statusUpdatesTask = Task(priority: .background) {
69+
for await update in Product.SubscriptionInfo.Status.updates {
70+
await subscriptionController.handle(update: update)
71+
}
72+
}
73+
}
74+
if storefrontUpdatesTask == nil {
75+
storefrontUpdatesTask = Task(priority: .background) {
76+
for await update in Storefront.updates {
77+
self.handle(storefrontUpdate: update)
78+
}
79+
}
80+
}
81+
}
82+
83+
private func waitUntilProductsLoaded() async {
84+
if let task = productLoadingTask {
85+
await task.value
86+
}
87+
// You load all the products at once, so you can skip this if the
88+
// dictionary is empty.
89+
else if loadedProducts.isEmpty {
90+
let newTask = Task {
91+
await loadProducts()
92+
}
93+
productLoadingTask = newTask
94+
await newTask.value
95+
}
96+
}
97+
98+
private func loadProducts() async {
99+
do {
100+
let products = try await Product.products(for: Self.allProductIDs)
101+
try Task.checkCancellation()
102+
print("Loaded \(products.count) products")
103+
loadedProducts = products.reduce(into: [:]) {
104+
$0[$1.id] = $1
105+
}
106+
let premiumProduct: Product? = Self.purchaseIDs.first.flatMap { loadedProducts[$0] }
107+
Task(priority: .utility) { @MainActor in
108+
self.productController.product = premiumProduct
109+
self.subscriptionController.subscriptions = products
110+
.compactMap { Subscription(subscription: $0) }
111+
// Now that you have loaded the products, have the subscription
112+
// controller update the entitlement based on the group ID.
113+
await self.subscriptionController.updateEntitlement()
114+
}
115+
} catch {
116+
print("Failed to get in-app products: \(error)")
117+
lastLoadError = error
118+
}
119+
productLoadingTask = nil
120+
}
121+
122+
private func handle(transaction: VerificationResult<StoreKit.Transaction>) async {
123+
guard case .verified(let transaction) = transaction else {
124+
print("Received unverified transaction: \(transaction)")
125+
return
126+
}
127+
// If you have a subscription, call checkEntitlement() which gets the
128+
// full status instead.
129+
if transaction.productType == .autoRenewable {
130+
await subscriptionController.updateEntitlement()
131+
} else if transaction.productID == Self.purchaseIDs.first {
132+
await productController.set(isEntitled: !transaction.isRevoked)
133+
}
134+
await transaction.finish()
135+
}
136+
137+
private func handle(storefrontUpdate newStorefront: Storefront) {
138+
print("Storefront changed to \(newStorefront)")
139+
// Cancel existing loading task if necessary.
140+
if let task = productLoadingTask {
141+
task.cancel()
142+
}
143+
// Load products again.
144+
productLoadingTask = Task(priority: .utility) {
145+
await self.loadProducts()
146+
}
147+
}
148+
149+
}
150+
151+
public extension StoreKit.Transaction {
152+
var isRevoked: Bool {
153+
// The revocation date is never in the future.
154+
revocationDate != nil
155+
}
156+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#if os(iOS)
2+
import Foundation
3+
import StoreKit
4+
import SwiftUI
5+
6+
@MainActor
7+
public final class StoreMessagesManager {
8+
private var pendingMessages: [Message] = []
9+
private var updatesTask: Task<Void, Never>?
10+
11+
public static let shared = StoreMessagesManager()
12+
13+
public var sensitiveViewIsPresented = false {
14+
didSet {
15+
handlePendingMessages()
16+
}
17+
}
18+
19+
public var displayAction: DisplayMessageAction? {
20+
didSet {
21+
handlePendingMessages()
22+
}
23+
}
24+
25+
private init() {
26+
self.updatesTask = Task.detached(priority: .background) {
27+
await self.updatesLoop()
28+
}
29+
}
30+
31+
deinit {
32+
updatesTask?.cancel()
33+
}
34+
35+
private func updatesLoop() async {
36+
for await message in Message.messages {
37+
if sensitiveViewIsPresented == false, let action = displayAction {
38+
display(message: message, with: action)
39+
} else {
40+
pendingMessages.append(message)
41+
}
42+
}
43+
}
44+
45+
private func handlePendingMessages() {
46+
if sensitiveViewIsPresented == false, let action = displayAction {
47+
let pendingMessages = self.pendingMessages
48+
self.pendingMessages = []
49+
for message in pendingMessages {
50+
display(message: message, with: action)
51+
}
52+
}
53+
}
54+
55+
private func display(message: Message, with display: DisplayMessageAction) {
56+
do {
57+
try display(message)
58+
} catch {
59+
print("Failed to display message: \(error)")
60+
}
61+
}
62+
63+
}
64+
65+
public struct StoreMessagesDeferredPreferenceKey: PreferenceKey {
66+
public static let defaultValue = false
67+
68+
public static func reduce(value: inout Bool, nextValue: () -> Bool) {
69+
value = value || nextValue()
70+
}
71+
}
72+
73+
private struct StoreMessagesDeferredModifier: ViewModifier {
74+
let areDeferred: Bool
75+
76+
func body(content: Content) -> some View {
77+
content.preference(key: StoreMessagesDeferredPreferenceKey.self, value: areDeferred)
78+
}
79+
}
80+
81+
public extension View {
82+
func storeMessagesDeferred(_ storeMessagesDeferred: Bool) -> some View {
83+
self.modifier(StoreMessagesDeferredModifier(areDeferred: storeMessagesDeferred))
84+
}
85+
}
86+
87+
#endif // os(iOS)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Foundation
2+
import StoreKit
3+
import Combine
4+
5+
@MainActor
6+
public final class StoreProductController: ObservableObject {
7+
@Published public internal(set) var product: Product?
8+
@Published public private(set) var isEntitled: Bool = false
9+
@Published public private(set) var purchaseError: Error?
10+
11+
private let productIDs: Set<String>
12+
13+
internal nonisolated init(identifiedBy productIDs: Set<String>) {
14+
self.productIDs = productIDs
15+
Task(priority: .background) {
16+
await self.updateEntitlement()
17+
}
18+
}
19+
20+
public func purchase() async {
21+
guard let product = product else {
22+
print("Product has not loaded yet")
23+
return
24+
}
25+
do {
26+
let result = try await product.purchase()
27+
switch result {
28+
case .success(let verificationResult):
29+
let transaction = try verificationResult.payloadValue
30+
self.isEntitled = true
31+
await transaction.finish()
32+
case .pending:
33+
print("Purchase pending user action")
34+
case .userCancelled:
35+
print("User cancelled purchase")
36+
@unknown default:
37+
print("Unknown result: \(result)")
38+
}
39+
} catch {
40+
purchaseError = error
41+
}
42+
}
43+
44+
internal func set(isEntitled: Bool) {
45+
self.isEntitled = isEntitled
46+
}
47+
48+
private func updateEntitlement() async {
49+
guard let productID = productIDs.first else {
50+
isEntitled = false
51+
return
52+
}
53+
54+
switch await StoreKit.Transaction.currentEntitlement(for: productID) {
55+
case .verified: isEntitled = true
56+
case .unverified(_, let error):
57+
print("Unverified entitlement for \(productID): \(error)")
58+
fallthrough
59+
case .none: isEntitled = false
60+
}
61+
}
62+
63+
}

0 commit comments

Comments
 (0)