OnDevice Kit

再インストールでもリセットされない無料トライアル:キーチェーンと StoreKit 2

「最初の 2 回は無料」は公正な提案です。ただし、アプリを削除して再インストールするたびに 2 回が無限に増えてしまわなければ、の話です。Swift による、小さく誠実な実装を紹介します。

数回の無料利用付きの買い切り課金は、オンデバイス AI アプリと相性が良いモデルです。サブスクリプションで賄うべきサーバー費用がなく、ユーザーは支払う前に自分のデータで機能が動くところを確かめられます。よくある実装ではカウンターを UserDefaults に保存します。しかしこのカウンターはアプリを削除すると消え、トライアルが最初からやり直しになります。

キーチェーンは挙動が異なります。このガイドでは、トライアルのカウンターをキーチェーンに保存し、アンロックを StoreKit 2 の非消耗型アイテムとして販売し、復元、ファミリー共有、承認と購入のリクエスト、返金に対応します。サーバーも、自前のレシート検証コードも不要です。

なぜキーチェーンなのか、そして何が保証されないのか

汎用パスワード(generic password)のキーチェーン項目は、アプリのコンテナの外に保存されます。現在の iOS では、アプリを削除しても項目は端末に残り、同じバンドル ID とチームで再インストールされたアプリは再びそれを読み取れます。端末を消去すると項目も削除されます。

これが何であるかははっきりさせておきましょう。長年観察されてきた挙動であって、ドキュメントで保証されたものではありません。Apple は iOS 10.3 のベータで一時的にこの挙動を変更し、その後元に戻しました。万一カウンターがリセットされても、最悪の場合でユーザーが無料利用をもう一度得られるだけ、という設計にしておいてください。それなら十分に許容できる最悪の場合です。

知っておくべき性質がさらに 2 つあります。

  • 項目は端末ごとです。iPhone と iPad を持っているユーザーは、それぞれで無料利用を得られます。kSecAttrSynchronizable を設定して iCloud キーチェーンで同期することもできますが、そうするとユーザーの iCloud 設定に依存することになり、カウンターのためにそこまでする価値はほとんどありません。
  • kSecAttrAccessibleAfterFirstUnlock を使ってください。デフォルト(WhenUnlocked)では端末のロック中に項目を読み取れず、利用枠を確認する最初のバックグラウンドタスクが動かなくなります。

小さなキーチェーンラッパー

Keychain.swift
import Foundation
import Security

enum Keychain {
    static var service: String { Bundle.main.bundleIdentifier ?? "app" }

    static func data(_ key: String) -> Data? {
        var query = base(key)
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne
        var out: AnyObject?
        return SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess ? out as? Data : nil
    }

    static func set(_ data: Data?, for key: String) {
        let query = base(key)
        SecItemDelete(query as CFDictionary)
        guard let data else { return }
        var add = query
        add[kSecValueData as String] = data
        add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
        SecItemAdd(add as CFDictionary, nil)
    }

    static func int(_ key: String) -> Int {
        data(key).flatMap { Int(String(decoding: $0, as: UTF8.self)) } ?? 0
    }

    static func set(_ value: Int, for key: String) {
        set(Data(String(value).utf8), for: key)
    }

    private static func base(_ key: String) -> [String: Any] {
        [kSecClass as String: kSecClassGenericPassword,
         kSecAttrService as String: service,
         kSecAttrAccount as String: key]
    }
}

削除してから追加する方式は SecItemUpdate よりシンプルで、数回しか書き込まない値であれば十分です。service にはバンドル ID を使うので、自分の 2 つのアプリが誤ってカウンターを共有することはありません。

無料利用枠

FreeQuota.swift
import Observation

@MainActor
@Observable
final class FreeQuota {
    let key: String
    let free: Int
    private(set) var used: Int

    init(_ name: String, free: Int) {
        key = "quota.\(name)"
        self.free = free
        used = Keychain.int("quota.\(name)")
    }

    var remaining: Int { max(0, free - used) }
    var isAvailable: Bool { remaining > 0 }

    func use(_ count: Int = 1) {
        used += count
        Keychain.set(used, for: key)
    }

    #if DEBUG
    func reset() { used = 0; Keychain.set(nil, for: key) }
    #endif
}

利用回数は、処理が成功した後にカウントします。前ではありません。モデルが失敗したりユーザーがキャンセルしたりした場合に、無料の 1 回を失わせるべきではありません。また reset() はデバッグビルドに残しておいてください。必ず必要になります。自分のテスト端末でも、アプリを削除してもトライアルがリセットされなくなるからです。

少しずつなら無料で使い続けられるべき機能には、「1 日 1 回」というバリエーションがあります。最後に使った日を "yyyy-M-d" 形式の文字列として同じキーチェーンに保存し、今日の日付と比較します。

アンロック:StoreKit 2 の非消耗型アイテム

StoreKit 2 は署名付きの JWS ペイロードを使って端末上でトランザクションを検証するため、買い切りのアンロックであればサーバーは不要です。次のクラスは、プロダクトの読み込み、購入、復元を行い、購入フロー外で発生する変更を監視します。

Unlock.swift
import StoreKit
import Observation

@MainActor
@Observable
final class Unlock {
    let productID: String
    private(set) var product: Product?
    private(set) var isUnlocked: Bool
    private(set) var isPurchasing = false
    var price: String? { product?.displayPrice }

    @ObservationIgnored private var updates: Task<Void, Never>?
    private var cacheKey: String { "unlock.\(productID)" }

