// StoreKit 2 bridge for `cranpose-storekit`.
//
// StoreKit 2 is Swift-only — `Product`, `Transaction`, `Transaction.updates`
// and the on-device JWS verification have no Objective-C surface, so this file
// is the whole reason the crate compiles Swift at all. Everything here is a
// thin translation layer: no policy, no caching decisions, no UI.
//
// The Rust side owns all state. Swift pushes snapshots through one C callback
// and never answers questions synchronously, because every StoreKit call is
// async and may block on the network or on a payment sheet.
//
// Threading: StoreKit callbacks arrive on Swift concurrency executors, i.e.
// arbitrary threads. The callback into Rust is therefore made from arbitrary
// threads and the Rust side guards its state with a mutex. Strings handed to
// the callback are valid only for the duration of the call — Rust copies them.
import Foundation
import StoreKit
// MARK: - C ABI
/// Message kinds pushed to Rust. A snapshot is `begin`, then any number of
/// `product`/`owned` rows, then `phase`, which commits it atomically.
private enum Kind: Int32 {
case begin = 0
case product = 1
case owned = 2
case phase = 3
case event = 4
case busy = 5
}
/// Mirrors `cranpose_storekit::ffi::StorePhaseCode`.
private enum PhaseCode: Int32 {
case unavailable = 0
case connecting = 1
case ready = 2
}
/// Mirrors `cranpose_storekit::ffi::EventCode`.
private enum EventCode: Int32 {
case purchased = 0
case cancelled = 1
case pending = 2
case failed = 3
case restored = 4
}
/// `(ctx, kind, arg0, arg1, a, b, c, d)`.
public typealias CranposeStoreCallback = @convention(c) (
UnsafeMutableRawPointer?,
Int32,
Int32,
Int32,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?
) -> Void
/// Callback + context, set once by `cranpose_storekit_start`.
///
/// `@unchecked Sendable`: the payload is a C function pointer and an opaque
/// pointer that Rust guarantees outlives the process (it points at a `static`).
private final class Sink: @unchecked Sendable {
let callback: CranposeStoreCallback
let ctx: UnsafeMutableRawPointer?
init(callback: @escaping CranposeStoreCallback, ctx: UnsafeMutableRawPointer?) {
self.callback = callback
self.ctx = ctx
}
func send(
_ kind: Kind,
_ arg0: Int32 = 0,
_ arg1: Int32 = 0,
_ a: String? = nil,
_ b: String? = nil,
_ c: String? = nil,
_ d: String? = nil
) {
// Nested `withCString` keeps every buffer alive across the call. Four
// levels is the whole product row; nothing needs more.
withOptionalCString(a) { pa in
withOptionalCString(b) { pb in
withOptionalCString(c) { pc in
withOptionalCString(d) { pd in
callback(ctx, kind.rawValue, arg0, arg1, pa, pb, pc, pd)
}
}
}
}
}
}
private func withOptionalCString<R>(
_ value: String?,
_ body: (UnsafePointer<CChar>?) -> R
) -> R {
guard let value else { return body(nil) }
return value.withCString { body($0) }
}
// MARK: - Bridge state
/// Process-wide bridge state. Guarded by `lock`; every field is touched from
/// StoreKit's executors as well as from the caller's thread.
private final class BridgeState: @unchecked Sendable {
private let lock = NSLock()
private var _sink: Sink?
private var _productIds: [String] = []
private var _started = false
static let shared = BridgeState()
var sink: Sink? {
lock.lock()
defer { lock.unlock() }
return _sink
}
var productIds: [String] {
lock.lock()
defer { lock.unlock() }
return _productIds
}
/// Records the sink and ids. Returns true the first time only, so the
/// `Transaction.updates` listener is spawned exactly once.
func start(sink: Sink, productIds: [String]) -> Bool {
lock.lock()
defer { lock.unlock() }
_sink = sink
_productIds = productIds
let first = !_started
_started = true
return first
}
}
// MARK: - Entry points
/// Connect to the App Store and begin watching for transactions.
///
/// `productIdsJoined` is a newline-separated list — product ids are
/// `[A-Za-z0-9._-]`, so a newline cannot occur inside one and no escaping or
/// JSON parser is needed on either side.
///
/// Safe to call again (a relaunch of the app's configure step); the
/// transaction listener is still spawned only once.
@_cdecl("cranpose_storekit_start")
public func cranpose_storekit_start(
_ productIdsJoined: UnsafePointer<CChar>,
_ ctx: UnsafeMutableRawPointer?,
_ callback: @escaping CranposeStoreCallback
) {
let ids = String(cString: productIdsJoined)
.split(separator: "\n")
.map(String.init)
.filter { !$0.isEmpty }
let sink = Sink(callback: callback, ctx: ctx)
let first = BridgeState.shared.start(sink: sink, productIds: ids)
guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) else {
// Below the StoreKit 2 floor. The app's deployment target should make
// this unreachable; report it rather than pretending to be connecting.
sink.send(.phase, PhaseCode.unavailable.rawValue, 0, "StoreKit 2 requires iOS 15 / macOS 12")
return
}
sink.send(.phase, PhaseCode.connecting.rawValue)
if first {
// Long-running: transactions that arrive without a purchase call —
// Ask to Buy approvals, purchases made on another device, subscription
// renewals, and anything the App Store retried after a crash. Apple
// requires this listener to exist for the app's whole lifetime.
Task.detached(priority: .background) {
for await update in Transaction.updates {
if case .verified(let transaction) = update {
await transaction.finish()
}
await refreshSnapshot()
}
}
}
Task.detached(priority: .userInitiated) {
await refreshSnapshot()
}
}
/// Begin a purchase for `productId`. Presents the App Store payment sheet.
@_cdecl("cranpose_storekit_purchase")
public func cranpose_storekit_purchase(_ productId: UnsafePointer<CChar>) {
let id = String(cString: productId)
guard let sink = BridgeState.shared.sink else { return }
guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) else {
sink.send(.event, EventCode.failed.rawValue, 0, "The App Store is unavailable on this system")
return
}
sink.send(.busy, 1)
Task.detached(priority: .userInitiated) {
await runPurchase(id: id, sink: sink)
sink.send(.busy, 0)
await refreshSnapshot()
}
}
/// Re-query what this Apple ID owns, after an explicit "Restore purchases".
///
/// `AppStore.sync()` can prompt for the App Store password, so Apple's rule is
/// that it runs only from a user action — never at launch. Launch restoration
/// is what `Transaction.currentEntitlements` already does for free.
@_cdecl("cranpose_storekit_restore")
public func cranpose_storekit_restore() {
guard let sink = BridgeState.shared.sink else { return }
guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) else {
sink.send(.event, EventCode.failed.rawValue, 0, "The App Store is unavailable on this system")
return
}
sink.send(.busy, 1)
Task.detached(priority: .userInitiated) {
do {
try await AppStore.sync()
} catch {
// A cancelled password prompt lands here too. Report it as a
// finished restore that found whatever is already known, rather
// than as a failure — the snapshot below is still authoritative.
sink.send(.event, EventCode.failed.rawValue, 0, error.localizedDescription)
}
let owned = await ownedProductIds()
await refreshSnapshot()
sink.send(.event, EventCode.restored.rawValue, Int32(owned.count))
sink.send(.busy, 0)
}
}
// MARK: - StoreKit work
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private func runPurchase(id: String, sink: Sink) async {
do {
let products = try await Product.products(for: [id])
guard let product = products.first else {
sink.send(.event, EventCode.failed.rawValue, 0, "That product is not available in your region’s App Store")
return
}
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
// Verified means Apple's signature over the transaction
// checked out against the device's copy of the certificate
// chain — this is the on-device receipt validation, and it is
// done before we grant anything.
await transaction.finish()
sink.send(.event, EventCode.purchased.rawValue, 0, transaction.productID)
case .unverified(_, let error):
// A transaction that fails signature verification is never
// granted; a jailbreak or a proxy is the usual cause.
sink.send(.event, EventCode.failed.rawValue, 0,
"This purchase could not be verified: \(error.localizedDescription)")
}
case .userCancelled:
sink.send(.event, EventCode.cancelled.rawValue)
case .pending:
// Ask to Buy, or a payment method needing action. It may complete
// later; `Transaction.updates` is what will tell us.
sink.send(.event, EventCode.pending.rawValue)
@unknown default:
sink.send(.event, EventCode.failed.rawValue, 0, "The App Store returned an unexpected result")
}
} catch {
sink.send(.event, EventCode.failed.rawValue, 0, describe(error))
}
}
/// Turn a StoreKit error into something that names the actual cause.
///
/// `localizedDescription` on these is close to useless while integrating —
/// `Product.PurchaseError.productUnavailable` renders as "Item Not Available",
/// which reads like a store outage when it almost always means the product is
/// not in a purchasable *state* yet. The distinction costs hours if the
/// message does not make it, so the typed cases are unwrapped here.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private func describe(_ error: Error) -> String {
if let purchaseError = error as? Product.PurchaseError {
switch purchaseError {
case .productUnavailable:
return "The App Store will not sell this product yet. In App Store "
+ "Connect the in-app purchase has to reach “Ready to Submit”: "
+ "availability, price, localization and a review screenshot "
+ "must all be filled in. A product with missing metadata still "
+ "returns a price, which is why this fails only at purchase."
case .purchaseNotAllowed:
return "Purchases are not allowed on this device — check Screen Time → Content & Privacy Restrictions."
case .ineligibleForOffer:
return "This account is not eligible for that offer."
case .invalidOfferIdentifier, .invalidOfferPrice, .invalidOfferSignature, .missingOfferParameters:
return "The promotional offer attached to this purchase is not valid."
case .invalidQuantity:
return "That quantity cannot be bought."
@unknown default:
return "The purchase could not be completed (\(purchaseError))."
}
}
if let storeError = error as? StoreKitError {
switch storeError {
case .userCancelled:
return "Purchase cancelled."
case .notAvailableInStorefront:
return "This product is not available in your App Store country. "
+ "Check the in-app purchase's Availability, and which storefront "
+ "the signed-in sandbox account belongs to."
case .notEntitled:
return "This app is not entitled to make purchases — the App ID needs the In-App Purchase capability."
case .networkError(let underlying):
return "The App Store is unreachable: \(underlying.localizedDescription)"
case .systemError(let underlying):
return "The App Store returned a system error: \(underlying.localizedDescription)"
case .unknown:
return "The App Store returned an unknown error. On a sandbox account this usually means the account has not finished signing in — Settings → Developer → Sandbox Apple Account."
@unknown default:
return "The App Store returned an error (\(storeError))."
}
}
return error.localizedDescription
}
/// Product ids this Apple ID currently owns, signature-verified.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private func ownedProductIds() async -> [String] {
var owned: [String] = []
for await entitlement in Transaction.currentEntitlements {
guard case .verified(let transaction) = entitlement else { continue }
if transaction.revocationDate != nil { continue }
if let expires = transaction.expirationDate, expires < Date() { continue }
owned.append(transaction.productID)
}
return owned
}
/// Fetch products + entitlements and push one committed snapshot.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private func refreshSnapshot() async {
guard let sink = BridgeState.shared.sink else { return }
let ids = BridgeState.shared.productIds
var products: [Product] = []
var failure: String?
if !ids.isEmpty {
do {
products = try await Product.products(for: Set(ids))
} catch {
failure = error.localizedDescription
}
}
let owned = await ownedProductIds()
sink.send(.begin)
for product in products {
sink.send(
.product, 0, 0,
product.id,
product.displayPrice,
product.displayName,
product.description
)
}
for id in owned {
sink.send(.owned, 0, 0, id)
}
// Entitlements are authoritative even when the product lookup failed, so
// a store outage never revokes an unlock the user already paid for: the
// phase still commits as ready, with the error attached for diagnostics.
let ready = failure == nil || !owned.isEmpty
sink.send(
.phase,
(ready ? PhaseCode.ready : PhaseCode.connecting).rawValue,
0,
failure
)
}