惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
爱范儿
爱范儿
博客园 - 三生石上(FineUI控件)
Vercel News
Vercel News
M
MIT News - Artificial intelligence
L
LangChain Blog
大猫的无限游戏
大猫的无限游戏
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Microsoft Azure Blog
Microsoft Azure Blog
J
Java Code Geeks
Recent Announcements
Recent Announcements
Stack Overflow Blog
Stack Overflow Blog
人人都是产品经理
人人都是产品经理
IT之家
IT之家
F
Fortinet All Blogs
博客园 - 聂微东
U
Unit 42
Martin Fowler
Martin Fowler
腾讯CDC
博客园_首页
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
量子位
阮一峰的网络日志
阮一峰的网络日志
博客园 - Franky

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
StoreKit 2 subscriptions + a screenshot mode that bypasse...
Yoshiaki Hirokawa · 2026-06-27 · via DEV Community

Yoshiaki Hirokawa

Two things every paid iOS app needs but nobody enjoys building:

  1. A correct subscription manager (purchase, restore, entitlement checks, transaction updates).
  2. 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 via Transaction.updates.
  • @unknown default is mandatory on the purchase-result switch — StoreKit can add cases.
  • Keep the shot-mode bypass narrow. It only forces isPro; it never fakes a real Transaction. 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 VerificationResult everywhere; trust nothing unverified.
  • Drive entitlement from Transaction.currentEntitlements and keep a Transaction.updates listener 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