├── .gitignore ├── .swiftlint.yml ├── Package.swift ├── README.md ├── Sources └── Storefront │ ├── NonConsumableDiscount.swift │ ├── ReviewManager.swift │ ├── Store.swift │ ├── StoreMessagesManager.swift │ └── SubscriptionSavings.swift └── Tests └── StorefrontTests └── Test.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.xcodeproj 3 | xcuserdata/ 4 | 5 | Packages/ 6 | .build/ 7 | .swiftpm -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - anyobject_protocol 3 | - array_init 4 | - attributes 5 | - closure_body_length 6 | - closure_end_indentation 7 | - closure_spacing 8 | - collection_alignment 9 | - contains_over_first_not_nil 10 | - convenience_type 11 | - discouraged_object_literal 12 | - discouraged_optional_boolean 13 | - empty_count 14 | - empty_string 15 | - empty_xctest_method 16 | - explicit_init 17 | - fallthrough 18 | - fatal_error_message 19 | - file_name 20 | - first_where 21 | - force_unwrapping 22 | - function_default_parameter_at_end 23 | - identical_operands 24 | - implicit_return 25 | - implicitly_unwrapped_optional 26 | - joined_default_parameter 27 | - last_where 28 | - legacy_random 29 | - let_var_whitespace 30 | - literal_expression_end_indentation 31 | - lower_acl_than_parent 32 | - modifier_order 33 | - multiline_arguments 34 | - multiline_arguments_brackets 35 | - multiline_function_chains 36 | - multiline_literal_brackets 37 | - multiline_parameters 38 | - multiline_parameters_brackets 39 | - no_grouping_extension 40 | - nslocalizedstring_key 41 | - number_separator 42 | - operator_usage_whitespace 43 | - overridden_super_call 44 | - override_in_extension 45 | - pattern_matching_keywords 46 | - private_action 47 | - private_outlet 48 | - prohibited_interface_builder 49 | - prohibited_super_call 50 | - redundant_nil_coalescing 51 | - redundant_type_annotation 52 | - required_enum_case 53 | - single_test_class 54 | - sorted_first_last 55 | - sorted_imports 56 | - static_operator 57 | - strict_fileprivate 58 | - strong_iboutlet 59 | - switch_case_on_newline 60 | - toggle_bool 61 | - trailing_closure 62 | - unneeded_parentheses_in_closure_argument 63 | - untyped_error_in_catch 64 | - unused_import 65 | - unused_private_declaration 66 | - vertical_parameter_alignment_on_call 67 | - vertical_whitespace_closing_braces 68 | - vertical_whitespace_opening_braces 69 | - xct_specific_matcher 70 | - yoda_condition 71 | 72 | line_length: 73 | warning: 300 74 | error: 300 75 | 76 | type_body_length: 77 | warning: 1000 78 | error: 1000 79 | 80 | file_length: 81 | warning: 1000 82 | error: 1000 83 | 84 | function_body_length: 85 | warning: 200 86 | error: 200 87 | 88 | closure_body_length: 89 | warning: 30 90 | error: 30 91 | 92 | cyclomatic_complexity: 93 | ignores_case_statements: true 94 | 95 | identifier_name: 96 | excluded: 97 | - id 98 | - ip 99 | - x 100 | - y 101 | 102 | excluded: 103 | - Dependencies 104 | - Tests/LinuxMain.swift 105 | - Tests/*/XCTestManifests.swift 106 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Storefront", 6 | platforms: [ 7 | .iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10) 8 | ], 9 | products: [ 10 | .library(name: "Storefront", targets: ["Storefront"]) 11 | ], 12 | targets: [ 13 | .target(name: "Storefront"), 14 | .testTarget(name: "StorefrontTests", dependencies: ["Storefront"]) 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Storefront 2 | -------------------------------------------------------------------------------- /Sources/Storefront/NonConsumableDiscount.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import StoreKit 3 | 4 | public struct NonConsumableDiscount { 5 | public let originalPrice: Decimal 6 | public let currentPrice: Decimal 7 | 8 | public init(originalPrice: Decimal, currentPrice: Decimal) { 9 | self.originalPrice = originalPrice 10 | self.currentPrice = currentPrice 11 | } 12 | 13 | public var formattedPercent: String { 14 | let percentageOff = 1 - (currentPrice / originalPrice) 15 | return percentageOff.formatted(.percent.rounded(rule: .down, increment: 1)) 16 | } 17 | 18 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 19 | public func formattedOriginalPrice(for nonCounsumable: Product) -> String { 20 | let currency = originalPrice.formatted(nonCounsumable.priceFormatStyle) 21 | return "\(currency)" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Storefront/ReviewManager.swift: -------------------------------------------------------------------------------- 1 | import StoreKit 2 | import SwiftUI 3 | 4 | /// A manager class that handles app review prompts based on user engagement points 5 | @Observable public class ReviewManager { 6 | /// The current engagement points accumulated by the user 7 | private(set) var engagementPoints: Int 8 | 9 | /// The thresholds at which review prompts will be triggered 10 | private let thresholds: [Int] 11 | 12 | /// UserDefaults keys 13 | private enum Keys { 14 | static let highestThresholdReached = "highestThresholdReached" 15 | static let currentPoints = "currentPoints" 16 | } 17 | 18 | /// UserDefaults instance 19 | private let defaults: UserDefaults 20 | 21 | /// Initialize the ReviewManager with custom thresholds 22 | init(thresholds: [Int] = [10, 50, 100], defaults: UserDefaults = .standard) { 23 | self.thresholds = thresholds.sorted() 24 | self.defaults = defaults 25 | self.engagementPoints = defaults.integer(forKey: Keys.currentPoints) 26 | } 27 | 28 | /// Add engagement points and check if a review should be requested 29 | /// - Parameter points: The number of points to add 30 | /// - Returns: True if a review prompt should be shown 31 | @MainActor 32 | public func add(engagementPoints points: Int) -> Bool { 33 | engagementPoints += points 34 | defaults.set(engagementPoints, forKey: Keys.currentPoints) 35 | 36 | let highestThresholdReached = defaults.integer(forKey: Keys.highestThresholdReached) 37 | 38 | // Check if we should show a review prompt 39 | if let nextThreshold = thresholds.first(where: { $0 > highestThresholdReached }), 40 | engagementPoints >= nextThreshold { 41 | 42 | defaults.set(nextThreshold, forKey: Keys.highestThresholdReached) 43 | return true 44 | } 45 | 46 | return false 47 | } 48 | 49 | /// Reset the engagement points and review history 50 | public func reset() { 51 | engagementPoints = 0 52 | defaults.set(0, forKey: Keys.currentPoints) 53 | defaults.set(0, forKey: Keys.highestThresholdReached) 54 | } 55 | } 56 | 57 | 58 | extension EnvironmentValues { 59 | @Entry public var reviewManager = ReviewManager(thresholds: [1, 10, 30], defaults: .standard) 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Storefront/Store.swift: -------------------------------------------------------------------------------- 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 @Observable 13 | public final class Store { 14 | public enum PurchaseFinishedAction { 15 | case dismissStore 16 | case noAction 17 | case displayError 18 | } 19 | 20 | private let productIdentifiers: Set 21 | 22 | public private(set) var currentStorefront: StoreKit.Storefront? 23 | 24 | public private(set) var nonConsumables: [Product] 25 | public private(set) var subscriptions: [Product] 26 | 27 | public private(set) var purchasedNonConsumables: [Product] = [] 28 | public private(set) var purchasedSubscriptions: [Product] = [] 29 | 30 | public private(set) var purchasedProductIdentifiers: Set 31 | 32 | public private(set) var purchaseError: (any LocalizedError)? 33 | 34 | public private(set) var purchaseInProgress: Bool 35 | 36 | private var lastLoadError: Error? 37 | private var productLoadingTask: Task? 38 | private var transactionUpdatesTask: Task? 39 | private var statusUpdatesTask: Task? 40 | private var storefrontUpdatesTask: Task? 41 | private let userDefaults: UserDefaults 42 | 43 | public init(productIdentifiers: Set, userDefaults: UserDefaults = .standard) { 44 | self.productIdentifiers = productIdentifiers 45 | self.userDefaults = userDefaults 46 | let purchasedProductsArray = userDefaults.object(forKey: "purchasedProducts") as? [String] 47 | self.purchasedProductIdentifiers = Set(purchasedProductsArray ?? []) 48 | print("Persisted Purchased Products:", Set(purchasedProductsArray ?? [])) 49 | 50 | self.purchaseInProgress = false 51 | 52 | nonConsumables = [] 53 | subscriptions = [] 54 | 55 | setupListenerTasksIfNecessary() 56 | 57 | Task(priority: .background) { 58 | // Request storefront 59 | self.currentStorefront = await StoreKit.Storefront.current 60 | 61 | //During store initialization, request products from the App Store. 62 | await self.requestProducts() 63 | 64 | //Deliver products that the customer purchases. 65 | await self.updateCustomerProductStatus() 66 | } 67 | } 68 | 69 | deinit { 70 | Task { @MainActor [weak self] in 71 | self?.productLoadingTask?.cancel() 72 | self?.transactionUpdatesTask?.cancel() 73 | self?.statusUpdatesTask?.cancel() 74 | self?.storefrontUpdatesTask?.cancel() 75 | } 76 | } 77 | 78 | @MainActor 79 | func updateCustomerProductStatus() async { 80 | var purchasedNonConsumables: [Product] = [] 81 | var purchasedSubscriptions: [Product] = [] 82 | 83 | //Iterate through all of the user's purchased products. 84 | for await result in Transaction.currentEntitlements { 85 | do { 86 | //Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. 87 | let transaction = try checkVerified(result) 88 | 89 | //Check the `productType` of the transaction and get the corresponding product from the store. 90 | switch transaction.productType { 91 | case .nonConsumable: 92 | if let nonConsumable = nonConsumables.first(where: { $0.id == transaction.productID }) { 93 | purchasedNonConsumables.append(nonConsumable) 94 | } 95 | case .autoRenewable: 96 | if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) { 97 | purchasedSubscriptions.append(subscription) 98 | } 99 | default: 100 | break 101 | } 102 | } catch { 103 | print("Transaction failed verification") 104 | } 105 | } 106 | 107 | //Update the store information with the purchased products. 108 | self.purchasedNonConsumables = purchasedNonConsumables 109 | 110 | //Update the store information with auto-renewable subscription products. 111 | self.purchasedSubscriptions = purchasedSubscriptions 112 | 113 | //Update locally persisted identifiers 114 | let purchasedProductIdentifiers = (purchasedNonConsumables + purchasedSubscriptions).map { $0.id } 115 | self.purchasedProductIdentifiers = Set(purchasedProductIdentifiers) 116 | userDefaults.set(purchasedProductIdentifiers, forKey: "purchasedProducts") 117 | print("Updated Purchased Products:", Set(purchasedProductIdentifiers)) 118 | } 119 | 120 | public func removePersistedPurchasedProducts() { 121 | userDefaults.removeObject(forKey: "purchasedProducts") 122 | } 123 | 124 | public func purchase(option product: Product) async -> PurchaseFinishedAction { 125 | purchaseInProgress = true 126 | let action: PurchaseFinishedAction 127 | do { 128 | let result = try await product.purchase() 129 | switch result { 130 | case .success(let verification): 131 | //Check whether the transaction is verified. If it isn't, 132 | //this function rethrows the verification error. 133 | let transaction = try checkVerified(verification) 134 | 135 | //The transaction is verified. Deliver content to the user. 136 | await updateCustomerProductStatus() 137 | 138 | //Always finish a transaction. 139 | await transaction.finish() 140 | action = .dismissStore 141 | case .pending: 142 | print("Purchase pending user action") 143 | action = .noAction 144 | case .userCancelled: 145 | print("User cancelled purchase") 146 | action = .noAction 147 | @unknown default: 148 | print("Unknown result: \(result)") 149 | action = .noAction 150 | } 151 | } catch let error as LocalizedError { 152 | purchaseError = error 153 | action = .displayError 154 | } catch { 155 | print("Purchase failed: \(error)") 156 | action = .noAction 157 | } 158 | purchaseInProgress = false 159 | return action 160 | } 161 | 162 | private func setupListenerTasksIfNecessary() { 163 | if transactionUpdatesTask == nil { 164 | transactionUpdatesTask = Task(priority: .background) { 165 | for await result in Transaction.updates { 166 | do { 167 | let transaction = try self.checkVerified(result) 168 | 169 | //Deliver products to the user. 170 | await self.updateCustomerProductStatus() 171 | 172 | //Always finish a transaction. 173 | await transaction.finish() 174 | } catch { 175 | //StoreKit has a transaction that fails verification. Don't deliver content to the user. 176 | print("Transaction failed verification") 177 | } 178 | } 179 | } 180 | } 181 | if statusUpdatesTask == nil { 182 | statusUpdatesTask = Task(priority: .background) { 183 | for await update in Product.SubscriptionInfo.Status.updates { 184 | do { 185 | let transaction = try self.checkVerified(update.transaction) 186 | let _ = try self.checkVerified(update.renewalInfo) 187 | 188 | //Deliver products to the user. 189 | await self.updateCustomerProductStatus() 190 | 191 | //Always finish a transaction. 192 | await transaction.finish() 193 | } catch { 194 | //StoreKit has a transaction that fails verification. Don't deliver content to the user. 195 | print("Transaction failed verification") 196 | } 197 | } 198 | } 199 | } 200 | if storefrontUpdatesTask == nil { 201 | storefrontUpdatesTask = Task(priority: .background) { 202 | for await update in Storefront.updates { 203 | print("Storefront changed to \(update)") 204 | currentStorefront = update 205 | // Cancel existing loading task if necessary. 206 | if let task = productLoadingTask { 207 | task.cancel() 208 | } 209 | // Load products again. 210 | productLoadingTask = Task(priority: .utility) { 211 | await self.requestProducts() 212 | } 213 | } 214 | } 215 | } 216 | } 217 | 218 | private func requestProducts() async { 219 | do { 220 | //Request products from the App Store using the identifiers. 221 | let storeProducts = try await Product.products(for: productIdentifiers) 222 | 223 | var newNonConsumable: [Product] = [] 224 | var newSubscriptions: [Product] = [] 225 | 226 | //Filter the products into categories based on their type. 227 | for product in storeProducts { 228 | switch product.type { 229 | case .nonConsumable: 230 | newNonConsumable.append(product) 231 | case .autoRenewable: 232 | newSubscriptions.append(product) 233 | default: 234 | //Ignore this product. 235 | print("Unknown product") 236 | } 237 | } 238 | 239 | //Sort each product category by price, lowest to highest, to update the store. 240 | nonConsumables = sortByPrice(newNonConsumable) 241 | subscriptions = sortByPrice(newSubscriptions) 242 | } catch { 243 | print("Failed to get in-app products: \(error)") 244 | lastLoadError = error 245 | } 246 | productLoadingTask = nil 247 | } 248 | 249 | func sortByPrice(_ products: [Product]) -> [Product] { 250 | products.sorted(by: { return $0.price < $1.price }) 251 | } 252 | 253 | func checkVerified(_ result: VerificationResult) throws -> T { 254 | //Check whether the JWS passes StoreKit verification. 255 | switch result { 256 | case .unverified: 257 | //StoreKit parses the JWS, but it fails verification. 258 | throw StoreError.failedVerification 259 | case .verified(let safe): 260 | //The result is verified. Return the unwrapped value. 261 | return safe 262 | } 263 | } 264 | 265 | } 266 | 267 | public extension Transaction { 268 | var isRevoked: Bool { 269 | // The revocation date is never in the future. 270 | revocationDate != nil 271 | } 272 | } 273 | 274 | public extension Product { 275 | var subscriptionInfo: Product.SubscriptionInfo { 276 | subscription.unsafelyUnwrapped 277 | } 278 | 279 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 280 | var priceText: String { 281 | "\(self.displayPrice)/\(self.subscriptionInfo.subscriptionPeriod.unit.localizedDescription.lowercased())" 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /Sources/Storefront/StoreMessagesManager.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) 2 | import Foundation 3 | import StoreKit 4 | import SwiftUI 5 | 6 | @available(iOS 16.0, *) 7 | @MainActor public final class StoreMessagesManager { 8 | private var pendingMessages: [Message] = [] 9 | private var updatesTask: Task? 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) 88 | -------------------------------------------------------------------------------- /Sources/Storefront/SubscriptionSavings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import StoreKit 3 | 4 | public struct SubscriptionSavings { 5 | public let percentSavings: Decimal 6 | public let granularPrice: Decimal 7 | public let granularPricePeriod: Product.SubscriptionPeriod.Unit 8 | 9 | public init(percentSavings: Decimal, granularPrice: Decimal, granularPricePeriod: Product.SubscriptionPeriod.Unit) { 10 | self.percentSavings = percentSavings 11 | self.granularPrice = granularPrice 12 | self.granularPricePeriod = granularPricePeriod 13 | } 14 | 15 | public var formattedPercent: String { 16 | return percentSavings.formatted(.percent.rounded(rule: .down, increment: 1)) 17 | } 18 | 19 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 20 | public func formattedPrice(for subscription: Product) -> String { 21 | let currency = granularPrice.formatted(subscription.priceFormatStyle) 22 | let period = granularPricePeriod.formatted(subscription.subscriptionPeriodUnitFormatStyle).lowercased() 23 | return "\(currency)/\(period)" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/StorefrontTests/Test.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | struct Test { 4 | 5 | @Test func blank() async throws { 6 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 7 | } 8 | 9 | } 10 | --------------------------------------------------------------------------------