PurpleStoreKitManager.swift (6125B)
1 // 2 // PurpleStoreKitManager.swift 3 // damus 4 // 5 // Created by Daniel D’Aquino on 2024-02-09. 6 // 7 8 import Foundation 9 import StoreKit 10 11 extension DamusPurple { 12 class StoreKitManager { // Has to be a class to get around Swift-imposed limitations of mutations on concurrently executing code associated with the purchase update monitoring task. 13 // The delegate is any object that wants to be notified of successful purchases. (e.g. A view that needs to update its UI) 14 var delegate: DamusPurpleStoreKitManagerDelegate? = nil { 15 didSet { 16 // Whenever the delegate is set, send it all recorded transactions to make sure it's up to date. 17 Task { 18 Log.info("Delegate changed. Try sending all recorded valid product transactions", for: .damus_purple) 19 guard let new_delegate = delegate else { 20 Log.info("Delegate is nil. Cannot send recorded product transactions", for: .damus_purple) 21 return 22 } 23 Log.info("Sending all %d recorded valid product transactions", for: .damus_purple, self.recorded_purchased_products.count) 24 25 for purchased_product in self.recorded_purchased_products { 26 new_delegate.product_was_purchased(product: purchased_product) 27 Log.info("Sent StoreKit tx to delegate", for: .damus_purple) 28 } 29 } 30 } 31 } 32 // Keep track of all recorded purchases so that we can send them to the delegate when it's set (whenever it's set) 33 var recorded_purchased_products: [PurchasedProduct] = [] 34 35 // Helper struct to keep track of a purchased product and its transaction 36 struct PurchasedProduct { 37 let tx: StoreKit.Transaction 38 let product: Product 39 } 40 41 // Singleton instance of StoreKitManager. To avoid losing purchase updates, there should only be one instance of StoreKitManager on the app. 42 static let standard = StoreKitManager() 43 44 init() { 45 Log.info("Initiliazing StoreKitManager", for: .damus_purple) 46 self.start() 47 } 48 49 func start() { 50 Task { 51 try await monitor_updates() 52 } 53 } 54 55 func get_products() async throws -> [Product] { 56 return try await Product.products(for: DamusPurpleType.allCases.map({ $0.rawValue })) 57 } 58 59 // Use this function to manually and immediately record a purchased product update 60 func record_purchased_product(_ purchased_product: PurchasedProduct) { 61 self.recorded_purchased_products.append(purchased_product) 62 self.delegate?.product_was_purchased(product: purchased_product) 63 } 64 65 // This function starts a task that monitors StoreKit updates and sends them to the delegate. 66 // This function will run indefinitely (It should never return), so it is important to run this as a background task. 67 private func monitor_updates() async throws { 68 Log.info("Monitoring StoreKit updates", for: .damus_purple) 69 // StoreKit.Transaction.updates is an async stream that emits updates whenever a purchase is verified. 70 for await update in StoreKit.Transaction.updates { 71 switch update { 72 case .verified(let tx): 73 let products = try await self.get_products() 74 let prod = products.filter({ prod in tx.productID == prod.id }).first 75 76 if let prod, 77 let expiration = tx.expirationDate, 78 Date.now < expiration 79 { 80 Log.info("Received valid transaction update from StoreKit", for: .damus_purple) 81 let purchased_product = PurchasedProduct(tx: tx, product: prod) 82 self.recorded_purchased_products.append(purchased_product) 83 self.delegate?.product_was_purchased(product: purchased_product) 84 Log.info("Sent tx to delegate (if exists)", for: .damus_purple) 85 } 86 case .unverified: 87 continue 88 } 89 } 90 } 91 92 // Use this function to complete a StoreKit purchase 93 // Specify the product and the app account token (UUID) to complete the purchase 94 // The account token is used to associate with the user's account on the server. 95 func purchase(product: Product, id: UUID) async throws -> Product.PurchaseResult { 96 return try await product.purchase(options: [.appAccountToken(id)]) 97 } 98 } 99 } 100 101 extension DamusPurple.StoreKitManager { 102 // This helper struct is used to encapsulate StoreKit products, metadata, and supplement with additional information 103 enum DamusPurpleType: String, CaseIterable { 104 case yearly = "purpleyearly" 105 case monthly = "purple" 106 107 func non_discounted_price(product: Product) -> String? { 108 switch self { 109 case .yearly: 110 return (product.price * 1.1984569224).formatted(product.priceFormatStyle) 111 case .monthly: 112 return nil 113 } 114 } 115 116 func label() -> String { 117 switch self { 118 case .yearly: 119 return NSLocalizedString("Annually", comment: "Annual renewal of purple subscription") 120 case .monthly: 121 return NSLocalizedString("Monthly", comment: "Monthly renewal of purple subscription") 122 } 123 } 124 } 125 } 126 127 // This protocol is used to describe the delegate of the StoreKitManager, which will receive updates. 128 protocol DamusPurpleStoreKitManagerDelegate { 129 func product_was_purchased(product: DamusPurple.StoreKitManager.PurchasedProduct) 130 }