use std::collections::HashSet;
use std::ffi::CString;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use crate::ffi::read_cstr;
#[cfg(target_os = "ios")]
#[path = "backend_ios.rs"]
mod backend;
#[cfg(not(target_os = "ios"))]
#[path = "backend_fake.rs"]
mod backend;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProductInfo {
pub id: String,
pub display_name: String,
pub display_price: String,
pub description: String,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ProductsState {
#[default]
Loading,
Ready,
Failed,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PurchaseOutcome {
Success,
Failed,
Cancelled,
Pending,
}
#[derive(Resource, Clone, Default)]
pub struct StoreConfig {
pub product_ids: Vec<String>,
}
#[derive(Resource, Default)]
pub struct StoreProducts {
pub state: ProductsState,
pub items: Vec<ProductInfo>,
}
impl StoreProducts {
pub fn get(&self, id: &str) -> Option<&ProductInfo> {
self.items.iter().find(|p| p.id == id)
}
}
#[derive(Resource, Default)]
pub struct Entitlements {
owned: HashSet<String>,
}
impl Entitlements {
pub fn owns(&self, id: &str) -> bool {
self.owned.contains(id)
}
pub fn iter(&self) -> impl Iterator<Item = &String> {
self.owned.iter()
}
}
#[derive(Message, Clone, Debug)]
pub struct PurchaseRequest(pub String);
#[derive(Message, Clone, Debug)]
pub struct RestoreRequest;
#[derive(Message, Clone, Debug)]
pub struct ProductsUpdated;
#[derive(Message, Clone, Debug)]
pub struct PurchaseCompleted {
pub product_id: String,
pub outcome: PurchaseOutcome,
}
#[derive(Message, Clone, Debug)]
pub struct EntitlementsChanged;
fn init(ids: &[String]) {
let Ok(joined) = CString::new(ids.join(",")) else {
return;
};
unsafe { backend::store_init(joined.as_ptr()) };
}
fn products_state() -> ProductsState {
match unsafe { backend::store_products_state() } {
1 => ProductsState::Ready,
2 => ProductsState::Failed,
_ => ProductsState::Loading,
}
}
fn products() -> Vec<ProductInfo> {
let json = unsafe { read_cstr(backend::store_products_json()) };
serde_json::from_str(&json).unwrap_or_default()
}
fn purchase(id: &str) {
let Ok(id) = CString::new(id) else {
return;
};
unsafe { backend::store_purchase(id.as_ptr()) };
}
fn purchase_result() -> Option<(PurchaseOutcome, String)> {
let outcome = match unsafe { backend::store_purchase_state() } {
2 => PurchaseOutcome::Success,
3 => PurchaseOutcome::Failed,
4 => PurchaseOutcome::Cancelled,
5 => PurchaseOutcome::Pending,
_ => return None,
};
let product = unsafe { read_cstr(backend::store_purchase_product()) };
Some((outcome, product))
}
fn purchase_clear() {
unsafe { backend::store_purchase_clear() };
}
fn restore() {
unsafe { backend::store_restore() };
}
fn entitlements_rev() -> u64 {
unsafe { backend::store_entitlements_rev() }
}
fn fetch_entitlements() -> Vec<String> {
let json = unsafe { read_cstr(backend::store_entitlements_json()) };
serde_json::from_str(&json).unwrap_or_default()
}
#[derive(Resource)]
struct StorePoll {
inited: bool,
last_products: ProductsState,
ent_rev: u64,
}
impl Default for StorePoll {
fn default() -> Self {
Self {
inited: false,
last_products: ProductsState::Loading,
ent_rev: 0,
}
}
}
pub struct StorePlugin;
impl Plugin for StorePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StoreProducts>()
.init_resource::<Entitlements>()
.init_resource::<StorePoll>()
.add_message::<PurchaseRequest>()
.add_message::<RestoreRequest>()
.add_message::<ProductsUpdated>()
.add_message::<PurchaseCompleted>()
.add_message::<EntitlementsChanged>()
.add_systems(Update, (init_once, pump_requests, poll_store).chain());
}
}
fn init_once(config: Option<Res<StoreConfig>>, mut poll: ResMut<StorePoll>) {
if poll.inited {
return;
}
if let Some(config) = config
&& !config.product_ids.is_empty()
{
init(&config.product_ids);
poll.inited = true;
}
}
fn pump_requests(
poll: Res<StorePoll>,
mut buys: MessageReader<PurchaseRequest>,
mut restores: MessageReader<RestoreRequest>,
) {
if !poll.inited {
return;
}
for buy in buys.read() {
purchase(&buy.0);
}
for _ in restores.read() {
restore();
}
}
fn poll_store(
mut poll: ResMut<StorePoll>,
mut store_products: ResMut<StoreProducts>,
mut entitlements: ResMut<Entitlements>,
mut products_updated: MessageWriter<ProductsUpdated>,
mut purchase_completed: MessageWriter<PurchaseCompleted>,
mut entitlements_changed: MessageWriter<EntitlementsChanged>,
) {
if !poll.inited {
return;
}
let state = products_state();
if state != poll.last_products {
poll.last_products = state;
store_products.state = state;
if state == ProductsState::Ready {
store_products.items = products();
}
products_updated.write(ProductsUpdated);
}
if let Some((outcome, product_id)) = purchase_result() {
if !product_id.is_empty() {
purchase_completed.write(PurchaseCompleted {
product_id,
outcome,
});
}
purchase_clear();
}
let rev = entitlements_rev();
if rev != poll.ent_rev {
poll.ent_rev = rev;
entitlements.owned = fetch_entitlements().into_iter().collect();
entitlements_changed.write(EntitlementsChanged);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn buy_once(mut requests: MessageWriter<PurchaseRequest>, mut fired: Local<bool>) {
if !*fired {
*fired = true;
requests.write(PurchaseRequest("com.test.removeads".into()));
}
}
#[test]
fn fake_purchase_flow_grants_entitlement() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(StorePlugin);
app.insert_resource(StoreConfig {
product_ids: vec!["com.test.removeads".into()],
});
app.add_systems(Update, buy_once);
for _ in 0..5 {
app.update();
}
let products = app.world().resource::<StoreProducts>();
assert_eq!(products.state, ProductsState::Ready);
assert!(products.get("com.test.removeads").is_some());
let entitlements = app.world().resource::<Entitlements>();
assert!(
entitlements.owns("com.test.removeads"),
"purchase should have granted the entitlement"
);
}
}