-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
799 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# CHANGELOG | ||
|
||
## 0.1.0 (2024-10-17) | ||
* Initial Release | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
// swift-tools-version: 5.8 | ||
import PackageDescription | ||
|
||
let package = Package( | ||
name: "StoreKitHelper", | ||
products: [ | ||
.library(name: "StoreKitHelper", targets: ["StoreKitHelper"]), | ||
], | ||
dependencies: [ | ||
|
||
], | ||
targets: [ | ||
.target(name: "StoreKitHelper", dependencies: []) | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,93 @@ | ||
# StoreKitHelper | ||
A lightweight StoreKit wrapper designed to simplify purchases with SwiftUI. | ||
|
||
A lightweight StoreKit2 wrapper designed to simplify purchases with SwiftUI. | ||
|
||
## How do I get started | ||
|
||
1. Define your in-app purchases (IAPs) or subscriptions by conforming to the `ProductRepresentable` protocol, as shown below: | ||
|
||
```swift | ||
enum AppProduct: String, CaseIterable, ProductRepresentable { | ||
|
||
case iap1 = "yourApp.purchase1" | ||
case iap2 = "yourApp.purchase2" | ||
case subscription1 = "yourApp.subscription1" | ||
case subscription2 = "yourApp.subscription2" | ||
|
||
func getId() -> String { | ||
rawValue | ||
} | ||
} | ||
``` | ||
|
||
2. Initialize `PurchaseHelper` | ||
|
||
```swift | ||
import StoreKitHelper | ||
|
||
struct MainView: View { | ||
@StateObject private var purchaseHelper = PurchaseHelper(products: AppProduct.allCases) | ||
|
||
var body: some View { | ||
VStack { | ||
... | ||
} | ||
.environmentObject(purchaseHelper) | ||
.onAppear { | ||
// this will handle fetching purchases and sync owned purchases | ||
purchaseHelper.fetchAndSync() | ||
} | ||
} | ||
} | ||
``` | ||
|
||
3. Display and handle purchases anywhere in the app. | ||
|
||
```swift | ||
struct PaywallView: View { | ||
@EnvironmentObject purchaseHelper: PurchaseHelper | ||
|
||
var body: some View { | ||
VStack { | ||
// get purchase info with all the product data | ||
let purchase = purchaseHelper.getProduct(AppProduct.subscription1) | ||
|
||
// your view presenting the product data | ||
PurchaseInfoView(purchase) | ||
.onTapGesture { | ||
purchaseHelper.purchase(AppProduct.subscription1) | ||
} | ||
} | ||
} | ||
.onChange(of: purchaseHelper.loadingInProgress) { newValue in | ||
if purchaseHelper.isPurchased(AppProduct.subscription1) { | ||
// Success! | ||
} | ||
} | ||
} | ||
} | ||
``` | ||
|
||
Since `PurchaseHelper` is an `ObservableObject`, any change will automatically propagate to the views utilizing any of the public properties. | ||
|
||
|
||
## Is That All? | ||
|
||
Yes, that’s all you need to get started! There’s no unnecessary boilerplate or overcomplicated abstractions. Everything runs smoothly behind the scenes. | ||
|
||
For more advanced configurations and details, check out the public properties and functions in the `PurchaseHelper` class. | ||
|
||
|
||
## Installation | ||
|
||
To integrate `StoreKitHelper` into your project, add it via Swift Package Manager or manually. | ||
|
||
### Swift Package Manager | ||
1. In Xcode, select `File > Add Packages`. | ||
2. Enter the URL of the StoreKitHelper repository. | ||
3. Choose the latest version and install. | ||
|
||
|
||
## Contributing | ||
|
||
Missing something or have suggestions? Feel free to open a PR, and we’ll take a look! |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
// | ||
// ProductRepresentable.swift | ||
// StoreKitHelper | ||
// | ||
// Created by Marcel Vojtkovszky on 2024-10-17. | ||
// | ||
|
||
public protocol ProductRepresentable { | ||
func getId() -> String | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
// | ||
// PurchaseHelper.swift | ||
// StoreKitHelper | ||
// | ||
// Created by Marcel Vojtkovszky on 2024-10-17. | ||
// | ||
|
||
import Foundation | ||
import StoreKit | ||
import Combine | ||
|
||
@MainActor | ||
public class PurchaseHelper: ObservableObject { | ||
|
||
/// Indicated if products have been fetched (if `fetchProducts` or `fetchAndSync` has been called yet) | ||
@Published private(set) public var productsFetched: Bool = false | ||
/// Indicated if purchases have been synced (if `syncPurchases` or `fetchAndSync` has been called yet) | ||
@Published private(set) public var purchasesSynced: Bool = false | ||
/// Indicates if we have all products and purchases fetched, in practice meaning we can safely use all the methods | ||
public var purchasesReady: Bool { | ||
return productsFetched && purchasesSynced | ||
} | ||
/// Is `true` whenever products are fetching, syncyng purchases is in progress, or purchase is in progress - but only one of those at once | ||
@Published private(set) public var loadingInProgress: Bool = false | ||
|
||
@Published private var purchasedProductIds: [String] = [] // active purchases | ||
@Published private var products: [Product] = [] // StoreKit products | ||
|
||
private let allProductIds: [String] | ||
private let storeKitCommunicator = StoreKitCommunicator() | ||
|
||
|
||
/// Initialize helper | ||
/// - Parameters: | ||
/// - productIds: all product ids supported by the app. | ||
/// - listenForUpdates: if set to true, listen for transaction updates otside of the app. this can generally be omitted If you call `fetchAndSync` every time main view appears, | ||
init(products: [ProductRepresentable]) { | ||
self.allProductIds = products.map { $0.getId() } | ||
|
||
Task { | ||
await storeKitCommunicator.listenForTransactionUpdatesAsync { _ in | ||
// result can be ignored, we just wanted to finish the transaction | ||
} | ||
} | ||
} | ||
|
||
/// Determine if a given product has been purchased. | ||
func isPurchased(_ product: ProductRepresentable) -> Bool { | ||
guard purchasesSynced else { | ||
print("PurchaseHelper purchases not synced yet. Call syncPurchases() first") | ||
return false | ||
} | ||
return purchasedProductIds.contains(product.getId()) | ||
} | ||
|
||
/// Get `StoreKit`product for given app product. | ||
/// Make sure `fetchAndSync` or `fetchProducts` is called before that | ||
func getProduct(_ product: ProductRepresentable) -> Product? { | ||
guard productsFetched else { | ||
print("PurchaseHelper products not fetched yet. Call fetchProducts() first") | ||
return nil | ||
} | ||
return products.first { $0.id == product.getId() } | ||
} | ||
|
||
/// Will initialize and handle fetching products and syncing purchases | ||
/// Suggested to call this when view appears as it will guarantee `purchasesReady` to end up being `true` | ||
func fetchAndSync() { | ||
guard !loadingInProgress else { | ||
print("PurchaseHelper purchase is in progress, fetchAndSync() ignored") | ||
return | ||
} | ||
|
||
print("PurchaseHelper fetchAndSync()") | ||
self.loadingInProgress = true | ||
let willFetchProducts = !self.productsFetched | ||
let productIds = self.allProductIds | ||
|
||
Task { | ||
if willFetchProducts { | ||
await storeKitCommunicator.fetchProductsAsync(productIds: productIds) { products, error in | ||
updateUI { [weak self] in | ||
guard let self = self else { return } | ||
if let products { | ||
self.products = products | ||
self.productsFetched = true | ||
} | ||
} | ||
} | ||
} | ||
await storeKitCommunicator.syncPurchasesAsync { ids in | ||
updateUI { [weak self] in | ||
guard let self = self else { return } | ||
self.purchasedProductIds = ids | ||
self.purchasesSynced = true | ||
} | ||
} | ||
updateUI { [weak self] in | ||
guard let self = self else { return } | ||
self.loadingInProgress = false | ||
} | ||
} | ||
} | ||
|
||
/// Fetches products from the store. | ||
/// - While the process is in progress, `loadingInProgress` will be `true` | ||
func fetchProducts() { | ||
guard !loadingInProgress else { | ||
print("PurchaseHelper purchase is in progress, fetchProducts() ignored") | ||
return | ||
} | ||
|
||
print("PurchaseHelper fetchProducts()") | ||
self.loadingInProgress = true | ||
let productIds = self.allProductIds | ||
|
||
Task { | ||
await storeKitCommunicator.fetchProductsAsync(productIds: productIds) { products, error in | ||
updateUI { [weak self] in | ||
guard let self = self else { return } | ||
self.products = products ?? [] | ||
self.productsFetched = true | ||
self.loadingInProgress = false | ||
} | ||
} | ||
} | ||
} | ||
|
||
/// Synch owned purchases (entitlements) from the store | ||
/// - While the process is in progress, `loadingInProgress` will be `true` | ||
func syncPurchases() { | ||
guard !loadingInProgress else { | ||
print("PurchaseHelper purchase is in progress, syncPurchases() ignored") | ||
return | ||
} | ||
|
||
print("PurchaseHelper syncPurchases()") | ||
self.loadingInProgress = true | ||
|
||
Task { | ||
await storeKitCommunicator.syncPurchasesAsync { ids in | ||
updateUI { [weak self] in | ||
guard let self = self else { return } | ||
self.purchasedProductIds = ids | ||
self.purchasesSynced = true | ||
self.loadingInProgress = false | ||
} | ||
} | ||
} | ||
} | ||
|
||
/// Init purchase of product | ||
/// - Before the process starts, `purchaseFlowSuccess` will be set to `false` and set to `true` only upon successful purchase. | ||
/// - While the process is in progress, `loadingInProgress` will be `true` | ||
func purchase(_ product: ProductRepresentable, options: Set<Product.PurchaseOption> = []) { | ||
guard !loadingInProgress else { | ||
print("PurchaseHelper purchase is in progress, purchase() ignored") | ||
return | ||
} | ||
|
||
print("PurchaseHelper purchase \(product.getId())") | ||
self.loadingInProgress = true | ||
|
||
if let storeProduct = getProduct(product) { | ||
Task { | ||
await storeKitCommunicator.purchaseAsync(product: storeProduct, options: options) { productId in | ||
updateUI { [weak self] in | ||
guard let self = self else { return } | ||
if let productId, !self.purchasedProductIds.contains(productId) { | ||
self.purchasedProductIds.append(productId) | ||
} | ||
self.loadingInProgress = false | ||
} | ||
} | ||
} | ||
} else { | ||
print("PurchaseHelper no product found with id \(product.getId())") | ||
} | ||
} | ||
|
||
private func updateUI(_ updates: @escaping () -> Void) { | ||
DispatchQueue.main.async { | ||
updates() | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
// | ||
// StoreKitCommunicator.swift | ||
// StoreKitHelper | ||
// | ||
// Created by Marcel Vojtkovszky on 2024-10-17. | ||
// | ||
|
||
import Foundation | ||
import StoreKit | ||
|
||
internal class StoreKitCommunicator { | ||
|
||
func fetchProductsAsync(productIds: [String], callback: ([Product]?, Error?) -> Void) async { | ||
do { | ||
let products = try await Product.products(for: productIds) | ||
print("PurchaseHelper products have been fetched \(products.map({ $0.id }))") | ||
callback(products, nil) | ||
} catch { | ||
print("PurchaseHelper Error fetching products: \(error)") | ||
callback(nil, error) | ||
} | ||
} | ||
|
||
func syncPurchasesAsync(callback: ([String]) -> Void) async { | ||
var newPurchasedProductIds: [String] = [] | ||
for await result in Transaction.currentEntitlements { | ||
if case .verified(let transaction) = result { | ||
newPurchasedProductIds.append(transaction.productID) | ||
await transaction.finish() | ||
} | ||
} | ||
print("PurchaseHelper syncPurchases complete, purchased products: \(newPurchasedProductIds)") | ||
callback(newPurchasedProductIds) | ||
} | ||
|
||
func purchaseAsync(product: Product, options: Set<Product.PurchaseOption> = [], callback: (String?) -> Void) async { | ||
do { | ||
let result = try await product.purchase(options: options) | ||
switch result { | ||
case .success(let verification): | ||
switch verification { | ||
case .verified(let transaction): | ||
print("PurchaseHelper transaction verified for \(transaction.productID)") | ||
await transaction.finish() | ||
callback(transaction.productID) | ||
case .unverified: | ||
print("PurchaseHelper transaction unverified") | ||
callback(nil) | ||
} | ||
case .userCancelled: | ||
print("PurchaseHelper user canceled the purchase") | ||
callback(nil) | ||
case .pending: | ||
print("PurchaseHelper purchase pending") | ||
callback(nil) | ||
@unknown default: | ||
print("PurchaseHelper encountered an unknown purchase result") | ||
callback(nil) | ||
} | ||
} catch { | ||
print("PurchaseHelper failed: \(error)") | ||
callback(nil) | ||
} | ||
} | ||
|
||
func listenForTransactionUpdatesAsync(callback: (String) -> Void) async { | ||
for await result in Transaction.updates { | ||
if case .verified(let transaction) = result { | ||
print("PurchaseHelper transaction updated outside the app: \(transaction.productID)") | ||
callback(transaction.productID) | ||
await transaction.finish() | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.