Files
interject/interject/Interject!/Model/Store.swift
T
2026-07-11 10:17:49 -05:00

201 lines
6.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
Abstract:
The store class is responsible for requesting products from the App Store and starting purchases.
*/
import Foundation
import StoreKit
typealias Transaction = StoreKit.Transaction
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
public enum StoreError: Error {
case failedVerification
}
class Store: ObservableObject {
@Published private(set) var tips: [Product]
@Published private(set) var purchasedTips: [Product] = []
var updateListenerTask: Task<Void, Error>? = nil
private let productIdToEmoji: [String: String]
init() {
productIdToEmoji = Store.loadProductIdToEmojiData()
//Initialize empty products, and then do a product request asynchronously to fill them in.
tips = []
//Start a transaction listener as close to app launch as possible so you don't miss any transactions.
updateListenerTask = listenForTransactions()
Task {
//During store initialization, request products from the App Store.
await requestProducts()
//Deliver products that the customer purchases.
await updateCustomerProductStatus()
}
}
deinit {
updateListenerTask?.cancel()
}
static func loadProductIdToEmojiData() -> [String: String] {
guard let path = Bundle.main.path(forResource: "Products", ofType: "plist"),
let plist = FileManager.default.contents(atPath: path),
let data = try? PropertyListSerialization.propertyList(from: plist, format: nil) as? [String: String] else {
return [:]
}
return data
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
//Iterate through any transactions that don't come from a direct call to `purchase()`.
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
//Deliver products to the user.
await self.updateCustomerProductStatus()
//Always finish a transaction.
await transaction.finish()
} catch {
//StoreKit has a transaction that fails verification. Don't deliver content to the user.
print("Transaction failed verification")
}
}
}
}
@MainActor
func requestProducts() async {
do {
//Request products from the App Store using the identifiers that the Products.plist file defines.
let storeProducts = try await Product.products(for: productIdToEmoji.keys)
var newTips: [Product] = []
//Filter the products into categories based on their type.
for product in storeProducts {
switch product.type {
case .consumable:
break
case .nonConsumable:
newTips.append(product)
case .autoRenewable:
break
case .nonRenewable:
break
default:
//Ignore this product.
print("Unknown product")
}
}
//Sort each product category by price, lowest to highest, to update the store.
tips = sortByPrice(newTips)
} catch {
print("Failed product request from the App Store server: \(error)")
}
}
func purchase(_ product: Product) async throws -> Transaction? {
//Begin purchasing the `Product` the user selects.
let result = try await product.purchase()
switch result {
case .success(let verification):
//Check whether the transaction is verified. If it isn't,
//this function rethrows the verification error.
let transaction = try checkVerified(verification)
//The transaction is verified. Deliver content to the user.
await updateCustomerProductStatus()
//Always finish a transaction.
await transaction.finish()
return transaction
case .userCancelled, .pending:
return nil
default:
return nil
}
}
func isPurchased(_ product: Product) async throws -> Bool {
//Determine whether the user purchases a given product.
switch product.type {
case .nonRenewable:
break
case .nonConsumable:
return purchasedTips.contains(product)
case .autoRenewable:
break
default:
return false
}
return true
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
//Check whether the JWS passes StoreKit verification.
switch result {
case .unverified:
//StoreKit parses the JWS, but it fails verification.
throw StoreError.failedVerification
case .verified(let safe):
//The result is verified. Return the unwrapped value.
return safe
}
}
@MainActor
func updateCustomerProductStatus() async {
var purchasedTips: [Product] = []
//Iterate through all of the user's purchased products.
for await result in Transaction.currentEntitlements {
do {
//Check whether the transaction is verified. If it isn’t, catch `failedVerification` error.
let transaction = try checkVerified(result)
//Check the `productType` of the transaction and get the corresponding product from the store.
switch transaction.productType {
case .nonConsumable:
if let tip = tips.first(where: { $0.id == transaction.productID }) {
purchasedTips.append(tip)
}
case .nonRenewable:
break
case .autoRenewable:
break
default:
break
}
} catch {
print()
}
}
//Update the store information with the purchased products.
self.purchasedTips = purchasedTips
}
func emoji(for productId: String) -> String {
return productIdToEmoji[productId]!
}
func sortByPrice(_ products: [Product]) -> [Product] {
products.sorted(by: { return $0.price < $1.price })
}
}