// StoreKit 2 bridge for bevy_ios_toolkit.
//
// Design contract with the Rust side (the bevy_ios_toolkit StoreKit module):
// - Every entry point is @_cdecl C-ABI, called FROM Rust.
// - StoreKit's async work runs in Tasks; results surface as POLLED state
// (Int32 / C-string getters), never callbacks into Rust — re-entrancy
// against winit's event loop is not safe. Every shim here uses this pattern.
// - *_json getters return pointers to buffers owned here, valid only until
// the next regenerating call; Rust copies immediately.
// - This file must COMPILE AND LINK even where StoreKit is unavailable:
// everything StoreKit-specific sits behind #if canImport(StoreKit), with
// linking stubs otherwise.
//
// Integration: link this package's `Store` product from your app target; it
// links StoreKit (a system framework) for you.
// Symbol prefix `store_` avoids collision with the game's own bridge.
//
// Deployment floor iOS 16 — AppTransaction and StoreKit 2 need no guards here.
// All shared state lives behind an OSAllocatedUnfairLock so the async StoreKit
// tasks and the synchronous C-ABI getters stay race-free and Swift-6 clean.
import Foundation
import os
#if canImport(StoreKit)
import StoreKit
private struct StoreState {
var environmentStarted = false
// 0 pending, 1 Xcode, 2 sandbox, 3 production, 4 unavailable, 5 unknown
var environmentState: Int32 = 0
var products: [Product] = []
var productsState: Int32 = 0 // 0 loading, 1 ready, 2 failed
var purchaseState: Int32 = 0 // 0 idle,1 buying,2 ok,3 fail,4 cancel,5 pending
var purchaseProduct: String = ""
var entitled: Set<String> = []
var entRev: UInt64 = 0
// Owned C-string buffers; freed when regenerated.
var productsJSONPtr: UnsafeMutablePointer<CChar>?
var entitlementsJSONPtr: UnsafeMutablePointer<CChar>?
var purchaseProductPtr: UnsafeMutablePointer<CChar>?
}
final class StoreBridge: @unchecked Sendable {
static let shared = StoreBridge()
private let state = OSAllocatedUnfairLock(initialState: StoreState())
private var updatesTask: Task<Void, Never>?
// MARK: Commands
func startEnvironmentResolution() {
let shouldStart = state.withLock { s -> Bool in
guard !s.environmentStarted else { return false }
s.environmentStarted = true
return true
}
guard shouldStart else { return }
Task { await self.resolveEnvironment() }
}
func start(ids: [String]) {
state.withLock { $0.productsState = 0 }
Task { await self.loadProducts(ids) }
Task { await self.refreshEntitlements() }
// Catch purchases on other devices / Ask-to-Buy approvals / renewals.
updatesTask = Task.detached { [weak self] in
for await update in Transaction.updates {
await self?.handle(update)
}
}
}
func purchase(_ id: String) {
let product = state.withLock { s -> Product? in
s.purchaseProduct = id
s.purchaseState = 1
return s.products.first { $0.id == id }
}
guard let product else {
setPurchaseState(3)
return
}
Task {
do {
switch try await product.purchase() {
case .success(let verification):
if case .verified(let transaction) = verification {
await transaction.finish()
await self.refreshEntitlements()
self.setPurchaseState(2)
} else {
self.setPurchaseState(3)
}
case .userCancelled:
self.setPurchaseState(4)
case .pending:
self.setPurchaseState(5)
@unknown default:
self.setPurchaseState(3)
}
} catch {
NSLog("[store] purchase failed: %@", String(describing: error))
self.setPurchaseState(3)
}
}
}
func clearPurchase() {
state.withLock {
$0.purchaseState = 0
$0.purchaseProduct = ""
}
}
func restore() {
Task {
try? await AppStore.sync()
await self.refreshEntitlements()
}
}
// MARK: Async work
private func resolveEnvironment() async {
do {
let result = try await AppTransaction.shared
switch result {
case .verified(let transaction):
let environment = transaction.environment
if environment == .xcode {
publishEnvironment(1, logValue: "xcode")
} else if environment == .sandbox {
publishEnvironment(2, logValue: "sandbox")
} else if environment == .production {
publishEnvironment(3, logValue: "production")
} else {
publishEnvironment(5, logValue: "unknown (\(environment.rawValue))")
}
case .unverified(_, let error):
publishEnvironment(
4,
logValue: "unavailable (verification failed: \(error))"
)
}
} catch {
publishEnvironment(4, logValue: "unavailable (\(error))")
}
}
private func publishEnvironment(_ value: Int32, logValue: String) {
state.withLock { $0.environmentState = value }
NSLog("[store] App Store environment: %@", logValue)
}
private func loadProducts(_ ids: [String]) async {
do {
let fetched = try await Product.products(for: ids)
let json = Self.productsJSON(fetched)
state.withLock { s in
s.products = fetched
if let old = s.productsJSONPtr { free(old) }
s.productsJSONPtr = strdup(json)
s.productsState = 1
}
} catch {
NSLog("[store] product load failed: %@", String(describing: error))
state.withLock { $0.productsState = 2 }
}
}
private func handle(_ result: VerificationResult<Transaction>) async {
if case .verified(let transaction) = result {
await transaction.finish()
await refreshEntitlements()
}
}
private func refreshEntitlements() async {
var owned = Set<String>()
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result, transaction.revocationDate == nil {
owned.insert(transaction.productID)
}
}
let json = Self.idsJSON(owned)
let snapshot = owned
state.withLock { s in
if snapshot != s.entitled {
s.entitled = snapshot
if let old = s.entitlementsJSONPtr { free(old) }
s.entitlementsJSONPtr = strdup(json)
s.entRev &+= 1
}
}
}
private func setPurchaseState(_ value: Int32) {
state.withLock { $0.purchaseState = value }
}
// MARK: Getters (called from C)
func environmentStateValue() -> Int32 { state.withLock { $0.environmentState } }
func productsStateValue() -> Int32 { state.withLock { $0.productsState } }
func purchaseStateValue() -> Int32 { state.withLock { $0.purchaseState } }
func entRevValue() -> UInt64 { state.withLock { $0.entRev } }
// withLockUnchecked: the result is a raw pointer (not Sendable), but it's
// only read synchronously by the C caller, which copies immediately.
func productsJSONValue() -> UnsafePointer<CChar>? {
state.withLockUnchecked { s in s.productsJSONPtr.map { UnsafePointer($0) } }
}
func entitlementsJSONValue() -> UnsafePointer<CChar>? {
state.withLockUnchecked { s in s.entitlementsJSONPtr.map { UnsafePointer($0) } }
}
func purchaseProductValue() -> UnsafePointer<CChar>? {
state.withLockUnchecked { s in
if let old = s.purchaseProductPtr { free(old) }
s.purchaseProductPtr = strdup(s.purchaseProduct)
return s.purchaseProductPtr.map { UnsafePointer($0) }
}
}
// MARK: JSON helpers
private static func productsJSON(_ products: [Product]) -> String {
let items: [[String: String]] = products.map {
[
"id": $0.id,
"display_name": $0.displayName,
"display_price": $0.displayPrice,
"description": $0.description,
]
}
return (try? JSONSerialization.data(withJSONObject: items))
.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
}
private static func idsJSON(_ ids: Set<String>) -> String {
return (try? JSONSerialization.data(withJSONObject: Array(ids)))
.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
}
}
@_cdecl("store_environment_init")
public func store_environment_init() { StoreBridge.shared.startEnvironmentResolution() }
@_cdecl("store_environment_state")
public func store_environment_state() -> Int32 { StoreBridge.shared.environmentStateValue() }
@_cdecl("store_init")
public func store_init(_ ids: UnsafePointer<CChar>) {
let list = String(cString: ids)
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
StoreBridge.shared.start(ids: list)
}
@_cdecl("store_products_state")
public func store_products_state() -> Int32 { StoreBridge.shared.productsStateValue() }
@_cdecl("store_products_json")
public func store_products_json() -> UnsafePointer<CChar>? { StoreBridge.shared.productsJSONValue() }
@_cdecl("store_purchase")
public func store_purchase(_ id: UnsafePointer<CChar>) { StoreBridge.shared.purchase(String(cString: id)) }
@_cdecl("store_purchase_state")
public func store_purchase_state() -> Int32 { StoreBridge.shared.purchaseStateValue() }
@_cdecl("store_purchase_product")
public func store_purchase_product() -> UnsafePointer<CChar>? { StoreBridge.shared.purchaseProductValue() }
@_cdecl("store_purchase_clear")
public func store_purchase_clear() { StoreBridge.shared.clearPurchase() }
@_cdecl("store_restore")
public func store_restore() { StoreBridge.shared.restore() }
@_cdecl("store_entitlements_rev")
public func store_entitlements_rev() -> UInt64 { StoreBridge.shared.entRevValue() }
@_cdecl("store_entitlements_json")
public func store_entitlements_json() -> UnsafePointer<CChar>? { StoreBridge.shared.entitlementsJSONValue() }
#else
// StoreKit unavailable: linking stubs. Products report failed, nothing owned.
@_cdecl("store_environment_init")
public func store_environment_init() {
NSLog("[store] App Store environment: unavailable (StoreKit unavailable)")
}
@_cdecl("store_environment_state") public func store_environment_state() -> Int32 { 4 }
@_cdecl("store_init") public func store_init(_ ids: UnsafePointer<CChar>) {}
@_cdecl("store_products_state") public func store_products_state() -> Int32 { 2 }
@_cdecl("store_products_json") public func store_products_json() -> UnsafePointer<CChar>? { nil }
@_cdecl("store_purchase") public func store_purchase(_ id: UnsafePointer<CChar>) {}
@_cdecl("store_purchase_state") public func store_purchase_state() -> Int32 { 0 }
@_cdecl("store_purchase_product") public func store_purchase_product() -> UnsafePointer<CChar>? { nil }
@_cdecl("store_purchase_clear") public func store_purchase_clear() {}
@_cdecl("store_restore") public func store_restore() {}
@_cdecl("store_entitlements_rev") public func store_entitlements_rev() -> UInt64 { 0 }
@_cdecl("store_entitlements_json") public func store_entitlements_json() -> UnsafePointer<CChar>? { nil }
#endif