Two things every paid iOS app needs but nobody enjoys building:
- A correct subscription manager (purchase, restore, entitlement checks, transaction updates).
- A way to capture App Store screenshots of the paid screens — without having a live sandbox purchase every time.
Here's how FishGo does both, with the second piece deliberately punching a hole through the first.
A compact StoreKit 2 manager
StoreKit 2 is async/await-native, so the whole manager fits in one observable class. We use @Observable (iOS 17+) so SwiftUI views react to isPro changes:
@Observable
final class StoreManager {
static let shared = StoreManager()
private(set) var isPro = false
private(set) var products: [Product] = []
private(set) var purchaseError: String?
private let productIDs = ["pro_monthly", "pro_yearly"]
private var transactionListener: Task<Void, Never>?
}
Loading products
func loadProducts() async {
do {
let storeProducts = try await Product.products(for: productIDs)
products = storeProducts.sorted { $0.price < $1.price }
} catch {
purchaseError = "商品情報の取得に失敗しました"
}
}
Purchasing, with verification
The important part of StoreKit 2 is that every result is a VerificationResult you must check:
func purchase(_ product: Product) async {
purchaseError = nil
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
await updatePurchaseStatus()
case .userCancelled:
break
case .pending:
purchaseError = "購入処理が保留中です"
@unknown default:
break
}
} catch {
purchaseError = "購入に失敗しました: \(error.localizedDescription)"
}
}
nonisolated private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
Listening for transactions out-of-band
Purchases can arrive outside your purchase flow (Ask to Buy approvals, renewals, another device). A long-lived listener keeps isPro correct:
private func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await result in Transaction.updates {
if let transaction = try? self?.checkVerified(result) {
await transaction.finish()
await self?.updatePurchaseStatus()
}
}
}
}
And the source of truth for entitlement is Transaction.currentEntitlements:
private func updatePurchaseStatus() async {
var hasEntitlement = false
for await result in Transaction.currentEntitlements {
if let transaction = try? checkVerified(result),
productIDs.contains(transaction.productID) {
hasEntitlement = true
break
}
}
isPro = hasEntitlement
}
The hole: a screenshot "shot mode"
Now the App Store screenshot problem. The paywall and the Pro-only screens look best when isPro == true, but you don't want to depend on a sandbox purchase succeeding during an automated screenshot run.
So StoreManager's initializer short-circuits when a launch flag is set:
init() {
// Shot mode: pin Pro state without touching StoreKit
if ShotMode.isEnabled && ShotMode.isPro {
isPro = true
return
}
transactionListener = listenForTransactions()
Task { await updatePurchaseStatus() }
}
ShotMode is just a thin reader over launch arguments / UserDefaults:
enum ShotMode {
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: "SHOT_MODE") }
static var isPro: Bool { UserDefaults.standard.bool(forKey: "SHOT_PRO") }
}
Run the UI test with -SHOT_MODE 1 -SHOT_PRO 1 and the app boots straight into a deterministic Pro state — no network, no store, no flaky purchase. Combined with mocked forecast data, every screenshot is identical run-to-run.
Pitfalls
-
Always
finish()a verified transaction. Unfinished transactions get replayed forever viaTransaction.updates. -
@unknown defaultis mandatory on the purchase-result switch — StoreKit can add cases. -
Keep the shot-mode bypass narrow. It only forces
isPro; it never fakes a realTransaction. The real verification path stays untouched for actual users. - Guard the bypass behind a launch argument, not a build flag you might ship. It only activates when explicitly passed at launch.
Takeaways
- StoreKit 2 lets you write a full subscription manager in ~100 lines with
async/await+@Observable. - Check
VerificationResulteverywhere; trust nothing unverified. - Drive entitlement from
Transaction.currentEntitlementsand keep aTransaction.updateslistener alive. - A tiny launch-flag bypass makes paid-screen screenshots deterministic — as long as it stays scoped to UI state, not real receipts.
FishGo is on the App Store: https://apps.apple.com/app/id6774428559
Top comments (0)