    init(_ productID: String) {
        self.productID = productID
        // Last known state first, so the UI doesn't flash a paywall at launch.
        isUnlocked = UserDefaults.standard.bool(forKey: "unlock.\(productID)")
        updates = Task { [weak self] in
            for await result in Transaction.updates {
                if case .verified(let t) = result { await t.finish() }
                await self?.refresh()
            }
        }
        Task { await load() }
    }

    func load() async {
        product = try? await Product.products(for: [productID]).first
        await refresh()
    }

    /// Re-reads entitlements: catches refunds, Family Sharing and Ask to Buy approvals.
    func refresh() async {
        var owned = false
        for await result in Transaction.currentEntitlements {
            if case .verified(let t) = result, t.productID == productID, t.revocationDate == nil {
                owned = true
            }
        }
        set(owned)
    }

    @discardableResult
    func buy() async throws -> Bool {
        if product == nil { await load() }
        guard let product else { throw StoreError.productNotFound }
        isPurchasing = true
        defer { isPurchasing = false }
        switch try await product.purchase() {
        case .success(.verified(let t)):
            await t.finish()
            set(true)
            return true
        case .success(.unverified):
            throw StoreError.unverified
        default:
            return false          // .pending (Ask to Buy) or .userCancelled
        }
    }

    func restore() async throws {
        try await AppStore.sync()
        await refresh()
        if !isUnlocked { throw StoreError.nothingToRestore }
    }

    private func set(_ owned: Bool) {
        isUnlocked = owned
        UserDefaults.standard.set(owned, forKey: cacheKey)
    }
}

enum StoreError: LocalizedError {
    case productNotFound, unverified, nothingToRestore
    var errorDescription: String? {
        switch self {
        case .productNotFound: "The App Store can't be reached right now."
        case .unverified: "The purchase couldn't be verified."
        case .nothingToRestore: "No purchase was found for this Apple Account."
        }
    }
}

いくつかの設計判断について説明しておきます。

  • 起動時に Transaction.updates の監視を開始する。承認と購入のリクエストの承認、別の端末での購入、返金はここに届きます。購入画面が開いていないときに届くこともあります。一度もイテレートしないと、Apple は警告をログに出力します。
  • revocationDate を確認する。返金や取り消しが行われた購入もトランザクションとして現れることがあります。取り消されたものでは何もアンロックしてはいけません。
  • 最後の状態を UserDefaults にキャッシュする。currentEntitlements は非同期です。キャッシュがないと、購入済みのユーザーにも起動のたびに一瞬ペイウォールが表示されます。このキャッシュは表示用のヒントであり、信頼できる情報源ではありません。refresh() が上書きします。
  • ファミリー共有にはコードが不要です。App Store Connect でプロダクトのファミリー共有を有効にすれば、家族メンバーの権利が currentEntitlements に現れます。

復元は必須

App Review ガイドライン 3.1.1 では、非消耗型の購入に対して復元の仕組みを用意することが求められています。ペイウォールと設定画面に、目に見える「購入を復元」ボタンを置いてください。AppStore.sync() はユーザーに認証を求めるため、このボタンからのみ呼び出し、起動時に自動で呼び出すことは絶対に避けてください。

機能へのアクセスを制限する

LetterView.swift
import SwiftUI

struct LetterView: View {
    @State private var quota = FreeQuota("letters", free: 2)
    @State private var unlock = Unlock("com.example.app.unlimited")
    @State private var showPaywall = false
    let text: String

    var body: some View {
        VStack(spacing: 16) {
            Button("Read this letter") { Task { await analyze() } }
            if !unlock.isUnlocked {
                Text("\(quota.remaining) free letters left").font(.footnote)
            }
        }
        .sheet(isPresented: $showPaywall) {
            VStack(spacing: 12) {
                Button("Unlock for \(unlock.price ?? "…")") {
                    Task { if (try? await unlock.buy()) == true { showPaywall = false } }
                }
                .disabled(unlock.isPurchasing)
                Button("Restore purchase") {
                    Task { try? await unlock.restore(); if unlock.isUnlocked { showPaywall = false } }
                }
            }
            .padding()
        }
    }

    private func analyze() async {
        guard unlock.isUnlocked || quota.isAvailable else { showPaywall = true; return }
        do {
            try await runModel(on: text)     // your feature
            if !unlock.isUnlocked { quota.use() }
        } catch {
            // failed work does not count against the trial
        }
    }

    private func runModel(on text: String) async throws { /* ... */ }
}

実際のアプリでは、Unlock をアプリレベルで一度だけ生成して下位に渡し、Transaction.updates のリスナーが 1 つだけになるようにします。

テスト

  1. Xcode で非消耗型アイテムを含む StoreKit Configuration ファイルを追加し、スキームで選択します。これで購入がローカルで実行され、シミュレータでも動作します。
  2. Debug › StoreKit › Manage Transactions で購入を返金し、Transaction.updates 経由でアプリが再びロックされることを確認します。
  3. Configuration で「Ask to Buy」(承認と購入のリクエスト)を有効にし、保留中の経路をテストします。
  4. 実機でアプリを削除して再インストールし、remaining が変わっていないことを確認します。その後、デバッグメニューから reset() を呼び出します。

ペイウォールの向こうにある機能がオンデバイスモデルであれば、残りの道筋は他のガイドで解説しています。モデルをバックグラウンドでダウンロードすると、Apple Intelligence が使える端末では活用し、それ以外ではフォールバックするをご覧ください。